]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(ssr/suspense): suspense hydration
authorEvan You <yyx990803@gmail.com>
Fri, 13 Mar 2020 02:19:41 +0000 (22:19 -0400)
committerEvan You <yyx990803@gmail.com>
Fri, 13 Mar 2020 17:05:05 +0000 (13:05 -0400)
In order to support hydration of async components, server-rendered
fragments must be explicitly marked with comment nodes.

19 files changed:
packages/compiler-core/__tests__/hydration.spec.ts
packages/compiler-ssr/__tests__/ssrComponent.spec.ts
packages/compiler-ssr/__tests__/ssrVFor.spec.ts
packages/compiler-ssr/__tests__/ssrVIf.spec.ts
packages/compiler-ssr/src/ssrCodegenTransform.ts
packages/compiler-ssr/src/transforms/ssrTransformComponent.ts
packages/compiler-ssr/src/transforms/ssrVFor.ts
packages/compiler-ssr/src/transforms/ssrVIf.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/components/Suspense.ts
packages/runtime-core/src/hydration.ts
packages/runtime-core/src/renderer.ts
packages/runtime-core/src/vnode.ts
packages/server-renderer/__tests__/renderToString.spec.ts
packages/server-renderer/__tests__/ssrSuspense.spec.ts
packages/server-renderer/src/helpers/ssrRenderSlot.ts
packages/server-renderer/src/helpers/ssrRenderSuspense.ts
packages/server-renderer/src/renderToString.ts
rollup.config.js

index 6d311bfe2421acd63e49da95a7cec1ce7a3eb35a..fc020a45706807b38a473bb55d2663504e7012f4 100644 (file)
@@ -98,7 +98,7 @@ describe('SSR hydration', () => {
     const msg = ref('foo')
     const fn = jest.fn()
     const { vnode, container } = mountWithHydration(
-      '<div><span>foo</span><span class="foo"></span></div>',
+      '<div><!----><span>foo</span><!----><span class="foo"></span><!----><!----></div>',
       () =>
         h('div', [
           [h('span', msg.value), [h('span', { class: msg.value, onClick: fn })]]
@@ -136,7 +136,9 @@ describe('SSR hydration', () => {
 
     msg.value = 'bar'
     await nextTick()
-    expect(vnode.el.innerHTML).toBe(`<span>bar</span><span class="bar"></span>`)
+    expect(vnode.el.innerHTML).toBe(
+      `<!----><span>bar</span><!----><span class="bar"></span><!----><!---->`
+    )
   })
 
   test('portal', async () => {
index 89e4b1d8d977d37986bd2f2405dc67bfaf9e4665..9872b22d79b060dcde44f9fddc057e4fd5351c22 100644 (file)
@@ -219,11 +219,11 @@ describe('ssr: components', () => {
             foo: ({ list }, _push, _parent, _scopeId) => {
               if (_push) {
                 if (_ctx.ok) {
-                  _push(\`<div\${_scopeId}>\`)
+                  _push(\`<div\${_scopeId}><!--1-->\`)
                   _ssrRenderList(list, (i) => {
                     _push(\`<span\${_scopeId}></span>\`)
                   })
-                  _push(\`</div>\`)
+                  _push(\`<!--0--></div>\`)
                 } else {
                   _push(\`<!---->\`)
                 }
@@ -242,11 +242,11 @@ describe('ssr: components', () => {
             bar: ({ ok }, _push, _parent, _scopeId) => {
               if (_push) {
                 if (ok) {
-                  _push(\`<div\${_scopeId}>\`)
+                  _push(\`<div\${_scopeId}><!--1-->\`)
                   _ssrRenderList(_ctx.list, (i) => {
                     _push(\`<span\${_scopeId}></span>\`)
                   })
-                  _push(\`</div>\`)
+                  _push(\`<!--0--></div>\`)
                 } else {
                   _push(\`<!---->\`)
                 }
@@ -281,7 +281,7 @@ describe('ssr: components', () => {
         .toMatchInlineSnapshot(`
         "
         return function ssrRender(_ctx, _push, _parent) {
-          _push(\`<div></div>\`)
+          _push(\`<!--1--><div></div><!--0-->\`)
         }"
       `)
 
index eb301ab9bbed6be4288ce772685f252230ea93c6..62697ffa2349638a7b165dcaf46d69b498694e01 100644 (file)
@@ -6,9 +6,11 @@ describe('ssr: v-for', () => {
       "const { ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\")
 
       return function ssrRender(_ctx, _push, _parent) {
+        _push(\`<!--1-->\`)
         _ssrRenderList(_ctx.list, (i) => {
           _push(\`<div></div>\`)
         })
+        _push(\`<!--0-->\`)
       }"
     `)
   })
@@ -19,9 +21,11 @@ describe('ssr: v-for', () => {
       "const { ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\")
 
       return function ssrRender(_ctx, _push, _parent) {
+        _push(\`<!--1-->\`)
         _ssrRenderList(_ctx.list, (i) => {
           _push(\`<div>foo<span>bar</span></div>\`)
         })
+        _push(\`<!--0-->\`)
       }"
     `)
   })
@@ -37,8 +41,9 @@ describe('ssr: v-for', () => {
       "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\")
 
       return function ssrRender(_ctx, _push, _parent) {
+        _push(\`<!--1-->\`)
         _ssrRenderList(_ctx.list, (row, i) => {
-          _push(\`<div>\`)
+          _push(\`<div><!--1-->\`)
           _ssrRenderList(row, (j) => {
             _push(\`<div>\${
               _ssrInterpolate(i)
@@ -46,8 +51,9 @@ describe('ssr: v-for', () => {
               _ssrInterpolate(j)
             }</div>\`)
           })
-          _push(\`</div>\`)
+          _push(\`<!--0--></div>\`)
         })
+        _push(\`<!--0-->\`)
       }"
     `)
   })
@@ -58,9 +64,11 @@ describe('ssr: v-for', () => {
       "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\")
 
       return function ssrRender(_ctx, _push, _parent) {
+        _push(\`<!--1-->\`)
         _ssrRenderList(_ctx.list, (i) => {
-          _push(\`\${_ssrInterpolate(i)}\`)
+          _push(\`<!--1-->\${_ssrInterpolate(i)}<!--0-->\`)
         })
+        _push(\`<!--0-->\`)
       }"
     `)
   })
@@ -73,9 +81,11 @@ describe('ssr: v-for', () => {
       "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\")
 
       return function ssrRender(_ctx, _push, _parent) {
+        _push(\`<!--1-->\`)
         _ssrRenderList(_ctx.list, (i) => {
           _push(\`<span>\${_ssrInterpolate(i)}</span>\`)
         })
+        _push(\`<!--0-->\`)
       }"
     `)
   })
@@ -89,13 +99,15 @@ describe('ssr: v-for', () => {
       "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\")
 
       return function ssrRender(_ctx, _push, _parent) {
+        _push(\`<!--1-->\`)
         _ssrRenderList(_ctx.list, (i) => {
-          _push(\`<span>\${
+          _push(\`<!--1--><span>\${
             _ssrInterpolate(i)
           }</span><span>\${
             _ssrInterpolate(i + 1)
-          }</span>\`)
+          }</span><!--0-->\`)
         })
+        _push(\`<!--0-->\`)
       }"
     `)
   })
@@ -111,9 +123,11 @@ describe('ssr: v-for', () => {
       "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\")
 
       return function ssrRender(_ctx, _push, _parent) {
+        _push(\`<!--1-->\`)
         _ssrRenderList(_ctx.list, ({ foo }, index) => {
           _push(\`<div>\${_ssrInterpolate(foo + _ctx.bar + index)}</div>\`)
         })
+        _push(\`<!--0-->\`)
       }"
     `)
   })
index 8ea086797fe028bc817a697bef1714944bb6703c..0e887c121071109b29f3bc8b2ef715a8108a2b0d 100644 (file)
@@ -80,7 +80,7 @@ describe('ssr: v-if', () => {
       "
       return function ssrRender(_ctx, _push, _parent) {
         if (_ctx.foo) {
-          _push(\`hello\`)
+          _push(\`<!--1-->hello<!--0-->\`)
         } else {
           _push(\`<!---->\`)
         }
@@ -110,7 +110,7 @@ describe('ssr: v-if', () => {
       "
       return function ssrRender(_ctx, _push, _parent) {
         if (_ctx.foo) {
-          _push(\`<div>hi</div><div>ho</div>\`)
+          _push(\`<!--1--><div>hi</div><div>ho</div><!--0-->\`)
         } else {
           _push(\`<!---->\`)
         }
@@ -126,9 +126,11 @@ describe('ssr: v-if', () => {
 
       return function ssrRender(_ctx, _push, _parent) {
         if (_ctx.foo) {
+          _push(\`<!--1-->\`)
           _ssrRenderList(_ctx.list, (i) => {
             _push(\`<div></div>\`)
           })
+          _push(\`<!--0-->\`)
         } else {
           _push(\`<!---->\`)
         }
@@ -145,7 +147,7 @@ describe('ssr: v-if', () => {
       "
       return function ssrRender(_ctx, _push, _parent) {
         if (_ctx.foo) {
-          _push(\`<div>hi</div><div>ho</div>\`)
+          _push(\`<!--1--><div>hi</div><div>ho</div><!--0-->\`)
         } else {
           _push(\`<div></div>\`)
         }
index b9ef0c1669109043e130a93ba6d1e4deadfd3617..c82c828080e8457d92f028c64306b5d29446d971 100644 (file)
@@ -10,7 +10,8 @@ import {
   createBlockStatement,
   CompilerOptions,
   IfStatement,
-  CallExpression
+  CallExpression,
+  isText
 } from '@vue/compiler-dom'
 import { isString, escapeHtml } from '@vue/shared'
 import { SSR_INTERPOLATE, ssrHelpers } from './runtimeHelpers'
@@ -28,7 +29,9 @@ import { ssrProcessElement } from './transforms/ssrTransformElement'
 
 export function ssrCodegenTransform(ast: RootNode, options: CompilerOptions) {
   const context = createSSRTransformContext(ast, options)
-  processChildren(ast.children, context)
+  const isFragment =
+    ast.children.length > 1 && ast.children.some(c => !isText(c))
+  processChildren(ast.children, context, isFragment)
   ast.codegenNode = createBlockStatement(context.body)
 
   // Finalize helpers.
@@ -104,8 +107,12 @@ function createChildContext(
 
 export function processChildren(
   children: TemplateChildNode[],
-  context: SSRTransformContext
+  context: SSRTransformContext,
+  asFragment = false
 ) {
+  if (asFragment) {
+    context.pushStringPart(`<!--1-->`)
+  }
   for (let i = 0; i < children.length; i++) {
     const child = children[i]
     if (child.type === NodeTypes.ELEMENT) {
@@ -128,14 +135,18 @@ export function processChildren(
       ssrProcessFor(child, context)
     }
   }
+  if (asFragment) {
+    context.pushStringPart(`<!--0-->`)
+  }
 }
 
 export function processChildrenAsStatement(
   children: TemplateChildNode[],
   parentContext: SSRTransformContext,
+  asFragment = false,
   withSlotScopeId = parentContext.withSlotScopeId
 ): BlockStatement {
   const childContext = createChildContext(parentContext, withSlotScopeId)
-  processChildren(children, childContext)
+  processChildren(children, childContext, asFragment)
   return createBlockStatement(childContext.body)
 }
index 1acf45ee5b827b00a4cab81732e678390f446524..ff4ea8fa9a2fa64aa97fea31522b8a957ab2775f 100644 (file)
@@ -30,7 +30,8 @@ import {
   traverseNode,
   ExpressionNode,
   TemplateNode,
-  SUSPENSE
+  SUSPENSE,
+  TRANSITION_GROUP
 } from '@vue/compiler-dom'
 import { SSR_RENDER_COMPONENT } from '../runtimeHelpers'
 import {
@@ -151,7 +152,7 @@ export function ssrProcessComponent(
       return ssrProcessSuspense(node, context)
     } else {
       // real fall-through (e.g. KeepAlive): just render its children.
-      processChildren(node.children, context)
+      processChildren(node.children, context, component === TRANSITION_GROUP)
     }
   } else {
     // finish up slot function expressions from the 1st pass.
@@ -167,6 +168,7 @@ export function ssrProcessComponent(
         processChildrenAsStatement(
           children,
           context,
+          false,
           true /* withSlotScopeId */
         ),
         vnodeBranch
index 1921b8f0020c3fd33f87a27d674ab836f4f992e0..a4a78a8db177311bb87db560242758e3706ca616 100644 (file)
@@ -4,7 +4,8 @@ import {
   processFor,
   createCallExpression,
   createFunctionExpression,
-  createForLoopParams
+  createForLoopParams,
+  NodeTypes
 } from '@vue/compiler-dom'
 import {
   SSRTransformContext,
@@ -21,14 +22,23 @@ export const ssrTransformFor = createStructuralDirectiveTransform(
 // This is called during the 2nd transform pass to construct the SSR-sepcific
 // codegen nodes.
 export function ssrProcessFor(node: ForNode, context: SSRTransformContext) {
+  const needFragmentWrapper =
+    node.children.length !== 1 || node.children[0].type !== NodeTypes.ELEMENT
   const renderLoop = createFunctionExpression(
     createForLoopParams(node.parseResult)
   )
-  renderLoop.body = processChildrenAsStatement(node.children, context)
+  renderLoop.body = processChildrenAsStatement(
+    node.children,
+    context,
+    needFragmentWrapper
+  )
+  // v-for always renders a fragment
+  context.pushStringPart(`<!--1-->`)
   context.pushStatement(
     createCallExpression(context.helper(SSR_RENDER_LIST), [
       node.source,
       renderLoop
     ])
   )
+  context.pushStringPart(`<!--0-->`)
 }
index d1c71e1d51fcdde9216cd69c31c03c0e88fd4f5b..aad7ad14d98b00579251a9d15ef452faf40ce575 100644 (file)
@@ -4,7 +4,10 @@ import {
   IfNode,
   createIfStatement,
   createBlockStatement,
-  createCallExpression
+  createCallExpression,
+  IfBranchNode,
+  BlockStatement,
+  NodeTypes
 } from '@vue/compiler-dom'
 import {
   SSRTransformContext,
@@ -23,17 +26,14 @@ export function ssrProcessIf(node: IfNode, context: SSRTransformContext) {
   const [rootBranch] = node.branches
   const ifStatement = createIfStatement(
     rootBranch.condition!,
-    processChildrenAsStatement(rootBranch.children, context)
+    processIfBranch(rootBranch, context)
   )
   context.pushStatement(ifStatement)
 
   let currentIf = ifStatement
   for (let i = 1; i < node.branches.length; i++) {
     const branch = node.branches[i]
-    const branchBlockStatement = processChildrenAsStatement(
-      branch.children,
-      context
-    )
+    const branchBlockStatement = processIfBranch(branch, context)
     if (branch.condition) {
       // else-if
       currentIf = currentIf.alternate = createIfStatement(
@@ -52,3 +52,15 @@ export function ssrProcessIf(node: IfNode, context: SSRTransformContext) {
     ])
   }
 }
+
+function processIfBranch(
+  branch: IfBranchNode,
+  context: SSRTransformContext
+): BlockStatement {
+  const { children } = branch
+  const needFragmentWrapper =
+    (children.length !== 1 || children[0].type !== NodeTypes.ELEMENT) &&
+    // optimize away nested fragments when the only child is a ForNode
+    !(children.length === 1 && children[0].type === NodeTypes.FOR)
+  return processChildrenAsStatement(children, context, needFragmentWrapper)
+}
index bf28cf552dbdc985a2e4f7b08f6d67b4cca2f9cf..ccba387d52bd9c6b97ca7acd6a6c7e8b82261301 100644 (file)
@@ -144,7 +144,6 @@ export interface ComponentInternalInstance {
 
   // suspense related
   asyncDep: Promise<any> | null
-  asyncResult: unknown
   asyncResolved: boolean
 
   // storage for any extra properties
@@ -215,7 +214,6 @@ export function createComponentInstance(
 
     // async dependency management
     asyncDep: null,
-    asyncResult: null,
     asyncResolved: false,
 
     // user namespace for storing whatever the user assigns to `this`
@@ -367,7 +365,7 @@ function setupStatefulComponent(
     if (isPromise(setupResult)) {
       if (isSSR) {
         // return the promise so server-renderer can wait on it
-        return setupResult.then(resolvedResult => {
+        return setupResult.then((resolvedResult: unknown) => {
           handleSetupResult(instance, resolvedResult, parentSuspense, isSSR)
         })
       } else if (__FEATURE_SUSPENSE__) {
index 34066daefb0f6bf92a8ad57c302d07581b509c7b..b67ededa3721739d05664d0ddc7c3814209e7c1a 100644 (file)
@@ -5,8 +5,8 @@ import { Slots } from '../componentSlots'
 import { RendererInternals, MoveType, SetupRenderEffectFn } from '../renderer'
 import { queuePostFlushCb, queueJob } from '../scheduler'
 import { updateHOCHostEl } from '../componentRenderUtils'
-import { handleError, ErrorCodes } from '../errorHandling'
 import { pushWarningContext, popWarningContext } from '../warning'
+import { handleError, ErrorCodes } from '../errorHandling'
 
 export interface SuspenseProps {
   onResolve?: () => void
@@ -59,7 +59,8 @@ export const SuspenseImpl = {
         rendererInternals
       )
     }
-  }
+  },
+  hydrate: hydrateSuspense
 }
 
 // Force-casted public typing for h and TSX props inference
@@ -97,14 +98,10 @@ function mountSuspense(
     rendererInternals
   ))
 
-  const { content, fallback } = normalizeSuspenseChildren(n2)
-  suspense.subTree = content
-  suspense.fallbackTree = fallback
-
   // start mounting the content subtree in an off-dom container
   patch(
     null,
-    content,
+    suspense.subTree,
     hiddenContainer,
     null,
     parentComponent,
@@ -117,7 +114,7 @@ function mountSuspense(
     // mount the fallback tree
     patch(
       null,
-      fallback,
+      suspense.fallbackTree,
       container,
       anchor,
       parentComponent,
@@ -125,7 +122,7 @@ function mountSuspense(
       isSVG,
       optimized
     )
-    n2.el = fallback.el
+    n2.el = suspense.fallbackTree.el
   } else {
     // Suspense has no async deps. Just resolve.
     suspense.resolve()
@@ -209,6 +206,7 @@ export interface SuspenseBoundary<
   subTree: HostVNode
   fallbackTree: HostVNode
   deps: number
+  isHydrating: boolean
   isResolved: boolean
   isUnmounted: boolean
   effects: Function[]
@@ -235,7 +233,8 @@ function createSuspenseBoundary<HostNode, HostElement>(
   anchor: HostNode | null,
   isSVG: boolean,
   optimized: boolean,
-  rendererInternals: RendererInternals<HostNode, HostElement>
+  rendererInternals: RendererInternals<HostNode, HostElement>,
+  isHydrating = false
 ): SuspenseBoundary<HostNode, HostElement> {
   const {
     p: patch,
@@ -245,6 +244,12 @@ function createSuspenseBoundary<HostNode, HostElement>(
     o: { parentNode }
   } = rendererInternals
 
+  const getCurrentTree = () =>
+    suspense.isResolved || suspense.isHydrating
+      ? suspense.subTree
+      : suspense.fallbackTree
+
+  const { content, fallback } = normalizeSuspenseChildren(vnode)
   const suspense: SuspenseBoundary<HostNode, HostElement> = {
     vnode,
     parent,
@@ -255,8 +260,9 @@ function createSuspenseBoundary<HostNode, HostElement>(
     hiddenContainer,
     anchor,
     deps: 0,
-    subTree: (null as unknown) as VNode, // will be set immediately after creation
-    fallbackTree: (null as unknown) as VNode, // will be set immediately after creation
+    subTree: content,
+    fallbackTree: fallback,
+    isHydrating,
     isResolved: false,
     isUnmounted: false,
     effects: [],
@@ -283,17 +289,22 @@ function createSuspenseBoundary<HostNode, HostElement>(
         container
       } = suspense
 
-      // this is initial anchor on mount
-      let { anchor } = suspense
-      // unmount fallback tree
-      if (fallbackTree.el) {
-        // if the fallback tree was mounted, it may have been moved
-        // as part of a parent suspense. get the latest anchor for insertion
-        anchor = next(fallbackTree)
-        unmount(fallbackTree, parentComponent, suspense, true)
+      if (suspense.isHydrating) {
+        suspense.isHydrating = false
+      } else {
+        // this is initial anchor on mount
+        let { anchor } = suspense
+        // unmount fallback tree
+        if (fallbackTree.el) {
+          // if the fallback tree was mounted, it may have been moved
+          // as part of a parent suspense. get the latest anchor for insertion
+          anchor = next(fallbackTree)
+          unmount(fallbackTree, parentComponent, suspense, true)
+        }
+        // move content from off-dom container to actual container
+        move(subTree, container, anchor, MoveType.ENTER)
       }
-      // move content from off-dom container to actual container
-      move(subTree, container, anchor, MoveType.ENTER)
+
       const el = (vnode.el = subTree.el!)
       // suspense as the root node of a component...
       if (parentComponent && parentComponent.subTree === vnode) {
@@ -367,19 +378,12 @@ function createSuspenseBoundary<HostNode, HostElement>(
     },
 
     move(container, anchor, type) {
-      move(
-        suspense.isResolved ? suspense.subTree : suspense.fallbackTree,
-        container,
-        anchor,
-        type
-      )
+      move(getCurrentTree(), container, anchor, type)
       suspense.container = container
     },
 
     next() {
-      return next(
-        suspense.isResolved ? suspense.subTree : suspense.fallbackTree
-      )
+      return next(getCurrentTree())
     },
 
     registerDep(instance, setupRenderEffect) {
@@ -392,6 +396,7 @@ function createSuspenseBoundary<HostNode, HostElement>(
         })
       }
 
+      const hydratedEl = instance.vnode.el
       suspense.deps++
       instance
         .asyncDep!.catch(err => {
@@ -411,14 +416,23 @@ function createSuspenseBoundary<HostNode, HostElement>(
             pushWarningContext(vnode)
           }
           handleSetupResult(instance, asyncSetupResult, suspense, false)
-          // unset placeholder, otherwise this will be treated as a hydration mount
-          vnode.el = null
+          if (hydratedEl) {
+            // vnode may have been replaced if an update happened before the
+            // async dep is reoslved.
+            vnode.el = hydratedEl
+          }
           setupRenderEffect(
             instance,
             vnode,
-            // component may have been moved before resolve
-            parentNode(instance.subTree.el)!,
-            next(instance.subTree),
+            // component may have been moved before resolve.
+            // if this is not a hydration, instance.subTree will be the comment
+            // placeholder.
+            hydratedEl
+              ? parentNode(hydratedEl)!
+              : parentNode(instance.subTree.el)!,
+            // anchor will not be used if this is hydration, so only need to
+            // consider the comment placeholder case.
+            hydratedEl ? null : next(instance.subTree),
             suspense,
             isSVG
           )
@@ -449,6 +463,53 @@ function createSuspenseBoundary<HostNode, HostElement>(
   return suspense
 }
 
+function hydrateSuspense(
+  node: Node,
+  vnode: VNode,
+  parentComponent: ComponentInternalInstance | null,
+  parentSuspense: SuspenseBoundary | null,
+  isSVG: boolean,
+  optimized: boolean,
+  rendererInternals: RendererInternals,
+  hydrateNode: (
+    node: Node,
+    vnode: VNode,
+    parentComponent: ComponentInternalInstance | null,
+    parentSuspense: SuspenseBoundary | null,
+    optimized: boolean
+  ) => Node | null
+): Node | null {
+  const suspense = (vnode.suspense = createSuspenseBoundary(
+    vnode,
+    parentSuspense,
+    parentComponent,
+    node.parentNode,
+    document.createElement('div'),
+    null,
+    isSVG,
+    optimized,
+    rendererInternals,
+    true /* hydrating */
+  ))
+  // there are two possible scenarios for server-rendered suspense:
+  // - success: ssr content should be fully resolved
+  // - failure: ssr content should be the fallback branch.
+  // however, on the client we don't really know if it has failed or not
+  // attempt to hydrate the DOM assuming it has succeeded, but we still
+  // need to construct a suspense boundary first
+  const result = hydrateNode(
+    node,
+    suspense.subTree,
+    parentComponent,
+    suspense,
+    optimized
+  )
+  if (suspense.deps === 0) {
+    suspense.resolve()
+  }
+  return result
+}
+
 export function normalizeSuspenseChildren(
   vnode: VNode
 ): {
index 93716ebb8628325ebb10cf469634e7aee1e01aa6..3aaa292816ae004d0cca56deba65498ff4621d28 100644 (file)
@@ -1,5 +1,5 @@
 import { VNode, normalizeVNode, Text, Comment, Static, Fragment } from './vnode'
-import { queuePostFlushCb, flushPostFlushCbs } from './scheduler'
+import { flushPostFlushCbs } from './scheduler'
 import { ComponentInternalInstance } from './component'
 import { invokeDirectiveHook } from './directives'
 import { warn } from './warning'
@@ -11,6 +11,11 @@ import {
   isString
 } from '@vue/shared'
 import { RendererInternals } from './renderer'
+import {
+  SuspenseImpl,
+  SuspenseBoundary,
+  queueEffectWithSuspense
+} from './components/Suspense'
 
 export type RootHydrateFunction = (
   vnode: VNode<Node, Element>,
@@ -25,16 +30,27 @@ const enum DOMNodeTypes {
 
 let hasMismatch = false
 
+const isSVGContainer = (container: Element) =>
+  /svg/.test(container.namespaceURI!) && container.tagName !== 'foreignObject'
+
+const isComment = (node: Node): node is Comment =>
+  node.nodeType === DOMNodeTypes.COMMENT
+
 // Note: hydration is DOM-specific
 // But we have to place it in core due to tight coupling with core - splitting
 // it out creates a ton of unnecessary complexity.
 // Hydration also depends on some renderer internal logic which needs to be
 // passed in via arguments.
-export function createHydrationFunctions({
-  mt: mountComponent,
-  p: patch,
-  o: { patchProp, createText }
-}: RendererInternals<Node, Element>) {
+export function createHydrationFunctions(
+  rendererInternals: RendererInternals<Node, Element>
+) {
+  const {
+    mt: mountComponent,
+    p: patch,
+    n: next,
+    o: { patchProp, nextSibling, parentNode }
+  } = rendererInternals
+
   const hydrate: RootHydrateFunction = (vnode, container) => {
     if (__DEV__ && !container.hasChildNodes()) {
       warn(
@@ -45,7 +61,7 @@ export function createHydrationFunctions({
       return
     }
     hasMismatch = false
-    hydrateNode(container.firstChild!, vnode)
+    hydrateNode(container.firstChild!, vnode, null, null)
     flushPostFlushCbs()
     if (hasMismatch && !__TEST__) {
       // this error should show up in production
@@ -56,7 +72,8 @@ export function createHydrationFunctions({
   const hydrateNode = (
     node: Node,
     vnode: VNode,
-    parentComponent: ComponentInternalInstance | null = null,
+    parentComponent: ComponentInternalInstance | null,
+    parentSuspense: SuspenseBoundary | null,
     optimized = false
   ): Node | null => {
     const { type, shapeFlag } = vnode
@@ -67,7 +84,7 @@ export function createHydrationFunctions({
     switch (type) {
       case Text:
         if (domType !== DOMNodeTypes.TEXT) {
-          return handleMismtach(node, vnode, parentComponent)
+          return handleMismtach(node, vnode, parentComponent, parentSuspense)
         }
         if ((node as Text).data !== vnode.children) {
           hasMismatch = true
@@ -79,48 +96,83 @@ export function createHydrationFunctions({
             )
           ;(node as Text).data = vnode.children as string
         }
-        return node.nextSibling
+        return nextSibling(node)
       case Comment:
         if (domType !== DOMNodeTypes.COMMENT) {
-          return handleMismtach(node, vnode, parentComponent)
+          return handleMismtach(node, vnode, parentComponent, parentSuspense)
         }
-        return node.nextSibling
+        return nextSibling(node)
       case Static:
         if (domType !== DOMNodeTypes.ELEMENT) {
-          return handleMismtach(node, vnode, parentComponent)
+          return handleMismtach(node, vnode, parentComponent, parentSuspense)
         }
-        return node.nextSibling
+        return nextSibling(node)
       case Fragment:
-        return hydrateFragment(node, vnode, parentComponent, optimized)
+        if (domType !== DOMNodeTypes.COMMENT) {
+          return handleMismtach(node, vnode, parentComponent, parentSuspense)
+        }
+        return hydrateFragment(
+          node as Comment,
+          vnode,
+          parentComponent,
+          parentSuspense,
+          optimized
+        )
       default:
         if (shapeFlag & ShapeFlags.ELEMENT) {
           if (
             domType !== DOMNodeTypes.ELEMENT ||
             vnode.type !== (node as Element).tagName.toLowerCase()
           ) {
-            return handleMismtach(node, vnode, parentComponent)
+            return handleMismtach(node, vnode, parentComponent, parentSuspense)
           }
           return hydrateElement(
             node as Element,
             vnode,
             parentComponent,
+            parentSuspense,
             optimized
           )
         } else if (shapeFlag & ShapeFlags.COMPONENT) {
           // when setting up the render effect, if the initial vnode already
           // has .el set, the component will perform hydration instead of mount
           // on its sub-tree.
-          mountComponent(vnode, null, null, parentComponent, null, false)
+          const container = parentNode(node)!
+          mountComponent(
+            vnode,
+            container,
+            null,
+            parentComponent,
+            parentSuspense,
+            isSVGContainer(container)
+          )
           const subTree = vnode.component!.subTree
-          return (subTree.anchor || subTree.el).nextSibling
+          if (subTree) {
+            return next(subTree)
+          } else {
+            // no subTree means this is an async component
+            // try to locate the ending node
+            return isComment(node) && node.data === '1'
+              ? locateClosingAsyncAnchor(node)
+              : nextSibling(node)
+          }
         } else if (shapeFlag & ShapeFlags.PORTAL) {
           if (domType !== DOMNodeTypes.COMMENT) {
-            return handleMismtach(node, vnode, parentComponent)
+            return handleMismtach(node, vnode, parentComponent, parentSuspense)
           }
-          hydratePortal(vnode, parentComponent, optimized)
-          return node.nextSibling
+          hydratePortal(vnode, parentComponent, parentSuspense, optimized)
+          return nextSibling(node)
         } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
-          // TODO Suspense
+          return (vnode.type as typeof SuspenseImpl).hydrate(
+            node,
+            vnode,
+            parentComponent,
+            parentSuspense,
+            isSVGContainer(parentNode(node)!),
+            optimized,
+            rendererInternals,
+            hydrateNode
+          )
         } else if (__DEV__) {
           warn('Invalid HostVNode type:', type, `(${typeof type})`)
         }
@@ -132,6 +184,7 @@ export function createHydrationFunctions({
     el: Element,
     vnode: VNode,
     parentComponent: ComponentInternalInstance | null,
+    parentSuspense: SuspenseBoundary | null,
     optimized: boolean
   ) => {
     optimized = optimized || vnode.dynamicChildren !== null
@@ -161,9 +214,9 @@ export function createHydrationFunctions({
           invokeDirectiveHook(onVnodeBeforeMount, parentComponent, vnode)
         }
         if (onVnodeMounted != null) {
-          queuePostFlushCb(() => {
+          queueEffectWithSuspense(() => {
             invokeDirectiveHook(onVnodeMounted, parentComponent, vnode)
-          })
+          }, parentSuspense)
         }
       }
       // children
@@ -177,6 +230,7 @@ export function createHydrationFunctions({
           vnode,
           el,
           parentComponent,
+          parentSuspense,
           optimized
         )
         let hasWarned = false
@@ -215,6 +269,7 @@ export function createHydrationFunctions({
     vnode: VNode,
     container: Element,
     parentComponent: ComponentInternalInstance | null,
+    parentSuspense: SuspenseBoundary | null,
     optimized: boolean
   ): Node | null => {
     optimized = optimized || vnode.dynamicChildren !== null
@@ -226,7 +281,13 @@ export function createHydrationFunctions({
         ? children[i]
         : (children[i] = normalizeVNode(children[i]))
       if (node) {
-        node = hydrateNode(node, vnode, parentComponent, optimized)
+        node = hydrateNode(
+          node,
+          vnode,
+          parentComponent,
+          parentSuspense,
+          optimized
+        )
       } else {
         hasMismatch = true
         if (__DEV__ && !hasWarned) {
@@ -237,34 +298,43 @@ export function createHydrationFunctions({
           hasWarned = true
         }
         // the SSRed DOM didn't contain enough nodes. Mount the missing ones.
-        patch(null, vnode, container)
+        patch(
+          null,
+          vnode,
+          container,
+          null,
+          parentComponent,
+          parentSuspense,
+          isSVGContainer(container)
+        )
       }
     }
     return node
   }
 
   const hydrateFragment = (
-    node: Node,
+    node: Comment,
     vnode: VNode,
     parentComponent: ComponentInternalInstance | null,
+    parentSuspense: SuspenseBoundary | null,
     optimized: boolean
   ) => {
-    const parent = node.parentNode as Element
-    parent.insertBefore((vnode.el = createText('')), node)
-    const next = hydrateChildren(
-      node,
-      vnode,
-      parent,
-      parentComponent,
-      optimized
+    return nextSibling(
+      (vnode.anchor = hydrateChildren(
+        nextSibling(node)!,
+        vnode,
+        parentNode(node)!,
+        parentComponent,
+        parentSuspense,
+        optimized
+      )!)
     )
-    parent.insertBefore((vnode.anchor = createText('')), next)
-    return next
   }
 
   const hydratePortal = (
     vnode: VNode,
     parentComponent: ComponentInternalInstance | null,
+    parentSuspense: SuspenseBoundary | null,
     optimized: boolean
   ) => {
     const targetSelector = vnode.props && vnode.props.target
@@ -277,6 +347,7 @@ export function createHydrationFunctions({
         vnode,
         target,
         parentComponent,
+        parentSuspense,
         optimized
       )
     } else if (__DEV__) {
@@ -290,7 +361,8 @@ export function createHydrationFunctions({
   const handleMismtach = (
     node: Node,
     vnode: VNode,
-    parentComponent: ComponentInternalInstance | null
+    parentComponent: ComponentInternalInstance | null,
+    parentSuspense: SuspenseBoundary | null
   ) => {
     hasMismatch = true
     __DEV__ &&
@@ -298,16 +370,43 @@ export function createHydrationFunctions({
         `Hydration node mismatch:\n- Client vnode:`,
         vnode.type,
         `\n- Server rendered DOM:`,
-        node
+        node,
+        node.nodeType === DOMNodeTypes.TEXT ? `(text)` : ``
       )
     vnode.el = null
-    const next = node.nextSibling
-    const container = node.parentNode as Element
+    const next = nextSibling(node)
+    const container = parentNode(node)!
     container.removeChild(node)
-    // TODO Suspense and SVG
-    patch(null, vnode, container, next, parentComponent)
+    // TODO Suspense
+    patch(
+      null,
+      vnode,
+      container,
+      next,
+      parentComponent,
+      parentSuspense,
+      isSVGContainer(container)
+    )
     return next
   }
 
+  const locateClosingAsyncAnchor = (node: Node | null): Node | null => {
+    let match = 0
+    while (node) {
+      node = nextSibling(node)
+      if (node && isComment(node)) {
+        if (node.data === '1') match++
+        if (node.data === '0') {
+          if (match === 0) {
+            return nextSibling(node)
+          } else {
+            match--
+          }
+        }
+      }
+    }
+    return node
+  }
+
   return [hydrate, hydrateNode] as const
 }
index 741beafec2d84e9a95f7513c7771f200a70734ed..f3d5fcbea57d8885ac40f6a143a2f0c1893d9cba 100644 (file)
@@ -202,7 +202,7 @@ type UnmountChildrenFn<HostNode, HostElement> = (
 
 export type MountComponentFn<HostNode, HostElement> = (
   initialVNode: VNode<HostNode, HostElement>,
-  container: HostElement | null, // only null during hydration
+  container: HostElement,
   anchor: HostNode | null,
   parentComponent: ComponentInternalInstance | null,
   parentSuspense: SuspenseBoundary<HostNode, HostElement> | null,
@@ -219,7 +219,7 @@ type ProcessTextOrCommentFn<HostNode, HostElement> = (
 export type SetupRenderEffectFn<HostNode, HostElement> = (
   instance: ComponentInternalInstance,
   initialVNode: VNode<HostNode, HostElement>,
-  container: HostElement | null, // only null during hydration
+  container: HostElement,
   anchor: HostNode | null,
   parentSuspense: SuspenseBoundary<HostNode, HostElement> | null,
   isSVG: boolean
@@ -991,7 +991,7 @@ function baseCreateRenderer<
 
   const mountComponent: MountComponentFn<HostNode, HostElement> = (
     initialVNode,
-    container, // only null during hydration
+    container,
     anchor,
     parentComponent,
     parentSuspense,
@@ -1031,9 +1031,10 @@ function baseCreateRenderer<
       parentSuspense.registerDep(instance, setupRenderEffect)
 
       // Give it a placeholder if this is not hydration
-      const placeholder = (instance.subTree = createVNode(Comment))
-      processCommentNode(null, placeholder, container!, anchor)
-      initialVNode.el = placeholder.el
+      if (!initialVNode.el) {
+        const placeholder = (instance.subTree = createVNode(Comment))
+        processCommentNode(null, placeholder, container!, anchor)
+      }
       return
     }
 
@@ -1069,12 +1070,17 @@ function baseCreateRenderer<
         }
         if (initialVNode.el && hydrateNode) {
           // vnode has adopted host node - perform hydration instead of mount.
-          hydrateNode(initialVNode.el as Node, subTree, instance)
+          hydrateNode(
+            initialVNode.el as Node,
+            subTree,
+            instance,
+            parentSuspense
+          )
         } else {
           patch(
             null,
             subTree,
-            container!, // container is only null during hydration
+            container,
             anchor,
             instance,
             parentSuspense,
index 272731e8a0502263e191b7f7d2e9d0e94fee4479..775ca7cc95be6dc345bae1dc35b913f79f6c061a 100644 (file)
@@ -212,7 +212,8 @@ export function createVNode(
 ): VNode {
   if (!type) {
     if (__DEV__) {
-      warn(`Invalid vnode type when creating vnode: ${type}.`)
+      debugger
+      warn(`fsef Invalid vnode type when creating vnode: ${type}.`)
     }
     type = Comment
   }
index 07d0e2f4f1e42a97cabea035a6aa24f60d3bff1f..710dbc9f4a3281316279380e88ab44eb7b9d2925 100644 (file)
@@ -257,7 +257,7 @@ describe('ssr: renderToString', () => {
         )
       ).toBe(
         `<div>parent<div class="child">` +
-          `<span>from slot</span>` +
+          `<!--1--><span>from slot</span><!--0-->` +
           `</div></div>`
       )
 
@@ -272,7 +272,9 @@ describe('ssr: renderToString', () => {
             }
           })
         )
-      ).toBe(`<div>parent<div class="child">fallback</div></div>`)
+      ).toBe(
+        `<div>parent<div class="child"><!--1-->fallback<!--0--></div></div>`
+      )
     })
 
     test('nested components with vnode slots', async () => {
@@ -316,7 +318,7 @@ describe('ssr: renderToString', () => {
         )
       ).toBe(
         `<div>parent<div class="child">` +
-          `<span>from slot</span>` +
+          `<!--1--><span>from slot</span><!--0-->` +
           `</div></div>`
       )
     })
@@ -328,13 +330,13 @@ describe('ssr: renderToString', () => {
       }
 
       const app = createApp({
+        components: { Child },
         template: `<div>parent<Child v-slot="{ msg }"><span>{{ msg }}</span></Child></div>`
       })
-      app.component('Child', Child)
 
       expect(await renderToString(app)).toBe(
         `<div>parent<div class="child">` +
-          `<span>from slot</span>` +
+          `<!--1--><span>from slot</span><!--0-->` +
           `</div></div>`
       )
     })
@@ -360,6 +362,7 @@ describe('ssr: renderToString', () => {
 
       expect(await renderToString(app)).toBe(
         `<div>parent<div class="child">` +
+          // no comment anchors because slot is used directly as element children
           `<span>from slot</span>` +
           `</div></div>`
       )
@@ -456,7 +459,9 @@ describe('ssr: renderToString', () => {
             createCommentVNode('qux')
           ])
         )
-      ).toBe(`<div>foo<span>bar</span><span>baz</span><!--qux--></div>`)
+      ).toBe(
+        `<div>foo<span>bar</span><!--1--><span>baz</span><!--0--><!--qux--></div>`
+      )
     })
 
     test('void elements', async () => {
index 38e016981a930908f3115c7722af3dfd95cb7392..02a50fa1652026d81a5154068b46614e3a506c40 100644 (file)
@@ -33,7 +33,7 @@ describe('SSR Suspense', () => {
         }
       })
 
-      expect(await renderToString(app)).toBe(`<div>async</div>`)
+      expect(await renderToString(app)).toBe(`<!--1--><div>async</div><!--0-->`)
     })
 
     test('with async component', async () => {
@@ -49,7 +49,7 @@ describe('SSR Suspense', () => {
         }
       })
 
-      expect(await renderToString(app)).toBe(`<div>async</div>`)
+      expect(await renderToString(app)).toBe(`<!--1--><div>async</div><!--0-->`)
     })
 
     test('fallback', async () => {
@@ -68,7 +68,9 @@ describe('SSR Suspense', () => {
         }
       })
 
-      expect(await renderToString(app)).toBe(`<div>fallback</div>`)
+      expect(await renderToString(app)).toBe(
+        `<!--1--><div>fallback</div><!--0-->`
+      )
       expect('Uncaught error in async setup').toHaveBeenWarned()
     })
   })
index 1aae61f2b1ae57d41e2f405a9a36af8e67559769..bfdf137f0049728ff9932f90327e2787947a436b 100644 (file)
@@ -18,6 +18,8 @@ export function ssrRenderSlot(
   push: PushFn,
   parentComponent: ComponentInternalInstance
 ) {
+  // template-compiled slots are always rendered as fragments
+  push(`<!--1-->`)
   const slotFn = slots[slotName]
   if (slotFn) {
     if (slotFn.length > 1) {
@@ -31,4 +33,5 @@ export function ssrRenderSlot(
   } else if (fallbackRenderFn) {
     fallbackRenderFn()
   }
+  push(`<!--0-->`)
 }
index efb4bcd9c410758c4a5b3da6ba6134898ce9dd13..3f1f66d3e2686a7719487348e3f79730e70fd064 100644 (file)
@@ -1,19 +1,30 @@
 import { PushFn, ResolvedSSRBuffer, createBuffer } from '../renderToString'
-import { NOOP } from '@vue/shared'
 
 type ContentRenderFn = (push: PushFn) => void
 
 export async function ssrRenderSuspense({
-  default: renderContent = NOOP,
-  fallback: renderFallback = NOOP
+  default: renderContent,
+  fallback: renderFallback
 }: Record<string, ContentRenderFn | undefined>): Promise<ResolvedSSRBuffer> {
   try {
-    const { push, getBuffer } = createBuffer()
-    renderContent(push)
-    return await getBuffer()
+    if (renderContent) {
+      const { push, getBuffer } = createBuffer()
+      push(`<!--1-->`)
+      renderContent(push)
+      push(`<!--0-->`)
+      return await getBuffer()
+    } else {
+      return []
+    }
   } catch {
-    const { push, getBuffer } = createBuffer()
-    renderFallback(push)
-    return getBuffer()
+    if (renderFallback) {
+      const { push, getBuffer } = createBuffer()
+      push(`<!--1-->`)
+      renderFallback(push)
+      push(`<!--0-->`)
+      return getBuffer()
+    } else {
+      return []
+    }
   }
 }
index ee9dd8143d30736c767ea6f2010a29f4d6221ba0..524c19c5ebb286cb572f726b84dc9f26849f5521 100644 (file)
@@ -256,7 +256,9 @@ function renderVNode(
       push(children ? `<!--${children}-->` : `<!---->`)
       break
     case Fragment:
+      push(`<!--1-->`) // open
       renderVNodeChildren(push, children as VNodeArrayChildren, parentComponent)
+      push(`<!--0-->`) // close
       break
     default:
       if (shapeFlag & ShapeFlags.ELEMENT) {
index c90f4e03c74a0ef1197585108f74d65e76546809..8aa1d1980a61e47f583147056dac1d36cd09e6f9 100644 (file)
@@ -106,7 +106,12 @@ function createConfig(format, output, plugins = []) {
     format === 'esm-bundler-runtime' ? `src/runtime.ts` : `src/index.ts`
 
   const external =
-    isGlobalBuild || isRawESMBuild ? [] : Object.keys(pkg.dependencies || {})
+    isGlobalBuild || isRawESMBuild
+      ? []
+      : [
+          ...Object.keys(pkg.dependencies || {}),
+          ...Object.keys(pkg.peerDependencies || {})
+        ]
 
   const nodePlugins = packageOptions.enableNonBrowserBranches
     ? [