From: Evan You Date: Wed, 12 Feb 2025 14:01:28 +0000 (+0800) Subject: wip(vapor): basic hydration X-Git-Tag: v3.6.0-alpha.1~16^2~49 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=64270ae1b42fbdb4e9d5a5ae8bafbefb0be15031;p=thirdparty%2Fvuejs%2Fcore.git wip(vapor): basic hydration --- diff --git a/packages/runtime-vapor/src/apiCreateApp.ts b/packages/runtime-vapor/src/apiCreateApp.ts index 8088e1aee6..e1b70cab98 100644 --- a/packages/runtime-vapor/src/apiCreateApp.ts +++ b/packages/runtime-vapor/src/apiCreateApp.ts @@ -7,6 +7,7 @@ import { unmountComponent, } from './component' import { + type App, type AppMountFn, type AppUnmountFn, type CreateAppFunction, @@ -20,6 +21,7 @@ import { import type { RawProps } from './componentProps' import { getGlobalThis } from '@vue/shared' import { optimizePropertyLookup } from './dom/prop' +import { withHydration } from './dom/hydrate' let _createApp: CreateAppFunction @@ -28,6 +30,9 @@ const mountApp: AppMountFn = (app, container) => { // clear content before mounting if (container.nodeType === 1 /* Node.ELEMENT_NODE */) { + if (__DEV__ && container.childNodes.length) { + warn('mount target container is not empty and will be cleared.') + } container.textContent = '' } @@ -38,21 +43,38 @@ const mountApp: AppMountFn = (app, container) => { false, app._context, ) - mountComponent(instance, container) flushOnAppMount() - return instance + return instance! +} + +let _hydrateApp: CreateAppFunction + +const hydrateApp: AppMountFn = (app, container) => { + optimizePropertyLookup() + + let instance: VaporComponentInstance + withHydration(container, () => { + instance = createComponent( + app._component, + app._props as RawProps, + null, + false, + app._context, + ) + mountComponent(instance, container) + flushOnAppMount() + }) + + return instance! } const unmountApp: AppUnmountFn = app => { unmountComponent(app._instance as VaporComponentInstance, app._container) } -export const createVaporApp: CreateAppFunction = ( - comp, - props, -) => { +function prepareApp() { // compile-time feature flags check if (__ESM_BUNDLER__ && !__TEST__) { initFeatureFlags() @@ -63,10 +85,9 @@ export const createVaporApp: CreateAppFunction = ( if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { setDevtoolsHook(target.__VUE_DEVTOOLS_GLOBAL_HOOK__, target) } +} - if (!_createApp) _createApp = createAppAPI(mountApp, unmountApp, getExposed) - const app = _createApp(comp, props) - +function postPrepareApp(app: App) { if (__DEV__) { app.config.globalProperties = new Proxy( {}, @@ -84,5 +105,27 @@ export const createVaporApp: CreateAppFunction = ( container = normalizeContainer(container) as ParentNode return mount(container, ...args) } +} + +export const createVaporApp: CreateAppFunction = ( + comp, + props, +) => { + prepareApp() + if (!_createApp) _createApp = createAppAPI(mountApp, unmountApp, getExposed) + const app = _createApp(comp, props) + postPrepareApp(app) + return app +} + +export const createVaporSSRApp: CreateAppFunction< + ParentNode, + VaporComponent +> = (comp, props) => { + prepareApp() + if (!_hydrateApp) + _hydrateApp = createAppAPI(hydrateApp, unmountApp, getExposed) + const app = _hydrateApp(comp, props) + postPrepareApp(app) return app } diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index af18870559..6ec39835bd 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -7,6 +7,7 @@ import { } from './component' import { createComment, createTextNode } from './dom/node' import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' +import { isHydrating } from './dom/hydrate' export type Block = | Node @@ -109,16 +110,23 @@ export function insert( ): void { anchor = anchor === 0 ? parent.firstChild : anchor if (block instanceof Node) { - parent.insertBefore(block, anchor) + if (!isHydrating) { + parent.insertBefore(block, anchor) + } } else if (isVaporComponent(block)) { - mountComponent(block, parent, anchor) + if (block.isMounted) { + insert(block.block!, parent, anchor) + } else { + mountComponent(block, parent, anchor) + } } else if (isArray(block)) { - for (let i = 0; i < block.length; i++) { - insert(block[i], parent, anchor) + for (const b of block) { + insert(b, parent, anchor) } } else { // fragment if (block.insert) { + // TODO handle hydration for vdom interop block.insert(parent, anchor) } else { insert(block.nodes, parent, anchor) @@ -127,6 +135,8 @@ export function insert( } } +export type InsertFn = typeof insert + export function prepend(parent: ParentNode, ...blocks: Block[]): void { let i = blocks.length while (i--) insert(blocks[i], parent, 0) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 3c39612bb8..3716ac7ae4 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -479,14 +479,10 @@ export function mountComponent( if (__DEV__) { startMeasure(instance, `mount`) } - if (!instance.isMounted) { - if (instance.bm) invokeArrayFns(instance.bm) - insert(instance.block, parent, anchor) - if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!)) - instance.isMounted = true - } else { - insert(instance.block, parent, anchor) - } + if (instance.bm) invokeArrayFns(instance.bm) + insert(instance.block, parent, anchor) + if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!)) + instance.isMounted = true if (__DEV__) { endMeasure(instance, `mount`) } diff --git a/packages/runtime-vapor/src/dom/hydrate.ts b/packages/runtime-vapor/src/dom/hydrate.ts new file mode 100644 index 0000000000..a958f1a42d --- /dev/null +++ b/packages/runtime-vapor/src/dom/hydrate.ts @@ -0,0 +1,113 @@ +import { child, next } from './node' + +export let isHydrating = false +export let currentHydrationNode: Node | null = null + +export function setCurrentHydrationNode(node: Node | null): void { + currentHydrationNode = node +} + +export function withHydration(container: ParentNode, fn: () => void): void { + adoptHydrationNode = adoptHydrationNodeImpl + isHydrating = true + currentHydrationNode = child(container) + const res = fn() + isHydrating = false + currentHydrationNode = null + return res +} + +export let adoptHydrationNode: ( + node: Node | null, + template?: string, +) => Node | null + +type Anchor = Comment & { + // previous open anchor + $p?: Anchor + // matching end anchor + $e?: Anchor +} + +const isComment = (node: Node, data: string): node is Anchor => + node.nodeType === 8 && (node as Comment).data === data + +/** + * Locate the first non-fragment-comment node and locate the next node + * while handling potential fragments. + */ +function adoptHydrationNodeImpl( + node: Node | null, + template?: string, +): Node | null { + if (!isHydrating || !node) { + return node + } + + let adopted: Node | undefined + let end: Node | undefined | null + + if (template) { + while (node.nodeType === 8) node = next(node) + adopted = end = node + } else if (isComment(node, '[')) { + // fragment + let start = node + let cur: Node = node + let fragmentDepth = 1 + // previously recorded fragment end + if (!end && node.$e) { + end = node.$e + } + while (true) { + cur = next(cur) + if (isComment(cur, '[')) { + // previously recorded fragment end + if (!end && node.$e) { + end = node.$e + } + fragmentDepth++ + cur.$p = start + start = cur + } else if (isComment(cur, ']')) { + fragmentDepth-- + // record fragment end on start node for later traversal + start.$e = cur + start = start.$p! + if (!fragmentDepth) { + // fragment end + end = cur + break + } + } else if (!adopted) { + adopted = cur + if (end) { + break + } + } + } + if (!adopted) { + throw new Error('hydration mismatch') + } + } else { + adopted = end = node + } + + if (__DEV__ && template) { + const type = adopted.nodeType + if ( + type === 8 || + (type === 1 && + !template.startsWith( + `<` + (adopted as Element).tagName.toLowerCase(), + )) || + (type === 3 && !template.startsWith((adopted as Text).data)) + ) { + // TODO recover + throw new Error('hydration mismatch!') + } + } + + currentHydrationNode = next(end!) + return adopted +} diff --git a/packages/runtime-vapor/src/dom/template.ts b/packages/runtime-vapor/src/dom/template.ts index 1eff20ec94..cb0e5ebaa0 100644 --- a/packages/runtime-vapor/src/dom/template.ts +++ b/packages/runtime-vapor/src/dom/template.ts @@ -1,13 +1,29 @@ +import { + adoptHydrationNode, + currentHydrationNode, + isHydrating, +} from './hydrate' +import { child } from './node' + +let t: HTMLTemplateElement + /*! #__NO_SIDE_EFFECTS__ */ export function template(html: string, root?: boolean) { - let node: ChildNode - const create = () => { - const t = document.createElement('template') - t.innerHTML = html - return t.content.firstChild! - } + let node: Node return (): Node & { $root?: true } => { - const ret = (node || (node = create())).cloneNode(true) + if (isHydrating) { + if (__DEV__ && !currentHydrationNode) { + // TODO this should not happen + throw new Error('No current hydration node') + } + return adoptHydrationNode(currentHydrationNode, html)! + } + if (!node) { + t = t || document.createElement('template') + t.innerHTML = html + node = child(t.content) + } + const ret = node.cloneNode(true) if (root) (ret as any).$root = true return ret } diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 89b6f50b07..16aeab9766 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -1,5 +1,5 @@ // public APIs -export { createVaporApp } from './apiCreateApp' +export { createVaporApp, createVaporSSRApp } from './apiCreateApp' export { defineVaporComponent } from './apiDefineComponent' export { vaporInteropPlugin } from './vdomInterop' export type { VaporDirective } from './directives/custom'