Merge tag 'v3.5.0-alpha.5'

This commit is contained in:
三咲智子 Kevin Deng 2024-08-06 17:39:59 +08:00
commit d23095e866
No known key found for this signature in database
46 changed files with 1736 additions and 844 deletions

View File

@ -82,7 +82,7 @@ Hi! I'm really excited that you are interested in contributing to Vue.js. Before
## Development Setup
You will need [Node.js](https://nodejs.org) **version 18.12+**, and [PNPM](https://pnpm.io) **version 8+**.
You will need [Node.js](https://nodejs.org) with minimum version as specified in the [`.node-version`](https://github.com/vuejs/core/blob/main/.node-version) file, and [PNPM](https://pnpm.io) with minimum version as specified in the [`"packageManager"` field in `package.json`](https://github.com/vuejs/core/blob/main/package.json#L4).
We also recommend installing [@antfu/ni](https://github.com/antfu/ni) to help switching between repos using different package managers. `ni` also provides the handy `nr` command which running npm scripts easier.

View File

@ -31,4 +31,4 @@ jobs:
- name: Run prettier
run: pnpm run format
- uses: autofix-ci/action@2891949f3779a1cafafae1523058501de3d4e944
- uses: autofix-ci/action@ff86a557419858bb967097bfc916833f5647fa8c

View File

@ -1,6 +1,31 @@
# [3.5.0-alpha.4](https://github.com/vuejs/core/compare/v3.4.34...v3.5.0-alpha.4) (2024-07-24)
# [3.5.0-alpha.5](https://github.com/vuejs/core/compare/v3.4.35...v3.5.0-alpha.5) (2024-07-31)
### Features
* **hydration:** support suppressing hydration mismatch via data-allow-mismatch ([94fb2b8](https://github.com/vuejs/core/commit/94fb2b8106a66bcca1a3f922a246a29fdd1274b1))
* lazy hydration strategies for async components ([#11458](https://github.com/vuejs/core/issues/11458)) ([d14a11c](https://github.com/vuejs/core/commit/d14a11c1cdcee88452f17ce97758743c863958f4))
## [3.4.35](https://github.com/vuejs/core/compare/v3.4.34...v3.4.35) (2024-07-31)
### Bug Fixes
* **teleport/ssr:** fix Teleport hydration regression due to targetStart anchor addition ([7b18cdb](https://github.com/vuejs/core/commit/7b18cdb0b53a94007ca6a3675bf41b5d3153fec6))
* **teleport/ssr:** ensure targetAnchor and targetStart not null during hydration ([#11456](https://github.com/vuejs/core/issues/11456)) ([12667da](https://github.com/vuejs/core/commit/12667da4879f980dcf2c50e36f3642d085a87d71)), closes [#11400](https://github.com/vuejs/core/issues/11400)
* **types/ref:** allow getter and setter types to be unrelated ([#11442](https://github.com/vuejs/core/issues/11442)) ([e0b2975](https://github.com/vuejs/core/commit/e0b2975ef65ae6a0be0aa0a0df43fb887c665251))
### Performance Improvements
* **runtime-core:** improve efficiency of normalizePropsOptions ([#11409](https://github.com/vuejs/core/issues/11409)) ([5680142](https://github.com/vuejs/core/commit/5680142e68096c42e66da9f4c6220d040d7c56ba)), closes [#9739](https://github.com/vuejs/core/issues/9739)
# [3.5.0-alpha.4](https://github.com/vuejs/core/compare/v3.4.34...v3.5.0-alpha.4) (2024-07-24)
### Bug Fixes
* **suspense/hydration:** fix hydration timing of async component inside suspense ([1b8e197](https://github.com/vuejs/core/commit/1b8e197a5b65d67a9703b8511786fb81df9aa7cc)), closes [#6638](https://github.com/vuejs/core/issues/6638)

View File

@ -1,7 +1,7 @@
{
"private": true,
"version": "3.5.0-alpha.4",
"packageManager": "pnpm@9.5.0",
"version": "3.5.0-alpha.5",
"packageManager": "pnpm@9.6.0",
"type": "module",
"scripts": {
"dev": "node scripts/dev.js vue vue-vapor",
@ -66,22 +66,22 @@
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-replace": "5.0.4",
"@swc/core": "^1.6.13",
"@swc/core": "^1.7.3",
"@types/hash-sum": "^1.0.2",
"@types/node": "^20.14.10",
"@types/node": "^20.14.13",
"@types/semver": "^7.5.8",
"@vitest/coverage-istanbul": "^1.6.0",
"@vitest/ui": "^1.6.0",
"@vue/consolidate": "1.0.0",
"conventional-changelog-cli": "^4.1.0",
"conventional-changelog-cli": "^5.0.0",
"enquirer": "^2.4.1",
"esbuild": "^0.23.0",
"esbuild-plugin-polyfill-node": "^0.3.0",
"eslint": "^9.6.0",
"eslint-plugin-import-x": "^0.5.3",
"eslint": "^9.8.0",
"eslint-plugin-import-x": "^3.1.0",
"eslint-plugin-vitest": "^0.5.4",
"estree-walker": "catalog:",
"jsdom": "^24.1.0",
"jsdom": "^24.1.1",
"lint-staged": "^15.2.7",
"lodash": "^4.17.21",
"magic-string": "^0.30.10",
@ -89,23 +89,23 @@
"marked": "^12.0.2",
"npm-run-all2": "^6.2.2",
"picocolors": "^1.0.1",
"prettier": "^3.3.2",
"prettier": "^3.3.3",
"pretty-bytes": "^6.1.1",
"pug": "^3.0.3",
"puppeteer": "~22.12.1",
"puppeteer": "~22.14.0",
"rimraf": "^5.0.9",
"rollup": "^4.18.1",
"rollup": "^4.19.1",
"rollup-plugin-dts": "^6.1.1",
"rollup-plugin-esbuild": "^6.1.1",
"rollup-plugin-polyfill-node": "^0.13.0",
"semver": "^7.6.2",
"semver": "^7.6.3",
"serve": "^14.2.3",
"simple-git-hooks": "^2.11.1",
"todomvc-app-css": "^2.4.3",
"tslib": "^2.6.3",
"tsx": "^4.16.2",
"typescript": "~5.4.5",
"typescript-eslint": "^7.15.0",
"typescript-eslint": "^7.17.0",
"vite": "catalog:",
"vitest": "^1.6.0"
},

View File

@ -1,6 +1,6 @@
{
"name": "@vue/compiler-core",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "@vue/compiler-core",
"main": "index.js",
"module": "dist/compiler-core.esm-bundler.js",

View File

@ -1,6 +1,6 @@
{
"name": "@vue/compiler-dom",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "@vue/compiler-dom",
"main": "index.js",
"module": "dist/compiler-dom.esm-bundler.js",

View File

@ -1,6 +1,6 @@
{
"name": "@vue/compiler-sfc",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "@vue/compiler-sfc",
"main": "dist/compiler-sfc.cjs.js",
"module": "dist/compiler-sfc.esm-browser.js",
@ -50,7 +50,7 @@
"@vue/shared": "workspace:*",
"estree-walker": "catalog:",
"magic-string": "catalog:",
"postcss": "^8.4.39",
"postcss": "^8.4.40",
"source-map-js": "catalog:"
},
"devDependencies": {
@ -61,7 +61,7 @@
"merge-source-map": "^1.1.0",
"minimatch": "^9.0.5",
"postcss-modules": "^6.0.0",
"postcss-selector-parser": "^6.1.0",
"postcss-selector-parser": "^6.1.1",
"pug": "^3.0.3",
"sass": "^1.77.8"
}

View File

@ -1,6 +1,6 @@
{
"name": "@vue/compiler-ssr",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "@vue/compiler-ssr",
"main": "dist/compiler-ssr.cjs.js",
"types": "dist/compiler-ssr.d.ts",

View File

@ -173,6 +173,16 @@ describe('ref with generic', <T extends { name: string }>() => {
expectType<string>(ss.value.name)
})
describe('allow getter and setter types to be unrelated', <T>() => {
const a = { b: ref(0) }
const c = ref(a)
c.value = a
const d = {} as T
const e = ref(d)
e.value = d
})
// shallowRef
type Status = 'initial' | 'ready' | 'invalidating'
const shallowStatus = shallowRef<Status>('initial')

View File

@ -1,5 +1,6 @@
import {
type ComputedRef,
type MaybeRef,
type Ref,
computed,
defineComponent,
@ -203,3 +204,10 @@ defineComponent({
expectType<{ foo: string }>(value)
})
}
{
const css: MaybeRef<string> = ''
watch(ref(css), value => {
expectType<string>(value)
})
}

View File

@ -1,6 +1,6 @@
{
"name": "@vue/reactivity",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "@vue/reactivity",
"main": "index.js",
"module": "dist/reactivity.esm-bundler.js",

View File

@ -54,16 +54,14 @@ export const arrayInstrumentations: Record<string | symbol, Function> = <any>{
fn: (item: unknown, index: number, array: unknown[]) => unknown,
thisArg?: unknown,
) {
const result = apply(this, 'filter', fn, thisArg)
return isProxy(this) && !isShallow(this) ? result.map(toReactive) : result
return apply(this, 'filter', fn, thisArg, v => v.map(toReactive))
},
find(
fn: (item: unknown, index: number, array: unknown[]) => boolean,
thisArg?: unknown,
) {
const result = apply(this, 'find', fn, thisArg)
return isProxy(this) && !isShallow(this) ? toReactive(result) : result
return apply(this, 'find', fn, thisArg, toReactive)
},
findIndex(
@ -77,8 +75,7 @@ export const arrayInstrumentations: Record<string | symbol, Function> = <any>{
fn: (item: unknown, index: number, array: unknown[]) => boolean,
thisArg?: unknown,
) {
const result = apply(this, 'findLast', fn, thisArg)
return isProxy(this) && !isShallow(this) ? toReactive(result) : result
return apply(this, 'findLast', fn, thisArg, toReactive)
},
findLastIndex(
@ -237,11 +234,14 @@ function apply(
method: ArrayMethods,
fn: (item: unknown, index: number, array: unknown[]) => unknown,
thisArg?: unknown,
wrappedRetFn?: (result: any) => unknown,
) {
const arr = shallowReadArray(self)
let needsWrap = false
let wrappedFn = fn
if (arr !== self) {
if (!isShallow(self)) {
needsWrap = !isShallow(self)
if (needsWrap) {
wrappedFn = function (this: unknown, item, index) {
return fn.call(this, toReactive(item), index, self)
}
@ -252,7 +252,8 @@ function apply(
}
}
// @ts-expect-error our code is limited to es2016 but user code is not
return arr[method](wrappedFn, thisArg)
const result = arr[method](wrappedFn, thisArg)
return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result
}
// instrument reduce and reduceRight to take ARRAY_ITERATE dependency

View File

@ -169,19 +169,6 @@ function createForEach(isReadonly: boolean, isShallow: boolean) {
}
}
interface Iterable {
[Symbol.iterator](): Iterator
}
interface Iterator {
next(value?: any): IterationResult
}
interface IterationResult {
value: any
done: boolean
}
function createIterableMethod(
method: string | symbol,
isReadonly: boolean,
@ -190,7 +177,7 @@ function createIterableMethod(
return function (
this: IterableCollections,
...args: unknown[]
): Iterable & Iterator {
): Iterable<unknown> & Iterator<unknown> {
const target = this[ReactiveFlags.RAW]
const rawTarget = toRaw(target)
const targetIsMap = isMap(rawTarget)

View File

@ -23,8 +23,9 @@ import { warn } from './warning'
declare const RefSymbol: unique symbol
export declare const RawSymbol: unique symbol
export interface Ref<T = any> {
value: T
export interface Ref<T = any, S = T> {
get value(): T
set value(_: S)
/**
* Type differentiator only.
* We need this to be in public d.ts but don't want it to show up in IDE
@ -51,7 +52,7 @@ export function isRef(r: any): r is Ref {
* @param value - The object to wrap in the ref.
* @see {@link https://vuejs.org/api/reactivity-core.html#ref}
*/
export function ref<T>(value: T): Ref<UnwrapRef<T>>
export function ref<T>(value: T): Ref<UnwrapRef<T>, UnwrapRef<T> | T>
export function ref<T = any>(): Ref<T | undefined>
export function ref(value?: unknown) {
return createRef(value, false)

View File

@ -265,7 +265,7 @@ describe('SSR hydration', () => {
const fn = vi.fn()
const teleportContainer = document.createElement('div')
teleportContainer.id = 'teleport'
teleportContainer.innerHTML = `<span>foo</span><span class="foo"></span><!--teleport anchor-->`
teleportContainer.innerHTML = `<!--teleport start anchor--><span>foo</span><span class="foo"></span><!--teleport anchor-->`
document.body.appendChild(teleportContainer)
const { vnode, container } = mountWithHydration(
@ -281,13 +281,14 @@ describe('SSR hydration', () => {
expect(vnode.anchor).toBe(container.lastChild)
expect(vnode.target).toBe(teleportContainer)
expect(vnode.targetStart).toBe(teleportContainer.childNodes[0])
expect((vnode.children as VNode[])[0].el).toBe(
teleportContainer.childNodes[0],
)
expect((vnode.children as VNode[])[1].el).toBe(
teleportContainer.childNodes[1],
)
expect(vnode.targetAnchor).toBe(teleportContainer.childNodes[2])
expect((vnode.children as VNode[])[1].el).toBe(
teleportContainer.childNodes[2],
)
expect(vnode.targetAnchor).toBe(teleportContainer.childNodes[3])
// event handler
triggerEvent('click', teleportContainer.querySelector('.foo')!)
@ -296,7 +297,7 @@ describe('SSR hydration', () => {
msg.value = 'bar'
await nextTick()
expect(teleportContainer.innerHTML).toBe(
`<span>bar</span><span class="bar"></span><!--teleport anchor-->`,
`<!--teleport start anchor--><span>bar</span><span class="bar"></span><!--teleport anchor-->`,
)
})
@ -326,7 +327,7 @@ describe('SSR hydration', () => {
const teleportHtml = ctx.teleports!['#teleport2']
expect(teleportHtml).toMatchInlineSnapshot(
`"<span>foo</span><span class="foo"></span><!--teleport anchor--><span>foo2</span><span class="foo2"></span><!--teleport anchor-->"`,
`"<!--teleport start anchor--><span>foo</span><span class="foo"></span><!--teleport anchor--><!--teleport start anchor--><span>foo2</span><span class="foo2"></span><!--teleport anchor-->"`,
)
teleportContainer.innerHTML = teleportHtml
@ -342,16 +343,18 @@ describe('SSR hydration', () => {
expect(teleportVnode2.anchor).toBe(container.childNodes[4])
expect(teleportVnode1.target).toBe(teleportContainer)
expect(teleportVnode1.targetStart).toBe(teleportContainer.childNodes[0])
expect((teleportVnode1 as any).children[0].el).toBe(
teleportContainer.childNodes[0],
teleportContainer.childNodes[1],
)
expect(teleportVnode1.targetAnchor).toBe(teleportContainer.childNodes[2])
expect(teleportVnode1.targetAnchor).toBe(teleportContainer.childNodes[3])
expect(teleportVnode2.target).toBe(teleportContainer)
expect(teleportVnode2.targetStart).toBe(teleportContainer.childNodes[4])
expect((teleportVnode2 as any).children[0].el).toBe(
teleportContainer.childNodes[3],
teleportContainer.childNodes[5],
)
expect(teleportVnode2.targetAnchor).toBe(teleportContainer.childNodes[5])
expect(teleportVnode2.targetAnchor).toBe(teleportContainer.childNodes[7])
// // event handler
triggerEvent('click', teleportContainer.querySelector('.foo')!)
@ -363,7 +366,7 @@ describe('SSR hydration', () => {
msg.value = 'bar'
await nextTick()
expect(teleportContainer.innerHTML).toMatchInlineSnapshot(
`"<span>bar</span><span class="bar"></span><!--teleport anchor--><span>bar2</span><span class="bar2"></span><!--teleport anchor-->"`,
`"<!--teleport start anchor--><span>bar</span><span class="bar"></span><!--teleport anchor--><!--teleport start anchor--><span>bar2</span><span class="bar2"></span><!--teleport anchor-->"`,
)
})
@ -390,7 +393,9 @@ describe('SSR hydration', () => {
)
const teleportHtml = ctx.teleports!['#teleport3']
expect(teleportHtml).toMatchInlineSnapshot(`"<!--teleport anchor-->"`)
expect(teleportHtml).toMatchInlineSnapshot(
`"<!--teleport start anchor--><!--teleport anchor-->"`,
)
teleportContainer.innerHTML = teleportHtml
document.body.appendChild(teleportContainer)
@ -413,7 +418,8 @@ describe('SSR hydration', () => {
expect(children[2].el).toBe(container.childNodes[6])
expect(teleportVnode.target).toBe(teleportContainer)
expect(teleportVnode.targetAnchor).toBe(teleportContainer.childNodes[0])
expect(teleportVnode.targetStart).toBe(teleportContainer.childNodes[0])
expect(teleportVnode.targetAnchor).toBe(teleportContainer.childNodes[1])
// // event handler
triggerEvent('click', container.querySelector('.foo')!)
@ -454,7 +460,7 @@ describe('SSR hydration', () => {
test('Teleport (as component root)', () => {
const teleportContainer = document.createElement('div')
teleportContainer.id = 'teleport4'
teleportContainer.innerHTML = `hello<!--teleport anchor-->`
teleportContainer.innerHTML = `<!--teleport start anchor-->hello<!--teleport anchor-->`
document.body.appendChild(teleportContainer)
const wrapper = {
@ -483,7 +489,7 @@ describe('SSR hydration', () => {
test('Teleport (nested)', () => {
const teleportContainer = document.createElement('div')
teleportContainer.id = 'teleport5'
teleportContainer.innerHTML = `<div><!--teleport start--><!--teleport end--></div><!--teleport anchor--><div>child</div><!--teleport anchor-->`
teleportContainer.innerHTML = `<!--teleport start anchor--><div><!--teleport start--><!--teleport end--></div><!--teleport anchor--><!--teleport start anchor--><div>child</div><!--teleport anchor-->`
document.body.appendChild(teleportContainer)
const { vnode, container } = mountWithHydration(
@ -498,7 +504,7 @@ describe('SSR hydration', () => {
expect(vnode.anchor).toBe(container.lastChild)
const childDivVNode = (vnode as any).children[0]
const div = teleportContainer.firstChild
const div = teleportContainer.childNodes[1]
expect(childDivVNode.el).toBe(div)
expect(vnode.targetAnchor).toBe(div?.nextSibling)
@ -512,6 +518,178 @@ describe('SSR hydration', () => {
)
})
test('Teleport unmount (full integration)', async () => {
const Comp1 = {
template: `
<Teleport to="#target">
<span>Teleported Comp1</span>
</Teleport>
`,
}
const Comp2 = {
template: `
<div>Comp2</div>
`,
}
const toggle = ref(true)
const App = {
template: `
<div>
<Comp1 v-if="toggle"/>
<Comp2 v-else/>
</div>
`,
components: {
Comp1,
Comp2,
},
setup() {
return { toggle }
},
}
const container = document.createElement('div')
const teleportContainer = document.createElement('div')
teleportContainer.id = 'target'
document.body.appendChild(teleportContainer)
// server render
const ctx: SSRContext = {}
container.innerHTML = await renderToString(h(App), ctx)
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
)
teleportContainer.innerHTML = ctx.teleports!['#target']
// hydrate
createSSRApp(App).mount(container)
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
)
expect(teleportContainer.innerHTML).toBe(
'<!--teleport start anchor--><span>Teleported Comp1</span><!--teleport anchor-->',
)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
toggle.value = false
await nextTick()
expect(container.innerHTML).toBe('<div><div>Comp2</div></div>')
expect(teleportContainer.innerHTML).toBe('')
})
test('Teleport unmount (mismatch + full integration)', async () => {
const Comp1 = {
template: `
<Teleport to="#target">
<span>Teleported Comp1</span>
</Teleport>
`,
}
const Comp2 = {
template: `
<div>Comp2</div>
`,
}
const toggle = ref(true)
const App = {
template: `
<div>
<Comp1 v-if="toggle"/>
<Comp2 v-else/>
</div>
`,
components: {
Comp1,
Comp2,
},
setup() {
return { toggle }
},
}
const container = document.createElement('div')
const teleportContainer = document.createElement('div')
teleportContainer.id = 'target'
document.body.appendChild(teleportContainer)
// server render
container.innerHTML = await renderToString(h(App))
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
)
expect(teleportContainer.innerHTML).toBe('')
// hydrate
createSSRApp(App).mount(container)
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
)
expect(teleportContainer.innerHTML).toBe('<span>Teleported Comp1</span>')
expect(`Hydration children mismatch`).toHaveBeenWarned()
toggle.value = false
await nextTick()
expect(container.innerHTML).toBe('<div><div>Comp2</div></div>')
expect(teleportContainer.innerHTML).toBe('')
})
test('Teleport target change (mismatch + full integration)', async () => {
const target = ref('#target1')
const Comp = {
template: `
<Teleport :to="target">
<span>Teleported</span>
</Teleport>
`,
setup() {
return { target }
},
}
const App = {
template: `
<div>
<Comp />
</div>
`,
components: {
Comp,
},
}
const container = document.createElement('div')
const teleportContainer1 = document.createElement('div')
teleportContainer1.id = 'target1'
const teleportContainer2 = document.createElement('div')
teleportContainer2.id = 'target2'
document.body.appendChild(teleportContainer1)
document.body.appendChild(teleportContainer2)
// server render
container.innerHTML = await renderToString(h(App))
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
)
expect(teleportContainer1.innerHTML).toBe('')
expect(teleportContainer2.innerHTML).toBe('')
// hydrate
createSSRApp(App).mount(container)
expect(container.innerHTML).toBe(
'<div><!--teleport start--><!--teleport end--></div>',
)
expect(teleportContainer1.innerHTML).toBe('<span>Teleported</span>')
expect(teleportContainer2.innerHTML).toBe('')
expect(`Hydration children mismatch`).toHaveBeenWarned()
target.value = '#target2'
await nextTick()
expect(teleportContainer1.innerHTML).toBe('')
expect(teleportContainer2.innerHTML).toBe('<span>Teleported</span>')
})
// compile SSR + client render fn from the same template & hydrate
test('full compiler integration', async () => {
const mounted: string[] = []
@ -1824,4 +2002,136 @@ describe('SSR hydration', () => {
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
})
})
describe('data-allow-mismatch', () => {
test('element text content', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="text">foo</div>`,
() => h('div', 'bar'),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="text">bar</div>',
)
expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
})
test('not enough children', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"></div>`,
() => h('div', [h('span', 'foo'), h('span', 'bar')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>',
)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})
test('too many children', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>`,
() => h('div', [h('span', 'foo')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><span>foo</span></div>',
)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})
test('complete mismatch', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>`,
() => h('div', [h('div', 'foo'), h('p', 'bar')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><div>foo</div><p>bar</p></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})
test('fragment mismatch removal', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
() => h('div', [h('span', 'replaced')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><span>replaced</span></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})
test('fragment not enough children', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
() => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})
test('fragment too many children', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
() => h('div', [[h('div', 'foo')], h('div', 'baz')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>',
)
// fragment ends early and attempts to hydrate the extra <div>bar</div>
// as 2nd fragment child.
expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
// excessive children removal
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})
test('comment mismatch (element)', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><span></span></div>`,
() => h('div', [createCommentVNode('hi')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><!--hi--></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})
test('comment mismatch (text)', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children">foobar</div>`,
() => h('div', [createCommentVNode('hi')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><!--hi--></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})
test('class mismatch', () => {
mountWithHydration(
`<div class="foo bar" data-allow-mismatch="class"></div>`,
() => h('div', { class: 'foo' }),
)
expect(`Hydration class mismatch`).not.toHaveBeenWarned()
})
test('style mismatch', () => {
mountWithHydration(
`<div style="color:red;" data-allow-mismatch="style"></div>`,
() => h('div', { style: { color: 'green' } }),
)
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
})
test('attr mismatch', () => {
mountWithHydration(`<div data-allow-mismatch="attribute"></div>`, () =>
h('div', { id: 'foo' }),
)
mountWithHydration(
`<div id="bar" data-allow-mismatch="attribute"></div>`,
() => h('div', { id: 'foo' }),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
})
})

View File

@ -1,6 +1,6 @@
{
"name": "@vue/runtime-core",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "@vue/runtime-core",
"main": "index.js",
"module": "dist/runtime-core.esm-bundler.js",

View File

@ -16,6 +16,7 @@ import { ErrorCodes, handleError } from './errorHandling'
import { isKeepAlive } from './components/KeepAlive'
import { queueJob } from './scheduler'
import { markAsyncBoundary } from './helpers/useId'
import { type HydrationStrategy, forEachElement } from './hydrationStrategies'
export type AsyncComponentResolveResult<T = Component> = T | { default: T } // es modules
@ -30,6 +31,7 @@ export interface AsyncComponentOptions<T = any> {
delay?: number
timeout?: number
suspensible?: boolean
hydrate?: HydrationStrategy
onError?: (
error: Error,
retry: () => void,
@ -54,6 +56,7 @@ export function defineAsyncComponent<
loadingComponent,
errorComponent,
delay = 200,
hydrate: hydrateStrategy,
timeout, // undefined = never times out
suspensible = true,
onError: userOnError,
@ -118,6 +121,24 @@ export function defineAsyncComponent<
__asyncLoader: load,
__asyncHydrate(el, instance, hydrate) {
const doHydrate = hydrateStrategy
? () => {
const teardown = hydrateStrategy(hydrate, cb =>
forEachElement(el, cb),
)
if (teardown) {
;(instance.bum || (instance.bum = [])).push(teardown)
}
}
: hydrate
if (resolvedComp) {
doHydrate()
} else {
load().then(() => !instance.isUnmounted && doHydrate())
}
},
get __asyncResolved() {
return resolvedComp
},

View File

@ -35,7 +35,7 @@ import { useSSRContext } from './helpers/useSsrContext'
export type WatchEffect = (onCleanup: OnCleanup) => void
export type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)
export type WatchSource<T = any> = Ref<T, any> | ComputedRef<T> | (() => T)
export type WatchCallback<V = any, OV = any> = (
value: V,

View File

@ -200,6 +200,15 @@ export interface ComponentOptionsBase<
* @internal
*/
__asyncResolved?: ConcreteComponent
/**
* Exposed for lazy hydration
* @internal
*/
__asyncHydrate?: (
el: Element,
instance: ComponentInternalInstance,
hydrate: () => void,
) => void
// Type differentiators ------------------------------------------------------

View File

@ -176,12 +176,10 @@ export type ExtractDefaultPropTypes<O> = O extends object
{ [K in keyof Pick<O, DefaultKeys<O>>]: InferPropType<O[K]> }
: {}
type NormalizedProp =
| null
| (PropOptions & {
[BooleanFlags.shouldCast]?: boolean
[BooleanFlags.shouldCastTrue]?: boolean
})
type NormalizedProp = PropOptions & {
[BooleanFlags.shouldCast]?: boolean
[BooleanFlags.shouldCastTrue]?: boolean
}
// normalized value is a tuple of the actual normalized options
// and an array of prop keys that need value casting (booleans and defaults)
@ -566,16 +564,36 @@ export function normalizePropsOptions(
const opt = raw[key]
const prop: NormalizedProp = (normalized[normalizedKey] =
isArray(opt) || isFunction(opt) ? { type: opt } : extend({}, opt))
if (prop) {
const booleanIndex = getTypeIndex(Boolean, prop.type)
const stringIndex = getTypeIndex(String, prop.type)
prop[BooleanFlags.shouldCast] = booleanIndex > -1
prop[BooleanFlags.shouldCastTrue] =
stringIndex < 0 || booleanIndex < stringIndex
// if the prop needs boolean casting or default value
if (booleanIndex > -1 || hasOwn(prop, 'default')) {
needCastKeys.push(normalizedKey)
const propType = prop.type
let shouldCast = false
let shouldCastTrue = true
if (isArray(propType)) {
for (let index = 0; index < propType.length; ++index) {
const type = propType[index]
const typeName = isFunction(type) && type.name
if (typeName === 'Boolean') {
shouldCast = true
break
} else if (typeName === 'String') {
// If we find `String` before `Boolean`, e.g. `[String, Boolean]`,
// we need to handle the casting slightly differently. Props
// passed as `<Comp checked="">` or `<Comp checked="checked">`
// will either be treated as strings or converted to a boolean
// `true`, depending on the order of the types.
shouldCastTrue = false
}
}
} else {
shouldCast = isFunction(propType) && propType.name === 'Boolean'
}
prop[BooleanFlags.shouldCast] = shouldCast
prop[BooleanFlags.shouldCastTrue] = shouldCastTrue
// if the prop needs boolean casting or default value
if (shouldCast || hasOwn(prop, 'default')) {
needCastKeys.push(normalizedKey)
}
}
}
@ -597,6 +615,7 @@ function validatePropName(key: string) {
return false
}
// dev only
// use function string name to check type constructors
// so that it works across vms / iframes.
function getType(ctor: Prop<any> | null): string {
@ -619,22 +638,6 @@ function getType(ctor: Prop<any> | null): string {
return ''
}
function isSameType(a: Prop<any> | null, b: Prop<any> | null): boolean {
return getType(a) === getType(b)
}
function getTypeIndex(
type: Prop<any>,
expectedTypes: PropType<any> | void | null | true,
): number {
if (isArray(expectedTypes)) {
return expectedTypes.findIndex(t => isSameType(t, type))
} else if (isFunction(expectedTypes)) {
return isSameType(expectedTypes, type) ? 0 : -1
}
return -1
}
/**
* dev only
*/

View File

@ -112,13 +112,8 @@ export const TeleportImpl = {
const mainAnchor = (n2.anchor = __DEV__
? createComment('teleport end')
: createText(''))
const targetStart = (n2.targetStart = createText(''))
const targetAnchor = (n2.targetAnchor = createText(''))
insert(placeholder, container, anchor)
insert(mainAnchor, container, anchor)
// attach a special property so we can skip teleported content in
// renderer's nextSibling search
targetStart[TeleportEndKey] = targetAnchor
const mount = (container: RendererElement, anchor: RendererNode) => {
// Teleport *always* has Array children. This is enforced in both the
@ -139,9 +134,8 @@ export const TeleportImpl = {
const mountToTarget = () => {
const target = (n2.target = resolveTarget(n2.props, querySelector))
const targetAnchor = prepareAnchor(target, n2, createText, insert)
if (target) {
insert(targetStart, target)
insert(targetAnchor, target)
// #2652 we could be teleporting from a non-SVG tree into an SVG tree
if (namespace !== 'svg' && isTargetSVG(target)) {
namespace = 'svg'
@ -375,7 +369,7 @@ function hydrateTeleport(
slotScopeIds: string[] | null,
optimized: boolean,
{
o: { nextSibling, parentNode, querySelector },
o: { nextSibling, parentNode, querySelector, insert, createText },
}: RendererInternals<Node, Element>,
hydrateChildren: (
node: Node | null,
@ -407,7 +401,8 @@ function hydrateTeleport(
slotScopeIds,
optimized,
)
vnode.targetAnchor = targetNode
vnode.targetStart = targetNode
vnode.targetAnchor = targetNode && nextSibling(targetNode)
} else {
vnode.anchor = nextSibling(node)
@ -416,21 +411,29 @@ function hydrateTeleport(
// could be nested teleports
let targetAnchor = targetNode
while (targetAnchor) {
targetAnchor = nextSibling(targetAnchor)
if (
targetAnchor &&
targetAnchor.nodeType === 8 &&
(targetAnchor as Comment).data === 'teleport anchor'
) {
vnode.targetAnchor = targetAnchor
;(target as TeleportTargetElement)._lpa =
vnode.targetAnchor && nextSibling(vnode.targetAnchor as Node)
break
if (targetAnchor && targetAnchor.nodeType === 8) {
if ((targetAnchor as Comment).data === 'teleport start anchor') {
vnode.targetStart = targetAnchor
} else if ((targetAnchor as Comment).data === 'teleport anchor') {
vnode.targetAnchor = targetAnchor
;(target as TeleportTargetElement)._lpa =
vnode.targetAnchor && nextSibling(vnode.targetAnchor as Node)
break
}
}
targetAnchor = nextSibling(targetAnchor)
}
// #11400 if the HTML corresponding to Teleport is not embedded in the
// correct position on the final page during SSR. the targetAnchor will
// always be null, we need to manually add targetAnchor to ensure
// Teleport it can properly unmount or move
if (!vnode.targetAnchor) {
prepareAnchor(target, vnode, createText, insert)
}
hydrateChildren(
targetNode,
targetNode && nextSibling(targetNode),
vnode,
target,
parentComponent,
@ -469,3 +472,24 @@ function updateCssVars(vnode: VNode) {
ctx.ut()
}
}
function prepareAnchor(
target: RendererElement | null,
vnode: TeleportVNode,
createText: RendererOptions['createText'],
insert: RendererOptions['insert'],
) {
const targetStart = (vnode.targetStart = createText(''))
const targetAnchor = (vnode.targetAnchor = createText(''))
// attach a special property, so we can skip teleported content in
// renderer's nextSibling search
targetStart[TeleportEndKey] = targetAnchor
if (target) {
insert(targetStart, target)
insert(targetAnchor, target)
}
return targetAnchor
}

View File

@ -60,9 +60,9 @@ export function renderList(
let ret: VNodeChild[]
const cached = (cache && cache[index!]) as VNode[] | undefined
const sourceIsArray = isArray(source)
const sourceIsReactiveArray = sourceIsArray && isReactive(source)
if (sourceIsArray || isString(source)) {
const sourceIsReactiveArray = sourceIsArray && isReactive(source)
if (sourceIsReactiveArray) {
source = shallowReadArray(source)
}

View File

@ -46,7 +46,7 @@ export type RootHydrateFunction = (
container: (Element | ShadowRoot) & { _vnode?: VNode },
) => void
enum DOMNodeTypes {
export enum DOMNodeTypes {
ELEMENT = 1,
TEXT = 3,
COMMENT = 8,
@ -75,7 +75,7 @@ const getContainerType = (container: Element): 'svg' | 'mathml' | undefined => {
return undefined
}
const isComment = (node: Node): node is Comment =>
export const isComment = (node: Node): node is Comment =>
node.nodeType === DOMNodeTypes.COMMENT
// Note: hydration is DOM-specific
@ -405,18 +405,20 @@ export function createHydrationFunctions(
)
let hasWarned = false
while (next) {
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
!hasWarned
) {
warn(
`Hydration children mismatch on`,
el,
`\nServer rendered element contains more child nodes than client vdom.`,
)
hasWarned = true
if (!isMismatchAllowed(el, MismatchTypes.CHILDREN)) {
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
!hasWarned
) {
warn(
`Hydration children mismatch on`,
el,
`\nServer rendered element contains more child nodes than client vdom.`,
)
hasWarned = true
}
logMismatchError()
}
logMismatchError()
// The SSRed DOM contains more nodes than it should. Remove them.
const cur = next
@ -425,14 +427,16 @@ export function createHydrationFunctions(
}
} else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (el.textContent !== vnode.children) {
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Hydration text content mismatch on`,
el,
`\n - rendered on server: ${el.textContent}` +
`\n - expected on client: ${vnode.children as string}`,
)
logMismatchError()
if (!isMismatchAllowed(el, MismatchTypes.TEXT)) {
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Hydration text content mismatch on`,
el,
`\n - rendered on server: ${el.textContent}` +
`\n - expected on client: ${vnode.children as string}`,
)
logMismatchError()
}
el.textContent = vnode.children as string
}
@ -562,18 +566,20 @@ export function createHydrationFunctions(
// because server rendered HTML won't contain a text node
insert((vnode.el = createText('')), container)
} else {
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
!hasWarned
) {
warn(
`Hydration children mismatch on`,
container,
`\nServer rendered element contains fewer child nodes than client vdom.`,
)
hasWarned = true
if (!isMismatchAllowed(container, MismatchTypes.CHILDREN)) {
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
!hasWarned
) {
warn(
`Hydration children mismatch on`,
container,
`\nServer rendered element contains fewer child nodes than client vdom.`,
)
hasWarned = true
}
logMismatchError()
}
logMismatchError()
// the SSRed DOM didn't contain enough nodes. Mount the missing ones.
patch(
@ -637,19 +643,21 @@ export function createHydrationFunctions(
slotScopeIds: string[] | null,
isFragment: boolean,
): Node | null => {
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Hydration node mismatch:\n- rendered on server:`,
node,
node.nodeType === DOMNodeTypes.TEXT
? `(text)`
: isComment(node) && node.data === '['
? `(start of fragment)`
: ``,
`\n- expected on client:`,
vnode.type,
)
logMismatchError()
if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Hydration node mismatch:\n- rendered on server:`,
node,
node.nodeType === DOMNodeTypes.TEXT
? `(text)`
: isComment(node) && node.data === '['
? `(start of fragment)`
: ``,
`\n- expected on client:`,
vnode.type,
)
logMismatchError()
}
vnode.el = null
@ -747,7 +755,7 @@ function propHasMismatch(
vnode: VNode,
instance: ComponentInternalInstance | null,
): boolean {
let mismatchType: string | undefined
let mismatchType: MismatchTypes | undefined
let mismatchKey: string | undefined
let actual: string | boolean | null | undefined
let expected: string | boolean | null | undefined
@ -757,7 +765,8 @@ function propHasMismatch(
actual = el.getAttribute('class')
expected = normalizeClass(clientValue)
if (!isSetEqual(toClassSet(actual || ''), toClassSet(expected))) {
mismatchType = mismatchKey = `class`
mismatchType = MismatchTypes.CLASS
mismatchKey = `class`
}
} else if (key === 'style') {
// style might be in different order, but that doesn't affect cascade
@ -782,7 +791,8 @@ function propHasMismatch(
}
if (!isMapEqual(actualMap, expectedMap)) {
mismatchType = mismatchKey = 'style'
mismatchType = MismatchTypes.STYLE
mismatchKey = 'style'
}
} else if (
(el instanceof SVGElement && isKnownSvgAttr(key)) ||
@ -808,15 +818,15 @@ function propHasMismatch(
: false
}
if (actual !== expected) {
mismatchType = `attribute`
mismatchType = MismatchTypes.ATTRIBUTE
mismatchKey = key
}
}
if (mismatchType) {
if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) {
const format = (v: any) =>
v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
const preSegment = `Hydration ${mismatchType} mismatch on`
const preSegment = `Hydration ${MismatchTypeString[mismatchType]} mismatch on`
const postSegment =
`\n - rendered on server: ${format(actual)}` +
`\n - expected on client: ${format(expected)}` +
@ -898,3 +908,48 @@ function resolveCssVars(
resolveCssVars(instance.parent, instance.vnode, expectedMap)
}
}
const allowMismatchAttr = 'data-allow-mismatch'
enum MismatchTypes {
TEXT = 0,
CHILDREN = 1,
CLASS = 2,
STYLE = 3,
ATTRIBUTE = 4,
}
const MismatchTypeString: Record<MismatchTypes, string> = {
[MismatchTypes.TEXT]: 'text',
[MismatchTypes.CHILDREN]: 'children',
[MismatchTypes.CLASS]: 'class',
[MismatchTypes.STYLE]: 'style',
[MismatchTypes.ATTRIBUTE]: 'attribute',
} as const
function isMismatchAllowed(
el: Element | null,
allowedType: MismatchTypes,
): boolean {
if (
allowedType === MismatchTypes.TEXT ||
allowedType === MismatchTypes.CHILDREN
) {
while (el && !el.hasAttribute(allowMismatchAttr)) {
el = el.parentElement
}
}
const allowedAttr = el && el.getAttribute(allowMismatchAttr)
if (allowedAttr == null) {
return false
} else if (allowedAttr === '') {
return true
} else {
const list = allowedAttr.split(',')
// text is a subset of children
if (allowedType === MismatchTypes.TEXT && list.includes('children')) {
return true
}
return allowedAttr.split(',').includes(MismatchTypeString[allowedType])
}
}

View File

@ -0,0 +1,111 @@
import { isString } from '@vue/shared'
import { DOMNodeTypes, isComment } from './hydration'
/**
* A lazy hydration strategy for async components.
* @param hydrate - call this to perform the actual hydration.
* @param forEachElement - iterate through the root elements of the component's
* non-hydrated DOM, accounting for possible fragments.
* @returns a teardown function to be called if the async component is unmounted
* before it is hydrated. This can be used to e.g. remove DOM event
* listeners.
*/
export type HydrationStrategy = (
hydrate: () => void,
forEachElement: (cb: (el: Element) => any) => void,
) => (() => void) | void
export type HydrationStrategyFactory<Options = any> = (
options?: Options,
) => HydrationStrategy
export const hydrateOnIdle: HydrationStrategyFactory = () => hydrate => {
const id = requestIdleCallback(hydrate)
return () => cancelIdleCallback(id)
}
export const hydrateOnVisible: HydrationStrategyFactory<string | number> =
(margin = 0) =>
(hydrate, forEach) => {
const ob = new IntersectionObserver(
entries => {
for (const e of entries) {
if (!e.isIntersecting) continue
ob.disconnect()
hydrate()
break
}
},
{
rootMargin: isString(margin) ? margin : margin + 'px',
},
)
forEach(el => ob.observe(el))
return () => ob.disconnect()
}
export const hydrateOnMediaQuery: HydrationStrategyFactory<string> =
query => hydrate => {
if (query) {
const mql = matchMedia(query)
if (mql.matches) {
hydrate()
} else {
mql.addEventListener('change', hydrate, { once: true })
return () => mql.removeEventListener('change', hydrate)
}
}
}
export const hydrateOnInteraction: HydrationStrategyFactory<
string | string[]
> =
(interactions = []) =>
(hydrate, forEach) => {
if (isString(interactions)) interactions = [interactions]
let hasHydrated = false
const doHydrate = (e: Event) => {
if (!hasHydrated) {
hasHydrated = true
teardown()
hydrate()
// replay event
e.target!.dispatchEvent(new (e.constructor as any)(e.type, e))
}
}
const teardown = () => {
forEach(el => {
for (const i of interactions) {
el.removeEventListener(i, doHydrate)
}
})
}
forEach(el => {
for (const i of interactions) {
el.addEventListener(i, doHydrate, { once: true })
}
})
return teardown
}
export function forEachElement(node: Node, cb: (el: Element) => void) {
// fragment
if (isComment(node) && node.data === '[') {
let depth = 1
let next = node.nextSibling
while (next) {
if (next.nodeType === DOMNodeTypes.ELEMENT) {
cb(next as Element)
} else if (isComment(next)) {
if (next.data === ']') {
if (--depth === 0) break
} else if (next.data === '[') {
depth++
}
}
next = next.nextSibling
}
} else {
cb(node as Element)
}
}

View File

@ -65,6 +65,12 @@ export { useAttrs, useSlots } from './apiSetupHelpers'
export { useModel } from './helpers/useModel'
export { useTemplateRef } from './helpers/useTemplateRef'
export { useId } from './helpers/useId'
export {
hydrateOnIdle,
hydrateOnVisible,
hydrateOnMediaQuery,
hydrateOnInteraction,
} from './hydrationStrategies'
// <script setup> API ----------------------------------------------------------
@ -329,6 +335,10 @@ export type {
AsyncComponentOptions,
AsyncComponentLoader,
} from './apiAsyncComponent'
export type {
HydrationStrategy,
HydrationStrategyFactory,
} from './hydrationStrategies'
export type { HMRRuntime } from './hmr'
// Internal API ----------------------------------------------------------------

View File

@ -1338,16 +1338,11 @@ function baseCreateRenderer(
}
}
if (
isAsyncWrapperVNode &&
!(type as ComponentOptions).__asyncResolved
) {
;(type as ComponentOptions).__asyncLoader!().then(
// note: we are moving the render call into an async callback,
// which means it won't track dependencies - but it's ok because
// a server-rendered async wrapper is already in resolved state
// and it will never need to change.
() => !instance.isUnmounted && hydrateSubTree(),
if (isAsyncWrapperVNode) {
;(type as ComponentOptions).__asyncHydrate!(
el as Element,
instance,
hydrateSubTree,
)
} else {
hydrateSubTree()

View File

@ -1,6 +1,6 @@
{
"name": "@vue/runtime-dom",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "@vue/runtime-dom",
"main": "index.js",
"module": "dist/runtime-dom.esm-bundler.js",

View File

@ -28,7 +28,7 @@ describe('ssrRenderTeleport', () => {
)
expect(html).toBe('<!--teleport start--><!--teleport end-->')
expect(ctx.teleports!['#target']).toBe(
`<div>content</div><!--teleport anchor-->`,
`<!--teleport start anchor--><div>content</div><!--teleport anchor-->`,
)
})
@ -56,7 +56,9 @@ describe('ssrRenderTeleport', () => {
expect(html).toBe(
'<!--teleport start--><div>content</div><!--teleport end-->',
)
expect(ctx.teleports!['#target']).toBe(`<!--teleport anchor-->`)
expect(ctx.teleports!['#target']).toBe(
`<!--teleport start anchor--><!--teleport anchor-->`,
)
})
test('teleport rendering (vnode)', async () => {
@ -73,7 +75,7 @@ describe('ssrRenderTeleport', () => {
)
expect(html).toBe('<!--teleport start--><!--teleport end-->')
expect(ctx.teleports!['#target']).toBe(
'<span>hello</span><!--teleport anchor-->',
'<!--teleport start anchor--><span>hello</span><!--teleport anchor-->',
)
})
@ -93,7 +95,9 @@ describe('ssrRenderTeleport', () => {
expect(html).toBe(
'<!--teleport start--><span>hello</span><!--teleport end-->',
)
expect(ctx.teleports!['#target']).toBe(`<!--teleport anchor-->`)
expect(ctx.teleports!['#target']).toBe(
`<!--teleport start anchor--><!--teleport anchor-->`,
)
})
test('multiple teleports with same target', async () => {
@ -115,7 +119,8 @@ describe('ssrRenderTeleport', () => {
'<div><!--teleport start--><!--teleport end--><!--teleport start--><!--teleport end--></div>',
)
expect(ctx.teleports!['#target']).toBe(
'<span>hello</span><!--teleport anchor-->world<!--teleport anchor-->',
'<!--teleport start anchor--><span>hello</span><!--teleport anchor-->' +
'<!--teleport start anchor-->world<!--teleport anchor-->',
)
})
@ -134,7 +139,7 @@ describe('ssrRenderTeleport', () => {
)
expect(html).toBe('<!--teleport start--><!--teleport end-->')
expect(ctx.teleports!['#target']).toBe(
`<div>content</div><!--teleport anchor-->`,
`<!--teleport start anchor--><div>content</div><!--teleport anchor-->`,
)
})
@ -169,7 +174,7 @@ describe('ssrRenderTeleport', () => {
await p
expect(html).toBe('<!--teleport start--><!--teleport end-->')
expect(ctx.teleports!['#target']).toBe(
`<div>content</div><!--teleport anchor-->`,
`<!--teleport start anchor--><div>content</div><!--teleport anchor-->`,
)
})
})

View File

@ -1,6 +1,6 @@
{
"name": "@vue/server-renderer",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "@vue/server-renderer",
"main": "index.js",
"module": "dist/server-renderer.esm-bundler.js",

View File

@ -29,9 +29,10 @@ export function ssrRenderTeleport(
if (disabled) {
contentRenderFn(parentPush)
teleportContent = `<!--teleport anchor-->`
teleportContent = `<!--teleport start anchor--><!--teleport anchor-->`
} else {
const { getBuffer, push } = createBuffer()
push(`<!--teleport start anchor-->`)
contentRenderFn(push)
push(`<!--teleport anchor-->`)
teleportContent = getBuffer()

View File

@ -9,7 +9,7 @@
"serve": "vite preview"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
"@vitejs/plugin-vue": "^5.1.1",
"vite": "catalog:"
},
"dependencies": {

View File

@ -82,7 +82,7 @@ function toggleDark() {
pkg="vue"
label="Vue Version"
>
<li>
<li :class="{ active: vueVersion === `@${currentCommit}` }">
<a @click="resetVueVersion">This Commit ({{ currentCommit }})</a>
</li>
<li>

View File

@ -74,7 +74,12 @@ onMounted(() => {
<ul class="versions" :class="{ expanded }">
<li v-if="!versions"><a>loading versions...</a></li>
<li v-for="ver of versions" :class="{ active: ver === version }">
<li
v-for="(ver, index) of versions"
:class="{
active: ver === version || (version === 'latest' && index === 0),
}"
>
<a @click="setVersion(ver)">v{{ ver }}</a>
</li>
<div @click="expanded = false">

View File

@ -11,7 +11,7 @@
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
"vite": "^5.3.3"
"@vitejs/plugin-vue": "^5.1.1",
"vite": "^5.3.5"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@vue/shared",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "internal utils shared across @vue packages",
"main": "index.js",
"module": "dist/shared.esm-bundler.js",

View File

@ -1,6 +1,6 @@
{
"name": "@vue/compat",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "Vue 3 compatibility build for Vue 2",
"main": "index.js",
"module": "dist/vue.runtime.esm-bundler.js",

View File

@ -8,7 +8,7 @@ describe('e2e: Transition', () => {
const baseUrl = `file://${path.resolve(__dirname, './transition.html')}`
const duration = process.env.CI ? 200 : 50
const buffer = process.env.CI ? 20 : 5
const buffer = 20
const transitionFinish = (time = duration) => timeout(time + buffer)
@ -29,8 +29,6 @@ describe('e2e: Transition', () => {
test(
'basic transition',
async () => {
await page().goto(baseUrl)
await page().waitForSelector('#app')
await page().evaluate(() => {
const { createApp, ref } = (window as any).Vue
createApp({
@ -1296,8 +1294,6 @@ describe('e2e: Transition', () => {
test(
'wrapping transition + fallthrough attrs',
async () => {
await page().goto(baseUrl)
await page().waitForSelector('#app')
await page().evaluate(() => {
const { createApp, ref } = (window as any).Vue
createApp({

View File

@ -30,12 +30,19 @@ export async function expectByPolling(
}
}
export function setupPuppeteer() {
export function setupPuppeteer(args?: string[]) {
let browser: Browser
let page: Page
const resolvedOptions = args
? {
...puppeteerOptions,
args: [...puppeteerOptions.args!, ...args],
}
: puppeteerOptions
beforeAll(async () => {
browser = await puppeteer.launch(puppeteerOptions)
browser = await puppeteer.launch(resolvedOptions)
}, 20000)
beforeEach(async () => {

View File

@ -0,0 +1,44 @@
<script src="../../dist/vue.global.js"></script>
<div><span id="custom-trigger">click here to hydrate</span></div>
<div id="app"><button>0</button></div>
<script>
window.isHydrated = false
const { createSSRApp, defineAsyncComponent, h, ref, onMounted } = Vue
const Comp = {
setup() {
const count = ref(0)
onMounted(() => {
console.log('hydrated')
window.isHydrated = true
})
return () => {
return h('button', { onClick: () => count.value++ }, count.value)
}
},
}
const AsyncComp = defineAsyncComponent({
loader: () => Promise.resolve(Comp),
hydrate: (hydrate, el) => {
const triggerEl = document.getElementById('custom-trigger')
triggerEl.addEventListener('click', hydrate, { once: true })
return () => {
window.teardownCalled = true
triggerEl.removeEventListener('click', hydrate)
}
}
})
const show = window.show = ref(true)
createSSRApp({
setup() {
onMounted(() => {
window.isRootMounted = true
})
return () => show.value ? h(AsyncComp) : 'off'
}
}).mount('#app')
</script>

View File

@ -0,0 +1,36 @@
<script src="../../dist/vue.global.js"></script>
<div id="app"><button>0</button></div>
<script>
window.isHydrated = false
const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnIdle } = Vue
const Comp = {
setup() {
const count = ref(0)
onMounted(() => {
console.log('hydrated')
window.isHydrated = true
})
return () => h('button', { onClick: () => count.value++ }, count.value)
},
}
const AsyncComp = defineAsyncComponent({
loader: () => new Promise(resolve => {
setTimeout(() => {
console.log('resolve')
resolve(Comp)
requestIdleCallback(() => {
console.log('busy')
})
}, 10)
}),
hydrate: hydrateOnIdle()
})
createSSRApp({
render: () => h(AsyncComp)
}).mount('#app')
</script>

View File

@ -0,0 +1,48 @@
<script src="../../dist/vue.global.js"></script>
<div>click to hydrate</div>
<div id="app"><button>0</button></div>
<style>body { margin: 0 }</style>
<script>
const isFragment = location.search.includes('?fragment')
if (isFragment) {
document.getElementById('app').innerHTML =
`<!--[--><!--[--><span>one</span><!--]--><button>0</button><span>two</span><!--]-->`
}
window.isHydrated = false
const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnInteraction } = Vue
const Comp = {
setup() {
const count = ref(0)
onMounted(() => {
console.log('hydrated')
window.isHydrated = true
})
return () => {
const button = h('button', { onClick: () => count.value++ }, count.value)
if (isFragment) {
return [[h('span', 'one')], button, h('span', 'two')]
} else {
return button
}
}
},
}
const AsyncComp = defineAsyncComponent({
loader: () => Promise.resolve(Comp),
hydrate: hydrateOnInteraction(['click', 'wheel'])
})
createSSRApp({
setup() {
onMounted(() => {
window.isRootMounted = true
})
return () => h(AsyncComp)
}
}).mount('#app')
</script>

View File

@ -0,0 +1,36 @@
<script src="../../dist/vue.global.js"></script>
<div>resize the window width to < 500px to hydrate</div>
<div id="app"><button>0</button></div>
<script>
window.isHydrated = false
const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnMediaQuery } = Vue
const Comp = {
setup() {
const count = ref(0)
onMounted(() => {
console.log('hydrated')
window.isHydrated = true
})
return () => {
return h('button', { onClick: () => count.value++ }, count.value)
}
},
}
const AsyncComp = defineAsyncComponent({
loader: () => Promise.resolve(Comp),
hydrate: hydrateOnMediaQuery('(max-width:500px)')
})
createSSRApp({
setup() {
onMounted(() => {
window.isRootMounted = true
})
return () => h(AsyncComp)
}
}).mount('#app')
</script>

View File

@ -0,0 +1,49 @@
<script src="../../dist/vue.global.js"></script>
<div style="height: 1000px">scroll to the bottom to hydrate</div>
<div id="app"><button>0</button></div>
<style>body { margin: 0 }</style>
<script>
const rootMargin = location.search.match(/rootMargin=(\d+)/)?.[1] ?? 0
const isFragment = location.search.includes('?fragment')
if (isFragment) {
document.getElementById('app').innerHTML =
`<!--[--><!--[--><span>one</span><!--]--><button>0</button><span>two</span><!--]-->`
}
window.isHydrated = false
const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnVisible } = Vue
const Comp = {
setup() {
const count = ref(0)
onMounted(() => {
console.log('hydrated')
window.isHydrated = true
})
return () => {
const button = h('button', { onClick: () => count.value++ }, count.value)
if (isFragment) {
return [[h('span', 'one')], button, h('span', 'two')]
} else {
return button
}
}
},
}
const AsyncComp = defineAsyncComponent({
loader: () => Promise.resolve(Comp),
hydrate: hydrateOnVisible(rootMargin + 'px')
})
createSSRApp({
setup() {
onMounted(() => {
window.isRootMounted = true
})
return () => h(AsyncComp)
}
}).mount('#app')
</script>

View File

@ -0,0 +1,118 @@
import path from 'node:path'
import { setupPuppeteer } from './e2eUtils'
import type { Ref } from '../../src/runtime'
declare const window: Window & {
isHydrated: boolean
isRootMounted: boolean
teardownCalled?: boolean
show: Ref<boolean>
}
describe('async component hydration strategies', () => {
const { page, click, text, count } = setupPuppeteer(['--window-size=800,600'])
async function goToCase(name: string, query = '') {
const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}.html${query}`)}`
await page().goto(file)
}
async function assertHydrationSuccess(n = '1') {
await click('button')
expect(await text('button')).toBe(n)
}
test('idle', async () => {
const messages: string[] = []
page().on('console', e => messages.push(e.text()))
await goToCase('idle')
// not hydrated yet
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
// wait for hydration
await page().waitForFunction(() => window.isHydrated)
// assert message order: hyration should happen after already queued main thread work
expect(messages.slice(1)).toMatchObject(['resolve', 'busy', 'hydrated'])
await assertHydrationSuccess()
})
test('visible', async () => {
await goToCase('visible')
await page().waitForFunction(() => window.isRootMounted)
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
// scroll down
await page().evaluate(() => window.scrollTo({ top: 1000 }))
await page().waitForFunction(() => window.isHydrated)
await assertHydrationSuccess()
})
test('visible (with rootMargin)', async () => {
await goToCase('visible', '?rootMargin=1000')
await page().waitForFunction(() => window.isRootMounted)
// should hydrate without needing to scroll
await page().waitForFunction(() => window.isHydrated)
await assertHydrationSuccess()
})
test('visible (fragment)', async () => {
await goToCase('visible', '?fragment')
await page().waitForFunction(() => window.isRootMounted)
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
expect(await count('span')).toBe(2)
// scroll down
await page().evaluate(() => window.scrollTo({ top: 1000 }))
await page().waitForFunction(() => window.isHydrated)
await assertHydrationSuccess()
})
test('media query', async () => {
await goToCase('media')
await page().waitForFunction(() => window.isRootMounted)
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
// resize
await page().setViewport({ width: 400, height: 600 })
await page().waitForFunction(() => window.isHydrated)
await assertHydrationSuccess()
})
test('interaction', async () => {
await goToCase('interaction')
await page().waitForFunction(() => window.isRootMounted)
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
await click('button')
await page().waitForFunction(() => window.isHydrated)
// should replay event
expect(await text('button')).toBe('1')
await assertHydrationSuccess('2')
})
test('interaction (fragment)', async () => {
await goToCase('interaction', '?fragment')
await page().waitForFunction(() => window.isRootMounted)
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
await click('button')
await page().waitForFunction(() => window.isHydrated)
// should replay event
expect(await text('button')).toBe('1')
await assertHydrationSuccess('2')
})
test('custom', async () => {
await goToCase('custom')
await page().waitForFunction(() => window.isRootMounted)
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
await click('#custom-trigger')
await page().waitForFunction(() => window.isHydrated)
await assertHydrationSuccess()
})
test('custom teardown', async () => {
await goToCase('custom')
await page().waitForFunction(() => window.isRootMounted)
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
await page().evaluate(() => (window.show.value = false))
expect(await text('#app')).toBe('off')
expect(await page().evaluate(() => window.isHydrated)).toBe(false)
expect(await page().evaluate(() => window.teardownCalled)).toBe(true)
})
})

View File

@ -1,6 +1,6 @@
{
"name": "vue",
"version": "3.5.0-alpha.4",
"version": "3.5.0-alpha.5",
"description": "The progressive JavaScript framework for building modern web UI.",
"main": "index.js",
"module": "dist/vue.runtime.esm-bundler.js",

File diff suppressed because it is too large Load Diff