wip: save

This commit is contained in:
daiwei 2025-03-21 14:43:27 +08:00
parent 9be697b38c
commit 257138810f
2 changed files with 312 additions and 61 deletions

View File

@ -0,0 +1,252 @@
import {
type LooseRawProps,
type VaporComponent,
createComponent as originalCreateComponent,
} from '../../src/component'
import { VaporTeleport, template } from '@vue/runtime-vapor'
import { makeRender } from '../_utils'
import { nextTick, onBeforeUnmount, onUnmounted, ref, shallowRef } from 'vue'
const define = makeRender()
describe('renderer: VaporTeleport', () => {
describe('eager mode', () => {
runSharedTests(false)
})
describe('defer mode', () => {
runSharedTests(true)
})
})
function runSharedTests(deferMode: boolean): void {
const createComponent = deferMode
? (
component: VaporComponent,
rawProps?: LooseRawProps | null,
...args: any[]
) => {
if (component === VaporTeleport) {
rawProps!.defer = () => true
}
return originalCreateComponent(component, rawProps, ...args)
}
: originalCreateComponent
test('should work', () => {
const target = document.createElement('div')
const root = document.createElement('div')
const { mount } = define({
setup() {
const n0 = createComponent(
VaporTeleport,
{
to: () => target,
},
{
default: () => template('<div>teleported</div>')(),
},
)
const n1 = template('<div>root</div>')()
return [n0, n1]
},
}).create()
mount(root)
expect(root.innerHTML).toBe('<!--teleport--><div>root</div>')
expect(target.innerHTML).toBe('<div>teleported</div>')
})
test.todo('should work with SVG', async () => {})
test('should update target', async () => {
const targetA = document.createElement('div')
const targetB = document.createElement('div')
const target = ref(targetA)
const root = document.createElement('div')
const { mount } = define({
setup() {
const n0 = createComponent(
VaporTeleport,
{
to: () => target.value,
},
{
default: () => template('<div>teleported</div>')(),
},
)
const n1 = template('<div>root</div>')()
return [n0, n1]
},
}).create()
mount(root)
expect(root.innerHTML).toBe('<!--teleport--><div>root</div>')
expect(targetA.innerHTML).toBe('<div>teleported</div>')
expect(targetB.innerHTML).toBe('')
target.value = targetB
await nextTick()
expect(root.innerHTML).toBe('<!--teleport--><div>root</div>')
expect(targetA.innerHTML).toBe('')
expect(targetB.innerHTML).toBe('<div>teleported</div>')
})
test('should update children', async () => {
const target = document.createElement('div')
const root = document.createElement('div')
const children = shallowRef([template('<div>teleported</div>')()])
const { mount } = define({
setup() {
const n0 = createComponent(
VaporTeleport,
{
to: () => target,
},
{
default: () => children.value,
},
)
const n1 = template('<div>root</div>')()
return [n0, n1]
},
}).create()
mount(root)
expect(target.innerHTML).toBe('<div>teleported</div>')
children.value = [template('')()]
await nextTick()
expect(target.innerHTML).toBe('')
children.value = [template('teleported')()]
await nextTick()
expect(target.innerHTML).toBe('teleported')
})
test('should remove children when unmounted', async () => {
const target = document.createElement('div')
const root = document.createElement('div')
function testUnmount(props: any) {
const { app } = define({
setup() {
const n0 = createComponent(VaporTeleport, props, {
default: () => template('<div>teleported</div>')(),
})
const n1 = template('<div>root</div>')()
return [n0, n1]
},
}).create()
app.mount(root)
expect(target.innerHTML).toBe(
props.disabled() ? '' : '<div>teleported</div>',
)
app.unmount()
expect(target.innerHTML).toBe('')
expect(target.children.length).toBe(0)
}
testUnmount({ to: () => target, disabled: () => false })
testUnmount({ to: () => target, disabled: () => true })
testUnmount({ to: () => null, disabled: () => true })
})
test('component with multi roots should be removed when unmounted', async () => {
const target = document.createElement('div')
const root = document.createElement('div')
const { component: Comp } = define({
setup() {
return [template('<p>')(), template('<p>')()]
},
})
const { app } = define({
setup() {
const n0 = createComponent(
VaporTeleport,
{
to: () => target,
},
{
default: () => createComponent(Comp),
},
)
const n1 = template('<div>root</div>')()
return [n0, n1]
},
}).create()
app.mount(root)
expect(target.innerHTML).toBe('<p></p><p></p>')
app.unmount()
expect(target.innerHTML).toBe('')
})
test.todo(
'descendent component should be unmounted when teleport is disabled and unmounted',
async () => {
const root = document.createElement('div')
const beforeUnmount = vi.fn()
const unmounted = vi.fn()
const { component: Comp } = define({
setup() {
onBeforeUnmount(beforeUnmount)
onUnmounted(unmounted)
return [template('<p>')(), template('<p>')()]
},
})
const { app } = define({
setup() {
const n0 = createComponent(
VaporTeleport,
{
to: () => null,
disabled: () => true,
},
{
default: () => createComponent(Comp),
},
)
return [n0]
},
}).create()
app.mount(root)
expect(beforeUnmount).toHaveBeenCalledTimes(0)
expect(unmounted).toHaveBeenCalledTimes(0)
app.unmount()
expect(beforeUnmount).toHaveBeenCalledTimes(1)
expect(unmounted).toHaveBeenCalledTimes(1)
},
)
test.todo('multiple teleport with same target', async () => {})
test.todo('should work when using template ref as target', async () => {})
test.todo('disabled', async () => {})
test.todo('moving teleport while enabled', async () => {})
test.todo('moving teleport while disabled', async () => {})
test.todo('should work with block tree', async () => {})
test.todo(
`the dir hooks of the Teleport's children should be called correctly`,
async () => {},
)
test.todo(
`ensure that target changes when disabled are updated correctly when enabled`,
async () => {},
)
test.todo('toggle sibling node inside target node', async () => {})
test.todo('unmount previous sibling node inside target node', async () => {})
test.todo('accessing template refs inside teleport', async () => {})
}

View File

@ -18,6 +18,7 @@ import { createComment, createTextNode, querySelector } from '../dom/node'
import type { LooseRawProps, LooseRawSlots } from '../component'
import { rawPropsProxyHandlers } from '../componentProps'
import { renderEffect } from '../renderEffect'
import { extend } from '@vue/shared'
export const VaporTeleportImpl = {
name: 'VaporTeleport',
@ -25,7 +26,6 @@ export const VaporTeleportImpl = {
__vapor: true,
process(props: LooseRawProps, slots: LooseRawSlots): TeleportFragment {
const children = slots.default && (slots.default as BlockFn)()
const frag = __DEV__
? new TeleportFragment('teleport')
: new TeleportFragment()
@ -35,31 +35,11 @@ export const VaporTeleportImpl = {
rawPropsProxyHandlers,
) as any as TeleportProps
renderEffect(() => frag.update(resolvedProps, children))
frag.remove = parent => {
const {
nodes,
target,
cachedTargetAnchor,
targetStart,
placeholder,
mainAnchor,
} = frag
remove(nodes, target || parent)
// remove anchors
if (targetStart) {
let parentNode = targetStart.parentNode!
remove(targetStart!, parentNode)
remove(cachedTargetAnchor!, parentNode)
}
if (placeholder && placeholder.isConnected) {
remove(placeholder!, parent)
remove(mainAnchor!, parent)
}
}
renderEffect(() => {
const children = slots.default && (slots.default as BlockFn)()
// access the props to trigger tracking
frag.update(extend({}, resolvedProps), children)
})
return frag
},
@ -67,12 +47,10 @@ export const VaporTeleportImpl = {
export class TeleportFragment extends VaporFragment {
anchor: Node
target?: ParentNode | null
targetStart?: Node | null
targetAnchor?: Node | null
cachedTargetAnchor?: Node
mainAnchor?: Node
placeholder?: Node
currentParent?: ParentNode | null
constructor(anchorLabel?: string) {
super([])
@ -81,33 +59,21 @@ export class TeleportFragment extends VaporFragment {
}
update(props: TeleportProps, children: Block): void {
// teardown previous
if (this.currentParent && this.nodes) {
remove(this.nodes, this.currentParent)
}
this.nodes = children
const disabled = isTeleportDisabled(props)
const parent = this.anchor.parentNode
if (!this.mainAnchor) {
this.mainAnchor = __DEV__
? createComment('teleport end')
: createTextNode()
}
if (!this.placeholder) {
this.placeholder = __DEV__
? createComment('teleport start')
: createTextNode()
}
if (parent) {
insert(this.placeholder, parent, this.anchor)
insert(this.mainAnchor, parent, this.anchor)
const mount = (parent: ParentNode, anchor: Node | null) => {
insert(this.nodes, (this.currentParent = parent), anchor)
}
const disabled = isTeleportDisabled(props)
if (disabled) {
this.target = this.anchor.parentNode
this.targetAnchor = parent ? this.mainAnchor : null
} else {
const target = (this.target = resolveTarget(
props,
querySelector,
) as ParentNode)
const mountToTarget = () => {
const target = (this.target = resolveTarget(props, querySelector))
if (target) {
if (
// initial mount
@ -116,21 +82,38 @@ export class TeleportFragment extends VaporFragment {
this.targetStart.parentNode !== target
) {
;[this.targetAnchor, this.targetStart] = prepareAnchor(target)
this.cachedTargetAnchor = this.targetAnchor
} else {
// re-mount or target not changed, use cached target anchor
this.targetAnchor = this.cachedTargetAnchor
}
mount(target, this.targetAnchor!)
} else if (__DEV__) {
warn('Invalid Teleport target on mount:', target, `(${typeof target})`)
warn(
`Invalid Teleport target on ${this.targetStart ? 'update' : 'mount'}:`,
target,
`(${typeof target})`,
)
}
}
const mountToTarget = () => {
insert(this.nodes, this.target!, this.targetAnchor)
if (parent && disabled) {
if (!this.mainAnchor) {
this.mainAnchor = __DEV__
? createComment('teleport end')
: createTextNode()
}
if (!this.placeholder) {
this.placeholder = __DEV__
? createComment('teleport start')
: createTextNode()
}
if (!this.mainAnchor.isConnected) {
insert(this.placeholder, parent, this.anchor)
insert(this.mainAnchor, parent, this.anchor)
}
mount(parent, this.mainAnchor)
}
if (parent) {
if (!disabled) {
if (isTeleportDeferred(props)) {
queuePostFlushCb(mountToTarget)
} else {
@ -139,14 +122,30 @@ export class TeleportFragment extends VaporFragment {
}
}
remove = (parent: ParentNode | undefined): void => {
// remove nodes
remove(this.nodes, this.currentParent || parent)
// remove anchors
if (this.targetStart) {
let parentNode = this.targetStart.parentNode!
remove(this.targetStart!, parentNode)
remove(this.targetAnchor!, parentNode)
}
if (this.placeholder) {
remove(this.placeholder!, parent)
remove(this.mainAnchor!, parent)
}
}
hydrate(): void {
// TODO
}
}
function prepareAnchor(target: ParentNode | null) {
const targetStart = createTextNode('targetStart')
const targetAnchor = createTextNode('targetAnchor')
const targetStart = createTextNode('')
const targetAnchor = createTextNode('')
// attach a special property, so we can skip teleported content in
// renderer's nextSibling search