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

756 lines
20 KiB
TypeScript
Raw Normal View History

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'
2019-09-07 15:28:40 +00:00
export interface SuspenseProps {
onResolve?: () => void
onPending?: () => void
onFallback?: () => void
timeout?: string | number
}
2020-02-15 16:40:09 +00:00
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 = {
// 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.
2019-11-01 16:43:27 +00:00
__isSuspense: true,
process(
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
optimized: boolean,
// platform-specific impl passed from renderer
rendererInternals: RendererInternals
) {
if (n1 == null) {
mountSuspense(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized,
rendererInternals
)
} else {
patchSuspense(
n1,
n2,
container,
anchor,
parentComponent,
isSVG,
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
2020-02-16 02:48:45 +00:00
new (): { $props: VNodeProps & SuspenseProps }
}
function mountSuspense(
vnode: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
optimized: boolean,
rendererInternals: RendererInternals
) {
const {
2020-02-15 16:40:09 +00:00
p: patch,
o: { createElement }
} = rendererInternals
const hiddenContainer = createElement('div')
const suspense = (vnode.suspense = createSuspenseBoundary(
vnode,
parentSuspense,
parentComponent,
container,
hiddenContainer,
anchor,
isSVG,
optimized,
rendererInternals
))
// start mounting the content subtree in an off-dom container
patch(
null,
(suspense.pendingBranch = vnode.ssContent!),
hiddenContainer,
null,
parentComponent,
suspense,
isSVG,
optimized
)
// 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,
optimized
)
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,
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,
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,
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, null)
}
// increment pending ID. this is used to invalidate async callbacks
// reset suspense state
suspense.deps = 0
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,
optimized
)
if (suspense.deps <= 0) {
suspense.resolve()
} else {
patch(
activeBranch,
newFallback,
container,
anchor,
parentComponent,
null, // fallback tree will not have suspense context
isSVG,
optimized
)
setActiveBranch(suspense, newFallback)
}
} else if (activeBranch && isSameVNodeType(newBranch, activeBranch)) {
// toggled "back" to current active branch
patch(
activeBranch,
newBranch,
container,
anchor,
parentComponent,
suspense,
isSVG,
optimized
)
// force resolve
suspense.resolve(true)
} else {
// switched to a 3rd branch
patch(
null,
newBranch,
suspense.hiddenContainer,
null,
parentComponent,
suspense,
isSVG,
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,
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,
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)
}
}
}
}
}
2019-09-07 15:28:40 +00:00
export interface SuspenseBoundary {
vnode: VNode<RendererNode, RendererElement, SuspenseProps>
parent: SuspenseBoundary | null
2019-09-11 21:38:26 +00:00
parentComponent: ComponentInternalInstance | null
isSVG: boolean
optimized: boolean
container: RendererElement
hiddenContainer: RendererElement
anchor: RendererNode | null
activeBranch: VNode | null
pendingBranch: VNode | null
2019-09-07 15:28:40 +00:00
deps: number
pendingId: number
timeout: number
isInFallback: boolean
isHydrating: boolean
isUnmounted: boolean
2019-09-11 00:53:28 +00:00
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
2019-09-07 15:28:40 +00:00
}
let hasWarned = false
function createSuspenseBoundary(
vnode: VNode,
parent: SuspenseBoundary | null,
2019-09-11 21:38:26 +00:00
parentComponent: ComponentInternalInstance | null,
container: RendererElement,
hiddenContainer: RendererElement,
anchor: RendererNode | null,
isSVG: boolean,
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'](
`<Suspense> is an experimental feature and its API will likely change.`
)
}
const {
2020-02-15 16:40:09 +00:00
p: patch,
m: move,
um: unmount,
n: next,
o: { parentNode, remove }
} = rendererInternals
const timeout = toNumber(vnode.props && vnode.props.timeout)
const suspense: SuspenseBoundary = {
2019-09-09 20:00:50 +00:00
vnode,
2019-09-09 17:59:53 +00:00
parent,
2019-09-11 21:38:26 +00:00
parentComponent,
isSVG,
optimized,
2019-09-10 15:01:11 +00:00
container,
2019-09-11 21:38:26 +00:00
hiddenContainer,
anchor,
2019-09-07 15:28:40 +00:00
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,
optimized
} = suspense
// invoke @recede 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,
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) {
if (!suspense.pendingBranch) {
return
}
const hydratedEl = instance.vnode.el
suspense.deps++
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
}
suspense.deps--
// 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
2020-05-01 13:42:58 +00:00
// 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),
2020-02-14 04:31:03 +00:00
suspense,
isSVG,
optimized
)
if (placeholder) {
remove(placeholder)
}
updateHOCHostEl(instance, vnode.el)
if (__DEV__) {
popWarningContext()
}
if (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
)
}
}
2019-09-07 15:28:40 +00:00
}
return suspense
2019-09-07 15:28:40 +00:00
}
2019-09-10 15:01:11 +00:00
function hydrateSuspense(
node: Node,
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
optimized: boolean,
rendererInternals: RendererInternals,
hydrateNode: (
node: Node,
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | 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,
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,
optimized
)
if (suspense.deps === 0) {
suspense.resolve()
}
return result
/* eslint-enable no-restricted-globals */
}
export function normalizeSuspenseChildren(
2019-09-10 15:01:11 +00:00
vnode: VNode
): {
content: VNode
fallback: VNode
} {
2019-09-10 15:17:26 +00:00
const { shapeFlag, children } = vnode
let content: VNode
let fallback: VNode
2019-09-10 15:01:11 +00:00
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(`<Suspense> slots expect a single root node.`)
}
s = singleChild
2019-09-10 15:01:11 +00:00
}
return normalizeVNode(s)
2019-09-10 15:01:11 +00:00
}
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)
}
}