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(
+ `
`,
+ )
+ 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(
+ ``,
+ )
+ 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(
+ ``,
+ )
+ 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)