From 1d67d9c3bfff0b8029c0e615096ae0f244d3e16a Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 9 Oct 2025 17:57:45 +0800 Subject: [PATCH 01/20] wip: hydrate vapor async component --- .../runtime-core/src/apiAsyncComponent.ts | 123 +++++++++++------- packages/runtime-core/src/index.ts | 9 +- .../src/apiDefineAsyncComponent.ts | 48 +++++-- packages/runtime-vapor/src/component.ts | 45 ++++++- packages/runtime-vapor/src/dom/hydration.ts | 18 ++- packages/runtime-vapor/src/fragment.ts | 1 + 6 files changed, 182 insertions(+), 62 deletions(-) diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index ac579f6fb8e..3c199334129 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -68,37 +68,14 @@ export function defineAsyncComponent< __asyncLoader: load, __asyncHydrate(el, instance, hydrate) { - let patched = false - ;(instance.bu || (instance.bu = [])).push(() => (patched = true)) - const performHydrate = () => { - // skip hydration if the component has been patched - if (patched) { - if (__DEV__) { - const resolvedComp = getResolvedComp() - warn( - `Skipping lazy hydration for component '${getComponentName(resolvedComp!) || resolvedComp!.__file}': ` + - `it was updated before lazy hydration performed.`, - ) - } - return - } - hydrate() - } - const doHydrate = hydrateStrategy - ? () => { - const teardown = hydrateStrategy(performHydrate, cb => - forEachElement(el, cb), - ) - if (teardown) { - ;(instance.bum || (instance.bum = [])).push(teardown) - } - } - : performHydrate - if (getResolvedComp()) { - doHydrate() - } else { - load().then(() => !instance.isUnmounted && doHydrate()) - } + performAsyncHydrate( + el, + instance, + hydrate, + getResolvedComp, + load, + hydrateStrategy, + ) }, get __asyncResolved() { @@ -130,19 +107,7 @@ export function defineAsyncComponent< (__FEATURE_SUSPENSE__ && suspensible && instance.suspense) || (__SSR__ && isInSSRComponentSetup) ) { - return load() - .then(comp => { - return () => createInnerComp(comp, instance) - }) - .catch(err => { - onError(err) - return () => - errorComponent - ? createVNode(errorComponent as ConcreteComponent, { - error: err, - }) - : null - }) + return loadInnerComponent(instance, load, onError, errorComponent) } const { loaded, error, delayed } = useAsyncComponentState( @@ -311,3 +276,73 @@ export const useAsyncComponentState = ( return { loaded, error, delayed } } + +/** + * shared between core and vapor + * @internal + */ +export function loadInnerComponent( + instance: ComponentInternalInstance, + load: () => Promise, + onError: (err: Error) => void, + errorComponent: ConcreteComponent | undefined, +): Promise<() => VNode | null> { + return load() + .then(comp => { + return () => createInnerComp(comp, instance) + }) + .catch(err => { + onError(err) + return () => + errorComponent + ? createVNode(errorComponent as ConcreteComponent, { + error: err, + }) + : null + }) +} + +/** + * shared between core and vapor + * @internal + */ +export function performAsyncHydrate( + el: Element, + instance: GenericComponentInstance, + hydrate: () => void, + getResolvedComp: () => any | undefined, + load: () => Promise, + hydrateStrategy: HydrationStrategy | undefined, +): void { + let patched = false + ;(instance.bu || (instance.bu = [])).push(() => (patched = true)) + const performHydrate = () => { + // skip hydration if the component has been patched + if (patched) { + if (__DEV__) { + const resolvedComp = getResolvedComp() + warn( + `Skipping lazy hydration for component '${getComponentName(resolvedComp!) || resolvedComp!.__file}': ` + + `it was updated before lazy hydration performed.`, + ) + } + return + } + hydrate() + } + const doHydrate = hydrateStrategy + ? () => { + const teardown = hydrateStrategy(performHydrate, cb => + forEachElement(el, cb), + ) + if (teardown) { + ;(instance.bum || (instance.bum = [])).push(teardown) + } + } + : performHydrate + if (getResolvedComp()) { + doHydrate() + } else { + load().then(() => !instance.isUnmounted && doHydrate()) + } +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index b7d811710b7..4704158c7f4 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -535,7 +535,12 @@ export { queueJob, flushOnAppMount } from './scheduler' /** * @internal */ -export { expose, nextUid, validateComponentName } from './component' +export { + expose, + nextUid, + validateComponentName, + isInSSRComponentSetup, +} from './component' /** * @internal */ @@ -595,6 +600,8 @@ export { createAsyncComponentContext, useAsyncComponentState, isAsyncWrapper, + performAsyncHydrate, + loadInnerComponent, } from './apiAsyncComponent' /** * @internal diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index e609dfa795d..76454d53f8a 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -5,7 +5,10 @@ import { createAsyncComponentContext, currentInstance, handleError, + isInSSRComponentSetup, + loadInnerComponent, markAsyncBoundary, + performAsyncHydrate, useAsyncComponentState, } from '@vue/runtime-dom' import { defineVaporComponent } from './apiDefineComponent' @@ -16,8 +19,9 @@ import { } from './component' import { renderEffect } from './renderEffect' import { DynamicFragment } from './fragment' +import { hydrateNode, isHydrating } from './dom/hydration' -/*! #__NO_SIDE_EFFECTS__ */ +/*@ __NO_SIDE_EFFECTS__ */ export function defineVaporAsyncComponent( source: AsyncComponentLoader | AsyncComponentOptions, ): T { @@ -29,9 +33,9 @@ export function defineVaporAsyncComponent( loadingComponent, errorComponent, delay, - // hydrate: hydrateStrategy, + hydrate: hydrateStrategy, timeout, - // suspensible = true, + suspensible = true, }, } = createAsyncComponentContext(source) @@ -40,9 +44,20 @@ export function defineVaporAsyncComponent( __asyncLoader: load, - // __asyncHydrate(el, instance, hydrate) { - // // TODO async hydrate - // }, + __asyncHydrate( + el: Element, + instance: VaporComponentInstance, + hydrate: () => void, + ) { + performAsyncHydrate( + el, + instance, + () => hydrateNode(el, hydrate), + getResolvedComp, + load, + hydrateStrategy, + ) + }, get __asyncResolved() { return getResolvedComp() @@ -52,9 +67,10 @@ export function defineVaporAsyncComponent( const instance = currentInstance as VaporComponentInstance markAsyncBoundary(instance) - const frag = __DEV__ - ? new DynamicFragment('async component') - : new DynamicFragment() + const frag = + __DEV__ || isHydrating + ? new DynamicFragment('async component') + : new DynamicFragment() // already resolved let resolvedComp = getResolvedComp() @@ -73,7 +89,19 @@ export function defineVaporAsyncComponent( ) } - // TODO suspense-controlled or SSR. + // TODO suspense-controlled + if (__FEATURE_SUSPENSE__ && suspensible && instance.suspense) { + } + + // SSR + if (__SSR__ && isInSSRComponentSetup) { + return loadInnerComponent( + instance as any, + load, + onError, + errorComponent, + ) + } const { loaded, error, delayed } = useAsyncComponentState( delay, diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 3828d7119c7..cb1cdc3a3db 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -15,6 +15,7 @@ import { currentInstance, endMeasure, expose, + isAsyncWrapper, nextUid, popWarningContext, pushWarningContext, @@ -118,8 +119,25 @@ export interface ObjectVaporComponent name?: string vapor?: boolean + /** + * marker for AsyncComponentWrapper + * @internal + */ __asyncLoader?: () => Promise + /** + * the inner component resolved by the VaporAsyncComponentWrapper + * @internal + */ __asyncResolved?: VaporComponent + /** + * Exposed for lazy hydration + * @internal + */ + __asyncHydrate?: ( + el: Element, + instance: VaporComponentInstance, + hydrate: () => void, + ) => void } interface SharedInternalOptions { @@ -237,6 +255,27 @@ export function createComponent( appContext, ) + // hydrating async component + if ( + isHydrating && + isAsyncWrapper(instance) && + component.__asyncHydrate && + !component.__asyncResolved + ) { + component.__asyncHydrate(currentHydrationNode! as Element, instance, () => + createComponent( + component, + rawProps, + rawSlots, + isSingleRoot, + once, + scopeId, + appContext, + ), + ) + return instance + } + // HMR if (__DEV__ && component.__hmrId) { registerHMR(instance) @@ -626,8 +665,10 @@ export function mountComponent( const block = instance.block if (isHydrating) { if ( - !(block instanceof Node) || - (isArray(block) && block.some(b => !(b instanceof Node))) + (!(block instanceof Node) || + (isArray(block) && block.some(b => !(b instanceof Node)))) && + isAsyncWrapper(instance) && + instance.type.__asyncResolved ) { insert(block, parent, anchor) } diff --git a/packages/runtime-vapor/src/dom/hydration.ts b/packages/runtime-vapor/src/dom/hydration.ts index 3f5eafbc2e4..593fc1da327 100644 --- a/packages/runtime-vapor/src/dom/hydration.ts +++ b/packages/runtime-vapor/src/dom/hydration.ts @@ -22,12 +22,21 @@ const isHydratingStack = [] as boolean[] export let isHydrating = false export let currentHydrationNode: Node | null = null +function pushIsHydrating(value: boolean): void { + isHydratingStack.push((isHydrating = value)) +} + +function popIsHydrating(): void { + isHydratingStack.pop() + isHydrating = isHydratingStack[isHydratingStack.length - 1] || false +} + export function runWithoutHydration(fn: () => any): any { try { - isHydrating = false + pushIsHydrating(false) return fn() } finally { - isHydrating = true + popIsHydrating() } } @@ -53,13 +62,12 @@ function performHydration( isOptimized = true } enableHydrationNodeLookup() - isHydratingStack.push((isHydrating = true)) + pushIsHydrating(true) setup() const res = fn() cleanup() currentHydrationNode = null - isHydratingStack.pop() - isHydrating = isHydratingStack[isHydratingStack.length - 1] || false + popIsHydrating() if (!isHydrating) disableHydrationNodeLookup() return res } diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index 65eb1af494c..6f98e026b7c 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -144,6 +144,7 @@ export class DynamicFragment extends VaporFragment { if (this.anchor) return // reuse the empty comment node as the anchor for empty if + // e.g. `
` -> `` if (this.anchorLabel === 'if' && isEmpty) { this.anchor = currentHydrationNode! if (!this.anchor) { From 5e368b4b590f77c337dfed833b324409991f29c0 Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 9 Oct 2025 21:20:31 +0800 Subject: [PATCH 02/20] types: add AsyncComponentInternalOptions interface --- packages/runtime-core/src/component.ts | 21 +++++++++++++++++ packages/runtime-core/src/componentOptions.ts | 23 ++----------------- packages/runtime-core/src/index.ts | 1 + packages/runtime-vapor/src/component.ts | 21 ++--------------- 4 files changed, 26 insertions(+), 40 deletions(-) diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 67d5a1b8720..1a2e5879d13 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -223,6 +223,27 @@ export interface ComponentInternalOptions { __name?: string } +export interface AsyncComponentInternalOptions< + R = ConcreteComponent, + I = ComponentInternalInstance, +> { + /** + * marker for AsyncComponentWrapper + * @internal + */ + __asyncLoader?: () => Promise + /** + * the inner component resolved by the AsyncComponentWrapper + * @internal + */ + __asyncResolved?: R + /** + * Exposed for lazy hydration + * @internal + */ + __asyncHydrate?: (el: Element, instance: I, hydrate: () => void) => void +} + export interface FunctionalComponent< P = {}, E extends EmitsOptions | Record = {}, diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 47e8f8e2743..8dc57775922 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -1,8 +1,8 @@ import { + type AsyncComponentInternalOptions, type Component, type ComponentInternalInstance, type ComponentInternalOptions, - type ConcreteComponent, type Data, type InternalRenderFunction, type SetupContext, @@ -127,6 +127,7 @@ export interface ComponentOptionsBase< Provide extends ComponentProvideOptions = ComponentProvideOptions, > extends LegacyOptions, ComponentInternalOptions, + AsyncComponentInternalOptions, ComponentCustomOptions { setup?: ( this: void, @@ -190,26 +191,6 @@ export interface ComponentOptionsBase< */ __ssrInlineRender?: boolean - /** - * marker for AsyncComponentWrapper - * @internal - */ - __asyncLoader?: () => Promise - /** - * the inner component resolved by the AsyncComponentWrapper - * @internal - */ - __asyncResolved?: ConcreteComponent - /** - * Exposed for lazy hydration - * @internal - */ - __asyncHydrate?: ( - el: Element, - instance: ComponentInternalInstance, - hydrate: () => void, - ) => void - // Type differentiators ------------------------------------------------------ // Note these are internal but need to be exposed in d.ts for type inference diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 4704158c7f4..31767c33c21 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -271,6 +271,7 @@ export type { GlobalDirectives, ComponentInstance, ComponentCustomElementInterface, + AsyncComponentInternalOptions, } from './component' export type { DefineComponent, diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index cb1cdc3a3db..2fd665b2340 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -1,4 +1,5 @@ import { + type AsyncComponentInternalOptions, type ComponentInternalOptions, type ComponentPropsOptions, EffectScope, @@ -104,6 +105,7 @@ export type FunctionalVaporComponent = VaporSetupFn & export interface ObjectVaporComponent extends ComponentInternalOptions, + AsyncComponentInternalOptions, SharedInternalOptions { setup?: VaporSetupFn inheritAttrs?: boolean @@ -119,25 +121,6 @@ export interface ObjectVaporComponent name?: string vapor?: boolean - /** - * marker for AsyncComponentWrapper - * @internal - */ - __asyncLoader?: () => Promise - /** - * the inner component resolved by the VaporAsyncComponentWrapper - * @internal - */ - __asyncResolved?: VaporComponent - /** - * Exposed for lazy hydration - * @internal - */ - __asyncHydrate?: ( - el: Element, - instance: VaporComponentInstance, - hydrate: () => void, - ) => void } interface SharedInternalOptions { From cb6d88cfb2ffa359c09684cc62c8e3afb5ce1262 Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 9 Oct 2025 22:41:40 +0800 Subject: [PATCH 03/20] fix: refine types in performAsyncHydrate for better type safety --- packages/runtime-core/src/apiAsyncComponent.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index 3c199334129..eff9346a842 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -3,6 +3,7 @@ import { type ComponentInternalInstance, type ComponentOptions, type ConcreteComponent, + type GenericComponent, type GenericComponentInstance, currentInstance, getComponentName, @@ -310,8 +311,8 @@ export function performAsyncHydrate( el: Element, instance: GenericComponentInstance, hydrate: () => void, - getResolvedComp: () => any | undefined, - load: () => Promise, + getResolvedComp: () => GenericComponent | undefined, + load: () => Promise, hydrateStrategy: HydrationStrategy | undefined, ): void { let patched = false @@ -320,9 +321,9 @@ export function performAsyncHydrate( // skip hydration if the component has been patched if (patched) { if (__DEV__) { - const resolvedComp = getResolvedComp() + const resolvedComp = getResolvedComp()! as GenericComponent warn( - `Skipping lazy hydration for component '${getComponentName(resolvedComp!) || resolvedComp!.__file}': ` + + `Skipping lazy hydration for component '${getComponentName(resolvedComp) || resolvedComp.__file}': ` + `it was updated before lazy hydration performed.`, ) } From a29b62f146a78ca948d846943d147bff8d316181 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 10 Oct 2025 10:15:46 +0800 Subject: [PATCH 04/20] wip: add tests --- .../runtime-vapor/__tests__/hydration.spec.ts | 65 ++++++++++++++++++- packages/runtime-vapor/src/component.ts | 3 + packages/runtime-vapor/src/dom/hydration.ts | 2 +- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index eeda18af306..c079c04e420 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -1,4 +1,8 @@ -import { createVaporSSRApp, delegateEvents } from '../src' +import { + createVaporSSRApp, + defineVaporAsyncComponent, + delegateEvents, +} from '../src' import { nextTick, reactive, ref } from '@vue/runtime-dom' import { compileScript, parse } from '@vue/compiler-sfc' import * as runtimeVapor from '../src' @@ -2952,8 +2956,63 @@ describe('Vapor Mode hydration', () => { }) }) - describe.todo('async component', async () => { - test('async component', async () => {}) + describe('async component', async () => { + test('async component', async () => { + const data = ref({ + spy: vi.fn(), + }) + + const compCode = `` + const SSRComp = compileVaporComponent(compCode, data, undefined, true) + let serverResolve: any + let AsyncComp = defineVaporAsyncComponent( + () => + new Promise(r => { + serverResolve = r + }), + ) + const appCode = `helloworld` + const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true) + + // server render + const htmlPromise = VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRApp), + ) + serverResolve(SSRComp) + const html = await htmlPromise + expect(html).toMatchInlineSnapshot( + `"helloworld"`, + ) + + // hydration + let clientResolve: any + AsyncComp = defineVaporAsyncComponent( + () => + new Promise(r => { + clientResolve = r + }), + ) + + const Comp = compileVaporComponent(compCode, data) + const App = compileVaporComponent(appCode, data, { AsyncComp }) + + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + createVaporSSRApp(App).mount(container) + + // hydration not complete yet + triggerEvent('click', container.querySelector('button')!) + expect(data.value.spy).not.toHaveBeenCalled() + + // resolve + clientResolve(Comp) + await new Promise(r => setTimeout(r)) + + // should be hydrated now + triggerEvent('click', container.querySelector('button')!) + expect(data.value.spy).toHaveBeenCalled() + }) test('update async wrapper before resolve', async () => {}) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 2fd665b2340..0f3232aa33c 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -79,6 +79,7 @@ import { currentHydrationNode, isHydrating, locateHydrationNode, + nextNode, setCurrentHydrationNode, } from './dom/hydration' import { type TeleportFragment, isVaporTeleport } from './components/Teleport' @@ -245,6 +246,7 @@ export function createComponent( component.__asyncHydrate && !component.__asyncResolved ) { + const node = nextNode(currentHydrationNode!) component.__asyncHydrate(currentHydrationNode! as Element, instance, () => createComponent( component, @@ -256,6 +258,7 @@ export function createComponent( appContext, ), ) + setCurrentHydrationNode(node) return instance } diff --git a/packages/runtime-vapor/src/dom/hydration.ts b/packages/runtime-vapor/src/dom/hydration.ts index 593fc1da327..9a168c8626c 100644 --- a/packages/runtime-vapor/src/dom/hydration.ts +++ b/packages/runtime-vapor/src/dom/hydration.ts @@ -153,7 +153,7 @@ function adoptTemplateImpl(node: Node, template: string): Node | null { return node } -function nextNode(node: Node): Node | null { +export function nextNode(node: Node): Node | null { return isComment(node, '[') ? locateEndAnchor(node as Anchor)!.nextSibling : node.nextSibling From 7cd268f02412b7806d0028bcfa681dce53c44053 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 10 Oct 2025 15:45:26 +0800 Subject: [PATCH 05/20] wip: save --- packages/runtime-core/src/component.ts | 2 +- .../runtime-vapor/__tests__/hydration.spec.ts | 87 ++++++++++++++++++- .../src/apiDefineAsyncComponent.ts | 22 ++++- packages/runtime-vapor/src/block.ts | 2 +- packages/runtime-vapor/src/component.ts | 25 +++--- 5 files changed, 121 insertions(+), 17 deletions(-) diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 1a2e5879d13..ac9ab459b6f 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -241,7 +241,7 @@ export interface AsyncComponentInternalOptions< * Exposed for lazy hydration * @internal */ - __asyncHydrate?: (el: Element, instance: I, hydrate: () => void) => void + __asyncHydrate?: (el: Element, instance: I, hydrate: () => any) => void } export interface FunctionalComponent< diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index c079c04e420..1d60b2b5eb8 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -89,7 +89,10 @@ function compileVaporComponent( components?: Record, ssr = false, ) { - return compile(``, data, components, { + if (!code.includes(`${code}` + } + return compile(code, data, components, { vapor: true, ssr, }) @@ -3014,7 +3017,87 @@ describe('Vapor Mode hydration', () => { expect(data.value.spy).toHaveBeenCalled() }) - test('update async wrapper before resolve', async () => {}) + // No longer needed, parent component updates in vapor mode no longer + // cause child components to re-render + // test.todo('update async wrapper before resolve', async () => {}) + + test('update async component after parent mount before async component resolve', async () => { + const data = ref({ + toggle: true, + }) + const compCode = ` + + + ` + const SSRComp = compileVaporComponent( + compCode, + undefined, + undefined, + true, + ) + let serverResolve: any + let AsyncComp = defineVaporAsyncComponent( + () => + new Promise(r => { + serverResolve = r + }), + ) + const appCode = ` + + + ` + const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true) + + // server render + const htmlPromise = VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRApp), + ) + serverResolve(SSRComp) + const html = await htmlPromise + expect(html).toMatchInlineSnapshot(`"

Async component

"`) + + // hydration + let clientResolve: any + AsyncComp = defineVaporAsyncComponent( + () => + new Promise(r => { + clientResolve = r + }), + ) + + const Comp = compileVaporComponent(compCode) + const App = compileVaporComponent(appCode, data, { AsyncComp }) + + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + createVaporSSRApp(App).mount(container) + + // resolve + clientResolve(Comp) + await new Promise(r => setTimeout(r)) + + // prevent lazy hydration since the component has been patched + expect('Skipping lazy hydration for component').toHaveBeenWarned() + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + expect(container.innerHTML).toMatchInlineSnapshot( + `"

Updated async component

"`, + ) + }) test('hydrate safely when property used by async setup changed before render', async () => {}) diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index 76454d53f8a..2b416388b77 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -10,6 +10,7 @@ import { markAsyncBoundary, performAsyncHydrate, useAsyncComponentState, + watch, } from '@vue/runtime-dom' import { defineVaporComponent } from './apiDefineComponent' import { @@ -20,6 +21,9 @@ import { import { renderEffect } from './renderEffect' import { DynamicFragment } from './fragment' import { hydrateNode, isHydrating } from './dom/hydration' +import { invokeArrayFns } from '@vue/shared' +import { insert, remove } from './block' +import { parentNode } from './dom/node' /*@ __NO_SIDE_EFFECTS__ */ export function defineVaporAsyncComponent( @@ -47,8 +51,24 @@ export function defineVaporAsyncComponent( __asyncHydrate( el: Element, instance: VaporComponentInstance, - hydrate: () => void, + hydrate: () => any, ) { + // The setup of async components is not executed during hydration, + // which means beforeUpdate won't be called when attrs change. We need to watch + // for attrs changes and manually call beforeUpdate to avoid unnecessary hydration + // and mount the async component + watch( + () => instance.attrs, + () => { + instance.bu && invokeArrayFns(instance.bu) + const block = hydrate() as VaporComponentInstance + const parent = parentNode(el)! + insert(block, parent, el) + remove(el, parent) + }, + { deep: true, once: true }, + ) + performAsyncHydrate( el, instance, diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 9dad78cb2f6..1f027ce4e7a 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -94,7 +94,7 @@ export function insert( } } else if (isVaporComponent(block)) { if (block.isMounted) { - insert(block.block!, parent, anchor) + if (block.block) insert(block.block, parent, anchor) } else { mountComponent(block, parent, anchor) } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 0f3232aa33c..551f676dc97 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -336,6 +336,8 @@ export function createComponent( } } + if (scopeId) setScopeId(instance.block, scopeId) + setActiveSub(prevSub) setCurrentInstance(...prevInstance) @@ -346,8 +348,6 @@ export function createComponent( onScopeDispose(() => unmountComponent(instance), true) - if (scopeId) setScopeId(instance.block, scopeId) - if (_insertionParent) { mountComponent(instance, _insertionParent, _insertionAnchor) } @@ -649,18 +649,19 @@ export function mountComponent( } if (instance.bm) invokeArrayFns(instance.bm) const block = instance.block - if (isHydrating) { - if ( - (!(block instanceof Node) || - (isArray(block) && block.some(b => !(b instanceof Node)))) && - isAsyncWrapper(instance) && - instance.type.__asyncResolved - ) { + // unresolved async component does not have a block + if (block) { + if (isHydrating) { + if ( + !(block instanceof Node) || + (isArray(block) && block.some(b => !(b instanceof Node))) + ) { + insert(block, parent, anchor) + } + } else { insert(block, parent, anchor) + setComponentScopeId(instance) } - } else { - insert(block, parent, anchor) - setComponentScopeId(instance) } if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!)) From 9c7aafe788d2d8afc45e386c63c5787c3e733bc9 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 10 Oct 2025 17:32:00 +0800 Subject: [PATCH 06/20] wip: save --- .../runtime-vapor/__tests__/hydration.spec.ts | 75 ++++++++++++++----- .../src/apiDefineAsyncComponent.ts | 4 +- packages/runtime-vapor/src/component.ts | 26 ++++--- 3 files changed, 74 insertions(+), 31 deletions(-) diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index 1d60b2b5eb8..78e396a8b44 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -3046,20 +3046,7 @@ describe('Vapor Mode hydration', () => { serverResolve = r }), ) - const appCode = ` - - - ` + const appCode = `` const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true) // server render @@ -3087,6 +3074,10 @@ describe('Vapor Mode hydration', () => { document.body.appendChild(container) createVaporSSRApp(App).mount(container) + // update before resolve + data.value.toggle = false + await nextTick() + // resolve clientResolve(Comp) await new Promise(r => setTimeout(r)) @@ -3099,13 +3090,61 @@ describe('Vapor Mode hydration', () => { ) }) - test('hydrate safely when property used by async setup changed before render', async () => {}) + // required vapor Suspense + test.todo( + 'hydrate safely when property used by async setup changed before render', + async () => {}, + ) - test('unmount async wrapper before load', async () => {}) + // required vapor Suspense + test.todo( + 'hydrate safely when property used by deep nested async setup changed before render', + async () => {}, + ) - test('nested async wrapper', async () => {}) + test('unmount async wrapper before load', async () => { + const data = ref({ + toggle: true, + }) + const compCode = `
async
` + const appCode = ` +
+ +
hi
+
+ ` + + // hydration + let clientResolve: any + const AsyncComp = defineVaporAsyncComponent( + () => + new Promise(r => { + clientResolve = r + }), + ) - test('unmount async wrapper before load (fragment)', async () => {}) + const Comp = compileVaporComponent(compCode) + const App = compileVaporComponent(appCode, data, { + AsyncComp, + }) + + const container = document.createElement('div') + container.innerHTML = '
async
' + createVaporSSRApp(App).mount(container) + + // unmount before resolve + data.value.toggle = false + await nextTick() + expect(container.innerHTML).toBe(`
hi
`) + + // resolve + clientResolve(Comp) + await new Promise(r => setTimeout(r)) + // should remain unmounted + expect(container.innerHTML).toBe(`
hi
`) + }) + + test('nested async wrapper', async () => {}) }) describe('force hydrate prop', async () => { diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index 2b416388b77..1d6e08f3304 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -22,7 +22,7 @@ import { renderEffect } from './renderEffect' import { DynamicFragment } from './fragment' import { hydrateNode, isHydrating } from './dom/hydration' import { invokeArrayFns } from '@vue/shared' -import { insert, remove } from './block' +import { type Block, insert, remove } from './block' import { parentNode } from './dom/node' /*@ __NO_SIDE_EFFECTS__ */ @@ -61,7 +61,7 @@ export function defineVaporAsyncComponent( () => instance.attrs, () => { instance.bu && invokeArrayFns(instance.bu) - const block = hydrate() as VaporComponentInstance + const block = hydrate() as Block const parent = parentNode(el)! insert(block, parent, el) remove(el, parent) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 551f676dc97..f99a8db38a2 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -246,6 +246,13 @@ export function createComponent( component.__asyncHydrate && !component.__asyncResolved ) { + // it may get unmounted before its inner component is loaded, + // so we need to give it a placeholder block that matches its + // adopted DOM + instance.block = currentHydrationNode! + // also mark it as mounted to ensure it can be unmounted + instance.isMounted = true + const node = nextNode(currentHydrationNode!) component.__asyncHydrate(currentHydrationNode! as Element, instance, () => createComponent( @@ -649,19 +656,16 @@ export function mountComponent( } if (instance.bm) invokeArrayFns(instance.bm) const block = instance.block - // unresolved async component does not have a block - if (block) { - if (isHydrating) { - if ( - !(block instanceof Node) || - (isArray(block) && block.some(b => !(b instanceof Node))) - ) { - insert(block, parent, anchor) - } - } else { + if (isHydrating) { + if ( + !(block instanceof Node) || + (isArray(block) && block.some(b => !(b instanceof Node))) + ) { insert(block, parent, anchor) - setComponentScopeId(instance) } + } else { + insert(block, parent, anchor) + setComponentScopeId(instance) } if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!)) From 629e0fdffae30f258d527a7db4dccea8b9350eab Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 10 Oct 2025 18:05:47 +0800 Subject: [PATCH 07/20] chore: update --- packages/runtime-vapor/src/block.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 1f027ce4e7a..7e484e957bd 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -94,7 +94,7 @@ export function insert( } } else if (isVaporComponent(block)) { if (block.isMounted) { - if (block.block) insert(block.block, parent, anchor) + insert(block.block, parent, anchor) } else { mountComponent(block, parent, anchor) } From 6b00e8b4709ddaebf44dd7fe422e3d9a44fd5c1c Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 10 Oct 2025 22:49:57 +0800 Subject: [PATCH 08/20] fix: refine asyncHydrate --- packages/runtime-core/src/component.ts | 2 +- .../src/apiDefineAsyncComponent.ts | 20 +++--- packages/runtime-vapor/src/component.ts | 70 +++++++++---------- 3 files changed, 47 insertions(+), 45 deletions(-) diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index ac9ab459b6f..1a2e5879d13 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -241,7 +241,7 @@ export interface AsyncComponentInternalOptions< * Exposed for lazy hydration * @internal */ - __asyncHydrate?: (el: Element, instance: I, hydrate: () => any) => void + __asyncHydrate?: (el: Element, instance: I, hydrate: () => void) => void } export interface FunctionalComponent< diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index 1d6e08f3304..c0f77f7e595 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -22,7 +22,7 @@ import { renderEffect } from './renderEffect' import { DynamicFragment } from './fragment' import { hydrateNode, isHydrating } from './dom/hydration' import { invokeArrayFns } from '@vue/shared' -import { type Block, insert, remove } from './block' +import { insert, remove } from './block' import { parentNode } from './dom/node' /*@ __NO_SIDE_EFFECTS__ */ @@ -51,20 +51,22 @@ export function defineVaporAsyncComponent( __asyncHydrate( el: Element, instance: VaporComponentInstance, - hydrate: () => any, + hydrate: () => void, ) { - // The setup of async components is not executed during hydration, - // which means beforeUpdate won't be called when attrs change. We need to watch - // for attrs changes and manually call beforeUpdate to avoid unnecessary hydration - // and mount the async component + // The setup of async components is not executed during hydration, which means + // the beforeUpdate hooks won't be called when attrs change. We need to watch + // for attrs changes and manually call beforeUpdate hooks to avoid unnecessary + // hydration and mount the async component watch( () => instance.attrs, () => { instance.bu && invokeArrayFns(instance.bu) - const block = hydrate() as Block const parent = parentNode(el)! - insert(block, parent, el) - remove(el, parent) + load().then(() => { + hydrate() + insert(instance.block, parent, el) + remove(el, parent) + }) }, { deep: true, once: true }, ) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index f99a8db38a2..6706a59eab8 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -239,6 +239,23 @@ export function createComponent( appContext, ) + // HMR + if (__DEV__ && component.__hmrId) { + registerHMR(instance) + instance.isSingleRoot = isSingleRoot + instance.hmrRerender = hmrRerender.bind(null, instance) + instance.hmrReload = hmrReload.bind(null, instance) + } + + if (__DEV__) { + pushWarningContext(instance) + startMeasure(instance, `init`) + + // cache normalized options for dev only emit check + instance.propsOptions = normalizePropsOptions(component) + instance.emitsOptions = normalizeEmitsOptions(component) + } + // hydrating async component if ( isHydrating && @@ -250,42 +267,36 @@ export function createComponent( // so we need to give it a placeholder block that matches its // adopted DOM instance.block = currentHydrationNode! - // also mark it as mounted to ensure it can be unmounted + // also mark it as mounted to ensure it can be unmounted before + // its inner component is resolved instance.isMounted = true const node = nextNode(currentHydrationNode!) component.__asyncHydrate(currentHydrationNode! as Element, instance, () => - createComponent( - component, - rawProps, - rawSlots, - isSingleRoot, - once, - scopeId, - appContext, - ), + setupComponent(instance, component, scopeId), ) setCurrentHydrationNode(node) - return instance + } else { + setupComponent(instance, component, scopeId) } - // HMR - if (__DEV__ && component.__hmrId) { - registerHMR(instance) - instance.isSingleRoot = isSingleRoot - instance.hmrRerender = hmrRerender.bind(null, instance) - instance.hmrReload = hmrReload.bind(null, instance) - } + onScopeDispose(() => unmountComponent(instance), true) - if (__DEV__) { - pushWarningContext(instance) - startMeasure(instance, `init`) + if (_insertionParent) { + mountComponent(instance, _insertionParent, _insertionAnchor) + } - // cache normalized options for dev only emit check - instance.propsOptions = normalizePropsOptions(component) - instance.emitsOptions = normalizeEmitsOptions(component) + if (isHydrating && _insertionAnchor !== undefined) { + advanceHydrationNode(_insertionParent!) } + return instance +} +export function setupComponent( + instance: VaporComponentInstance, + component: VaporComponent, + scopeId: string | undefined, +): void { const prevInstance = setCurrentInstance(instance) const prevSub = setActiveSub() @@ -352,17 +363,6 @@ export function createComponent( popWarningContext() endMeasure(instance, 'init') } - - onScopeDispose(() => unmountComponent(instance), true) - - if (_insertionParent) { - mountComponent(instance, _insertionParent, _insertionAnchor) - } - - if (isHydrating && _insertionAnchor !== undefined) { - advanceHydrationNode(_insertionParent!) - } - return instance } export let isApplyingFallthroughProps = false From 519b2b7650557d46bac0422fe18feb1d258737c5 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 11 Oct 2025 10:01:01 +0800 Subject: [PATCH 09/20] fix: improve async hydration handling --- .../src/apiDefineAsyncComponent.ts | 21 ++++++++++++++----- packages/runtime-vapor/src/block.ts | 2 +- packages/runtime-vapor/src/component.ts | 8 +++---- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index c0f77f7e595..083179c3517 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -51,18 +51,25 @@ export function defineVaporAsyncComponent( __asyncHydrate( el: Element, instance: VaporComponentInstance, + // Note: this hydrate function essentially calls the setup method of the component + // not the actual hydrate function hydrate: () => void, ) { - // The setup of async components is not executed during hydration, which means - // the beforeUpdate hooks won't be called when attrs change. We need to watch - // for attrs changes and manually call beforeUpdate hooks to avoid unnecessary - // hydration and mount the async component + // if async component needs to be updated before hydration, hydration is no longer needed. + let isHydrated = false watch( () => instance.attrs, () => { + // early return if already hydrated + if (isHydrated) return + + // call the beforeUpdate hook to avoid calling hydrate in performAsyncHydrate instance.bu && invokeArrayFns(instance.bu) + + // mount the inner component and remove the placeholder const parent = parentNode(el)! load().then(() => { + if (instance.isUnmounted) return hydrate() insert(instance.block, parent, el) remove(el, parent) @@ -74,7 +81,11 @@ export function defineVaporAsyncComponent( performAsyncHydrate( el, instance, - () => hydrateNode(el, hydrate), + () => { + hydrateNode(el, hydrate) + insert(instance.block, parentNode(el)!, el) + isHydrated = true + }, getResolvedComp, load, hydrateStrategy, diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 7e484e957bd..9dad78cb2f6 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -94,7 +94,7 @@ export function insert( } } else if (isVaporComponent(block)) { if (block.isMounted) { - insert(block.block, parent, anchor) + insert(block.block!, parent, anchor) } else { mountComponent(block, parent, anchor) } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 6706a59eab8..1df4129eb6c 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -266,16 +266,16 @@ export function createComponent( // it may get unmounted before its inner component is loaded, // so we need to give it a placeholder block that matches its // adopted DOM - instance.block = currentHydrationNode! + const el = (instance.block = currentHydrationNode!) // also mark it as mounted to ensure it can be unmounted before // its inner component is resolved instance.isMounted = true - const node = nextNode(currentHydrationNode!) - component.__asyncHydrate(currentHydrationNode! as Element, instance, () => + // advance current hydration node to the nextSibling + setCurrentHydrationNode(nextNode(el)) + component.__asyncHydrate(el as Element, instance, () => setupComponent(instance, component, scopeId), ) - setCurrentHydrationNode(node) } else { setupComponent(instance, component, scopeId) } From c87af7c53a9154790c61c5604e34e5b43035bd66 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 11 Oct 2025 12:04:56 +0800 Subject: [PATCH 10/20] wip: save --- .../runtime-core/src/apiAsyncComponent.ts | 4 ++-- packages/runtime-core/src/index.ts | 1 + .../src/apiDefineAsyncComponent.ts | 24 +++++++++++++------ packages/runtime-vapor/src/component.ts | 7 ++++-- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index eff9346a842..53d0e4ad3fb 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -151,10 +151,10 @@ export function defineAsyncComponent< }) as T } -function createInnerComp( +export function createInnerComp( comp: ConcreteComponent, parent: ComponentInternalInstance, -) { +): VNode { const { ref, props, children, ce } = parent.vnode const vnode = createVNode(comp, props, children) // ensure inner component inherits the async wrapper's ref owner diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 31767c33c21..c6033815d77 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -603,6 +603,7 @@ export { isAsyncWrapper, performAsyncHydrate, loadInnerComponent, + createInnerComp, } from './apiAsyncComponent' /** * @internal diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index 083179c3517..99264d90c54 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -3,10 +3,11 @@ import { type AsyncComponentOptions, ErrorCodes, createAsyncComponentContext, + createInnerComp as createSSRInnerComp, currentInstance, handleError, isInSSRComponentSetup, - loadInnerComponent, + loadInnerComponent as loadSSRInnerComponent, markAsyncBoundary, performAsyncHydrate, useAsyncComponentState, @@ -82,9 +83,11 @@ export function defineVaporAsyncComponent( el, instance, () => { - hydrateNode(el, hydrate) - insert(instance.block, parentNode(el)!, el) - isHydrated = true + hydrateNode(el, () => { + hydrate() + insert(instance.block, parentNode(el)!, el) + isHydrated = true + }) }, getResolvedComp, load, @@ -108,7 +111,13 @@ export function defineVaporAsyncComponent( // already resolved let resolvedComp = getResolvedComp() if (resolvedComp) { - frag.update(() => createInnerComp(resolvedComp!, instance)) + // SSR + if (__SSR__ && isInSSRComponentSetup) { + return () => createSSRInnerComp(resolvedComp! as any, instance as any) + } + + // TODO handling insertionState + frag!.update(() => createInnerComp(resolvedComp!, instance)) return frag } @@ -128,7 +137,7 @@ export function defineVaporAsyncComponent( // SSR if (__SSR__ && isInSSRComponentSetup) { - return loadInnerComponent( + return loadSSRInnerComponent( instance as any, load, onError, @@ -164,9 +173,10 @@ export function defineVaporAsyncComponent( } else if (loadingComponent && !delayed.value) { render = () => createComponent(loadingComponent) } - frag.update(render) + frag!.update(render) }) + // TODO handling insertionState return frag }, }) as T diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 1df4129eb6c..1078f9fe067 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -77,9 +77,10 @@ import { adoptTemplate, advanceHydrationNode, currentHydrationNode, + isComment, isHydrating, + locateEndAnchor, locateHydrationNode, - nextNode, setCurrentHydrationNode, } from './dom/hydration' import { type TeleportFragment, isVaporTeleport } from './components/Teleport' @@ -272,7 +273,9 @@ export function createComponent( instance.isMounted = true // advance current hydration node to the nextSibling - setCurrentHydrationNode(nextNode(el)) + setCurrentHydrationNode( + isComment(el, '[') ? locateEndAnchor(el)! : el.nextSibling, + ) component.__asyncHydrate(el as Element, instance, () => setupComponent(instance, component, scopeId), ) From 7f90e97715de08ee3e5bd6def63b44e879bd3dab Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 11 Oct 2025 17:18:08 +0800 Subject: [PATCH 11/20] wip: save --- packages/runtime-vapor/src/component.ts | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 1078f9fe067..db18667cc0d 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -43,13 +43,7 @@ import { setActiveSub, unref, } from '@vue/reactivity' -import { - EMPTY_OBJ, - invokeArrayFns, - isArray, - isFunction, - isString, -} from '@vue/shared' +import { EMPTY_OBJ, invokeArrayFns, isFunction, isString } from '@vue/shared' import { type DynamicPropsSource, type RawProps, @@ -285,8 +279,8 @@ export function createComponent( onScopeDispose(() => unmountComponent(instance), true) - if (_insertionParent) { - mountComponent(instance, _insertionParent, _insertionAnchor) + if (_insertionParent || isHydrating) { + mountComponent(instance, _insertionParent!, _insertionAnchor) } if (isHydrating && _insertionAnchor !== undefined) { @@ -658,19 +652,10 @@ export function mountComponent( startMeasure(instance, `mount`) } if (instance.bm) invokeArrayFns(instance.bm) - const block = instance.block - if (isHydrating) { - if ( - !(block instanceof Node) || - (isArray(block) && block.some(b => !(b instanceof Node))) - ) { - insert(block, parent, anchor) - } - } else { - insert(block, parent, anchor) + if (!isHydrating) { + insert(instance.block, parent, anchor) setComponentScopeId(instance) } - if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!)) instance.isMounted = true if (__DEV__) { From 84ac0f1d715a52ff9e25e85c9d8535762314b454 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 11 Oct 2025 21:35:32 +0800 Subject: [PATCH 12/20] test: add more test --- .../runtime-vapor/__tests__/hydration.spec.ts | 105 +++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index 78e396a8b44..86e513e894f 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -3144,7 +3144,110 @@ describe('Vapor Mode hydration', () => { expect(container.innerHTML).toBe(`
hi
`) }) - test('nested async wrapper', async () => {}) + test('nested async wrapper', async () => { + const toggleCode = ` + + + ` + + const SSRToggle = compileVaporComponent( + toggleCode, + undefined, + undefined, + true, + ) + + const wrapperCode = `` + const SSRWrapper = compileVaporComponent( + wrapperCode, + undefined, + undefined, + true, + ) + + const data = ref({ + count: 0, + fn: vi.fn(), + }) + + const childCode = ` + + + ` + + const SSRChild = compileVaporComponent(childCode, data, undefined, true) + + const appCode = ` + + + + + + + + ` + + const SSRApp = compileVaporComponent( + appCode, + undefined, + { + Toggle: SSRToggle, + Wrapper: SSRWrapper, + Child: SSRChild, + }, + true, + ) + + const root = document.createElement('div') + + // server render + root.innerHTML = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRApp), + ) + expect(root.innerHTML).toMatchInlineSnapshot( + `"
0
"`, + ) + + const Toggle = compileVaporComponent(toggleCode) + const Wrapper = compileVaporComponent(wrapperCode) + const Child = compileVaporComponent(childCode, data) + + const App = compileVaporComponent(appCode, undefined, { + Toggle, + Wrapper, + Child, + }) + + // hydration + createVaporSSRApp(App).mount(root) + await nextTick() + await nextTick() + expect(root.innerHTML).toMatchInlineSnapshot( + `"
1
"`, + ) + expect(data.value.fn).toBeCalledTimes(1) + }) }) describe('force hydrate prop', async () => { From fd32b89d4fe8d98d363187d877526164bb32b583 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 11 Oct 2025 22:09:44 +0800 Subject: [PATCH 13/20] chore: remove todo --- packages/runtime-vapor/src/apiDefineAsyncComponent.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index 99264d90c54..95e95391c9c 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -116,7 +116,6 @@ export function defineVaporAsyncComponent( return () => createSSRInnerComp(resolvedComp! as any, instance as any) } - // TODO handling insertionState frag!.update(() => createInnerComp(resolvedComp!, instance)) return frag } @@ -176,7 +175,6 @@ export function defineVaporAsyncComponent( frag!.update(render) }) - // TODO handling insertionState return frag }, }) as T From e94c250a9646565ce54d5d4a1d1cd2545639cbe9 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 11 Oct 2025 22:11:15 +0800 Subject: [PATCH 14/20] chore: tweaks --- packages/runtime-vapor/src/dom/hydration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-vapor/src/dom/hydration.ts b/packages/runtime-vapor/src/dom/hydration.ts index 9a168c8626c..593fc1da327 100644 --- a/packages/runtime-vapor/src/dom/hydration.ts +++ b/packages/runtime-vapor/src/dom/hydration.ts @@ -153,7 +153,7 @@ function adoptTemplateImpl(node: Node, template: string): Node | null { return node } -export function nextNode(node: Node): Node | null { +function nextNode(node: Node): Node | null { return isComment(node, '[') ? locateEndAnchor(node as Anchor)!.nextSibling : node.nextSibling From 04be65ca9fdd88756fc433e8ebb02fb1f7210267 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sun, 12 Oct 2025 10:10:18 +0800 Subject: [PATCH 15/20] test: add e2e tests --- .../e2e/hydration-strat-custom-vapor.html | 56 ++++ .../e2e/hydration-strat-idle-vapor.html | 48 +++ .../hydration-strat-interaction-vapor.html | 59 ++++ .../e2e/hydration-strat-media-vapor.html | 48 +++ .../e2e/hydration-strat-visible-vapor.html | 69 +++++ .../__tests__/e2e/hydrationStrategies.spec.ts | 280 +++++++++--------- 6 files changed, 427 insertions(+), 133 deletions(-) create mode 100644 packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html create mode 100644 packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html create mode 100644 packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html create mode 100644 packages/vue/__tests__/e2e/hydration-strat-media-vapor.html create mode 100644 packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html diff --git a/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html new file mode 100644 index 00000000000..a9824c0e832 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html @@ -0,0 +1,56 @@ +
click here to hydrate
+
+ + \ No newline at end of file diff --git a/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html new file mode 100644 index 00000000000..98bc0142c70 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html @@ -0,0 +1,48 @@ +
+ + + \ No newline at end of file diff --git a/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html new file mode 100644 index 00000000000..7ad79a939ff --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html @@ -0,0 +1,59 @@ +
click to hydrate
+
+ + + \ No newline at end of file diff --git a/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html new file mode 100644 index 00000000000..d03b6ea0469 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html @@ -0,0 +1,48 @@ +
resize the window width to < 500px to hydrate
+
+ + \ No newline at end of file diff --git a/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html new file mode 100644 index 00000000000..bd842999d16 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html @@ -0,0 +1,69 @@ + + +
scroll to the bottom to hydrate
+
+ + + \ No newline at end of file diff --git a/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts b/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts index d792edf1960..b846a1620fe 100644 --- a/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts +++ b/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts @@ -10,10 +10,13 @@ declare const window: Window & { } describe('async component hydration strategies', () => { - const { page, click, text, count } = setupPuppeteer(['--window-size=800,600']) + const { page, click, text, count } = setupPuppeteer([ + '--window-size=800,600', + '--disable-web-security', + ]) - async function goToCase(name: string, query = '') { - const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}.html${query}`)}` + async function goToCase(name: string, query = '', vapor = false) { + const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}${vapor ? '-vapor' : ''}.html${query}`)}` await page().goto(file) } @@ -22,138 +25,149 @@ describe('async component hydration strategies', () => { 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() + describe('vdom', () => { + runSharedTests(false) }) - 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() + describe('vapor', () => { + runSharedTests(true) }) - 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('visible (root v-if) should not throw error', async () => { - const spy = vi.fn() - const currentPage = page() - currentPage.on('pageerror', spy) - await goToCase('visible', '?v-if') - await page().waitForFunction(() => window.isRootMounted) - expect(await page().evaluate(() => window.isHydrated)).toBe(false) - expect(spy).toBeCalledTimes(0) - currentPage.off('pageerror', spy) - }) - - 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() - }) - - // #13255 - test('media query (patched before hydration)', async () => { - const spy = vi.fn() - const currentPage = page() - currentPage.on('pageerror', spy) - - const warn: any[] = [] - currentPage.on('console', e => warn.push(e.text())) - - await goToCase('media') - await page().waitForFunction(() => window.isRootMounted) - expect(await page().evaluate(() => window.isHydrated)).toBe(false) - - // patch - await page().evaluate(() => (window.show.value = false)) - await click('button') - expect(await text('button')).toBe('1') - - // resize - await page().setViewport({ width: 400, height: 600 }) - await page().waitForFunction(() => window.isHydrated) - await assertHydrationSuccess('2') - - expect(spy).toBeCalledTimes(0) - currentPage.off('pageerror', spy) - expect( - warn.some(w => w.includes('Skipping lazy hydration for component')), - ).toBe(true) - }) - - 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) - }) + function runSharedTests(vapor: boolean) { + test('idle', async () => { + const messages: string[] = [] + page().on('console', e => messages.push(e.text())) + + await goToCase('idle', '', vapor) + // 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', '', vapor) + 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', vapor) + 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', vapor) + 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('visible (root v-if) should not throw error', async () => { + const spy = vi.fn() + const currentPage = page() + currentPage.on('pageerror', spy) + await goToCase('visible', '?v-if', vapor) + await page().waitForFunction(() => window.isRootMounted) + expect(await page().evaluate(() => window.isHydrated)).toBe(false) + expect(spy).toBeCalledTimes(0) + currentPage.off('pageerror', spy) + }) + + test('media query', async () => { + await goToCase('media', '', vapor) + 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() + }) + + // #13255 + test('media query (patched before hydration)', async () => { + const spy = vi.fn() + const currentPage = page() + currentPage.on('pageerror', spy) + + const warn: any[] = [] + currentPage.on('console', e => warn.push(e.text())) + + await goToCase('media', '', vapor) + await page().waitForFunction(() => window.isRootMounted) + expect(await page().evaluate(() => window.isHydrated)).toBe(false) + + // patch + await page().evaluate(() => (window.show.value = false)) + await click('button') + expect(await text('button')).toBe('1') + + // resize + await page().setViewport({ width: 400, height: 600 }) + await page().waitForFunction(() => window.isHydrated) + await assertHydrationSuccess('2') + + expect(spy).toBeCalledTimes(0) + currentPage.off('pageerror', spy) + expect( + warn.some(w => w.includes('Skipping lazy hydration for component')), + ).toBe(true) + }) + + // TODO: problem is button click trigger twice + test.skipIf(vapor)('interaction', async () => { + await goToCase('interaction', '', vapor) + 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.skipIf(vapor)('interaction (fragment)', async () => { + await goToCase('interaction', '?fragment', vapor) + 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', '', vapor) + 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', '', vapor) + 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) + }) + } }) From 95f67ec97da0322c332572682429d007bdc1c0ad Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 02:11:18 +0000 Subject: [PATCH 16/20] [autofix.ci] apply automated fixes --- .../e2e/hydration-strat-custom-vapor.html | 35 +++++--- .../e2e/hydration-strat-idle-vapor.html | 22 +++-- .../hydration-strat-interaction-vapor.html | 25 ++++-- .../e2e/hydration-strat-media-vapor.html | 90 ++++++++++--------- .../e2e/hydration-strat-visible-vapor.html | 28 ++++-- 5 files changed, 125 insertions(+), 75 deletions(-) diff --git a/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html index a9824c0e832..a8bb0375943 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html @@ -3,11 +3,20 @@ \ No newline at end of file + diff --git a/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html index 98bc0142c70..ef3ab7ad21e 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html @@ -1,13 +1,21 @@
- \ No newline at end of file + diff --git a/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html index 7ad79a939ff..5c6daad8fbe 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html @@ -8,11 +8,20 @@ \ No newline at end of file + diff --git a/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html index d03b6ea0469..1c6c3ddfe84 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html @@ -1,48 +1,58 @@
resize the window width to < 500px to hydrate
-
+
- \ No newline at end of file + const show = (window.show = ref(true)) + return createComponent(AsyncComp, { value: () => show.value }) + }, + }).mount('#app') + diff --git a/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html index bd842999d16..e933edad8fb 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html @@ -10,11 +10,21 @@ \ No newline at end of file + From fabf9e5ea5db25ea5c889ba306698e32c4fc53cd Mon Sep 17 00:00:00 2001 From: daiwei Date: Sun, 12 Oct 2025 10:45:56 +0800 Subject: [PATCH 17/20] wip: save --- .../runtime-core/src/hydrationStrategies.ts | 6 +- .../hydration-strat-interaction-vapor.html | 15 ++- .../e2e/hydration-strat-media-vapor.html | 99 +++++++++---------- .../e2e/hydration-strat-visible-vapor.html | 16 +-- .../__tests__/e2e/hydrationStrategies.spec.ts | 5 +- 5 files changed, 74 insertions(+), 67 deletions(-) diff --git a/packages/runtime-core/src/hydrationStrategies.ts b/packages/runtime-core/src/hydrationStrategies.ts index bad39884830..5802d5a40d0 100644 --- a/packages/runtime-core/src/hydrationStrategies.ts +++ b/packages/runtime-core/src/hydrationStrategies.ts @@ -91,8 +91,10 @@ export const hydrateOnInteraction: HydrationStrategyFactory< hasHydrated = true teardown() hydrate() - // replay event - e.target!.dispatchEvent(new (e.constructor as any)(e.type, e)) + // replay event if the event is not delegated + if (!(`$evt${e.type}` in e.target!)) { + e.target!.dispatchEvent(new (e.constructor as any)(e.type, e)) + } } } const teardown = () => { diff --git a/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html index 5c6daad8fbe..8a59da1f2e5 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html @@ -38,15 +38,20 @@ console.log('hydrated') window.isHydrated = true }) - const n0 = template('', true)() - const x0 = child(n0) - n0.$evtclick = () => count.value++ - renderEffect(() => setText(x0, count.value)) + if (isFragment) { const n1 = template('one')() + const n0 = template('', true)() + const x0 = child(n0) + n0.$evtclick = () => count.value++ + renderEffect(() => setText(x0, count.value)) const n2 = template('two')() return [n1, n0, n2] } else { + const n0 = template('', true)() + const x0 = child(n0) + n0.$evtclick = () => count.value++ + renderEffect(() => setText(x0, count.value)) return n0 } }, @@ -65,4 +70,4 @@ return createComponent(AsyncComp) }, }).mount('#app') - + \ No newline at end of file diff --git a/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html index 1c6c3ddfe84..6aea0db9316 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html @@ -1,58 +1,57 @@
resize the window width to < 500px to hydrate
-
+
- + createVaporSSRApp({ + setup() { + onMounted(() => { + window.isRootMounted = true + }) + + const show = (window.show = ref(true)) + return createComponent(AsyncComp, { value: () => show.value }) + }, + }).mount('#app') + \ No newline at end of file diff --git a/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html index e933edad8fb..c7ab1a8dd5b 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html @@ -17,7 +17,6 @@ hydrateOnVisible, delegateEvents, template, - createIf, createComponent, child, renderEffect, @@ -46,18 +45,21 @@ window.isHydrated = true }) - const n0 = template('', true)() - const x0 = child(n0) - n0.$evtclick = () => count.value++ - renderEffect(() => setText(x0, count.value)) - if (isVIf) { return template('')() } else if (isFragment) { const n1 = template('one')() + const n0 = template('', true)() + const x0 = child(n0) + n0.$evtclick = () => count.value++ + renderEffect(() => setText(x0, count.value)) const n2 = template('two')() return [n1, n0, n2] } else { + const n0 = template('', true)() + const x0 = child(n0) + n0.$evtclick = () => count.value++ + renderEffect(() => setText(x0, count.value)) return n0 } }, @@ -76,4 +78,4 @@ return createComponent(AsyncComp) }, }).mount('#app') - + \ No newline at end of file diff --git a/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts b/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts index b846a1620fe..1fb29124524 100644 --- a/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts +++ b/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts @@ -128,8 +128,7 @@ describe('async component hydration strategies', () => { ).toBe(true) }) - // TODO: problem is button click trigger twice - test.skipIf(vapor)('interaction', async () => { + test('interaction', async () => { await goToCase('interaction', '', vapor) await page().waitForFunction(() => window.isRootMounted) expect(await page().evaluate(() => window.isHydrated)).toBe(false) @@ -140,7 +139,7 @@ describe('async component hydration strategies', () => { await assertHydrationSuccess('2') }) - test.skipIf(vapor)('interaction (fragment)', async () => { + test('interaction (fragment)', async () => { await goToCase('interaction', '?fragment', vapor) await page().waitForFunction(() => window.isRootMounted) expect(await page().evaluate(() => window.isHydrated)).toBe(false) From 20b3e86c6613dcee546be9d57d10926089581811 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 02:47:01 +0000 Subject: [PATCH 18/20] [autofix.ci] apply automated fixes --- .../hydration-strat-interaction-vapor.html | 2 +- .../e2e/hydration-strat-media-vapor.html | 98 +++++++++---------- .../e2e/hydration-strat-visible-vapor.html | 2 +- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html index 8a59da1f2e5..6d448c7d9c4 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html @@ -70,4 +70,4 @@ return createComponent(AsyncComp) }, }).mount('#app') - \ No newline at end of file + diff --git a/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html index 6aea0db9316..9aaa4d81afe 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html @@ -1,57 +1,57 @@
resize the window width to < 500px to hydrate
-
+
- \ No newline at end of file + const show = (window.show = ref(true)) + return createComponent(AsyncComp, { value: () => show.value }) + }, + }).mount('#app') + diff --git a/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html index c7ab1a8dd5b..a1c738a6df3 100644 --- a/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html +++ b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html @@ -78,4 +78,4 @@ return createComponent(AsyncComp) }, }).mount('#app') - \ No newline at end of file + From 77e3917f471da5a7804d885a4b8ed2293ca87d22 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sun, 12 Oct 2025 20:50:39 +0800 Subject: [PATCH 19/20] chore: update test-e2e script to include esm-browser-vapor --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e94789865e9..dbac818bcd1 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "format-check": "prettier --check --cache .", "test": "vitest", "test-unit": "vitest --project unit --project unit-jsdom", - "test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e", + "test-e2e": "node scripts/build.js vue -f global+esm-browser-vapor -d && vitest --project e2e", "test-e2e-vapor": "pnpm run prepare-e2e-vapor && vitest --project e2e-vapor", "prepare-e2e-vapor": "node scripts/build.js -f cjs+esm-bundler+esm-bundler-runtime && pnpm run -C packages-private/vapor-e2e-test build", "test-dts": "run-s build-dts test-dts-only", From 220f9ca88a0a5f8af6d8c32e1c220043f61a2aba Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 13 Oct 2025 09:39:18 +0800 Subject: [PATCH 20/20] wip: handling fragment --- .../runtime-vapor/__tests__/hydration.spec.ts | 115 ++++++++++++++++++ .../src/apiDefineAsyncComponent.ts | 18 ++- packages/runtime-vapor/src/component.ts | 19 ++- packages/runtime-vapor/src/dom/hydration.ts | 22 ++-- 4 files changed, 160 insertions(+), 14 deletions(-) diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index 86e513e894f..a20c6f3f55a 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -3090,6 +3090,78 @@ describe('Vapor Mode hydration', () => { ) }) + test('update async component (fragment root) after parent mount before async component resolve', async () => { + const data = ref({ + toggle: true, + }) + const compCode = ` + + + ` + const SSRComp = compileVaporComponent( + compCode, + undefined, + undefined, + true, + ) + let serverResolve: any + let AsyncComp = defineVaporAsyncComponent( + () => + new Promise(r => { + serverResolve = r + }), + ) + const appCode = `` + const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true) + + // server render + const htmlPromise = VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRApp), + ) + serverResolve(SSRComp) + const html = await htmlPromise + expect(html).toMatchInlineSnapshot( + `"

Async component

fragment root

"`, + ) + + // hydration + let clientResolve: any + AsyncComp = defineVaporAsyncComponent( + () => + new Promise(r => { + clientResolve = r + }), + ) + + const Comp = compileVaporComponent(compCode) + const App = compileVaporComponent(appCode, data, { AsyncComp }) + + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + createVaporSSRApp(App).mount(container) + + // update before resolve + data.value.toggle = false + await nextTick() + + // resolve + clientResolve(Comp) + await new Promise(r => setTimeout(r)) + + // prevent lazy hydration since the component has been patched + expect('Skipping lazy hydration for component').toHaveBeenWarned() + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + expect(container.innerHTML).toMatchInlineSnapshot( + `"

Updated async component

fragment root

"`, + ) + }) + // required vapor Suspense test.todo( 'hydrate safely when property used by async setup changed before render', @@ -3144,6 +3216,49 @@ describe('Vapor Mode hydration', () => { expect(container.innerHTML).toBe(`
hi
`) }) + test('unmount async wrapper before load (fragment)', async () => { + const data = ref({ + toggle: true, + }) + const compCode = `
async
fragment
` + const appCode = ` +
+ +
hi
+
+ ` + + // hydration + let clientResolve: any + const AsyncComp = defineVaporAsyncComponent( + () => + new Promise(r => { + clientResolve = r + }), + ) + + const Comp = compileVaporComponent(compCode) + const App = compileVaporComponent(appCode, data, { + AsyncComp, + }) + + const container = document.createElement('div') + container.innerHTML = + '
async
fragment
' + createVaporSSRApp(App).mount(container) + + // unmount before resolve + data.value.toggle = false + await nextTick() + expect(container.innerHTML).toBe(`
hi
`) + + // resolve + clientResolve(Comp) + await new Promise(r => setTimeout(r)) + // should remain unmounted + expect(container.innerHTML).toBe(`
hi
`) + }) + test('nested async wrapper', async () => { const toggleCode = `