import { Comment, type VNode, type VNodeProps, closeBlock, createVNode, currentBlock, isBlockTreeEnabled, isSameVNodeType, normalizeVNode, openBlock, } from '../vnode' import { ShapeFlags, isArray, isFunction, toNumber } from '@vue/shared' import { type ComponentInternalInstance, handleSetupResult } from '../component' import type { Slots } from '../componentSlots' import { type ElementNamespace, MoveType, type RendererElement, type RendererInternals, type RendererNode, type SetupRenderEffectFn, } from '../renderer' import { queuePostFlushCb } from '../scheduler' import { filterSingleRoot, updateHOCHostEl } from '../componentRenderUtils' import { assertNumber, popWarningContext, pushWarningContext, warn, } from '../warning' import { ErrorCodes, handleError } from '../errorHandling' import { NULL_DYNAMIC_COMPONENT } from '../helpers/resolveAssets' export interface SuspenseProps { onResolve?: () => void onPending?: () => void onFallback?: () => void timeout?: string | number /** * Allow suspense to be captured by parent suspense * * @default false */ suspensible?: boolean } export const isSuspense = (type: any): boolean => type.__isSuspense // incrementing unique id for every pending branch let suspenseId = 0 /** * For testing only */ export const resetSuspenseId = () => (suspenseId = 0) // Suspense exposes a component-like API, and is treated like a component // in the compiler, but internally it's a special built-in type that hooks // directly into the renderer. export const SuspenseImpl = { name: 'Suspense', // In order to make Suspense tree-shakable, we need to avoid importing it // directly in the renderer. The renderer checks for the __isSuspense flag // on a vnode's type and calls the `process` method, passing in renderer // internals. __isSuspense: true, process( n1: VNode | null, n2: VNode, container: RendererElement, anchor: RendererNode | null, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, namespace: ElementNamespace, slotScopeIds: string[] | null, optimized: boolean, // platform-specific impl passed from renderer rendererInternals: RendererInternals, ) { if (n1 == null) { mountSuspense( n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, rendererInternals, ) } else { // #8678 if the current suspense needs to be patched and parentSuspense has // not been resolved. this means that both the current suspense and parentSuspense // need to be patched. because parentSuspense's pendingBranch includes the // current suspense, it will be processed twice: // 1. current patch // 2. mounting along with the pendingBranch of parentSuspense // it is necessary to skip the current patch to avoid multiple mounts // of inner components. if (parentSuspense && parentSuspense.deps > 0) { n2.suspense = n1.suspense! n2.suspense.vnode = n2 n2.el = n1.el return } patchSuspense( n1, n2, container, anchor, parentComponent, namespace, slotScopeIds, optimized, rendererInternals, ) } }, hydrate: hydrateSuspense, create: createSuspenseBoundary, normalize: normalizeSuspenseChildren, } // Force-casted public typing for h and TSX props inference export const Suspense = (__FEATURE_SUSPENSE__ ? SuspenseImpl : null) as unknown as { __isSuspense: true new (): { $props: VNodeProps & SuspenseProps $slots: { default(): VNode[] fallback(): VNode[] } } } function triggerEvent( vnode: VNode, name: 'onResolve' | 'onPending' | 'onFallback', ) { const eventListener = vnode.props && vnode.props[name] if (isFunction(eventListener)) { eventListener() } } function mountSuspense( vnode: VNode, container: RendererElement, anchor: RendererNode | null, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, namespace: ElementNamespace, slotScopeIds: string[] | null, optimized: boolean, rendererInternals: RendererInternals, ) { const { p: patch, o: { createElement }, } = rendererInternals const hiddenContainer = createElement('div') const suspense = (vnode.suspense = createSuspenseBoundary( vnode, parentSuspense, parentComponent, container, hiddenContainer, anchor, namespace, slotScopeIds, optimized, rendererInternals, )) // start mounting the content subtree in an off-dom container patch( null, (suspense.pendingBranch = vnode.ssContent!), hiddenContainer, null, parentComponent, suspense, namespace, slotScopeIds, ) // now check if we have encountered any async deps if (suspense.deps > 0) { // has async // invoke @fallback event triggerEvent(vnode, 'onPending') triggerEvent(vnode, 'onFallback') // mount the fallback tree patch( null, vnode.ssFallback!, container, anchor, parentComponent, null, // fallback tree will not have suspense context namespace, slotScopeIds, ) setActiveBranch(suspense, vnode.ssFallback!) } else { // Suspense has no async deps. Just resolve. suspense.resolve(false, true) } } function patchSuspense( n1: VNode, n2: VNode, container: RendererElement, anchor: RendererNode | null, parentComponent: ComponentInternalInstance | null, namespace: ElementNamespace, slotScopeIds: string[] | null, optimized: boolean, { p: patch, um: unmount, o: { createElement } }: RendererInternals, ) { const suspense = (n2.suspense = n1.suspense)! suspense.vnode = n2 n2.el = n1.el const newBranch = n2.ssContent! const newFallback = n2.ssFallback! const { activeBranch, pendingBranch, isInFallback, isHydrating } = suspense if (pendingBranch) { suspense.pendingBranch = newBranch if (isSameVNodeType(newBranch, pendingBranch)) { // same root type but content may have changed. patch( pendingBranch, newBranch, suspense.hiddenContainer, null, parentComponent, suspense, namespace, slotScopeIds, optimized, ) if (suspense.deps <= 0) { suspense.resolve() } else if (isInFallback) { // It's possible that the app is in hydrating state when patching the // suspense instance. If someone updates the dependency during component // setup in children of suspense boundary, that would be problemtic // because we aren't actually showing a fallback content when // patchSuspense is called. In such case, patch of fallback content // should be no op if (!isHydrating) { patch( activeBranch, newFallback, container, anchor, parentComponent, null, // fallback tree will not have suspense context namespace, slotScopeIds, optimized, ) setActiveBranch(suspense, newFallback) } } } else { // toggled before pending tree is resolved // increment pending ID. this is used to invalidate async callbacks suspense.pendingId = suspenseId++ if (isHydrating) { // if toggled before hydration is finished, the current DOM tree is // no longer valid. set it as the active branch so it will be unmounted // when resolved suspense.isHydrating = false suspense.activeBranch = pendingBranch } else { unmount(pendingBranch, parentComponent, suspense) } // reset suspense state suspense.deps = 0 // discard effects from pending branch suspense.effects.length = 0 // discard previous container suspense.hiddenContainer = createElement('div') if (isInFallback) { // already in fallback state patch( null, newBranch, suspense.hiddenContainer, null, parentComponent, suspense, namespace, slotScopeIds, optimized, ) if (suspense.deps <= 0) { suspense.resolve() } else { patch( activeBranch, newFallback, container, anchor, parentComponent, null, // fallback tree will not have suspense context namespace, slotScopeIds, optimized, ) setActiveBranch(suspense, newFallback) } } else if (activeBranch && isSameVNodeType(newBranch, activeBranch)) { // toggled "back" to current active branch patch( activeBranch, newBranch, container, anchor, parentComponent, suspense, namespace, slotScopeIds, optimized, ) // force resolve suspense.resolve(true) } else { // switched to a 3rd branch patch( null, newBranch, suspense.hiddenContainer, null, parentComponent, suspense, namespace, slotScopeIds, optimized, ) if (suspense.deps <= 0) { suspense.resolve() } } } } else { if (activeBranch && isSameVNodeType(newBranch, activeBranch)) { // root did not change, just normal patch patch( activeBranch, newBranch, container, anchor, parentComponent, suspense, namespace, slotScopeIds, optimized, ) setActiveBranch(suspense, newBranch) } else { // root node toggled // invoke @pending event triggerEvent(n2, 'onPending') // mount pending branch in off-dom container suspense.pendingBranch = newBranch if (newBranch.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) { suspense.pendingId = newBranch.component!.suspenseId! } else { suspense.pendingId = suspenseId++ } patch( null, newBranch, suspense.hiddenContainer, null, parentComponent, suspense, namespace, slotScopeIds, optimized, ) if (suspense.deps <= 0) { // incoming branch has no async deps, resolve now. suspense.resolve() } else { const { timeout, pendingId } = suspense if (timeout > 0) { setTimeout(() => { if (suspense.pendingId === pendingId) { suspense.fallback(newFallback) } }, timeout) } else if (timeout === 0) { suspense.fallback(newFallback) } } } } } export interface SuspenseBoundary { vnode: VNode parent: SuspenseBoundary | null parentComponent: ComponentInternalInstance | null namespace: ElementNamespace container: RendererElement hiddenContainer: RendererElement activeBranch: VNode | null pendingBranch: VNode | null deps: number pendingId: number timeout: number isInFallback: boolean isHydrating: boolean isUnmounted: boolean effects: Function[] resolve(force?: boolean, sync?: boolean): void fallback(fallbackVNode: VNode): void move( container: RendererElement, anchor: RendererNode | null, type: MoveType, ): void next(): RendererNode | null registerDep( instance: ComponentInternalInstance, setupRenderEffect: SetupRenderEffectFn, ): void unmount(parentSuspense: SuspenseBoundary | null, doRemove?: boolean): void } let hasWarned = false function createSuspenseBoundary( vnode: VNode, parentSuspense: SuspenseBoundary | null, parentComponent: ComponentInternalInstance | null, container: RendererElement, hiddenContainer: RendererElement, anchor: RendererNode | null, namespace: ElementNamespace, slotScopeIds: string[] | null, optimized: boolean, rendererInternals: RendererInternals, isHydrating = false, ): SuspenseBoundary { /* istanbul ignore if */ if (__DEV__ && !__TEST__ && !hasWarned) { hasWarned = true // @ts-expect-error `console.info` cannot be null error // eslint-disable-next-line no-console console[console.info ? 'info' : 'log']( ` is an experimental feature and its API will likely change.`, ) } const { p: patch, m: move, um: unmount, n: next, o: { parentNode, remove }, } = rendererInternals // if set `suspensible: true`, set the current suspense as a dep of parent suspense let parentSuspenseId: number | undefined const isSuspensible = isVNodeSuspensible(vnode) if (isSuspensible) { if (parentSuspense?.pendingBranch) { parentSuspenseId = parentSuspense.pendingId parentSuspense.deps++ } } const timeout = vnode.props ? toNumber(vnode.props.timeout) : undefined if (__DEV__) { assertNumber(timeout, `Suspense timeout`) } const initialAnchor = anchor const suspense: SuspenseBoundary = { vnode, parent: parentSuspense, parentComponent, namespace, container, hiddenContainer, deps: 0, pendingId: suspenseId++, timeout: typeof timeout === 'number' ? timeout : -1, activeBranch: null, pendingBranch: null, isInFallback: !isHydrating, isHydrating, isUnmounted: false, effects: [], resolve(resume = false, sync = false) { if (__DEV__) { if (!resume && !suspense.pendingBranch) { throw new Error( `suspense.resolve() is called without a pending branch.`, ) } if (suspense.isUnmounted) { throw new Error( `suspense.resolve() is called on an already unmounted suspense boundary.`, ) } } const { vnode, activeBranch, pendingBranch, pendingId, effects, parentComponent, container, } = suspense // if there's a transition happening we need to wait it to finish. let delayEnter: boolean | null = false if (suspense.isHydrating) { suspense.isHydrating = false } else if (!resume) { delayEnter = activeBranch && pendingBranch!.transition && pendingBranch!.transition.mode === 'out-in' if (delayEnter) { activeBranch!.transition!.afterLeave = () => { if (pendingId === suspense.pendingId) { move( pendingBranch!, container, anchor === initialAnchor ? next(activeBranch!) : anchor, MoveType.ENTER, ) queuePostFlushCb(effects) } } } // unmount current active tree if (activeBranch) { // if the fallback tree was mounted, it may have been moved // as part of a parent suspense. get the latest anchor for insertion // #8105 if `delayEnter` is true, it means that the mounting of // `activeBranch` will be delayed. if the branch switches before // transition completes, both `activeBranch` and `pendingBranch` may // coexist in the `hiddenContainer`. This could result in // `next(activeBranch!)` obtaining an incorrect anchor // (got `pendingBranch.el`). // Therefore, after the mounting of activeBranch is completed, // it is necessary to get the latest anchor. if (parentNode(activeBranch.el!) !== suspense.hiddenContainer) { anchor = next(activeBranch) } unmount(activeBranch, parentComponent, suspense, true) } if (!delayEnter) { // move content from off-dom container to actual container move(pendingBranch!, container, anchor, MoveType.ENTER) } } setActiveBranch(suspense, pendingBranch!) suspense.pendingBranch = null suspense.isInFallback = false // flush buffered effects // check if there is a pending parent suspense let parent = suspense.parent let hasUnresolvedAncestor = false while (parent) { if (parent.pendingBranch) { // found a pending parent suspense, merge buffered post jobs // into that parent parent.effects.push(...effects) hasUnresolvedAncestor = true break } parent = parent.parent } // no pending parent suspense nor transition, flush all jobs if (!hasUnresolvedAncestor && !delayEnter) { queuePostFlushCb(effects) } suspense.effects = [] // resolve parent suspense if all async deps are resolved if (isSuspensible) { if ( parentSuspense && parentSuspense.pendingBranch && parentSuspenseId === parentSuspense.pendingId ) { parentSuspense.deps-- if (parentSuspense.deps === 0 && !sync) { parentSuspense.resolve() } } } // invoke @resolve event triggerEvent(vnode, 'onResolve') }, fallback(fallbackVNode) { if (!suspense.pendingBranch) { return } const { vnode, activeBranch, parentComponent, container, namespace } = suspense // invoke @fallback event triggerEvent(vnode, 'onFallback') const anchor = next(activeBranch!) const mountFallback = () => { if (!suspense.isInFallback) { return } // mount the fallback tree patch( null, fallbackVNode, container, anchor, parentComponent, null, // fallback tree will not have suspense context namespace, slotScopeIds, optimized, ) setActiveBranch(suspense, fallbackVNode) } const delayEnter = fallbackVNode.transition && fallbackVNode.transition.mode === 'out-in' if (delayEnter) { activeBranch!.transition!.afterLeave = mountFallback } suspense.isInFallback = true // unmount current active branch unmount( activeBranch!, parentComponent, null, // no suspense so unmount hooks fire now true, // shouldRemove ) if (!delayEnter) { mountFallback() } }, move(container, anchor, type) { suspense.activeBranch && move(suspense.activeBranch, container, anchor, type) suspense.container = container }, next() { return suspense.activeBranch && next(suspense.activeBranch) }, registerDep(instance, setupRenderEffect) { const isInPendingSuspense = !!suspense.pendingBranch if (isInPendingSuspense) { suspense.deps++ } const hydratedEl = instance.vnode.el instance .asyncDep!.catch(err => { handleError(err, instance, ErrorCodes.SETUP_FUNCTION) }) .then(asyncSetupResult => { // retry when the setup() promise resolves. // component may have been unmounted before resolve. if ( instance.isUnmounted || suspense.isUnmounted || suspense.pendingId !== instance.suspenseId ) { return } // retry from this component instance.asyncResolved = true const { vnode } = instance if (__DEV__) { pushWarningContext(vnode) } handleSetupResult(instance, asyncSetupResult, false) if (hydratedEl) { // vnode may have been replaced if an update happened before the // async dep is resolved. vnode.el = hydratedEl } const placeholder = !hydratedEl && instance.subTree.el setupRenderEffect( instance, vnode, // component may have been moved before resolve. // if this is not a hydration, instance.subTree will be the comment // placeholder. parentNode(hydratedEl || 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, namespace, optimized, ) if (placeholder) { remove(placeholder) } updateHOCHostEl(instance, vnode.el) if (__DEV__) { popWarningContext() } // only decrease deps count if suspense is not already resolved if (isInPendingSuspense && --suspense.deps === 0) { suspense.resolve() } }) }, unmount(parentSuspense, doRemove) { suspense.isUnmounted = true if (suspense.activeBranch) { unmount( suspense.activeBranch, parentComponent, parentSuspense, doRemove, ) } if (suspense.pendingBranch) { unmount( suspense.pendingBranch, parentComponent, parentSuspense, doRemove, ) } }, } return suspense } function hydrateSuspense( node: Node, vnode: VNode, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, namespace: ElementNamespace, slotScopeIds: string[] | null, optimized: boolean, rendererInternals: RendererInternals, hydrateNode: ( node: Node, vnode: VNode, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, slotScopeIds: string[] | null, optimized: boolean, ) => Node | null, ): Node | null { const suspense = (vnode.suspense = createSuspenseBoundary( vnode, parentSuspense, parentComponent, node.parentNode!, // eslint-disable-next-line no-restricted-globals document.createElement('div'), null, namespace, slotScopeIds, 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.pendingBranch = vnode.ssContent!), parentComponent, suspense, slotScopeIds, optimized, ) if (suspense.deps === 0) { suspense.resolve(false, true) } return result /* eslint-enable no-restricted-globals */ } function normalizeSuspenseChildren(vnode: VNode) { const { shapeFlag, children } = vnode const isSlotChildren = shapeFlag & ShapeFlags.SLOTS_CHILDREN vnode.ssContent = normalizeSuspenseSlot( isSlotChildren ? (children as Slots).default : children, ) vnode.ssFallback = isSlotChildren ? normalizeSuspenseSlot((children as Slots).fallback) : createVNode(Comment) } function normalizeSuspenseSlot(s: any) { let block: VNode[] | null | undefined if (isFunction(s)) { const trackBlock = isBlockTreeEnabled && s._c if (trackBlock) { // disableTracking: false // allow block tracking for compiled slots // (see ./componentRenderContext.ts) s._d = false openBlock() } s = s() if (trackBlock) { s._d = true block = currentBlock closeBlock() } } if (isArray(s)) { const singleChild = filterSingleRoot(s) if ( __DEV__ && !singleChild && s.filter(child => child !== NULL_DYNAMIC_COMPONENT).length > 0 ) { warn(` slots expect a single root node.`) } s = singleChild } s = normalizeVNode(s) if (block && !s.dynamicChildren) { s.dynamicChildren = block.filter(c => c !== s) } return s } export function queueEffectWithSuspense( fn: Function | Function[], suspense: SuspenseBoundary | null, ): void { if (suspense && suspense.pendingBranch) { if (isArray(fn)) { suspense.effects.push(...fn) } else { suspense.effects.push(fn) } } else { queuePostFlushCb(fn) } } function setActiveBranch(suspense: SuspenseBoundary, branch: VNode) { suspense.activeBranch = branch const { vnode, parentComponent } = suspense let el = branch.el // if branch has no el after patch, it's a HOC wrapping async components // drill and locate the placeholder comment node while (!el && branch.component) { branch = branch.component.subTree el = branch.el } vnode.el = el // in case suspense is the root node of a component, // recursively update the HOC el if (parentComponent && parentComponent.subTree === vnode) { parentComponent.vnode.el = el updateHOCHostEl(parentComponent, el) } } function isVNodeSuspensible(vnode: VNode) { return vnode.props?.suspensible != null && vnode.props.suspensible !== false }