From a3cc970030579f2c55d893d6e83bbc05324adad4 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 12 Mar 2020 22:19:41 -0400 Subject: [PATCH] feat(ssr/suspense): suspense hydration In order to support hydration of async components, server-rendered fragments must be explicitly marked with comment nodes. --- .../compiler-core/__tests__/hydration.spec.ts | 6 +- .../__tests__/ssrComponent.spec.ts | 10 +- .../compiler-ssr/__tests__/ssrVFor.spec.ts | 24 ++- .../compiler-ssr/__tests__/ssrVIf.spec.ts | 8 +- .../compiler-ssr/src/ssrCodegenTransform.ts | 19 +- .../src/transforms/ssrTransformComponent.ts | 6 +- .../compiler-ssr/src/transforms/ssrVFor.ts | 14 +- .../compiler-ssr/src/transforms/ssrVIf.ts | 24 ++- packages/runtime-core/src/component.ts | 4 +- .../runtime-core/src/components/Suspense.ts | 133 +++++++++---- packages/runtime-core/src/hydration.ts | 185 ++++++++++++++---- packages/runtime-core/src/renderer.ts | 22 ++- packages/runtime-core/src/vnode.ts | 3 +- .../__tests__/renderToString.spec.ts | 17 +- .../__tests__/ssrSuspense.spec.ts | 8 +- .../src/helpers/ssrRenderSlot.ts | 3 + .../src/helpers/ssrRenderSuspense.ts | 29 ++- .../server-renderer/src/renderToString.ts | 2 + rollup.config.js | 7 +- 19 files changed, 385 insertions(+), 139 deletions(-) diff --git a/packages/compiler-core/__tests__/hydration.spec.ts b/packages/compiler-core/__tests__/hydration.spec.ts index 6d311bfe242..fc020a45706 100644 --- a/packages/compiler-core/__tests__/hydration.spec.ts +++ b/packages/compiler-core/__tests__/hydration.spec.ts @@ -98,7 +98,7 @@ describe('SSR hydration', () => { const msg = ref('foo') const fn = jest.fn() const { vnode, container } = mountWithHydration( - '
foo
', + '
foo
', () => 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(`bar`) + expect(vnode.el.innerHTML).toBe( + `bar` + ) }) test('portal', async () => { diff --git a/packages/compiler-ssr/__tests__/ssrComponent.spec.ts b/packages/compiler-ssr/__tests__/ssrComponent.spec.ts index 89e4b1d8d97..9872b22d79b 100644 --- a/packages/compiler-ssr/__tests__/ssrComponent.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrComponent.spec.ts @@ -219,11 +219,11 @@ describe('ssr: components', () => { foo: ({ list }, _push, _parent, _scopeId) => { if (_push) { if (_ctx.ok) { - _push(\`\`) + _push(\`\`) _ssrRenderList(list, (i) => { _push(\`\`) }) - _push(\`\`) + _push(\`\`) } else { _push(\`\`) } @@ -242,11 +242,11 @@ describe('ssr: components', () => { bar: ({ ok }, _push, _parent, _scopeId) => { if (_push) { if (ok) { - _push(\`\`) + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { _push(\`\`) }) - _push(\`\`) + _push(\`\`) } else { _push(\`\`) } @@ -281,7 +281,7 @@ describe('ssr: components', () => { .toMatchInlineSnapshot(` " return function ssrRender(_ctx, _push, _parent) { - _push(\`
\`) + _push(\`
\`) }" `) diff --git a/packages/compiler-ssr/__tests__/ssrVFor.spec.ts b/packages/compiler-ssr/__tests__/ssrVFor.spec.ts index eb301ab9bbe..62697ffa234 100644 --- a/packages/compiler-ssr/__tests__/ssrVFor.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrVFor.spec.ts @@ -6,9 +6,11 @@ describe('ssr: v-for', () => { "const { ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\") return function ssrRender(_ctx, _push, _parent) { + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { _push(\`
\`) }) + _push(\`\`) }" `) }) @@ -19,9 +21,11 @@ describe('ssr: v-for', () => { "const { ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\") return function ssrRender(_ctx, _push, _parent) { + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { _push(\`
foobar
\`) }) + _push(\`\`) }" `) }) @@ -37,8 +41,9 @@ describe('ssr: v-for', () => { "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\") return function ssrRender(_ctx, _push, _parent) { + _push(\`\`) _ssrRenderList(_ctx.list, (row, i) => { - _push(\`
\`) + _push(\`
\`) _ssrRenderList(row, (j) => { _push(\`
\${ _ssrInterpolate(i) @@ -46,8 +51,9 @@ describe('ssr: v-for', () => { _ssrInterpolate(j) }
\`) }) - _push(\`
\`) + _push(\`
\`) }) + _push(\`\`) }" `) }) @@ -58,9 +64,11 @@ describe('ssr: v-for', () => { "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\") return function ssrRender(_ctx, _push, _parent) { + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { - _push(\`\${_ssrInterpolate(i)}\`) + _push(\`\${_ssrInterpolate(i)}\`) }) + _push(\`\`) }" `) }) @@ -73,9 +81,11 @@ describe('ssr: v-for', () => { "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\") return function ssrRender(_ctx, _push, _parent) { + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { _push(\`\${_ssrInterpolate(i)}\`) }) + _push(\`\`) }" `) }) @@ -89,13 +99,15 @@ describe('ssr: v-for', () => { "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\") return function ssrRender(_ctx, _push, _parent) { + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { - _push(\`\${ + _push(\`\${ _ssrInterpolate(i) }\${ _ssrInterpolate(i + 1) - }\`) + }\`) }) + _push(\`\`) }" `) }) @@ -111,9 +123,11 @@ describe('ssr: v-for', () => { "const { ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require(\\"@vue/server-renderer\\") return function ssrRender(_ctx, _push, _parent) { + _push(\`\`) _ssrRenderList(_ctx.list, ({ foo }, index) => { _push(\`
\${_ssrInterpolate(foo + _ctx.bar + index)}
\`) }) + _push(\`\`) }" `) }) diff --git a/packages/compiler-ssr/__tests__/ssrVIf.spec.ts b/packages/compiler-ssr/__tests__/ssrVIf.spec.ts index 8ea086797fe..0e887c12107 100644 --- a/packages/compiler-ssr/__tests__/ssrVIf.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrVIf.spec.ts @@ -80,7 +80,7 @@ describe('ssr: v-if', () => { " return function ssrRender(_ctx, _push, _parent) { if (_ctx.foo) { - _push(\`hello\`) + _push(\`hello\`) } else { _push(\`\`) } @@ -110,7 +110,7 @@ describe('ssr: v-if', () => { " return function ssrRender(_ctx, _push, _parent) { if (_ctx.foo) { - _push(\`
hi
ho
\`) + _push(\`
hi
ho
\`) } else { _push(\`\`) } @@ -126,9 +126,11 @@ describe('ssr: v-if', () => { return function ssrRender(_ctx, _push, _parent) { if (_ctx.foo) { + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { _push(\`
\`) }) + _push(\`\`) } else { _push(\`\`) } @@ -145,7 +147,7 @@ describe('ssr: v-if', () => { " return function ssrRender(_ctx, _push, _parent) { if (_ctx.foo) { - _push(\`
hi
ho
\`) + _push(\`
hi
ho
\`) } else { _push(\`
\`) } diff --git a/packages/compiler-ssr/src/ssrCodegenTransform.ts b/packages/compiler-ssr/src/ssrCodegenTransform.ts index b9ef0c16691..c82c828080e 100644 --- a/packages/compiler-ssr/src/ssrCodegenTransform.ts +++ b/packages/compiler-ssr/src/ssrCodegenTransform.ts @@ -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(``) + } 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(``) + } } 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) } diff --git a/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts b/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts index 1acf45ee5b8..ff4ea8fa9a2 100644 --- a/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts +++ b/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts @@ -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 diff --git a/packages/compiler-ssr/src/transforms/ssrVFor.ts b/packages/compiler-ssr/src/transforms/ssrVFor.ts index 1921b8f0020..a4a78a8db17 100644 --- a/packages/compiler-ssr/src/transforms/ssrVFor.ts +++ b/packages/compiler-ssr/src/transforms/ssrVFor.ts @@ -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(``) context.pushStatement( createCallExpression(context.helper(SSR_RENDER_LIST), [ node.source, renderLoop ]) ) + context.pushStringPart(``) } diff --git a/packages/compiler-ssr/src/transforms/ssrVIf.ts b/packages/compiler-ssr/src/transforms/ssrVIf.ts index d1c71e1d51f..aad7ad14d98 100644 --- a/packages/compiler-ssr/src/transforms/ssrVIf.ts +++ b/packages/compiler-ssr/src/transforms/ssrVIf.ts @@ -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) +} diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index bf28cf552db..ccba387d52b 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -144,7 +144,6 @@ export interface ComponentInternalInstance { // suspense related asyncDep: Promise | 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__) { diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index 34066daefb0..b67ededa372 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -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( anchor: HostNode | null, isSVG: boolean, optimized: boolean, - rendererInternals: RendererInternals + rendererInternals: RendererInternals, + isHydrating = false ): SuspenseBoundary { const { p: patch, @@ -245,6 +244,12 @@ function createSuspenseBoundary( o: { parentNode } } = rendererInternals + const getCurrentTree = () => + suspense.isResolved || suspense.isHydrating + ? suspense.subTree + : suspense.fallbackTree + + const { content, fallback } = normalizeSuspenseChildren(vnode) const suspense: SuspenseBoundary = { vnode, parent, @@ -255,8 +260,9 @@ function createSuspenseBoundary( 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( 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( }, 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( }) } + const hydratedEl = instance.vnode.el suspense.deps++ instance .asyncDep!.catch(err => { @@ -411,14 +416,23 @@ function createSuspenseBoundary( 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( 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 ): { diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index 93716ebb862..3aaa292816a 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -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, @@ -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) { +export function createHydrationFunctions( + rendererInternals: RendererInternals +) { + 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 } diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 741beafec2d..f3d5fcbea57 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -202,7 +202,7 @@ type UnmountChildrenFn = ( export type MountComponentFn = ( initialVNode: VNode, - container: HostElement | null, // only null during hydration + container: HostElement, anchor: HostNode | null, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, @@ -219,7 +219,7 @@ type ProcessTextOrCommentFn = ( export type SetupRenderEffectFn = ( instance: ComponentInternalInstance, initialVNode: VNode, - container: HostElement | null, // only null during hydration + container: HostElement, anchor: HostNode | null, parentSuspense: SuspenseBoundary | null, isSVG: boolean @@ -991,7 +991,7 @@ function baseCreateRenderer< const mountComponent: MountComponentFn = ( 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, diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 272731e8a05..775ca7cc95b 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -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 } diff --git a/packages/server-renderer/__tests__/renderToString.spec.ts b/packages/server-renderer/__tests__/renderToString.spec.ts index 07d0e2f4f1e..710dbc9f4a3 100644 --- a/packages/server-renderer/__tests__/renderToString.spec.ts +++ b/packages/server-renderer/__tests__/renderToString.spec.ts @@ -257,7 +257,7 @@ describe('ssr: renderToString', () => { ) ).toBe( `
parent
` + - `from slot` + + `from slot` + `
` ) @@ -272,7 +272,9 @@ describe('ssr: renderToString', () => { } }) ) - ).toBe(`
parent
fallback
`) + ).toBe( + `
parent
fallback
` + ) }) test('nested components with vnode slots', async () => { @@ -316,7 +318,7 @@ describe('ssr: renderToString', () => { ) ).toBe( `
parent
` + - `from slot` + + `from slot` + `
` ) }) @@ -328,13 +330,13 @@ describe('ssr: renderToString', () => { } const app = createApp({ + components: { Child }, template: `
parent{{ msg }}
` }) - app.component('Child', Child) expect(await renderToString(app)).toBe( `
parent
` + - `from slot` + + `from slot` + `
` ) }) @@ -360,6 +362,7 @@ describe('ssr: renderToString', () => { expect(await renderToString(app)).toBe( `
parent
` + + // no comment anchors because slot is used directly as element children `from slot` + `
` ) @@ -456,7 +459,9 @@ describe('ssr: renderToString', () => { createCommentVNode('qux') ]) ) - ).toBe(`
foobarbaz
`) + ).toBe( + `
foobarbaz
` + ) }) test('void elements', async () => { diff --git a/packages/server-renderer/__tests__/ssrSuspense.spec.ts b/packages/server-renderer/__tests__/ssrSuspense.spec.ts index 38e016981a9..02a50fa1652 100644 --- a/packages/server-renderer/__tests__/ssrSuspense.spec.ts +++ b/packages/server-renderer/__tests__/ssrSuspense.spec.ts @@ -33,7 +33,7 @@ describe('SSR Suspense', () => { } }) - expect(await renderToString(app)).toBe(`
async
`) + expect(await renderToString(app)).toBe(`
async
`) }) test('with async component', async () => { @@ -49,7 +49,7 @@ describe('SSR Suspense', () => { } }) - expect(await renderToString(app)).toBe(`
async
`) + expect(await renderToString(app)).toBe(`
async
`) }) test('fallback', async () => { @@ -68,7 +68,9 @@ describe('SSR Suspense', () => { } }) - expect(await renderToString(app)).toBe(`
fallback
`) + expect(await renderToString(app)).toBe( + `
fallback
` + ) expect('Uncaught error in async setup').toHaveBeenWarned() }) }) diff --git a/packages/server-renderer/src/helpers/ssrRenderSlot.ts b/packages/server-renderer/src/helpers/ssrRenderSlot.ts index 1aae61f2b1a..bfdf137f004 100644 --- a/packages/server-renderer/src/helpers/ssrRenderSlot.ts +++ b/packages/server-renderer/src/helpers/ssrRenderSlot.ts @@ -18,6 +18,8 @@ export function ssrRenderSlot( push: PushFn, parentComponent: ComponentInternalInstance ) { + // template-compiled slots are always rendered as fragments + push(``) const slotFn = slots[slotName] if (slotFn) { if (slotFn.length > 1) { @@ -31,4 +33,5 @@ export function ssrRenderSlot( } else if (fallbackRenderFn) { fallbackRenderFn() } + push(``) } diff --git a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts index efb4bcd9c41..3f1f66d3e26 100644 --- a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts +++ b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts @@ -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): Promise { try { - const { push, getBuffer } = createBuffer() - renderContent(push) - return await getBuffer() + if (renderContent) { + const { push, getBuffer } = createBuffer() + push(``) + renderContent(push) + push(``) + return await getBuffer() + } else { + return [] + } } catch { - const { push, getBuffer } = createBuffer() - renderFallback(push) - return getBuffer() + if (renderFallback) { + const { push, getBuffer } = createBuffer() + push(``) + renderFallback(push) + push(``) + return getBuffer() + } else { + return [] + } } } diff --git a/packages/server-renderer/src/renderToString.ts b/packages/server-renderer/src/renderToString.ts index ee9dd8143d3..524c19c5ebb 100644 --- a/packages/server-renderer/src/renderToString.ts +++ b/packages/server-renderer/src/renderToString.ts @@ -256,7 +256,9 @@ function renderVNode( push(children ? `` : ``) break case Fragment: + push(``) // open renderVNodeChildren(push, children as VNodeArrayChildren, parentComponent) + push(``) // close break default: if (shapeFlag & ShapeFlags.ELEMENT) { diff --git a/rollup.config.js b/rollup.config.js index c90f4e03c74..8aa1d1980a6 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -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 ? [