]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
wip(vapor): basic hydration
authorEvan You <evan@vuejs.org>
Wed, 12 Feb 2025 14:01:28 +0000 (22:01 +0800)
committerEvan You <evan@vuejs.org>
Fri, 7 Mar 2025 12:49:20 +0000 (20:49 +0800)
packages/runtime-vapor/src/apiCreateApp.ts
packages/runtime-vapor/src/block.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/dom/hydrate.ts [new file with mode: 0644]
packages/runtime-vapor/src/dom/template.ts
packages/runtime-vapor/src/index.ts

index 8088e1aee6d49f4cae7989723575242e9027867d..e1b70cab98281961b9fb25beff5e176db86a0d84 100644 (file)
@@ -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<ParentNode, VaporComponent>
 
@@ -28,6 +30,9 @@ const mountApp: AppMountFn<ParentNode> = (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<ParentNode> = (app, container) => {
     false,
     app._context,
   )
-
   mountComponent(instance, container)
   flushOnAppMount()
 
-  return instance
+  return instance!
+}
+
+let _hydrateApp: CreateAppFunction<ParentNode, VaporComponent>
+
+const hydrateApp: AppMountFn<ParentNode> = (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<ParentNode, VaporComponent> = (
-  comp,
-  props,
-) => {
+function prepareApp() {
   // compile-time feature flags check
   if (__ESM_BUNDLER__ && !__TEST__) {
     initFeatureFlags()
@@ -63,10 +85,9 @@ export const createVaporApp: CreateAppFunction<ParentNode, VaporComponent> = (
   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<ParentNode, VaporComponent> = (
     container = normalizeContainer(container) as ParentNode
     return mount(container, ...args)
   }
+}
+
+export const createVaporApp: CreateAppFunction<ParentNode, VaporComponent> = (
+  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
 }
index af18870559414e921b4aacc304a495c317443512..6ec39835bdf4825e2bf1fee2a7a5d4f3be0599ff 100644 (file)
@@ -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)
index 3c39612bb89d77ae185f6a4b278adec5377a5122..3716ac7ae4754c27f8eca0708459a8cb04ba1fec 100644 (file)
@@ -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 (file)
index 0000000..a958f1a
--- /dev/null
@@ -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
+}
index 1eff20ec94cdd73d27faa0c579afa9d2a6f22087..cb0e5ebaa039f0208625407e1ca6f1f122b80973 100644 (file)
@@ -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
   }
index 89b6f50b071907c83f909919f611dfe9c13cb557..16aeab9766f75527ce888e5a1ce2d46a74be2411 100644 (file)
@@ -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'