diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index c7562436179..0a20f083233 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -614,7 +614,6 @@ function createSuspenseBoundary( } } } - // invoke @resolve event triggerEvent(vnode, 'onResolve') }, @@ -902,3 +901,21 @@ function isVNodeSuspensible(vnode: VNode) { const suspensible = vnode.props && vnode.props.suspensible return suspensible != null && suspensible !== false } + +export type ssrSuspenseBoundary = { + deps: number + resolve: (node: VNode) => void + vnode: VNode + parentSuspense: null | ssrSuspenseBoundary +} & SuspenseBoundary +export function createSSRSuspenseBoundary(vnode: VNode) { + return { + deps: 0, + resolve(vnode: VNode) { + // invoke @resolve event + triggerEvent(vnode, 'onResolve') + }, + vnode: vnode, + parentSuspense: null, + } +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 7f716b5f4e8..0e9560cd2c0 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -113,7 +113,12 @@ export { createVNode, cloneVNode, mergeProps, isVNode } from './vnode' export { Fragment, Text, Comment, Static, type VNodeRef } from './vnode' // Built-in components export { Teleport, type TeleportProps } from './components/Teleport' -export { Suspense, type SuspenseProps } from './components/Suspense' +export { + Suspense, + createSSRSuspenseBoundary, + type ssrSuspenseBoundary, + type SuspenseProps, +} from './components/Suspense' export { KeepAlive, type KeepAliveProps } from './components/KeepAlive' export { BaseTransition, diff --git a/packages/server-renderer/__tests__/render.spec.ts b/packages/server-renderer/__tests__/render.spec.ts index 1b1d6256e8c..4b895ce4bd6 100644 --- a/packages/server-renderer/__tests__/render.spec.ts +++ b/packages/server-renderer/__tests__/render.spec.ts @@ -963,6 +963,7 @@ function testRender(type: string, render: typeof renderToString) { _push, createVNode(resolveDynamicComponent('B'), null, null), _parent, + null, ) }, }), diff --git a/packages/server-renderer/__tests__/ssrSuspense.spec.ts b/packages/server-renderer/__tests__/ssrSuspense.spec.ts index eef642d0042..84cdb72f200 100644 --- a/packages/server-renderer/__tests__/ssrSuspense.spec.ts +++ b/packages/server-renderer/__tests__/ssrSuspense.spec.ts @@ -1,5 +1,6 @@ import { Suspense, createApp, h } from 'vue' import { renderToString } from '../src/renderToString' +import { expect } from 'vitest' describe('SSR Suspense', () => { const ResolvingAsync = { @@ -8,6 +9,12 @@ describe('SSR Suspense', () => { }, } + const ResolvingSync = { + setup() { + return () => h('div', 'sync') + }, + } + const RejectingAsync = { setup() { return new Promise((_, reject) => reject('foo')) @@ -78,6 +85,106 @@ describe('SSR Suspense', () => { expect('missing template or render function').toHaveBeenWarned() }) + test('suspense onResolve & ssr render & async', async () => { + const onResolve = vi.fn() + const Comp = { + render() { + return h( + Suspense, + { + onResolve, + }, + { + default: h('div', [h(ResolvingAsync), h(ResolvingAsync)]), + fallback: h('div', 'fallback'), + }, + ) + }, + } + expect(await renderToString(createApp(Comp))).toBe( + `
async
async
`, + ) + expect(onResolve).toHaveBeenCalledTimes(1) + }) + + test('suspense onResolve & ssr render & nested async deps', async () => { + const onResolve = vi.fn() + const child = { + async setup() { + return () => h(ResolvingAsync) + }, + } + const Comp = { + render() { + return h( + Suspense, + { + onResolve, + }, + { + default: h('div', [h(child)]), + fallback: h('div', 'fallback'), + }, + ) + }, + } + expect(await renderToString(createApp(Comp))).toBe( + `
async
`, + ) + expect(onResolve).toHaveBeenCalledTimes(1) + }) + + test('suspense onResolve & ssr render & sync', async () => { + const onResolve = vi.fn() + const Comp = { + render() { + return h( + Suspense, + { + onResolve, + }, + { + default: h('div', [h(ResolvingSync), h(ResolvingSync)]), + fallback: h('div', 'fallback'), + }, + ) + }, + } + expect(await renderToString(createApp(Comp))).toBe( + `
sync
sync
`, + ) + expect(onResolve).toHaveBeenCalledTimes(1) + }) + + test('nested suspense onResolve & ssr render', async () => { + const onResolve = vi.fn() + const onNestedResolve = vi.fn() + const Comp = { + render() { + return h( + Suspense, + { + onResolve, + }, + { + default: h( + Suspense, + { + onResolve: onNestedResolve, + }, + { + default: h(ResolvingAsync), + }, + ), + }, + ) + }, + } + expect(await renderToString(createApp(Comp))).toBe(`
async
`) + expect(onResolve).toHaveBeenCalledTimes(1) + expect(onNestedResolve).toHaveBeenCalledTimes(1) + }) + test('failing suspense in passing suspense', async () => { const Comp = { errorCaptured: vi.fn(() => false), diff --git a/packages/server-renderer/src/helpers/ssrRenderComponent.ts b/packages/server-renderer/src/helpers/ssrRenderComponent.ts index 4277bb1747e..106740f03db 100644 --- a/packages/server-renderer/src/helpers/ssrRenderComponent.ts +++ b/packages/server-renderer/src/helpers/ssrRenderComponent.ts @@ -17,6 +17,7 @@ export function ssrRenderComponent( return renderComponentVNode( createVNode(comp, props, children), parentComponent, + null, slotScopeId, ) } diff --git a/packages/server-renderer/src/helpers/ssrRenderSlot.ts b/packages/server-renderer/src/helpers/ssrRenderSlot.ts index 19aa4ce63b7..a935a2f93e4 100644 --- a/packages/server-renderer/src/helpers/ssrRenderSlot.ts +++ b/packages/server-renderer/src/helpers/ssrRenderSlot.ts @@ -70,6 +70,7 @@ export function ssrRenderSlotInner( push, validSlotContent, parentComponent, + null, slotScopeId, ) } else if (fallbackRenderFn) { diff --git a/packages/server-renderer/src/render.ts b/packages/server-renderer/src/render.ts index 97179526456..2d19f09a17b 100644 --- a/packages/server-renderer/src/render.ts +++ b/packages/server-renderer/src/render.ts @@ -10,7 +10,9 @@ import { type VNode, type VNodeArrayChildren, type VNodeProps, + createSSRSuspenseBoundary, mergeProps, + type ssrSuspenseBoundary, ssrUtils, warn, } from 'vue' @@ -89,6 +91,7 @@ export function createBuffer() { export function renderComponentVNode( vnode: VNode, parentComponent: ComponentInternalInstance | null = null, + suspense: ssrSuspenseBoundary | null = null, slotScopeId?: string, ): SSRBuffer | Promise { const instance = createComponentInstance(vnode, parentComponent, null) @@ -96,7 +99,8 @@ export function renderComponentVNode( const hasAsyncSetup = isPromise(res) let prefetches = instance.sp /* LifecycleHooks.SERVER_PREFETCH */ if (hasAsyncSetup || prefetches) { - const p: Promise = Promise.resolve(res as Promise) + suspense && suspense.deps++ + let p: Promise = Promise.resolve(res as Promise) .then(() => { // instance.sp may be null until an async setup resolves, so evaluate it here if (hasAsyncSetup) prefetches = instance.sp @@ -108,14 +112,33 @@ export function renderComponentVNode( }) // Note: error display is already done by the wrapped lifecycle hook function. .catch(NOOP) - return p.then(() => renderComponentSubTree(instance, slotScopeId)) + return p.then(() => { + const subtree = renderComponentSubTree(instance, suspense, slotScopeId) + if (__FEATURE_SUSPENSE__ && suspense) { + // resolve suspense + suspense.deps-- + if (suspense.deps <= 0) { + suspense.resolve(suspense.vnode) + } + + // resolve parent suspense + if (suspense.parentSuspense) { + suspense.parentSuspense.deps-- + if (suspense.deps <= 0) { + suspense.parentSuspense.resolve(suspense.parentSuspense.vnode) + } + } + } + return subtree + }) } else { - return renderComponentSubTree(instance, slotScopeId) + return renderComponentSubTree(instance, suspense, slotScopeId) } } function renderComponentSubTree( instance: ComponentInternalInstance, + parentSuspense: ssrSuspenseBoundary | null, slotScopeId?: string, ): SSRBuffer | Promise { const comp = instance.type as Component @@ -131,7 +154,13 @@ function renderComponentSubTree( } } } - renderVNode(push, (instance.subTree = root), instance, slotScopeId) + renderVNode( + push, + (instance.subTree = root), + instance, + parentSuspense, + slotScopeId, + ) } else { if ( (!instance.render || instance.render === NOOP) && @@ -199,6 +228,7 @@ function renderComponentSubTree( push, (instance.subTree = renderComponentRoot(instance)), instance, + parentSuspense, slotScopeId, ) } else { @@ -214,6 +244,7 @@ export function renderVNode( push: PushFn, vnode: VNode, parentComponent: ComponentInternalInstance, + parentSuspense: ssrSuspenseBoundary | null = null, slotScopeId?: string, ): void { const { type, shapeFlag, children, dirs, props } = vnode @@ -245,19 +276,62 @@ export function renderVNode( push, children as VNodeArrayChildren, parentComponent, + parentSuspense, slotScopeId, ) push(``) // close break default: if (shapeFlag & ShapeFlags.ELEMENT) { - renderElementVNode(push, vnode, parentComponent, slotScopeId) + renderElementVNode( + push, + vnode, + parentComponent, + parentSuspense, + slotScopeId, + ) } else if (shapeFlag & ShapeFlags.COMPONENT) { - push(renderComponentVNode(vnode, parentComponent, slotScopeId)) + push( + renderComponentVNode( + vnode, + parentComponent, + parentSuspense, + slotScopeId, + ), + ) } else if (shapeFlag & ShapeFlags.TELEPORT) { - renderTeleportVNode(push, vnode, parentComponent, slotScopeId) + renderTeleportVNode( + push, + vnode, + parentComponent, + parentSuspense, + slotScopeId, + ) } else if (shapeFlag & ShapeFlags.SUSPENSE) { - renderVNode(push, vnode.ssContent!, parentComponent, slotScopeId) + if (!vnode.suspense) { + vnode.suspense = createSSRSuspenseBoundary( + vnode, + ) as ssrSuspenseBoundary + } + + // nested suspense + if (parentSuspense) { + parentSuspense.deps++ + ;(vnode.suspense as ssrSuspenseBoundary).parentSuspense = + parentSuspense + } + renderVNode( + push, + vnode.ssContent!, + parentComponent, + vnode.suspense as ssrSuspenseBoundary, + slotScopeId, + ) + + // sync + if (vnode.suspense && vnode.suspense.deps! <= 0) { + ;(vnode.suspense as ssrSuspenseBoundary).resolve(vnode) + } } else { warn( '[@vue/server-renderer] Invalid VNode type:', @@ -272,10 +346,17 @@ export function renderVNodeChildren( push: PushFn, children: VNodeArrayChildren, parentComponent: ComponentInternalInstance, - slotScopeId?: string, + parentSuspense: ssrSuspenseBoundary | null, + slotScopeId?: string | undefined, ): void { for (let i = 0; i < children.length; i++) { - renderVNode(push, normalizeVNode(children[i]), parentComponent, slotScopeId) + renderVNode( + push, + normalizeVNode(children[i]), + parentComponent, + parentSuspense, + slotScopeId, + ) } } @@ -283,6 +364,7 @@ function renderElementVNode( push: PushFn, vnode: VNode, parentComponent: ComponentInternalInstance, + parentSuspense: ssrSuspenseBoundary | null, slotScopeId?: string, ) { const tag = vnode.type as string @@ -333,6 +415,7 @@ function renderElementVNode( push, children as VNodeArrayChildren, parentComponent, + parentSuspense, slotScopeId, ) } @@ -364,6 +447,7 @@ function renderTeleportVNode( push: PushFn, vnode: VNode, parentComponent: ComponentInternalInstance, + parentSuspense: ssrSuspenseBoundary | null, slotScopeId?: string, ) { const target = vnode.props && vnode.props.to @@ -387,6 +471,7 @@ function renderTeleportVNode( push, vnode.children as VNodeArrayChildren, parentComponent, + parentSuspense, slotScopeId, ) }, diff --git a/packages/server-renderer/src/renderToStream.ts b/packages/server-renderer/src/renderToStream.ts index e6b02d1cf99..80efc8f686b 100644 --- a/packages/server-renderer/src/renderToStream.ts +++ b/packages/server-renderer/src/renderToStream.ts @@ -73,7 +73,7 @@ export function renderToSimpleStream( // provide the ssr context to the tree input.provide(ssrContextKey, context) - Promise.resolve(renderComponentVNode(vnode)) + Promise.resolve(renderComponentVNode(vnode, undefined, null)) .then(buffer => unrollBuffer(buffer, stream)) .then(() => resolveTeleports(context)) .then(() => { diff --git a/packages/server-renderer/src/renderToString.ts b/packages/server-renderer/src/renderToString.ts index 9f7f9a1d74f..0e9eae34ada 100644 --- a/packages/server-renderer/src/renderToString.ts +++ b/packages/server-renderer/src/renderToString.ts @@ -81,7 +81,7 @@ export async function renderToString( vnode.appContext = input._context // provide the ssr context to the tree input.provide(ssrContextKey, context) - const buffer = await renderComponentVNode(vnode) + const buffer = await renderComponentVNode(vnode, undefined, null) const result = await unrollBuffer(buffer as SSRBuffer)