From: edison Date: Mon, 13 Oct 2025 01:42:26 +0000 (+0800) Subject: wip: hydrate vapor async component (#13976) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=1d87fc80e5d7159b3e960d6aa65e4d9d621c2566;p=thirdparty%2Fvuejs%2Fcore.git wip: hydrate vapor async component (#13976) --- diff --git a/package.json b/package.json index e94789865e..dbac818bcd 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", diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index ac579f6fb8..53d0e4ad3f 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, @@ -68,37 +69,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 +108,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( @@ -185,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 @@ -311,3 +277,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: () => GenericComponent | 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()! as GenericComponent + 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/component.ts b/packages/runtime-core/src/component.ts index 67d5a1b872..1a2e5879d1 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 47e8f8e274..8dc5777592 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/hydrationStrategies.ts b/packages/runtime-core/src/hydrationStrategies.ts index bad3988483..5802d5a40d 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/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index b7d811710b..c6033815d7 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, @@ -535,7 +536,12 @@ export { queueJob, flushOnAppMount } from './scheduler' /** * @internal */ -export { expose, nextUid, validateComponentName } from './component' +export { + expose, + nextUid, + validateComponentName, + isInSSRComponentSetup, +} from './component' /** * @internal */ @@ -595,6 +601,9 @@ export { createAsyncComponentContext, useAsyncComponentState, isAsyncWrapper, + performAsyncHydrate, + loadInnerComponent, + createInnerComp, } from './apiAsyncComponent' /** * @internal diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index eeda18af30..a20c6f3f55 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' @@ -85,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, }) @@ -2952,18 +2959,410 @@ 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() + }) + + // 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) + + // 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

"`, + ) + }) + + 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 }) - test('update async wrapper before resolve', async () => {}) + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + createVaporSSRApp(App).mount(container) - test('hydrate safely when property used by async setup changed before render', async () => {}) + // 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

"`, + ) + }) - test('unmount async wrapper before load', async () => {}) + // required vapor Suspense + test.todo( + 'hydrate safely when property used by async setup changed before render', + async () => {}, + ) - test('nested async wrapper', async () => {}) + // required vapor Suspense + test.todo( + 'hydrate safely when property used by deep nested async setup changed before render', + async () => {}, + ) - test('unmount async wrapper before load (fragment)', 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 + }), + ) + + 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('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 = ` + + + ` + + 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 () => { diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index e609dfa795..5072340af5 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -3,10 +3,15 @@ import { type AsyncComponentOptions, ErrorCodes, createAsyncComponentContext, + createInnerComp as createSSRInnerComp, currentInstance, handleError, + isInSSRComponentSetup, + loadInnerComponent as loadSSRInnerComponent, markAsyncBoundary, + performAsyncHydrate, useAsyncComponentState, + watch, } from '@vue/runtime-dom' import { defineVaporComponent } from './apiDefineComponent' import { @@ -16,8 +21,18 @@ import { } from './component' import { renderEffect } from './renderEffect' import { DynamicFragment } from './fragment' - -/*! #__NO_SIDE_EFFECTS__ */ +import { + hydrateNode, + isComment, + isHydrating, + locateEndAnchor, + removeFragmentNodes, +} from './dom/hydration' +import { invokeArrayFns } from '@vue/shared' +import { insert, remove } from './block' +import { parentNode } from './dom/node' + +/*@ __NO_SIDE_EFFECTS__ */ export function defineVaporAsyncComponent( source: AsyncComponentLoader | AsyncComponentOptions, ): T { @@ -29,9 +44,9 @@ export function defineVaporAsyncComponent( loadingComponent, errorComponent, delay, - // hydrate: hydrateStrategy, + hydrate: hydrateStrategy, timeout, - // suspensible = true, + suspensible = true, }, } = createAsyncComponentContext(source) @@ -40,9 +55,57 @@ export function defineVaporAsyncComponent( __asyncLoader: load, - // __asyncHydrate(el, instance, hydrate) { - // // TODO async hydrate - // }, + __asyncHydrate( + el: Element, + instance: VaporComponentInstance, + // Note: this hydrate function essentially calls the setup method of the component + // not the actual hydrate function + hydrate: () => void, + ) { + // 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() + if (isComment(el, '[')) { + const endAnchor = locateEndAnchor(el)! + removeFragmentNodes(el, endAnchor) + insert(instance.block, parent, endAnchor) + } else { + insert(instance.block, parent, el) + remove(el, parent) + } + }) + }, + { deep: true, once: true }, + ) + + performAsyncHydrate( + el, + instance, + () => { + hydrateNode(el, () => { + hydrate() + insert(instance.block, parentNode(el)!, el) + isHydrated = true + }) + }, + getResolvedComp, + load, + hydrateStrategy, + ) + }, get __asyncResolved() { return getResolvedComp() @@ -52,14 +115,20 @@ 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() if (resolvedComp) { - frag.update(() => createInnerComp(resolvedComp!, instance)) + // SSR + if (__SSR__ && isInSSRComponentSetup) { + return () => createSSRInnerComp(resolvedComp! as any, instance as any) + } + + frag!.update(() => createInnerComp(resolvedComp!, instance)) return frag } @@ -73,7 +142,19 @@ export function defineVaporAsyncComponent( ) } - // TODO suspense-controlled or SSR. + // TODO suspense-controlled + if (__FEATURE_SUSPENSE__ && suspensible && instance.suspense) { + } + + // SSR + if (__SSR__ && isInSSRComponentSetup) { + return loadSSRInnerComponent( + instance as any, + load, + onError, + errorComponent, + ) + } const { loaded, error, delayed } = useAsyncComponentState( delay, @@ -103,7 +184,7 @@ export function defineVaporAsyncComponent( } else if (loadingComponent && !delayed.value) { render = () => createComponent(loadingComponent) } - frag.update(render) + frag!.update(render) }) return frag diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 3828d7119c..0139d9cf1c 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, @@ -15,6 +16,7 @@ import { currentInstance, endMeasure, expose, + isAsyncWrapper, nextUid, popWarningContext, pushWarningContext, @@ -41,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, @@ -70,12 +66,14 @@ import { getSlot, } from './componentSlots' import { hmrReload, hmrRerender } from './hmr' -import { createElement } from './dom/node' +import { _next, createElement } from './dom/node' import { adoptTemplate, advanceHydrationNode, currentHydrationNode, + isComment, isHydrating, + locateEndAnchor, locateHydrationNode, setCurrentHydrationNode, } from './dom/hydration' @@ -103,6 +101,7 @@ export type FunctionalVaporComponent = VaporSetupFn & export interface ObjectVaporComponent extends ComponentInternalOptions, + AsyncComponentInternalOptions, SharedInternalOptions { setup?: VaporSetupFn inheritAttrs?: boolean @@ -118,8 +117,6 @@ export interface ObjectVaporComponent name?: string vapor?: boolean - __asyncLoader?: () => Promise - __asyncResolved?: VaporComponent } interface SharedInternalOptions { @@ -254,6 +251,64 @@ export function createComponent( instance.emitsOptions = normalizeEmitsOptions(component) } + // hydrating async component + if ( + isHydrating && + isAsyncWrapper(instance) && + 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 + const el = currentHydrationNode! + if (isComment(el, '[')) { + const end = _next(locateEndAnchor(el)!) + const block = (instance.block = [el as Node]) + let cur = el as Node + while (true) { + let n = _next(cur) + if (n && n !== end) { + block.push((cur = n)) + } else { + break + } + } + } else { + instance.block = el + } + // also mark it as mounted to ensure it can be unmounted before + // its inner component is resolved + instance.isMounted = true + + // advance current hydration node to the nextSibling + setCurrentHydrationNode( + isComment(el, '[') ? locateEndAnchor(el)! : el.nextSibling, + ) + component.__asyncHydrate(el as Element, instance, () => + setupComponent(instance, component, scopeId), + ) + } else { + setupComponent(instance, component, scopeId) + } + + onScopeDispose(() => unmountComponent(instance), true) + + if (_insertionParent || isHydrating) { + mountComponent(instance, _insertionParent!, _insertionAnchor) + } + + 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() @@ -311,6 +366,8 @@ export function createComponent( } } + if (scopeId) setScopeId(instance.block, scopeId) + setActiveSub(prevSub) setCurrentInstance(...prevInstance) @@ -318,19 +375,6 @@ export function createComponent( popWarningContext() endMeasure(instance, 'init') } - - onScopeDispose(() => unmountComponent(instance), true) - - if (scopeId) setScopeId(instance.block, scopeId) - - if (_insertionParent) { - mountComponent(instance, _insertionParent, _insertionAnchor) - } - - if (isHydrating && _insertionAnchor !== undefined) { - advanceHydrationNode(_insertionParent!) - } - return instance } export let isApplyingFallthroughProps = false @@ -623,19 +667,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__) { diff --git a/packages/runtime-vapor/src/dom/hydration.ts b/packages/runtime-vapor/src/dom/hydration.ts index 3f5eafbc2e..a542b360cb 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 } @@ -239,15 +247,7 @@ function handleMismatch(node: Node, template: string): Node { // fragment if (isComment(node, '[')) { - const end = locateEndAnchor(node as Anchor) - while (true) { - const next = _next(node) - if (next && next !== end) { - remove(next, parentNode(node)!) - } else { - break - } - } + removeFragmentNodes(node) } const next = _next(node) @@ -280,3 +280,15 @@ export const logMismatchError = (): void => { console.error('Hydration completed but contains mismatches.') hasLoggedMismatchError = true } + +export function removeFragmentNodes(node: Node, endAnchor?: Node): void { + const end = endAnchor || locateEndAnchor(node as Anchor) + while (true) { + const next = _next(node) + if (next && next !== end) { + remove(next, parentNode(node)!) + } else { + break + } + } +} diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index 65eb1af494..6f98e026b7 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) { 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 0000000000..a8bb037594 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html @@ -0,0 +1,69 @@ +
click here to hydrate
+
+ + 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 0000000000..ef3ab7ad21 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html @@ -0,0 +1,56 @@ +
+ + 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 0000000000..6d448c7d9c --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html @@ -0,0 +1,73 @@ +
click to hydrate
+
+ + + 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 0000000000..9aaa4d81af --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html @@ -0,0 +1,57 @@ +
resize the window width to < 500px to hydrate
+
+ + 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 0000000000..a1c738a6df --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html @@ -0,0 +1,81 @@ + + +
scroll to the bottom to hydrate
+
+ + + diff --git a/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts b/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts index d792edf196..1fb2912452 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,148 @@ 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) + }) + + test('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('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) + }) + } })