import { VNode, normalizeVNode, VNodeChild, VNodeProps, isSameVNodeType } from '../vnode' import { isFunction, isArray, ShapeFlags, toNumber } from '@vue/shared' import { ComponentInternalInstance, handleSetupResult } from '../component' import { Slots } from '../componentSlots' import { RendererInternals, MoveType, SetupRenderEffectFn, RendererNode, RendererElement } from '../renderer' import { queuePostFlushCb } from '../scheduler' import { filterSingleRoot, updateHOCHostEl } from '../componentRenderUtils' import { pushWarningContext, popWarningContext, warn } from '../warning' import { handleError, ErrorCodes } from '../errorHandling' export interface SuspenseProps { onResolve?: () => void onPending?: () => void onFallback?: () => void timeout?: string | number } export const isSuspense = (type: any): boolean => type.__isSuspense // 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, isSVG: boolean, slotScopeIds: string[] | null, optimized: boolean, // platform-specific impl passed from renderer rendererInternals: RendererInternals ) { if (n1 == null) { mountSuspense( n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, rendererInternals ) } else { patchSuspense( n1, n2, container, anchor, parentComponent, isSVG, slotScopeIds, optimized, rendererInternals ) } }, hydrate: hydrateSuspense, create: createSuspenseBoundary } // Force-casted public typing for h and TSX props inference export const Suspense = ((__FEATURE_SUSPENSE__ ? SuspenseImpl : null) as any) as { __isSuspense: true new (): { $props: VNodeProps & SuspenseProps } } function mountSuspense( vnode: VNode, container: RendererElement, anchor: RendererNode | null, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, isSVG: boolean, 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, isSVG, slotScopeIds, optimized, rendererInternals )) // start mounting the content subtree in an off-dom container patch( null, (suspense.pendingBranch = vnode.ssContent!), hiddenContainer, null, parentComponent, suspense, isSVG, slotScopeIds ) // now check if we have encountered any async deps if (suspense.deps > 0) { // has async // mount the fallback tree patch( null, vnode.ssFallback!, container, anchor, parentComponent, null, // fallback tree will not have suspense context isSVG, slotScopeIds ) setActiveBranch(suspense, vnode.ssFallback!) } else { // Suspense has no async deps. Just resolve. suspense.resolve() } } function patchSuspense( n1: VNode, n2: VNode, container: RendererElement, anchor: RendererNode | null, parentComponent: ComponentInternalInstance | null, isSVG: boolean, 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, isSVG, slotScopeIds, optimized ) if (suspense.deps <= 0) { suspense.resolve() } else if (isInFallback) { patch( activeBranch, newFallback, container, anchor, parentComponent, null, // fallback tree will not have suspense context isSVG, slotScopeIds, optimized ) setActiveBranch(suspense, newFallback) } } else { // toggled before pending tree is resolved suspense.pendingId++ 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) } // increment pending ID. this is used to invalidate async callbacks // 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, isSVG, slotScopeIds, optimized ) if (suspense.deps <= 0) { suspense.resolve() } else { patch( activeBranch, newFallback, container, anchor, parentComponent, null, // fallback tree will not have suspense context isSVG, slotScopeIds, optimized ) setActiveBranch(suspense, newFallback) } } else if (activeBranch && isSameVNodeType(newBranch, activeBranch)) { // toggled "back" to current active branch patch( activeBranch, newBranch, container, anchor, parentComponent, suspense, isSVG, slotScopeIds, optimized ) // force resolve suspense.resolve(true) } else { // switched to a 3rd branch patch( null, newBranch, suspense.hiddenContainer, null, parentComponent, suspense, isSVG, 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, isSVG, slotScopeIds, optimized ) setActiveBranch(suspense, newBranch) } else { // root node toggled // invoke @pending event const onPending = n2.props && n2.props.onPending if (isFunction(onPending)) { onPending() } // mount pending branch in off-dom container suspense.pendingBranch = newBranch suspense.pendingId++ patch( null, newBranch, suspense.hiddenContainer, null, parentComponent, suspense, isSVG, 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 isSVG: boolean container: RendererElement hiddenContainer: RendererElement anchor: RendererNode | null activeBranch: VNode | null pendingBranch: VNode | null deps: number pendingId: number timeout: number isInFallback: boolean isHydrating: boolean isUnmounted: boolean effects: Function[] resolve(force?: 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, parent: SuspenseBoundary | null, parentComponent: ComponentInternalInstance | null, container: RendererElement, hiddenContainer: RendererElement, anchor: RendererNode | null, isSVG: boolean, slotScopeIds: string[] | null, optimized: boolean, rendererInternals: RendererInternals, isHydrating = false ): SuspenseBoundary { /* istanbul ignore if */ if (__DEV__ && !__TEST__ && !hasWarned) { hasWarned = true // @ts-ignore `console.info` cannot be null error 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 const timeout = toNumber(vnode.props && vnode.props.timeout) const suspense: SuspenseBoundary = { vnode, parent, parentComponent, isSVG, container, hiddenContainer, anchor, deps: 0, pendingId: 0, timeout: typeof timeout === 'number' ? timeout : -1, activeBranch: null, pendingBranch: null, isInFallback: true, isHydrating, isUnmounted: false, effects: [], resolve(resume = 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 (suspense.isHydrating) { suspense.isHydrating = false } else if (!resume) { const delayEnter = activeBranch && pendingBranch!.transition && pendingBranch!.transition.mode === 'out-in' if (delayEnter) { activeBranch!.transition!.afterLeave = () => { if (pendingId === suspense.pendingId) { move(pendingBranch!, container, anchor, MoveType.ENTER) } } } // this is initial anchor on mount let { anchor } = suspense // 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 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, flush all jobs if (!hasUnresolvedAncestor) { queuePostFlushCb(effects) } suspense.effects = [] // invoke @resolve event const onResolve = vnode.props && vnode.props.onResolve if (isFunction(onResolve)) { onResolve() } }, fallback(fallbackVNode) { if (!suspense.pendingBranch) { return } const { vnode, activeBranch, parentComponent, container, isSVG } = suspense // invoke @fallback event const onFallback = vnode.props && vnode.props.onFallback if (isFunction(onFallback)) { 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 isSVG, slotScopeIds, optimized ) setActiveBranch(suspense, fallbackVNode) } const delayEnter = fallbackVNode.transition && fallbackVNode.transition.mode === 'out-in' if (delayEnter) { activeBranch!.transition!.afterLeave = mountFallback } // unmount current active branch unmount( activeBranch!, parentComponent, null, // no suspense so unmount hooks fire now true // shouldRemove ) suspense.isInFallback = true 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, isSVG, 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, isSVG: boolean, 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 { /* eslint-disable no-restricted-globals */ const suspense = (vnode.suspense = createSuspenseBoundary( vnode, parentSuspense, parentComponent, node.parentNode!, document.createElement('div'), null, isSVG, 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() } return result /* eslint-enable no-restricted-globals */ } export function normalizeSuspenseChildren( vnode: VNode ): { content: VNode fallback: VNode } { const { shapeFlag, children } = vnode let content: VNode let fallback: VNode if (shapeFlag & ShapeFlags.SLOTS_CHILDREN) { content = normalizeSuspenseSlot((children as Slots).default) fallback = normalizeSuspenseSlot((children as Slots).fallback) } else { content = normalizeSuspenseSlot(children as VNodeChild) fallback = normalizeVNode(null) } return { content, fallback } } function normalizeSuspenseSlot(s: any) { if (isFunction(s)) { s = s() } if (isArray(s)) { const singleChild = filterSingleRoot(s) if (__DEV__ && !singleChild) { warn(` slots expect a single root node.`) } s = singleChild } return normalizeVNode(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 const el = (vnode.el = branch.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) } }