From 612cde76ce0c20fd4d1de9aa36949a0c9315516e Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 25 Apr 2025 09:43:15 +0800 Subject: [PATCH] wip: test hydrate v-if in PROD --- .../runtime-vapor/__tests__/hydration.spec.ts | 455 +++++++++++------- packages/runtime-vapor/src/block.ts | 7 +- packages/runtime-vapor/src/dom/node.ts | 61 ++- .../__tests__/ssrAttrFallthrough.spec.ts | 2 +- .../server-renderer/__tests__/ssrSlot.spec.ts | 6 +- 5 files changed, 338 insertions(+), 193 deletions(-) diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index f67a4c420..f1fd4760d 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -78,6 +78,15 @@ const triggerEvent = (type: string, el: Element) => { el.dispatchEvent(event) } +async function runWithEnv(isProd: boolean, fn: () => Promise) { + if (isProd) __DEV__ = false + try { + await fn() + } finally { + if (isProd) __DEV__ = true + } +} + describe('Vapor Mode hydration', () => { delegateEvents('click') @@ -639,188 +648,306 @@ describe('Vapor Mode hydration', () => { }) describe('if', () => { - test('basic toggle - true -> false', async () => { - const data = ref(true) - const { container } = await testHydration( - ``, - undefined, - data, - ) - expect(container.innerHTML).toMatchInlineSnapshot( - `"
foo
"`, - ) - - data.value = false - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot(`""`) + describe('DEV mode', () => { + runTests() }) - test('basic toggle - false -> true', async () => { - const data = ref(false) - const { container } = await testHydration( - ``, - undefined, - data, - ) - expect(container.innerHTML).toMatchInlineSnapshot(`""`) - - data.value = true - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot( - `"
foo
"`, - ) + describe('PROD mode', () => { + runTests(true) }) - test('v-if/else-if/else chain - switch branches', async () => { - const data = ref('a') - const { container } = await testHydration( - ``, - undefined, - data, - ) - expect(container.innerHTML).toMatchInlineSnapshot( - `"
foo
"`, - ) + function runTests(isProd: boolean = false) { + const anchorLabel = isProd ? '$' : 'if' - data.value = 'b' - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot( - `"
bar
"`, - ) + test('basic toggle - true -> false', async () => { + runWithEnv(isProd, async () => { + const data = ref(true) + const { container } = await testHydration( + ``, + undefined, + data, + ) + expect(container.innerHTML).toBe( + `
foo
`, + ) - data.value = 'c' - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot( - `"
baz
"`, - ) - }) + data.value = false + await nextTick() + expect(container.innerHTML).toBe(``) + }) + }) - test('nested if', async () => { - const data = reactive({ outer: true, inner: true }) - const { container } = await testHydration( - ``, - undefined, - data, - ) - expect(container.innerHTML).toMatchInlineSnapshot( - `"
outer
inner
"`, - ) + test('basic toggle - false -> true', async () => { + runWithEnv(isProd, async () => { + // v-if="false" is rendered as in the server-rendered HTML + // it reused as anchor, so the anchor label is empty in PROD + let anchorLabel = isProd ? '' : 'if' + if (isProd) __DEV__ = false + const data = ref(false) + const { container } = await testHydration( + ``, + undefined, + data, + ) + expect(container.innerHTML).toBe(``) - data.inner = false - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot( - `"
outer
"`, - ) + data.value = true + await nextTick() + expect(container.innerHTML).toBe( + `
foo
`, + ) + }) + }) - data.outer = false - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot(`""`) - }) + test('v-if/else-if/else chain - switch branches', async () => { + runWithEnv(isProd, async () => { + const data = ref('a') + const { container } = await testHydration( + ``, + undefined, + data, + ) + expect(container.innerHTML).toBe( + `
foo
`, + ) - test('on component', async () => { - const data = ref(true) - const { container } = await testHydration( - ``, - { Child: `` }, - data, - ) - expect(container.innerHTML).toMatchInlineSnapshot(`"foo"`) + data.value = 'b' + await nextTick() + // In PROD, the anchor of v-else-if (DynamicFragment) is an empty text node, + // so it won't be rendered + expect(container.innerHTML).toBe( + `
bar
${isProd ? '' : ``}`, + ) - data.value = false - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot(`""`) - }) + data.value = 'c' + await nextTick() + // same as above + expect(container.innerHTML).toBe( + `
baz
${isProd ? '' : ``}`, + ) - test('on component with anchor insertion', async () => { - const data = ref(true) - const { container } = await testHydration( - ``, - { Child: `` }, - data, - ) - expect(container.innerHTML).toMatchInlineSnapshot( - `"
foo
"`, - ) + data.value = 'a' + await nextTick() + expect(container.innerHTML).toBe( + `
foo
`, + ) + }) + }) - data.value = false - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot( - `"
"`, - ) - }) + test('nested if', async () => { + runWithEnv(isProd, async () => { + const data = reactive({ outer: true, inner: true }) + const { container } = await testHydration( + ``, + undefined, + data, + ) + expect(container.innerHTML).toBe( + `
` + + `outer` + + `
inner
` + + `
`, + ) - test('consecutive v-if on component with anchor insertion', async () => { - const data = ref(true) - const { container } = await testHydration( - ``, - { Child: `` }, - data, - ) - expect(container.innerHTML).toMatchInlineSnapshot( - `"
foofoo
"`, - ) + data.inner = false + await nextTick() + expect(container.innerHTML).toBe( + `
` + + `outer` + + `` + + `
`, + ) - data.value = false - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot( - `"
"`, - ) - }) + data.outer = false + await nextTick() + expect(container.innerHTML).toBe(``) + }) + }) - test('consecutive v-if on fragment component with anchor insertion', async () => { - const data = ref(true) - const { container } = await testHydration( - ``, + { Child: `` }, + data, + ) + expect(container.innerHTML).toBe(`foo`) - data.value = false - await nextTick() - expect(container.innerHTML).toMatchInlineSnapshot( - `"
"`, - ) - }) + data.value = false + await nextTick() + expect(container.innerHTML).toBe(``) + }) + }) + + test('v-if/else-if/else chain on component - switch branches', async () => { + runWithEnv(isProd, async () => { + const data = ref('a') + const { container } = await testHydration( + ``, + { + Child1: ``, + Child2: ``, + Child3: ``, + }, + data, + ) + expect(container.innerHTML).toBe( + `a child1`, + ) + + data.value = 'b' + await nextTick() + // In PROD, the anchor of v-else-if (DynamicFragment) is an empty text node, + // so it won't be rendered + expect(container.innerHTML).toBe( + `b child2${isProd ? '' : ``}`, + ) + + data.value = 'c' + await nextTick() + // same as above + expect(container.innerHTML).toBe( + `c child3${isProd ? '' : ``}`, + ) + + data.value = 'a' + await nextTick() + expect(container.innerHTML).toBe( + `a child1`, + ) + }) + }) + + test('on component with anchor insertion', async () => { + runWithEnv(isProd, async () => { + const data = ref(true) + const { container } = await testHydration( + ``, + { Child: `` }, + data, + ) + expect(container.innerHTML).toBe( + `
` + + `` + + `foo` + + `` + + `
`, + ) + + data.value = false + await nextTick() + expect(container.innerHTML).toBe( + `
` + + `` + + `` + + `` + + `
`, + ) + }) + }) + + test('consecutive v-if on component with anchor insertion', async () => { + runWithEnv(isProd, async () => { + const data = ref(true) + const { container } = await testHydration( + ``, + { Child: `` }, + data, + ) + expect(container.innerHTML).toBe( + `
` + + `` + + `foo` + + `foo` + + `` + + `
`, + ) + + data.value = false + await nextTick() + expect(container.innerHTML).toBe( + `
` + + `` + + `` + + `` + + `` + + `
`, + ) + }) + }) + + test('consecutive v-if on fragment component with anchor insertion', async () => { + runWithEnv(isProd, async () => { + const data = ref(true) + const { container } = await testHydration( + ``, + { + Child: ``, + }, + data, + ) + expect(container.innerHTML).toBe( + `
` + + `` + + `
true
-true-` + + `
true
-true-` + + `` + + `
`, + ) + + data.value = false + await nextTick() + expect(container.innerHTML).toBe( + `
` + + `` + + `` + + `` + + `` + + `
`, + ) + }) + }) + } }) test.todo('for') diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 36d0bc387..15128d89d 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -5,7 +5,7 @@ import { mountComponent, unmountComponent, } from './component' -import { createComment, createTextNode, next } from './dom/node' +import { createComment, createTextNode, nextSiblingAnchor } from './dom/node' import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' import { currentHydrationNode, isComment, isHydrating } from './dom/hydration' import { isDynamicFragmentEndAnchor, warn } from '@vue/runtime-dom' @@ -88,8 +88,9 @@ export class DynamicFragment extends VaporFragment { if (isComment(currentHydrationNode!, '')) { this.anchor = currentHydrationNode } else { - const anchor = next(currentHydrationNode!) - if (isDynamicFragmentEndAnchor(anchor)) { + // find next sibling `` as anchor + const anchor = nextSiblingAnchor(currentHydrationNode!, '$')! + if (anchor && isDynamicFragmentEndAnchor(anchor)) { this.anchor = anchor } else if (__DEV__) { // TODO warning diff --git a/packages/runtime-vapor/src/dom/node.ts b/packages/runtime-vapor/src/dom/node.ts index 0740c1f7c..1ea4831f3 100644 --- a/packages/runtime-vapor/src/dom/node.ts +++ b/packages/runtime-vapor/src/dom/node.ts @@ -46,16 +46,8 @@ function _next(node: Node): Node { } /*! #__NO_SIDE_EFFECTS__ */ -function __next(node: Node): Node { - // treat dynamic node (...) as a single node - if (isComment(node, '[[')) { - node = locateEndAnchor(node, '[[', ']]')! - } - - // treat dynamic node (...) as a single node - else if (isComment(node, '[')) { - node = locateEndAnchor(node)! - } +export function __next(node: Node): Node { + node = handleWrappedNode(node) let n = node.nextSibling! while (n && isNonHydrationNode(n)) { @@ -109,12 +101,12 @@ export function disableHydrationNodeLookup(): void { /*! #__NO_SIDE_EFFECTS__ */ export function prev(node: Node): Node | null { - // treat dynamic node (...) as a single node + // process dynamic node (...) as a single one if (isComment(node, ']]')) { node = locateStartAnchor(node, '[[', ']]')! } - // treat dynamic node (...) as a single node + // process fragment node (...) as a single one else if (isComment(node, ']')) { node = locateStartAnchor(node)! } @@ -134,20 +126,45 @@ function isNonHydrationNode(node: Node) { isDynamicAnchor(node) || // fragment end anchor (``) isComment(node, ']') || - isDynamicFragmentAnchor(node) - ) -} - -function isDynamicFragmentAnchor(node: Node) { - return __DEV__ - ? // v-if anchor (``) - isComment(node, 'if') || + // dynamic fragment anchors + (__DEV__ + ? // v-if anchor (``) + isComment(node, 'if') || // v-for anchor (``) isComment(node, 'for') || // v-slot anchor (``) isComment(node, 'slot') || // dynamic-component anchor (``) isComment(node, 'dynamic-component') - : // TODO ? - isComment(node, '$') + : isComment(node, '$')) + ) +} + +export function nextSiblingAnchor( + node: Node, + anchorLabel: string, +): Comment | null { + node = handleWrappedNode(node) + + let n = node.nextSibling + while (n) { + if (isComment(n, anchorLabel)) return n + n = n.nextSibling + } + + return null +} + +function handleWrappedNode(node: Node): Node { + // process dynamic node (...) as a single one + if (isComment(node, '[[')) { + return locateEndAnchor(node, '[[', ']]')! + } + + // process fragment (...) as a single one + else if (isComment(node, '[')) { + return locateEndAnchor(node)! + } + + return node } diff --git a/packages/server-renderer/__tests__/ssrAttrFallthrough.spec.ts b/packages/server-renderer/__tests__/ssrAttrFallthrough.spec.ts index e8cfa75e7..471a48edb 100644 --- a/packages/server-renderer/__tests__/ssrAttrFallthrough.spec.ts +++ b/packages/server-renderer/__tests__/ssrAttrFallthrough.spec.ts @@ -25,7 +25,7 @@ describe('ssr: attr fallthrough', () => { template: ``, } expect(await renderToString(createApp(Parent, { ok: true }))).toBe( - `
`, + `
`, ) expect(await renderToString(createApp(Parent, { ok: false }))).toBe( ``, diff --git a/packages/server-renderer/__tests__/ssrSlot.spec.ts b/packages/server-renderer/__tests__/ssrSlot.spec.ts index 02872274a..7f104e424 100644 --- a/packages/server-renderer/__tests__/ssrSlot.spec.ts +++ b/packages/server-renderer/__tests__/ssrSlot.spec.ts @@ -94,7 +94,7 @@ describe('ssr: slot', () => { template: ``, }), ), - ).toBe(`
hello
`) + ).toBe(`
hello
`) }) test('fragment slot (template v-if + multiple elements)', async () => { @@ -106,7 +106,7 @@ describe('ssr: slot', () => { }), ), ).toBe( - `
one
two
`, + `
one
two
`, ) }) @@ -135,7 +135,7 @@ describe('ssr: slot', () => { template: `
foo
`, }), ), - ).toBe(`
foo
`) + ).toBe(`
foo
`) }) // #9933