From 1e1e13a64e6d29d0ab3858fce68a14e85d110aae Mon Sep 17 00:00:00 2001 From: edison Date: Tue, 21 Oct 2025 09:40:50 +0800 Subject: [PATCH] feat(hydration): hydrate vapor async component (#14003) --- package.json | 2 +- .../runtime-core/src/apiAsyncComponent.ts | 85 ++-- packages/runtime-core/src/component.ts | 21 + packages/runtime-core/src/componentOptions.ts | 23 +- .../runtime-core/src/hydrationStrategies.ts | 6 +- packages/runtime-core/src/index.ts | 2 + .../runtime-vapor/__tests__/hydration.spec.ts | 417 +++++++++++++++++- .../src/apiDefineAsyncComponent.ts | 89 +++- packages/runtime-vapor/src/component.ts | 82 +++- .../e2e/hydration-strat-custom-vapor.html | 69 +++ .../e2e/hydration-strat-idle-vapor.html | 56 +++ .../hydration-strat-interaction-vapor.html | 73 +++ .../e2e/hydration-strat-media-vapor.html | 57 +++ .../e2e/hydration-strat-visible-vapor.html | 81 ++++ .../__tests__/e2e/hydrationStrategies.spec.ts | 279 ++++++------ packages/vue/src/index-with-vapor.ts | 2 +- packages/vue/src/index.ts | 109 +---- packages/vue/src/indexBase.ts | 107 +++++ packages/vue/src/runtime-with-vapor.ts | 2 +- packages/vue/src/runtime.ts | 29 +- packages/vue/src/runtimeBase.ts | 27 ++ packages/vue/src/vaporAliases.ts | 7 + 22 files changed, 1272 insertions(+), 353 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 create mode 100644 packages/vue/src/indexBase.ts create mode 100644 packages/vue/src/runtimeBase.ts create mode 100644 packages/vue/src/vaporAliases.ts diff --git a/package.json b/package.json index 26e4f5f6ca..f72036f7b0 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 1b7d60c8b2..068a77e209 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() { @@ -311,3 +289,48 @@ export const useAsyncComponentState = ( return { loaded, error, delayed } } + +/** + * 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 78314be69d..a2e94e1b1c 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -227,6 +227,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 0565f4fbd3..b15fe1e696 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -274,6 +274,7 @@ export type { GlobalDirectives, ComponentInstance, ComponentCustomElementInterface, + AsyncComponentInternalOptions, } from './component' export type { DefineComponent, @@ -587,6 +588,7 @@ export { createAsyncComponentContext, useAsyncComponentState, isAsyncWrapper, + performAsyncHydrate, } from './apiAsyncComponent' /** * @internal diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index 2e48ae5cee..fea78e9be1 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -1,5 +1,9 @@ -import { createVaporSSRApp, delegateEvents } from '../src' -import { nextTick, reactive, ref } from '@vue/runtime-dom' +import { + createVaporSSRApp, + defineVaporAsyncComponent, + delegateEvents, +} from '../src' +import { defineAsyncComponent, nextTick, reactive, ref } from '@vue/runtime-dom' import { compileScript, parse } from '@vue/compiler-sfc' import * as runtimeVapor from '../src' import * as runtimeDom from '@vue/runtime-dom' @@ -3591,6 +3595,415 @@ describe('Vapor Mode hydration', () => { }) }) + 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 + // use defineAsyncComponent in SSR + let AsyncComp = defineAsyncComponent( + () => + 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 + }), + ) as any + + 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 + // use defineAsyncComponent in SSR + let AsyncComp = defineAsyncComponent( + () => + 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 + }), + ) as any + + 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 + // use defineAsyncComponent in SSR + let AsyncComp = defineAsyncComponent( + () => + 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 + }), + ) as any + + 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', + 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', 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.todo('Suspense') describe('force hydrate prop', async () => { diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index 9021ab160d..7fe1cfc2ac 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -6,7 +6,9 @@ import { currentInstance, handleError, markAsyncBoundary, + performAsyncHydrate, useAsyncComponentState, + watch, } from '@vue/runtime-dom' import { defineVaporComponent } from './apiDefineComponent' import { @@ -16,8 +18,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 +41,9 @@ export function defineVaporAsyncComponent( loadingComponent, errorComponent, delay, - // hydrate: hydrateStrategy, + hydrate: hydrateStrategy, timeout, - // suspensible = true, + suspensible = true, }, } = createAsyncComponentContext(source) @@ -40,9 +52,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 +112,15 @@ 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)) + frag!.update(() => createInnerComp(resolvedComp!, instance)) return frag } @@ -73,7 +134,9 @@ export function defineVaporAsyncComponent( ) } - // TODO suspense-controlled or SSR. + // TODO suspense-controlled + if (__FEATURE_SUSPENSE__ && suspensible && instance.suspense) { + } const { loaded, error, delayed } = useAsyncComponentState( delay, @@ -103,7 +166,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 6ea662583d..1a3acf5c4d 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, isKeepAlive, nextUid, popWarningContext, @@ -67,12 +69,14 @@ import { adoptTemplate, advanceHydrationNode, currentHydrationNode, + isComment, isHydrating, + locateEndAnchor, locateHydrationNode, locateNextNode, setCurrentHydrationNode, } from './dom/hydration' -import { createElement } from './dom/node' +import { _next, createElement } from './dom/node' import { type TeleportFragment, isVaporTeleport } from './components/Teleport' import type { KeepAliveInstance } from './components/KeepAlive' import { @@ -99,6 +103,7 @@ export type FunctionalVaporComponent = VaporSetupFn & export interface ObjectVaporComponent extends ComponentInternalOptions, + AsyncComponentInternalOptions, SharedInternalOptions { setup?: VaporSetupFn inheritAttrs?: boolean @@ -260,6 +265,63 @@ 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), + ) + } else { + setupComponent(instance, component) + } + + 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, +): void { const prevInstance = setCurrentInstance(instance) const prevSub = setActiveSub() @@ -317,6 +379,8 @@ export function createComponent( } } + // TODO: scopeid + setActiveSub(prevSub) setCurrentInstance(...prevInstance) @@ -324,18 +388,6 @@ export function createComponent( popWarningContext() endMeasure(instance, 'init') } - - onScopeDispose(() => unmountComponent(instance), true) - - if (_insertionParent) { - mountComponent(instance, _insertionParent, _insertionAnchor) - } - - if (isHydrating && _isLastInsertion) { - advanceHydrationNode(_insertionParent!) - } - - return instance } export let isApplyingFallthroughProps = false @@ -617,7 +669,9 @@ export function mountComponent( startMeasure(instance, `mount`) } if (instance.bm) invokeArrayFns(instance.bm) - insert(instance.block, parent, anchor) + if (!isHydrating) { + insert(instance.block, parent, anchor) + } if (instance.m) queuePostFlushCb(instance.m!) if ( instance.shapeFlag! & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE && 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) + }) + } }) diff --git a/packages/vue/src/index-with-vapor.ts b/packages/vue/src/index-with-vapor.ts index 21f4c8073c..9e139113ce 100644 --- a/packages/vue/src/index-with-vapor.ts +++ b/packages/vue/src/index-with-vapor.ts @@ -1,3 +1,3 @@ // for type generation only -export * from './index' +export * from './indexBase' export * from '@vue/runtime-vapor' diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 785f3fd4bb..8d2de8d301 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,107 +1,2 @@ -// This entry is the "full-build" that includes both the runtime -// and the compiler, and supports on-the-fly compilation of the template option. -import { initDev } from './dev' -import { - type CompilerError, - type CompilerOptions, - compile, -} from '@vue/compiler-dom' -import { - type RenderFunction, - registerRuntimeCompiler, - warn, -} from '@vue/runtime-dom' -import * as runtimeDom from '@vue/runtime-dom' -import { - NOOP, - extend, - genCacheKey, - generateCodeFrame, - isString, -} from '@vue/shared' -import type { InternalRenderFunction } from 'packages/runtime-core/src/component' - -if (__DEV__) { - initDev() -} - -const compileCache: Record = Object.create(null) - -function compileToFunction( - template: string | HTMLElement, - options?: CompilerOptions, -): RenderFunction { - if (!isString(template)) { - if (template.nodeType) { - template = template.innerHTML - } else { - __DEV__ && warn(`invalid template option: `, template) - return NOOP - } - } - - const key = genCacheKey(template, options) - const cached = compileCache[key] - if (cached) { - return cached - } - - if (template[0] === '#') { - const el = document.querySelector(template) - if (__DEV__ && !el) { - warn(`Template element not found or is empty: ${template}`) - } - // __UNSAFE__ - // Reason: potential execution of JS expressions in in-DOM template. - // The user must make sure the in-DOM template is trusted. If it's rendered - // by the server, the template should not contain any user data. - template = el ? el.innerHTML : `` - } - - const opts = extend( - { - hoistStatic: true, - onError: __DEV__ ? onError : undefined, - onWarn: __DEV__ ? e => onError(e, true) : NOOP, - } as CompilerOptions, - options, - ) - - if (!opts.isCustomElement && typeof customElements !== 'undefined') { - opts.isCustomElement = tag => !!customElements.get(tag) - } - - const { code } = compile(template, opts) - - function onError(err: CompilerError, asWarning = false) { - const message = asWarning - ? err.message - : `Template compilation error: ${err.message}` - const codeFrame = - err.loc && - generateCodeFrame( - template as string, - err.loc.start.offset, - err.loc.end.offset, - ) - warn(codeFrame ? `${message}\n${codeFrame}` : message) - } - - // The wildcard import results in a huge object with every export - // with keys that cannot be mangled, and can be quite heavy size-wise. - // In the global build we know `Vue` is available globally so we can avoid - // the wildcard object. - const render = ( - __GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom) - ) as RenderFunction - - // mark the function as runtime compiled - ;(render as InternalRenderFunction)._rc = true - - return (compileCache[key] = render) -} - -registerRuntimeCompiler(compileToFunction) - -export { compileToFunction as compile } -export * from '@vue/runtime-dom' +export * from './indexBase' +export * from './vaporAliases' diff --git a/packages/vue/src/indexBase.ts b/packages/vue/src/indexBase.ts new file mode 100644 index 0000000000..785f3fd4bb --- /dev/null +++ b/packages/vue/src/indexBase.ts @@ -0,0 +1,107 @@ +// This entry is the "full-build" that includes both the runtime +// and the compiler, and supports on-the-fly compilation of the template option. +import { initDev } from './dev' +import { + type CompilerError, + type CompilerOptions, + compile, +} from '@vue/compiler-dom' +import { + type RenderFunction, + registerRuntimeCompiler, + warn, +} from '@vue/runtime-dom' +import * as runtimeDom from '@vue/runtime-dom' +import { + NOOP, + extend, + genCacheKey, + generateCodeFrame, + isString, +} from '@vue/shared' +import type { InternalRenderFunction } from 'packages/runtime-core/src/component' + +if (__DEV__) { + initDev() +} + +const compileCache: Record = Object.create(null) + +function compileToFunction( + template: string | HTMLElement, + options?: CompilerOptions, +): RenderFunction { + if (!isString(template)) { + if (template.nodeType) { + template = template.innerHTML + } else { + __DEV__ && warn(`invalid template option: `, template) + return NOOP + } + } + + const key = genCacheKey(template, options) + const cached = compileCache[key] + if (cached) { + return cached + } + + if (template[0] === '#') { + const el = document.querySelector(template) + if (__DEV__ && !el) { + warn(`Template element not found or is empty: ${template}`) + } + // __UNSAFE__ + // Reason: potential execution of JS expressions in in-DOM template. + // The user must make sure the in-DOM template is trusted. If it's rendered + // by the server, the template should not contain any user data. + template = el ? el.innerHTML : `` + } + + const opts = extend( + { + hoistStatic: true, + onError: __DEV__ ? onError : undefined, + onWarn: __DEV__ ? e => onError(e, true) : NOOP, + } as CompilerOptions, + options, + ) + + if (!opts.isCustomElement && typeof customElements !== 'undefined') { + opts.isCustomElement = tag => !!customElements.get(tag) + } + + const { code } = compile(template, opts) + + function onError(err: CompilerError, asWarning = false) { + const message = asWarning + ? err.message + : `Template compilation error: ${err.message}` + const codeFrame = + err.loc && + generateCodeFrame( + template as string, + err.loc.start.offset, + err.loc.end.offset, + ) + warn(codeFrame ? `${message}\n${codeFrame}` : message) + } + + // The wildcard import results in a huge object with every export + // with keys that cannot be mangled, and can be quite heavy size-wise. + // In the global build we know `Vue` is available globally so we can avoid + // the wildcard object. + const render = ( + __GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom) + ) as RenderFunction + + // mark the function as runtime compiled + ;(render as InternalRenderFunction)._rc = true + + return (compileCache[key] = render) +} + +registerRuntimeCompiler(compileToFunction) + +export { compileToFunction as compile } +export * from '@vue/runtime-dom' diff --git a/packages/vue/src/runtime-with-vapor.ts b/packages/vue/src/runtime-with-vapor.ts index 4f03329ede..eee717fc13 100644 --- a/packages/vue/src/runtime-with-vapor.ts +++ b/packages/vue/src/runtime-with-vapor.ts @@ -1,2 +1,2 @@ -export * from './runtime' +export * from './runtimeBase' export * from '@vue/runtime-vapor' diff --git a/packages/vue/src/runtime.ts b/packages/vue/src/runtime.ts index af1ffe7a12..1c81ab0bad 100644 --- a/packages/vue/src/runtime.ts +++ b/packages/vue/src/runtime.ts @@ -1,27 +1,2 @@ -// This entry exports the runtime only, and is built as -// `dist/vue.esm-bundler.js` which is used by default for bundlers. -import { NOOP } from '@vue/shared' -import { initDev } from './dev' -import { type RenderFunction, warn } from '@vue/runtime-dom' - -if (__DEV__) { - initDev() -} - -export * from '@vue/runtime-dom' - -export const compile = (_template: string): RenderFunction => { - if (__DEV__) { - warn( - `Runtime compilation is not supported in this build of Vue.` + - (__ESM_BUNDLER__ - ? ` Configure your bundler to alias "vue" to "vue/dist/vue.esm-bundler.js".` - : __ESM_BROWSER__ - ? ` Use "vue.esm-browser.js" instead.` - : __GLOBAL__ - ? ` Use "vue.global.js" instead.` - : ``) /* should not happen */, - ) - } - return NOOP -} +export * from './runtimeBase' +export * from './vaporAliases' diff --git a/packages/vue/src/runtimeBase.ts b/packages/vue/src/runtimeBase.ts new file mode 100644 index 0000000000..af1ffe7a12 --- /dev/null +++ b/packages/vue/src/runtimeBase.ts @@ -0,0 +1,27 @@ +// This entry exports the runtime only, and is built as +// `dist/vue.esm-bundler.js` which is used by default for bundlers. +import { NOOP } from '@vue/shared' +import { initDev } from './dev' +import { type RenderFunction, warn } from '@vue/runtime-dom' + +if (__DEV__) { + initDev() +} + +export * from '@vue/runtime-dom' + +export const compile = (_template: string): RenderFunction => { + if (__DEV__) { + warn( + `Runtime compilation is not supported in this build of Vue.` + + (__ESM_BUNDLER__ + ? ` Configure your bundler to alias "vue" to "vue/dist/vue.esm-bundler.js".` + : __ESM_BROWSER__ + ? ` Use "vue.esm-browser.js" instead.` + : __GLOBAL__ + ? ` Use "vue.global.js" instead.` + : ``) /* should not happen */, + ) + } + return NOOP +} diff --git a/packages/vue/src/vaporAliases.ts b/packages/vue/src/vaporAliases.ts new file mode 100644 index 0000000000..f426d9d664 --- /dev/null +++ b/packages/vue/src/vaporAliases.ts @@ -0,0 +1,7 @@ +// Vapor-only APIs do not exist in the standard build, yet SSR executes +// the standard entry. We alias them to the core implementations so SSR +// keeps working without the Vapor runtime. +export { + defineAsyncComponent as defineVaporAsyncComponent, + defineComponent as defineVaporComponent, +} from '@vue/runtime-core' -- 2.47.3