vue3-core/packages/runtime-core/src/components/Suspense.ts

900 lines
25 KiB
TypeScript

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<RendererNode, RendererElement, SuspenseProps>
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'](
`<Suspense> 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(`<Suspense> 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
}