Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/runtime-core/src/apiCreateApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,12 @@ export interface VaporInVdomInterface {
*/
hasTransitionChild(vnode: VNode): boolean
getTransitionElement(vnode: VNode): RendererElement | undefined
/**
* Enters what vapor mounted under `suspense` while it was pending, right
* before the boundary reveals its branch: the boundary's own walk stops
* at a vapor component.
*/
enterDeferred(suspense: SuspenseBoundary, enters: Function[]): void
/**
* Re-resolves the hooks of the rows a mounted vapor slot renders under a
* TransitionGroup (`vnode.vs.group`) and hands over their elements.
Expand Down
14 changes: 10 additions & 4 deletions packages/runtime-core/src/components/Suspense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { filterSingleRoot, updateHOCHostEl } from '../componentRenderUtils'
import { assertNumber, warn } from '../warning'
import { ErrorCodes, handleError } from '../errorHandling'
import { NULL_DYNAMIC_COMPONENT } from '../helpers/resolveAssets'
import { isVaporInteropEnabled } from '../vaporInteropState'

export interface SuspenseProps {
onResolve?: () => void
Expand Down Expand Up @@ -192,15 +193,20 @@ function triggerEvent(
// entered here: beforeEnter right before the branch moves into the document,
// enter in the post flush ahead of the buffered jobs, so the transition still
// sees the mount as its appear
function enterDeferred(branch: VNode): void {
function enterDeferred(suspense: SuspenseBoundary, branch: VNode): void {
const enters: Function[] = []
collectDeferredEnters(branch, enters, true)
if (isVaporInteropEnabled) {
const { parentComponent } = suspense
const vapor = parentComponent && parentComponent.appContext.vapor
if (vapor) vapor.enterDeferred(suspense, enters)
}
if (enters.length) {
queuePostFlushCb(enters)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}

function collectDeferredEnters(
export function collectDeferredEnters(
vnode: VNode,
enters: Function[],
isRoot: boolean,
Expand Down Expand Up @@ -645,7 +651,7 @@ function createSuspenseBoundary(
// the leave is not cancelled when the boundary gets unmounted in
// the meantime: the branch is gone, don't bring its DOM back
if (pendingId === suspense.pendingId && !suspense.isUnmounted) {
enterDeferred(pendingBranch!)
enterDeferred(suspense, pendingBranch!)
move(
pendingBranch!,
container,
Expand Down Expand Up @@ -699,7 +705,7 @@ function createSuspenseBoundary(
}
if (!delayEnter) {
// move content from off-dom container to actual container
enterDeferred(pendingBranch!)
enterDeferred(suspense, pendingBranch!)
move(
pendingBranch!,
container,
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,10 @@ export {
invalidateMount,
queuePostRenderEffect,
} from './renderer'
/**
* @internal
*/
export { collectDeferredEnters } from './components/Suspense'
/**
* @internal
*/
Expand Down
261 changes: 261 additions & 0 deletions packages/runtime-vapor/__tests__/vdomInterop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10080,6 +10080,267 @@ describe('vdomInterop', () => {
expect(html()).toContain('<div>vapor</div>')
expect(order).toEqual(['suspense resolved', 'vapor mounted'])
})

// a page logging its mount, the hooks of a transition it nests, and the
// async child keeping the boundary pending
const HEAD = `<script setup>
import { onMounted } from 'vue'
const data = _data
const components = _components
onMounted(() => data.value.log('page mounted'))
</script>`
const hooks = `appear :css="false"
@before-appear="data.log('nested beforeAppear')"
@appear="data.log('nested appear')"`
const ASYNC_CHILD = `<script setup>
const data = _data
await data.value.wait
</script>
<template><span>async</span></template>`

// mounts `template` as the page of a pending boundary, applies `change`
// while it pends, then reveals it
const runWhilePending = async (
vapor: boolean,
template: string,
data: Ref<any>,
change: () => void,
components: Record<string, any> = {},
) => {
const calls: string[] = []
const pending = deferred()
data.value.wait = pending.promise
data.value.log = (s: string) => calls.push(s)
const AsyncChild = compile(ASYNC_CHILD, data, {}, { vapor })
const Page = compile(
HEAD + template,
data,
{ AsyncChild, ...components },
{ vapor },
)
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp({
setup: () => () => h(Suspense, null, { default: () => h(Page) }),
})
app.use(vaporInteropPlugin)
try {
app.mount(host)
change()
await nextTick()
const whilePending = calls.slice()
pending.resolve()
await flushResolution(pending.promise)
return { whilePending, calls: calls.slice(), host: host.innerHTML }
} finally {
app.unmount()
host.remove()
}
}

test('enters a transition nested in the pending branch on reveal', async () => {
const nested = `<Transition ${hooks}><p>p</p></Transition>`
const rows: {
label: string
vapor: boolean
first: string
components?: (data: Ref<any>) => Record<string, any>
}[] = [
{ label: 'vdom', vapor: false, first: nested },
{ label: 'vapor', vapor: true, first: nested },
{
label: 'vdom Transition under a vapor parent',
vapor: true,
first: `<components.VdomTransition ${hooks}>
<p>p</p>
</components.VdomTransition>`,
components: () => ({ VdomTransition: Transition }),
},
{
label:
'vdom component nesting its own Transition, under a vapor parent',
vapor: true,
first: `<components.VdomCard />`,
components: data => ({
VdomCard: compile(
`<script setup>const data = _data</script>
<template>
<div>${nested}</div>
</template>`,
data,
{},
{ vapor: false },
),
}),
},
{
label: 'vapor Transition under a vdom parent',
vapor: false,
first: `<components.VaporT />`,
components: data => ({
VaporT: compile(`<template>${nested}</template>`, data),
}),
},
]
for (const { label, vapor, first, components } of rows) {
const data = ref<any>({})
const out = await runWhilePending(
vapor,
`<template>
<div>
${first}
<components.AsyncChild />
</div>
</template>`,
data,
() => {},
components && components(data),
)
expect(out.whilePending, label).toEqual([])
expect(out.calls, label).toEqual([
'nested beforeAppear',
'nested appear',
'page mounted',
])
expect(out.host, label).toContain('<p>p</p>')
}
})

test('mounts a teleport of the pending branch on reveal with its transition', async () => {
const run = async (vapor: boolean, disabled: boolean) => {
const calls: string[] = []
const pending = deferred()
const data = ref<any>({
wait: pending.promise,
log: (s: string) => calls.push(s),
})
const AsyncChild = compile(ASYNC_CHILD, data, {}, { vapor })
const Page = compile(
HEAD +
`<template>
<div>
<Teleport to="#tele-reveal" ${disabled ? 'disabled' : ''}>
<Transition ${hooks}><p>p</p></Transition>
</Teleport>
<components.AsyncChild />
</div>
</template>`,
data,
{ AsyncChild },
{ vapor },
)
const host = document.createElement('div')
const target = document.createElement('div')
target.id = 'tele-reveal'
document.body.appendChild(host)
document.body.appendChild(target)
const app = createApp({
setup: () => () => h(Suspense, null, { default: () => h(Page) }),
})
app.use(vaporInteropPlugin)
try {
app.mount(host)
await nextTick()
const whilePending = {
calls: calls.slice(),
target: target.innerHTML,
}
pending.resolve()
await flushResolution(pending.promise)
return {
whilePending,
calls: calls.slice(),
target: target.innerHTML,
host: host.innerHTML,
}
} finally {
app.unmount()
host.remove()
target.remove()
}
}

for (const disabled of [false, true]) {
for (const vapor of [false, true]) {
const label = `${vapor ? 'vapor' : 'vdom'} ${disabled ? 'disabled' : 'enabled'}`
const out = await run(vapor, disabled)
expect(out.whilePending, label).toEqual({ calls: [], target: '' })
// the mount waits for the reveal: the content enters in the document
expect(out.calls, label).toEqual([
'nested beforeAppear',
'page mounted',
'nested appear',
])
expect(out.target, label).toBe(disabled ? '' : '<p>p</p>')
expect(out.host, label).toBe(
disabled
? '<div><!--teleport start--><p>p</p><!--teleport end--><span>async</span></div>'
: '<div><!--teleport start--><!--teleport end--><span>async</span></div>',
)
}
}
})

test('enters a branch toggled into the pending branch on reveal', async () => {
for (const vapor of [false, true]) {
const label = vapor ? 'vapor' : 'vdom'
const data = ref<any>({ show: false })
const out = await runWhilePending(
vapor,
`<template>
<div>
<Transition ${hooks}><p v-if="data.show">p</p></Transition>
<components.AsyncChild />
</div>
</template>`,
data,
() => (data.value.show = true),
)
expect(out.whilePending, label).toEqual([])
expect(out.calls, label).toEqual([
'nested beforeAppear',
'nested appear',
'page mounted',
])
expect(out.host, label).toContain('<p>p</p>')
}
})

test('enters rows added to the pending branch on reveal', async () => {
const groupHooks = `appear :css="false"
@before-appear="el => data.log('beforeAppear ' + el.textContent)"
@appear="el => data.log('appear ' + el.textContent)"`
// the rows of a fragment group are inserted by the list, those of a
// tag group by the group into its element
for (const tag of ['', 'tag="ul"']) {
for (const vapor of [false, true]) {
const label = `${vapor ? 'vapor' : 'vdom'} ${tag || 'fragment'}`
const data = ref<any>({ items: [1] })
const out = await runWhilePending(
vapor,
`<template>
<div>
<TransitionGroup ${tag} ${groupHooks}>
<li v-for="i in data.items" :key="i">{{ i }}</li>
</TransitionGroup>
<components.AsyncChild />
</div>
</template>`,
data,
() => data.value.items.push(2),
)
expect(out.whilePending, label).toEqual([])
expect(out.calls, label).toEqual([
'beforeAppear 1',
'beforeAppear 2',
'appear 1',
'appear 2',
'page mounted',
])
expect(out.host, label).toContain('<li>1</li><li>2</li>')
}
}
})
})

describe('current scope', () => {
Expand Down
14 changes: 11 additions & 3 deletions packages/runtime-vapor/src/apiCreateFor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,16 +429,24 @@ export const createFor = (
const needKey = renderItem.length > 1
const needIndex = renderItem.length > 2

// rows added under a pending boundary enter on its reveal
const suspense = ctx.suspense
type InsertForBlock = (block: ForBlock, anchor: Node | undefined) => void
// IS_COMPONENT requires component teardown ordering but does not guarantee
// that block.nodes is a VaporComponentInstance. Component fallback may
// produce a plain DOM node, so these blocks still use the generic block path.
const insertForBlock: InsertForBlock = isSingleNode
? (block, anchor) => insertNode(block.nodes as Node, parent!, anchor)
? (block, anchor) =>
insertNode(block.nodes as Node, parent!, anchor, suspense)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: isFragment
? (block, anchor) =>
insertFragment(block.nodes as VaporFragment, parent!, anchor)
: (block, anchor) => insert(block.nodes, parent!, anchor)
insertFragment(
block.nodes as VaporFragment,
parent!,
anchor,
suspense,
)
: (block, anchor) => insert(block.nodes, parent!, anchor, suspense)

type RemoveForBlock = (block: ForBlock) => void
const removeForBlock: RemoveForBlock = isSingleNode
Expand Down
Loading
Loading