]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor): vapor teleport (#13082)
authoredison <daiwei521@126.com>
Mon, 20 Oct 2025 08:10:07 +0000 (16:10 +0800)
committerGitHub <noreply@github.com>
Mon, 20 Oct 2025 08:10:07 +0000 (16:10 +0800)
24 files changed:
packages/compiler-vapor/src/utils.ts
packages/runtime-core/src/components/Teleport.ts
packages/runtime-core/src/index.ts
packages/runtime-vapor/__tests__/block.spec.ts
packages/runtime-vapor/__tests__/components/Teleport.spec.ts [new file with mode: 0644]
packages/runtime-vapor/src/apiCreateDynamicComponent.ts
packages/runtime-vapor/src/apiCreateFor.ts
packages/runtime-vapor/src/apiCreateFragment.ts
packages/runtime-vapor/src/apiCreateIf.ts
packages/runtime-vapor/src/apiDefineAsyncComponent.ts
packages/runtime-vapor/src/apiTemplateRef.ts
packages/runtime-vapor/src/block.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/componentSlots.ts
packages/runtime-vapor/src/components/KeepAlive.ts
packages/runtime-vapor/src/components/Teleport.ts [new file with mode: 0644]
packages/runtime-vapor/src/components/Transition.ts
packages/runtime-vapor/src/components/TransitionGroup.ts
packages/runtime-vapor/src/directives/vShow.ts
packages/runtime-vapor/src/fragment.ts [new file with mode: 0644]
packages/runtime-vapor/src/hmr.ts
packages/runtime-vapor/src/index.ts
packages/runtime-vapor/src/renderEffect.ts
packages/runtime-vapor/src/vdomInterop.ts

index 3ddd7ece58b36bf3c0c9f35416a0b81bde272e20..955273b1eda45e063fd1f233fa43fcf6b66c5c7a 100644 (file)
@@ -120,8 +120,15 @@ export function isKeepAliveTag(tag: string): boolean {
   return tag === 'keepalive' || tag === 'vaporkeepalive'
 }
 
+export function isTeleportTag(tag: string): boolean {
+  tag = tag.toLowerCase()
+  return tag === 'teleport' || tag === 'vaporteleport'
+}
+
 export function isBuiltInComponent(tag: string): string | undefined {
-  if (isKeepAliveTag(tag)) {
+  if (isTeleportTag(tag)) {
+    return 'VaporTeleport'
+  } else if (isKeepAliveTag(tag)) {
     return 'VaporKeepAlive'
   } else if (isTransitionTag(tag)) {
     return 'VaporTransition'
index 7e3b132902f09e7663184fabbe46215c35938fa2..346d2f813eb6d396be2f3ab39b0c8d6b31de82d6 100644 (file)
@@ -27,10 +27,10 @@ export const TeleportEndKey: unique symbol = Symbol('_vte')
 
 export const isTeleport = (type: any): boolean => type.__isTeleport
 
-const isTeleportDisabled = (props: VNode['props']): boolean =>
+export const isTeleportDisabled = (props: VNode['props']): boolean =>
   props && (props.disabled || props.disabled === '')
 
-const isTeleportDeferred = (props: VNode['props']): boolean =>
+export const isTeleportDeferred = (props: VNode['props']): boolean =>
   props && (props.defer || props.defer === '')
 
 const isTargetSVG = (target: RendererElement): boolean =>
@@ -39,7 +39,7 @@ const isTargetSVG = (target: RendererElement): boolean =>
 const isTargetMathML = (target: RendererElement): boolean =>
   typeof MathMLElement === 'function' && target instanceof MathMLElement
 
-const resolveTarget = <T = RendererElement>(
+export const resolveTarget = <T = RendererElement>(
   props: TeleportProps | null,
   select: RendererOptions['querySelector'],
 ): T | null => {
index d0fae060a318b718b30b2666f2ebe700f6167ec0..c77b34135242c96cd07dc2e1bb6215e787d4ad83 100644 (file)
@@ -354,6 +354,7 @@ export type {
   HydrationStrategyFactory,
 } from './hydrationStrategies'
 export type { HMRRuntime } from './hmr'
+export type { SchedulerJob } from './scheduler'
 
 // Internal API ----------------------------------------------------------------
 
@@ -530,7 +531,7 @@ export { baseEmit, isEmitListener } from './componentEmits'
 /**
  * @internal
  */
-export { type SchedulerJob, queueJob, flushOnAppMount } from './scheduler'
+export { queueJob, flushOnAppMount } from './scheduler'
 /**
  * @internal
  */
@@ -567,6 +568,14 @@ export { startMeasure, endMeasure } from './profiling'
  * @internal
  */
 export { initFeatureFlags } from './featureFlags'
+/**
+ * @internal
+ */
+export {
+  resolveTarget as resolveTeleportTarget,
+  isTeleportDisabled,
+  isTeleportDeferred,
+} from './components/Teleport'
 /**
  * @internal
  */
index 9f76c7f0333bb8b0ba9b39ecc68bebbe31dd9f5a..f0144dee3df2f6cae77be369b9f140037a6d786c 100644 (file)
@@ -1,10 +1,5 @@
-import {
-  VaporFragment,
-  insert,
-  normalizeBlock,
-  prepend,
-  remove,
-} from '../src/block'
+import { insert, normalizeBlock, prepend, remove } from '../src/block'
+import { VaporFragment } from '../src/fragment'
 
 const node1 = document.createTextNode('node1')
 const node2 = document.createTextNode('node2')
diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts
new file mode 100644 (file)
index 0000000..45a2e58
--- /dev/null
@@ -0,0 +1,1258 @@
+import {
+  type LooseRawProps,
+  type VaporComponent,
+  createComponent as createComp,
+  createComponent,
+} from '../../src/component'
+import {
+  type VaporDirective,
+  VaporTeleport,
+  child,
+  createIf,
+  createTemplateRefSetter,
+  createVaporApp,
+  defineVaporComponent,
+  renderEffect,
+  setInsertionState,
+  setText,
+  template,
+  vaporInteropPlugin,
+  withVaporDirectives,
+} from '@vue/runtime-vapor'
+import { makeRender } from '../_utils'
+import {
+  h,
+  nextTick,
+  onBeforeUnmount,
+  onMounted,
+  onUnmounted,
+  ref,
+  shallowRef,
+} from 'vue'
+
+import type { HMRRuntime } from '@vue/runtime-dom'
+declare var __VUE_HMR_RUNTIME__: HMRRuntime
+const { createRecord, rerender, reload } = __VUE_HMR_RUNTIME__
+
+const define = makeRender()
+
+describe('renderer: VaporTeleport', () => {
+  describe('eager mode', () => {
+    runSharedTests(false)
+  })
+
+  describe('defer mode', () => {
+    runSharedTests(true)
+
+    test('should be able to target content appearing later than the teleport with defer', () => {
+      const root = document.createElement('div')
+      document.body.appendChild(root)
+
+      const { mount } = define({
+        setup() {
+          const n1 = createComp(
+            VaporTeleport,
+            {
+              to: () => '#target',
+              defer: () => true,
+            },
+            {
+              default: () => template('<div>teleported</div>')(),
+            },
+          )
+          const n2 = template('<div id=target></div>')()
+          return [n1, n2]
+        },
+      }).create()
+      mount(root)
+
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><!--teleport end--><div id="target"><div>teleported</div></div>',
+      )
+    })
+
+    test.todo('defer mode should work inside suspense', () => {})
+
+    test('update before mounted with defer', async () => {
+      const root = document.createElement('div')
+      document.body.appendChild(root)
+
+      const show = ref(false)
+      const foo = ref('foo')
+      const Header = defineVaporComponent({
+        props: { foo: String },
+        setup(props) {
+          const n0 = template(`<div> </div>`)()
+          const x0 = child(n0 as any)
+          renderEffect(() => setText(x0 as any, props.foo))
+          return [n0]
+        },
+      })
+      const Footer = defineVaporComponent({
+        setup() {
+          foo.value = 'bar'
+          return template('<div>Footer</div>')()
+        },
+      })
+
+      const { mount } = define({
+        setup() {
+          return createIf(
+            () => show.value,
+            () => {
+              const n1 = createComp(
+                VaporTeleport,
+                { to: () => '#targetId', defer: () => true },
+                { default: () => createComp(Header, { foo: () => foo.value }) },
+              )
+              const n2 = createComp(Footer)
+              const n3 = template('<div id="targetId"></div>')()
+              return [n1, n2, n3]
+            },
+            () => template('<div></div>')(),
+          )
+        },
+      }).create()
+      mount(root)
+
+      expect(root.innerHTML).toBe('<div></div><!--if-->')
+
+      show.value = true
+      await nextTick()
+      expect(root.innerHTML).toBe(
+        `<!--teleport start--><!--teleport end--><div>Footer</div><div id="targetId"><div>bar</div></div><!--if-->`,
+      )
+    })
+  })
+
+  describe('HMR', () => {
+    test('rerender child + rerender parent', async () => {
+      const target = document.createElement('div')
+      const root = document.createElement('div')
+      const childId = 'test1-child-rerender'
+      const parentId = 'test1-parent-rerender'
+
+      const { component: Child } = define({
+        __hmrId: childId,
+        render() {
+          return template('<div>teleported</div>')()
+        },
+      })
+      createRecord(childId, Child as any)
+
+      const { mount, component: Parent } = define({
+        __hmrId: parentId,
+        render() {
+          const n0 = createComp(
+            VaporTeleport,
+            {
+              to: () => target,
+            },
+            {
+              default: () => createComp(Child),
+            },
+          )
+          const n1 = template('<div>root</div>')()
+          return [n0, n1]
+        },
+      }).create()
+      createRecord(parentId, Parent as any)
+      mount(root)
+
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('<div>teleported</div>')
+
+      // rerender child
+      rerender(childId, () => {
+        return template('<div>teleported 2</div>')()
+      })
+
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('<div>teleported 2</div>')
+
+      // rerender parent
+      rerender(parentId, () => {
+        const n0 = createComp(
+          VaporTeleport,
+          {
+            to: () => target,
+          },
+          {
+            default: () => createComp(Child),
+          },
+        )
+        const n1 = template('<div>root 2</div>')()
+        return [n0, n1]
+      })
+
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><!--teleport end--><div>root 2</div>',
+      )
+      expect(target.innerHTML).toBe('<div>teleported 2</div>')
+    })
+
+    test.todo('parent rerender + toggle disabled', async () => {
+      const target = document.createElement('div')
+      const root = document.createElement('div')
+      const parentId = 'test3-parent-rerender'
+      const disabled = ref(true)
+
+      const Child = defineVaporComponent({
+        render() {
+          return template('<div>teleported</div>')()
+        },
+      })
+
+      const { mount, component: Parent } = define({
+        __hmrId: parentId,
+        render() {
+          const n2 = template('<div><div>root</div></div>', true)() as any
+          setInsertionState(n2, 0)
+          createComp(
+            VaporTeleport,
+            {
+              to: () => target,
+              disabled: () => disabled.value,
+            },
+            {
+              default: () => createComp(Child),
+            },
+          )
+          return n2
+        },
+      }).create()
+      createRecord(parentId, Parent as any)
+      mount(root)
+
+      expect(root.innerHTML).toBe(
+        '<div><!--teleport start--><div>teleported</div><!--teleport end--><div>root</div></div>',
+      )
+      expect(target.innerHTML).toBe('')
+
+      // rerender parent
+      rerender(parentId, () => {
+        const n2 = template('<div><div>root 2</div></div>', true)() as any
+        setInsertionState(n2, 0)
+        createComp(
+          VaporTeleport,
+          {
+            to: () => target,
+            disabled: () => disabled.value,
+          },
+          {
+            default: () => createComp(Child),
+          },
+        )
+        return n2
+      })
+
+      expect(root.innerHTML).toBe(
+        '<div><!--teleport start--><div>teleported</div><!--teleport end--><div>root 2</div></div>',
+      )
+      expect(target.innerHTML).toBe('')
+
+      // toggle disabled
+      disabled.value = false
+      await nextTick()
+      expect(root.innerHTML).toBe(
+        '<div><!--teleport start--><!--teleport end--><div>root 2</div></div>',
+      )
+      expect(target.innerHTML).toBe('<div>teleported</div>')
+    })
+
+    test('reload child + reload parent', async () => {
+      const target = document.createElement('div')
+      const root = document.createElement('div')
+      const childId = 'test1-child-reload'
+      const parentId = 'test1-parent-reload'
+
+      const { component: Child } = define({
+        __hmrId: childId,
+        setup() {
+          const msg = ref('teleported')
+          return { msg }
+        },
+        render(ctx) {
+          const n0 = template(`<div> </div>`)()
+          const x0 = child(n0 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0]
+        },
+      })
+      createRecord(childId, Child as any)
+
+      const { mount, component: Parent } = define({
+        __hmrId: parentId,
+        setup() {
+          const msg = ref('root')
+          const disabled = ref(false)
+          return { msg, disabled }
+        },
+        render(ctx) {
+          const n0 = createComp(
+            VaporTeleport,
+            {
+              to: () => target,
+              disabled: () => ctx.disabled,
+            },
+            {
+              default: () => createComp(Child),
+            },
+          )
+          const n1 = template(`<div> </div>`)()
+          const x0 = child(n1 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0, n1]
+        },
+      }).create()
+      createRecord(parentId, Parent as any)
+      mount(root)
+
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('<div>teleported</div>')
+
+      // reload child by changing msg
+      reload(childId, {
+        __hmrId: childId,
+        __vapor: true,
+        setup() {
+          const msg = ref('teleported 2')
+          return { msg }
+        },
+        render(ctx: any) {
+          const n0 = template(`<div> </div>`)()
+          const x0 = child(n0 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0]
+        },
+      })
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('<div>teleported 2</div>')
+
+      // reload parent by changing msg
+      reload(parentId, {
+        __hmrId: parentId,
+        __vapor: true,
+        setup() {
+          const msg = ref('root 2')
+          const disabled = ref(false)
+          return { msg, disabled }
+        },
+        render(ctx: any) {
+          const n0 = createComp(
+            VaporTeleport,
+            {
+              to: () => target,
+              disabled: () => ctx.disabled,
+            },
+            {
+              default: () => createComp(Child),
+            },
+          )
+          const n1 = template(`<div> </div>`)()
+          const x0 = child(n1 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0, n1]
+        },
+      })
+
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><!--teleport end--><div>root 2</div>',
+      )
+      expect(target.innerHTML).toBe('<div>teleported 2</div>')
+
+      // reload parent again by changing disabled
+      reload(parentId, {
+        __hmrId: parentId,
+        __vapor: true,
+        setup() {
+          const msg = ref('root 2')
+          const disabled = ref(true)
+          return { msg, disabled }
+        },
+        render(ctx: any) {
+          const n0 = createComp(
+            VaporTeleport,
+            {
+              to: () => target,
+              disabled: () => ctx.disabled,
+            },
+            {
+              default: () => createComp(Child),
+            },
+          )
+          const n1 = template(`<div> </div>`)()
+          const x0 = child(n1 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0, n1]
+        },
+      })
+
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><div>teleported 2</div><!--teleport end--><div>root 2</div>',
+      )
+      expect(target.innerHTML).toBe('')
+    })
+
+    test('reload single root child + toggle disabled', async () => {
+      const target = document.createElement('div')
+      const root = document.createElement('div')
+      const childId = 'test2-child-reload'
+      const parentId = 'test2-parent-reload'
+
+      const disabled = ref(true)
+      const { component: Child } = define({
+        __hmrId: childId,
+        setup() {
+          const msg = ref('teleported')
+          return { msg }
+        },
+        render(ctx) {
+          const n0 = template(`<div> </div>`)()
+          const x0 = child(n0 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0]
+        },
+      })
+      createRecord(childId, Child as any)
+
+      const { mount, component: Parent } = define({
+        __hmrId: parentId,
+        setup() {
+          const msg = ref('root')
+          return { msg, disabled }
+        },
+        render(ctx) {
+          const n0 = createComp(
+            VaporTeleport,
+            {
+              to: () => target,
+              disabled: () => ctx.disabled,
+            },
+            {
+              // with single root child
+              default: () => createComp(Child),
+            },
+          )
+          const n1 = template(`<div> </div>`)()
+          const x0 = child(n1 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0, n1]
+        },
+      }).create()
+      createRecord(parentId, Parent as any)
+      mount(root)
+
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><div>teleported</div><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('')
+
+      // reload child by changing msg
+      reload(childId, {
+        __hmrId: childId,
+        __vapor: true,
+        setup() {
+          const msg = ref('teleported 2')
+          return { msg }
+        },
+        render(ctx: any) {
+          const n0 = template(`<div> </div>`)()
+          const x0 = child(n0 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0]
+        },
+      })
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><div>teleported 2</div><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('')
+
+      // reload child again by changing msg
+      reload(childId, {
+        __hmrId: childId,
+        __vapor: true,
+        setup() {
+          const msg = ref('teleported 3')
+          return { msg }
+        },
+        render(ctx: any) {
+          const n0 = template(`<div> </div>`)()
+          const x0 = child(n0 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0]
+        },
+      })
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><div>teleported 3</div><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('')
+
+      // toggle disabled
+      disabled.value = false
+      await nextTick()
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('<div>teleported 3</div>')
+    })
+
+    test('reload multiple root children + toggle disabled', async () => {
+      const target = document.createElement('div')
+      const root = document.createElement('div')
+      const childId = 'test3-child-reload'
+      const parentId = 'test3-parent-reload'
+
+      const disabled = ref(true)
+      const { component: Child } = define({
+        __hmrId: childId,
+        setup() {
+          const msg = ref('teleported')
+          return { msg }
+        },
+        render(ctx) {
+          const n0 = template(`<div> </div>`)()
+          const x0 = child(n0 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0]
+        },
+      })
+      createRecord(childId, Child as any)
+
+      const { mount, component: Parent } = define({
+        __hmrId: parentId,
+        setup() {
+          const msg = ref('root')
+          return { msg, disabled }
+        },
+        render(ctx) {
+          const n0 = createComp(
+            VaporTeleport,
+            {
+              to: () => target,
+              disabled: () => ctx.disabled,
+            },
+            {
+              default: () => {
+                // with multiple root children
+                return [createComp(Child), template(`<span>child</span>`)()]
+              },
+            },
+          )
+          const n1 = template(`<div> </div>`)()
+          const x0 = child(n1 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0, n1]
+        },
+      }).create()
+      createRecord(parentId, Parent as any)
+      mount(root)
+
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><div>teleported</div><span>child</span><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('')
+
+      // reload child by changing msg
+      reload(childId, {
+        __hmrId: childId,
+        __vapor: true,
+        setup() {
+          const msg = ref('teleported 2')
+          return { msg }
+        },
+        render(ctx: any) {
+          const n0 = template(`<div> </div>`)()
+          const x0 = child(n0 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0]
+        },
+      })
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><div>teleported 2</div><span>child</span><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('')
+
+      // reload child again by changing msg
+      reload(childId, {
+        __hmrId: childId,
+        __vapor: true,
+        setup() {
+          const msg = ref('teleported 3')
+          return { msg }
+        },
+        render(ctx: any) {
+          const n0 = template(`<div> </div>`)()
+          const x0 = child(n0 as any)
+          renderEffect(() => setText(x0 as any, ctx.msg))
+          return [n0]
+        },
+      })
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><div>teleported 3</div><span>child</span><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('')
+
+      // toggle disabled
+      disabled.value = false
+      await nextTick()
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><!--teleport end--><div>root</div>',
+      )
+      expect(target.innerHTML).toBe('<div>teleported 3</div><span>child</span>')
+    })
+  })
+
+  describe('VDOM interop', () => {
+    test('render vdom component', async () => {
+      const target = document.createElement('div')
+      const root = document.createElement('div')
+
+      const VDOMComp = {
+        setup() {
+          return () => h('h1', null, 'vdom comp')
+        },
+      }
+
+      const disabled = ref(true)
+      const App = defineVaporComponent({
+        setup() {
+          const n1 = createComponent(
+            VaporTeleport,
+            {
+              to: () => target,
+              defer: () => '',
+              disabled: () => disabled.value,
+            },
+            {
+              default: () => {
+                const n0 = createComponent(VDOMComp)
+                return n0
+              },
+            },
+            true,
+          )
+          return n1
+        },
+      })
+
+      const app = createVaporApp(App)
+      app.use(vaporInteropPlugin)
+      app.mount(root)
+
+      expect(target.innerHTML).toBe('')
+      expect(root.innerHTML).toBe(
+        '<!--teleport start--><h1>vdom comp</h1><!--teleport end-->',
+      )
+
+      disabled.value = false
+      await nextTick()
+      expect(root.innerHTML).toBe('<!--teleport start--><!--teleport end-->')
+      expect(target.innerHTML).toBe('<h1>vdom comp</h1>')
+    })
+  })
+})
+
+function runSharedTests(deferMode: boolean): void {
+  const createComponent = deferMode
+    ? (
+        component: VaporComponent,
+        rawProps?: LooseRawProps | null,
+        ...args: any[]
+      ) => {
+        if (component === VaporTeleport) {
+          rawProps!.defer = () => true
+        }
+        return createComp(component, rawProps, ...args)
+      }
+    : createComp
+
+  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 start--><!--teleport end--><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 start--><!--teleport end--><div>root</div>',
+    )
+    expect(targetA.innerHTML).toBe('<div>teleported</div>')
+    expect(targetB.innerHTML).toBe('')
+
+    target.value = targetB
+    await nextTick()
+
+    expect(root.innerHTML).toBe(
+      '<!--teleport start--><!--teleport end--><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('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()
+    await nextTick()
+    expect(beforeUnmount).toHaveBeenCalledTimes(1)
+    expect(unmounted).toHaveBeenCalledTimes(1)
+  })
+
+  test('multiple teleport with same target', async () => {
+    const target = document.createElement('div')
+    const root = document.createElement('div')
+
+    const child1 = shallowRef(template('<div>one</div>')())
+    const child2 = shallowRef(template('two')())
+
+    const { mount } = define({
+      setup() {
+        const n0 = template('<div></div>')()
+        setInsertionState(n0 as any)
+        createComponent(
+          VaporTeleport,
+          {
+            to: () => target,
+          },
+          {
+            default: () => child1.value,
+          },
+        )
+        setInsertionState(n0 as any)
+        createComponent(
+          VaporTeleport,
+          {
+            to: () => target,
+          },
+          {
+            default: () => child2.value,
+          },
+        )
+        return [n0]
+      },
+    }).create()
+    mount(root)
+    expect(root.innerHTML).toBe(
+      '<div><!--teleport start--><!--teleport end--><!--teleport start--><!--teleport end--></div>',
+    )
+    expect(target.innerHTML).toBe('<div>one</div>two')
+
+    // update existing content
+    child1.value = [
+      template('<div>one</div>')(),
+      template('<div>two</div>')(),
+    ] as any
+    child2.value = [template('three')()] as any
+    await nextTick()
+    expect(target.innerHTML).toBe('<div>one</div><div>two</div>three')
+
+    // toggling
+    child1.value = [] as any
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      '<div><!--teleport start--><!--teleport end--><!--teleport start--><!--teleport end--></div>',
+    )
+    expect(target.innerHTML).toBe('three')
+
+    // toggle back
+    child1.value = [
+      template('<div>one</div>')(),
+      template('<div>two</div>')(),
+    ] as any
+    child2.value = [template('three')()] as any
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      '<div><!--teleport start--><!--teleport end--><!--teleport start--><!--teleport end--></div>',
+    )
+    // should append
+    expect(target.innerHTML).toBe('<div>one</div><div>two</div>three')
+
+    // toggle the other teleport
+    child2.value = [] as any
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      '<div><!--teleport start--><!--teleport end--><!--teleport start--><!--teleport end--></div>',
+    )
+    expect(target.innerHTML).toBe('<div>one</div><div>two</div>')
+  })
+
+  test('should work when using template ref as target', async () => {
+    const root = document.createElement('div')
+    const target = ref<HTMLElement | null>(null)
+    const disabled = ref(true)
+
+    const { mount } = define({
+      setup() {
+        const setTemplateRef = createTemplateRefSetter()
+        const n0 = template('<div></div>')() as any
+        setTemplateRef(n0, target)
+
+        const n1 = createComponent(
+          VaporTeleport,
+          {
+            to: () => target.value,
+            disabled: () => disabled.value,
+          },
+          {
+            default: () => template('<div>teleported</div>')(),
+          },
+        )
+        return [n0, n1]
+      },
+    }).create()
+    mount(root)
+
+    expect(root.innerHTML).toBe(
+      '<div></div><!--teleport start--><div>teleported</div><!--teleport end-->',
+    )
+    disabled.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      '<div><div>teleported</div></div><!--teleport start--><!--teleport end-->',
+    )
+  })
+
+  test('disabled', async () => {
+    const target = document.createElement('div')
+    const root = document.createElement('div')
+
+    const disabled = ref(false)
+    const { mount } = define({
+      setup() {
+        const n0 = createComponent(
+          VaporTeleport,
+          {
+            to: () => target,
+            disabled: () => disabled.value,
+          },
+          {
+            default: () => template('<div>teleported</div>')(),
+          },
+        )
+        const n1 = template('<div>root</div>')()
+        return [n0, n1]
+      },
+    }).create()
+    mount(root)
+
+    expect(root.innerHTML).toBe(
+      '<!--teleport start--><!--teleport end--><div>root</div>',
+    )
+    expect(target.innerHTML).toBe('<div>teleported</div>')
+
+    disabled.value = true
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      '<!--teleport start--><div>teleported</div><!--teleport end--><div>root</div>',
+    )
+    expect(target.innerHTML).toBe('')
+
+    // toggle back
+    disabled.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      '<!--teleport start--><!--teleport end--><div>root</div>',
+    )
+    expect(target.innerHTML).toBe('<div>teleported</div>')
+  })
+
+  test(`the dir hooks of the Teleport's children should be called correctly`, async () => {
+    const target = document.createElement('div')
+    const root = document.createElement('div')
+    const toggle = ref(true)
+
+    const spy = vi.fn()
+    const teardown = vi.fn()
+    const dir: VaporDirective = vi.fn((el, source) => {
+      spy()
+      return teardown
+    })
+
+    const { mount } = define({
+      setup() {
+        return createComponent(
+          VaporTeleport,
+          {
+            to: () => target,
+          },
+          {
+            default: () => {
+              return createIf(
+                () => toggle.value,
+                () => {
+                  const n1 = template('<div>foo</div>')() as any
+                  withVaporDirectives(n1, [[dir]])
+                  return n1
+                },
+              )
+            },
+          },
+        )
+      },
+    }).create()
+
+    mount(root)
+    expect(root.innerHTML).toBe('<!--teleport start--><!--teleport end-->')
+    expect(target.innerHTML).toBe('<div>foo</div><!--if-->')
+    expect(spy).toHaveBeenCalledTimes(1)
+    expect(teardown).not.toHaveBeenCalled()
+
+    toggle.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--teleport start--><!--teleport end-->')
+    expect(target.innerHTML).toBe('<!--if-->')
+    expect(spy).toHaveBeenCalledTimes(1)
+    expect(teardown).toHaveBeenCalledTimes(1)
+  })
+
+  test(`ensure that target changes when disabled are updated correctly when enabled`, async () => {
+    const root = document.createElement('div')
+    const target1 = document.createElement('div')
+    const target2 = document.createElement('div')
+    const target3 = document.createElement('div')
+    const target = ref(target1)
+    const disabled = ref(true)
+
+    const { mount } = define({
+      setup() {
+        return createComponent(
+          VaporTeleport,
+          {
+            to: () => target.value,
+            disabled: () => disabled.value,
+          },
+          {
+            default: () => template('<div>teleported</div>')(),
+          },
+        )
+      },
+    }).create()
+    mount(root)
+
+    disabled.value = false
+    await nextTick()
+    expect(target1.innerHTML).toBe('<div>teleported</div>')
+    expect(target2.innerHTML).toBe('')
+    expect(target3.innerHTML).toBe('')
+
+    disabled.value = true
+    await nextTick()
+    target.value = target2
+    await nextTick()
+    expect(target1.innerHTML).toBe('')
+    expect(target2.innerHTML).toBe('')
+    expect(target3.innerHTML).toBe('')
+
+    target.value = target3
+    await nextTick()
+    expect(target1.innerHTML).toBe('')
+    expect(target2.innerHTML).toBe('')
+    expect(target3.innerHTML).toBe('')
+
+    disabled.value = false
+    await nextTick()
+    expect(target1.innerHTML).toBe('')
+    expect(target2.innerHTML).toBe('')
+    expect(target3.innerHTML).toBe('<div>teleported</div>')
+  })
+
+  test('toggle sibling node inside target node', async () => {
+    const root = document.createElement('div')
+    const show = ref(false)
+    const { mount } = define({
+      setup() {
+        return createIf(
+          () => show.value,
+          () => {
+            return createComponent(
+              VaporTeleport,
+              {
+                to: () => root,
+              },
+              {
+                default: () => template('<div>teleported</div>')(),
+              },
+            )
+          },
+          () => {
+            return template('<div>foo</div>')()
+          },
+        )
+      },
+    }).create()
+
+    mount(root)
+    expect(root.innerHTML).toBe('<div>foo</div><!--if-->')
+
+    show.value = true
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      '<!--teleport start--><!--teleport end--><!--if--><div>teleported</div>',
+    )
+
+    show.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe('<div>foo</div><!--if-->')
+  })
+
+  test('unmount previous sibling node inside target node', async () => {
+    const root = document.createElement('div')
+    const parentShow = ref(false)
+    const childShow = ref(true)
+
+    const { component: Comp } = define({
+      setup() {
+        return createComponent(
+          VaporTeleport,
+          { to: () => root },
+          {
+            default: () => {
+              return template('<div>foo</div>')()
+            },
+          },
+        )
+      },
+    })
+
+    const { mount } = define({
+      setup() {
+        return createIf(
+          () => parentShow.value,
+          () =>
+            createIf(
+              () => childShow.value,
+              () => createComponent(Comp),
+              () => template('bar')(),
+            ),
+          () => template('foo')(),
+        )
+      },
+    }).create()
+
+    mount(root)
+    expect(root.innerHTML).toBe('foo<!--if-->')
+
+    parentShow.value = true
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      '<!--teleport start--><!--teleport end--><!--if--><!--if--><div>foo</div>',
+    )
+
+    parentShow.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe('foo<!--if-->')
+  })
+
+  test('accessing template refs inside teleport', async () => {
+    const target = document.createElement('div')
+    const tRef = ref()
+    let tRefInMounted
+
+    const { mount } = define({
+      setup() {
+        onMounted(() => {
+          tRefInMounted = tRef.value
+        })
+        const n1 = createComponent(
+          VaporTeleport,
+          {
+            to: () => target,
+          },
+          {
+            default: () => {
+              const setTemplateRef = createTemplateRefSetter()
+              const n0 = template('<div>teleported</div>')() as any
+              setTemplateRef(n0, tRef)
+              return n0
+            },
+          },
+        )
+        return n1
+      },
+    }).create()
+    mount(target)
+
+    const child = target.children[0]
+    expect(child.outerHTML).toBe(`<div>teleported</div>`)
+    expect(tRefInMounted).toBe(child)
+  })
+}
index 8a127c2daf196ce32dc8fa386b19fe1e4ad58798..1dc00dbb3d19389bb431a20279ca13017caf3029 100644 (file)
@@ -1,5 +1,5 @@
 import { currentInstance, resolveDynamicComponent } from '@vue/runtime-dom'
-import { DynamicFragment, type VaporFragment, insert } from './block'
+import { insert } from './block'
 import { createComponentWithFallback, emptyContext } from './component'
 import { renderEffect } from './renderEffect'
 import type { RawProps } from './componentProps'
@@ -10,6 +10,7 @@ import {
   resetInsertionState,
 } from './insertionState'
 import { isHydrating, locateHydrationNode } from './dom/hydration'
+import { DynamicFragment, type VaporFragment } from './fragment'
 
 export function createDynamicComponent(
   getter: () => any,
index 71a448d2f7171e83af304bf15805566c050b4b1a..25126315e19d6ee5587e7e4f4ad10b821bcacade 100644 (file)
@@ -13,14 +13,7 @@ import {
 } from '@vue/reactivity'
 import { isArray, isObject, isString } from '@vue/shared'
 import { createComment, createTextNode } from './dom/node'
-import {
-  type Block,
-  ForFragment,
-  VaporFragment,
-  insert,
-  remove,
-  remove as removeBlock,
-} from './block'
+import { type Block, insert, remove } from './block'
 import { warn } from '@vue/runtime-dom'
 import { currentInstance, isVaporComponent } from './component'
 import type { DynamicSlot } from './componentSlots'
@@ -28,6 +21,7 @@ import { renderEffect } from './renderEffect'
 import { VaporVForFlags } from '../../shared/src/vaporFlags'
 import { applyTransitionHooks } from './components/Transition'
 import { isHydrating, locateHydrationNode } from './dom/hydration'
+import { ForFragment, VaporFragment } from './fragment'
 import {
   insertionAnchor,
   insertionParent,
@@ -435,7 +429,7 @@ export const createFor = (
       block.scope!.stop()
     }
     if (doRemove) {
-      removeBlock(block.nodes, parent!)
+      remove(block.nodes, parent!)
     }
     if (doDeregister) {
       for (const selector of selectors) {
index 50179b89ef95857f617f660c10033ef34fd21c19..d4bb8763681979bc4155d1c563b730b8b8740c97 100644 (file)
@@ -1,4 +1,5 @@
-import { type Block, type BlockFn, DynamicFragment } from './block'
+import type { Block, BlockFn } from './block'
+import { DynamicFragment } from './fragment'
 import { renderEffect } from './renderEffect'
 
 export function createKeyedFragment(key: () => any, render: BlockFn): Block {
index f573a61b16bdf80b475fd251e38f3110916e4b59..37f6077b0f5517755f61400026bf82f64d4c2052 100644 (file)
@@ -1,4 +1,4 @@
-import { type Block, type BlockFn, DynamicFragment, insert } from './block'
+import { type Block, type BlockFn, insert } from './block'
 import { isHydrating, locateHydrationNode } from './dom/hydration'
 import {
   insertionAnchor,
@@ -6,6 +6,7 @@ import {
   resetInsertionState,
 } from './insertionState'
 import { renderEffect } from './renderEffect'
+import { DynamicFragment } from './fragment'
 
 export function createIf(
   condition: () => any,
index b06e255f794e8f36c3b28fb02c3990ab440e67de..9021ab160d6dee8421b57ba9718deeaa26ac75c7 100644 (file)
@@ -14,8 +14,8 @@ import {
   type VaporComponentInstance,
   createComponent,
 } from './component'
-import { DynamicFragment } from './block'
 import { renderEffect } from './renderEffect'
+import { DynamicFragment } from './fragment'
 
 /*! #__NO_SIDE_EFFECTS__ */
 export function defineVaporAsyncComponent<T extends VaporComponent>(
index 5ddba415abd93fa4c5cf38dd139a83a38cd63734..bbe60d829a890dfd5241360e85bacbc7b6b5515b 100644 (file)
@@ -22,7 +22,7 @@ import {
   isString,
   remove,
 } from '@vue/shared'
-import { DynamicFragment, isFragment } from './block'
+import { DynamicFragment, isFragment } from './fragment'
 
 export type NodeRef =
   | string
index 146c2ec5ee9fb275f468e830640a7f8089ee3eb0..3543f7c18b9f7b8c84a9f4047516ea9cdafce4c0 100644 (file)
@@ -1,29 +1,24 @@
 import { isArray } from '@vue/shared'
 import {
   type VaporComponentInstance,
-  currentInstance,
   isVaporComponent,
   mountComponent,
   unmountComponent,
 } from './component'
-import { createComment, createTextNode } from './dom/node'
-import { EffectScope, setActiveSub } from '@vue/reactivity'
 import { isHydrating } from './dom/hydration'
-import type { NodeRef } from './apiTemplateRef'
+import {
+  type DynamicFragment,
+  type VaporFragment,
+  isFragment,
+} from './fragment'
+import { TeleportFragment } from './components/Teleport'
 import {
   type TransitionHooks,
   type TransitionProps,
   type TransitionState,
-  type VNode,
-  isKeepAlive,
   performTransitionEnter,
   performTransitionLeave,
 } from '@vue/runtime-dom'
-import {
-  applyTransitionHooks,
-  applyTransitionLeaveHooks,
-} from './components/Transition'
-import type { KeepAliveInstance } from './components/KeepAlive'
 
 export interface TransitionOptions {
   $key?: any
@@ -48,171 +43,6 @@ export type Block = TransitionBlock | VaporComponentInstance | Block[]
 
 export type BlockFn = (...args: any[]) => Block
 
-export class VaporFragment<T extends Block = Block>
-  implements TransitionOptions
-{
-  nodes: T
-  vnode?: VNode | null = null
-  anchor?: Node
-  setRef?: (
-    instance: VaporComponentInstance,
-    ref: NodeRef,
-    refFor: boolean,
-    refKey: string | undefined,
-  ) => void
-  fallback?: BlockFn
-  $key?: any
-  $transition?: VaporTransitionHooks | undefined
-  insert?: (
-    parent: ParentNode,
-    anchor: Node | null,
-    transitionHooks?: TransitionHooks,
-  ) => void
-  remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void
-
-  constructor(nodes: T) {
-    this.nodes = nodes
-  }
-}
-
-export class ForFragment extends VaporFragment<Block[]> {
-  constructor(nodes: Block[]) {
-    super(nodes)
-  }
-}
-
-export class DynamicFragment extends VaporFragment {
-  anchor: Node
-  scope: EffectScope | undefined
-  current?: BlockFn
-
-  constructor(anchorLabel?: string) {
-    super([])
-    this.anchor =
-      __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode()
-  }
-
-  update(render?: BlockFn, key: any = render): void {
-    if (key === this.current) {
-      return
-    }
-    this.current = key
-
-    const prevSub = setActiveSub()
-    const parent = this.anchor.parentNode
-    const transition = this.$transition
-    const renderBranch = () => {
-      if (render) {
-        this.scope = new EffectScope()
-        this.nodes = this.scope.run(render) || []
-        if (isKeepAlive(instance)) {
-          ;(instance as KeepAliveInstance).process(this.nodes)
-        }
-        if (transition) {
-          this.$transition = applyTransitionHooks(this.nodes, transition)
-        }
-        if (parent) insert(this.nodes, parent, this.anchor)
-      } else {
-        this.scope = undefined
-        this.nodes = []
-      }
-    }
-    const instance = currentInstance!
-    // teardown previous branch
-    if (this.scope) {
-      if (isKeepAlive(instance)) {
-        ;(instance as KeepAliveInstance).process(this.nodes)
-      } else {
-        this.scope.stop()
-      }
-      const mode = transition && transition.mode
-      if (mode) {
-        applyTransitionLeaveHooks(this.nodes, transition, renderBranch)
-        parent && remove(this.nodes, parent)
-        if (mode === 'out-in') {
-          setActiveSub(prevSub)
-          return
-        }
-      } else {
-        parent && remove(this.nodes, parent)
-      }
-    }
-
-    renderBranch()
-
-    if (this.fallback) {
-      // set fallback for nested fragments
-      const hasNestedFragment = isFragment(this.nodes)
-      if (hasNestedFragment) {
-        setFragmentFallback(this.nodes as VaporFragment, this.fallback)
-      }
-
-      const invalidFragment = findInvalidFragment(this)
-      if (invalidFragment) {
-        parent && remove(this.nodes, parent)
-        const scope = this.scope || (this.scope = new EffectScope())
-        scope.run(() => {
-          // for nested fragments, render invalid fragment's fallback
-          if (hasNestedFragment) {
-            renderFragmentFallback(invalidFragment)
-          } else {
-            this.nodes = this.fallback!() || []
-          }
-        })
-        parent && insert(this.nodes, parent, this.anchor)
-      }
-    }
-
-    setActiveSub(prevSub)
-  }
-}
-
-export function setFragmentFallback(
-  fragment: VaporFragment,
-  fallback: BlockFn,
-): void {
-  if (fragment.fallback) {
-    const originalFallback = fragment.fallback
-    // if the original fallback also renders invalid blocks,
-    // this ensures proper fallback chaining
-    fragment.fallback = () => {
-      const fallbackNodes = originalFallback()
-      if (isValidBlock(fallbackNodes)) {
-        return fallbackNodes
-      }
-      return fallback()
-    }
-  } else {
-    fragment.fallback = fallback
-  }
-
-  if (isFragment(fragment.nodes)) {
-    setFragmentFallback(fragment.nodes, fragment.fallback)
-  }
-}
-
-function renderFragmentFallback(fragment: VaporFragment): void {
-  if (fragment instanceof ForFragment) {
-    fragment.nodes[0] = [fragment.fallback!() || []] as Block[]
-  } else if (fragment instanceof DynamicFragment) {
-    fragment.update(fragment.fallback)
-  } else {
-    // vdom slots
-  }
-}
-
-function findInvalidFragment(fragment: VaporFragment): VaporFragment | null {
-  if (isValidBlock(fragment.nodes)) return null
-
-  return isFragment(fragment.nodes)
-    ? findInvalidFragment(fragment.nodes) || fragment
-    : fragment
-}
-
-export function isFragment(val: NonNullable<unknown>): val is VaporFragment {
-  return val instanceof VaporFragment
-}
-
 export function isBlock(val: NonNullable<unknown>): val is Block {
   return (
     val instanceof Node ||
@@ -340,8 +170,12 @@ export function normalizeBlock(block: Block): Node[] {
   } else if (isVaporComponent(block)) {
     nodes.push(...normalizeBlock(block.block!))
   } else {
-    nodes.push(...normalizeBlock(block.nodes))
-    block.anchor && nodes.push(block.anchor)
+    if (block instanceof TeleportFragment) {
+      nodes.push(block.placeholder!, block.anchor!)
+    } else {
+      nodes.push(...normalizeBlock(block.nodes))
+      block.anchor && nodes.push(block.anchor)
+    }
   }
   return nodes
 }
index 0ab63342115769bf5203fa2e9d65a5cc25c5aa23..d4c5ce006e40bd93aac30d968bf9730e3040bf07 100644 (file)
@@ -26,7 +26,7 @@ import {
   unregisterHMR,
   warn,
 } from '@vue/runtime-dom'
-import { type Block, DynamicFragment, insert, isBlock, remove } from './block'
+import { type Block, insert, isBlock, remove } from './block'
 import {
   type ShallowRef,
   markRaw,
@@ -52,7 +52,7 @@ import {
   resolveDynamicProps,
   setupPropsValidation,
 } from './componentProps'
-import { renderEffect } from './renderEffect'
+import { type RenderEffect, renderEffect } from './renderEffect'
 import { emit, normalizeEmitsOptions } from './componentEmits'
 import { setDynamicProps } from './dom/prop'
 import {
@@ -66,12 +66,14 @@ import {
 import { hmrReload, hmrRerender } from './hmr'
 import { createElement } from './dom/node'
 import { isHydrating, locateHydrationNode } from './dom/hydration'
+import { type TeleportFragment, isVaporTeleport } from './components/Teleport'
 import type { KeepAliveInstance } from './components/KeepAlive'
 import {
   insertionAnchor,
   insertionParent,
   resetInsertionState,
 } from './insertionState'
+import { DynamicFragment } from './fragment'
 
 export { currentInstance } from '@vue/runtime-dom'
 
@@ -187,7 +189,7 @@ export function createComponent(
     const cached = (currentInstance as KeepAliveInstance).getCachedComponent(
       component,
     )
-    // @ts-expect-error cached may be a fragment
+    // @ts-expect-error
     if (cached) return cached
   }
 
@@ -204,6 +206,18 @@ export function createComponent(
     return frag
   }
 
+  // teleport
+  if (isVaporTeleport(component)) {
+    const frag = component.process(rawProps!, rawSlots!)
+    if (!isHydrating && _insertionParent) {
+      insert(frag, _insertionParent, _insertionAnchor)
+    } else {
+      frag.hydrate()
+    }
+
+    return frag as any
+  }
+
   const instance = new VaporComponentInstance(
     component,
     rawProps as RawProps,
@@ -421,6 +435,8 @@ export class VaporComponentInstance implements GenericComponentInstance {
   devtoolsRawSetupState?: any
   hmrRerender?: () => void
   hmrReload?: (newComp: VaporComponent) => void
+  renderEffects?: RenderEffect[]
+  parentTeleport?: TeleportFragment | null
   propsOptions?: NormalizedPropsOptions
   emitsOptions?: ObjectEmitsOptions | null
   isSingleRoot?: boolean
index 78d823b367424ff1797865ef626b882cc04e79a2..49b577ec3dc035473ed00042bab13fe54793df91 100644 (file)
@@ -1,5 +1,5 @@
 import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared'
-import { type Block, type BlockFn, DynamicFragment, insert } from './block'
+import { type Block, type BlockFn, insert } from './block'
 import { rawPropsProxyHandlers } from './componentProps'
 import { currentInstance, isRef } from '@vue/runtime-dom'
 import type { LooseRawProps, VaporComponentInstance } from './component'
@@ -10,6 +10,7 @@ import {
   resetInsertionState,
 } from './insertionState'
 import { isHydrating, locateHydrationNode } from './dom/hydration'
+import { DynamicFragment } from './fragment'
 
 export type RawSlots = Record<string, VaporSlot> & {
   $?: DynamicSlotSource[]
index 37c137cb400e992ab2e42f85f1ae0785a3d6a40b..3367ea089bd461129272e08dc13cda9dc654a4b1 100644 (file)
@@ -12,13 +12,7 @@ import {
   warn,
   watch,
 } from '@vue/runtime-dom'
-import {
-  type Block,
-  type VaporFragment,
-  insert,
-  isFragment,
-  remove,
-} from '../block'
+import { type Block, insert, remove } from '../block'
 import {
   type ObjectVaporComponent,
   type VaporComponent,
@@ -28,6 +22,7 @@ import {
 import { defineVaporComponent } from '../apiDefineComponent'
 import { ShapeFlags, invokeArrayFns, isArray } from '@vue/shared'
 import { createElement } from '../dom/node'
+import { type VaporFragment, isFragment } from '../fragment'
 
 export interface KeepAliveInstance extends VaporComponentInstance {
   activate: (
diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts
new file mode 100644 (file)
index 0000000..8609303
--- /dev/null
@@ -0,0 +1,203 @@
+import {
+  type TeleportProps,
+  isTeleportDeferred,
+  isTeleportDisabled,
+  queuePostFlushCb,
+  resolveTeleportTarget,
+  warn,
+} from '@vue/runtime-dom'
+import { type Block, type BlockFn, insert, remove } from '../block'
+import { createComment, createTextNode, querySelector } from '../dom/node'
+import {
+  type LooseRawProps,
+  type LooseRawSlots,
+  isVaporComponent,
+} from '../component'
+import { rawPropsProxyHandlers } from '../componentProps'
+import { renderEffect } from '../renderEffect'
+import { extend, isArray } from '@vue/shared'
+import { VaporFragment } from '../fragment'
+
+export const VaporTeleportImpl = {
+  name: 'VaporTeleport',
+  __isTeleport: true,
+  __vapor: true,
+
+  process(props: LooseRawProps, slots: LooseRawSlots): TeleportFragment {
+    return new TeleportFragment(props, slots)
+  },
+}
+
+export class TeleportFragment extends VaporFragment {
+  anchor?: Node
+  private rawProps?: LooseRawProps
+  private resolvedProps?: TeleportProps
+  private rawSlots?: LooseRawSlots
+
+  target?: ParentNode | null
+  targetAnchor?: Node | null
+  targetStart?: Node | null
+
+  placeholder?: Node
+  mountContainer?: ParentNode | null
+  mountAnchor?: Node | null
+
+  constructor(props: LooseRawProps, slots: LooseRawSlots) {
+    super([])
+    this.rawProps = props
+    this.rawSlots = slots
+    this.anchor = __DEV__ ? createComment('teleport end') : createTextNode()
+
+    renderEffect(() => {
+      // access the props to trigger tracking
+      this.resolvedProps = extend(
+        {},
+        new Proxy(
+          this.rawProps!,
+          rawPropsProxyHandlers,
+        ) as any as TeleportProps,
+      )
+      this.handlePropsUpdate()
+    })
+
+    this.initChildren()
+  }
+
+  get parent(): ParentNode | null {
+    return this.anchor ? this.anchor.parentNode : null
+  }
+
+  private initChildren(): void {
+    renderEffect(() => {
+      this.handleChildrenUpdate(
+        this.rawSlots!.default && (this.rawSlots!.default as BlockFn)(),
+      )
+    })
+
+    // for hmr
+    if (__DEV__) {
+      const nodes = this.nodes
+      if (isVaporComponent(nodes)) {
+        nodes.parentTeleport = this
+      } else if (isArray(nodes)) {
+        nodes.forEach(
+          node => isVaporComponent(node) && (node.parentTeleport = this),
+        )
+      }
+    }
+  }
+
+  private handleChildrenUpdate(children: Block): void {
+    // not mounted yet
+    if (!this.parent) {
+      this.nodes = children
+      return
+    }
+
+    // teardown previous nodes
+    remove(this.nodes, this.mountContainer!)
+    // mount new nodes
+    insert((this.nodes = children), this.mountContainer!, this.mountAnchor!)
+  }
+
+  private handlePropsUpdate(): void {
+    // not mounted yet
+    if (!this.parent) return
+
+    const mount = (parent: ParentNode, anchor: Node | null) => {
+      insert(
+        this.nodes,
+        (this.mountContainer = parent),
+        (this.mountAnchor = anchor),
+      )
+    }
+
+    const mountToTarget = () => {
+      const target = (this.target = resolveTeleportTarget(
+        this.resolvedProps!,
+        querySelector,
+      ))
+      if (target) {
+        if (
+          // initial mount into target
+          !this.targetAnchor ||
+          // target changed
+          this.targetAnchor.parentNode !== target
+        ) {
+          insert((this.targetStart = createTextNode('')), target)
+          insert((this.targetAnchor = createTextNode('')), target)
+        }
+
+        mount(target, this.targetAnchor!)
+      } else if (__DEV__) {
+        warn(
+          `Invalid Teleport target on ${this.targetAnchor ? 'update' : 'mount'}:`,
+          target,
+          `(${typeof target})`,
+        )
+      }
+    }
+
+    // mount into main container
+    if (isTeleportDisabled(this.resolvedProps!)) {
+      mount(this.parent, this.anchor!)
+    }
+    // mount into target container
+    else {
+      if (isTeleportDeferred(this.resolvedProps!)) {
+        queuePostFlushCb(mountToTarget)
+      } else {
+        mountToTarget()
+      }
+    }
+  }
+
+  insert = (container: ParentNode, anchor: Node | null): void => {
+    // insert anchors in the main view
+    this.placeholder = __DEV__
+      ? createComment('teleport start')
+      : createTextNode()
+    insert(this.placeholder, container, anchor)
+    insert(this.anchor!, container, anchor)
+    this.handlePropsUpdate()
+  }
+
+  remove = (parent: ParentNode | undefined = this.parent!): void => {
+    // remove nodes
+    if (this.nodes) {
+      remove(this.nodes, this.mountContainer!)
+      this.nodes = []
+    }
+
+    // remove anchors
+    if (this.targetStart) {
+      remove(this.targetStart!, this.target!)
+      this.targetStart = undefined
+      remove(this.targetAnchor!, this.target!)
+      this.targetAnchor = undefined
+    }
+
+    if (this.anchor) {
+      remove(this.anchor, this.anchor.parentNode!)
+      this.anchor = undefined
+    }
+
+    if (this.placeholder) {
+      remove(this.placeholder!, parent)
+      this.placeholder = undefined
+    }
+
+    this.mountContainer = undefined
+    this.mountAnchor = undefined
+  }
+
+  hydrate = (): void => {
+    //TODO
+  }
+}
+
+export function isVaporTeleport(
+  value: unknown,
+): value is typeof VaporTeleportImpl {
+  return value === VaporTeleportImpl
+}
index 017cb0fd5c880d37523a1657bfc84ec6293c1409..c72bc0d517b5871e96ad3137a353bd92272e9cb8 100644 (file)
@@ -14,12 +14,7 @@ import {
   useTransitionState,
   warn,
 } from '@vue/runtime-dom'
-import {
-  type Block,
-  type TransitionBlock,
-  type VaporTransitionHooks,
-  isFragment,
-} from '../block'
+import type { Block, TransitionBlock, VaporTransitionHooks } from '../block'
 import {
   type FunctionalVaporComponent,
   type VaporComponentInstance,
@@ -28,6 +23,7 @@ import {
 } from '../component'
 import { extend, isArray } from '@vue/shared'
 import { renderEffect } from '../renderEffect'
+import { isFragment } from '../fragment'
 
 const decorate = (t: typeof VaporTransition) => {
   t.displayName = 'VaporTransition'
index 074a28c4ac69b0b51b111d10889ddc9759a75aa6..8c54fa25a5746d88b5f00726e5afe18569ea9895 100644 (file)
@@ -17,11 +17,9 @@ import {
 import { extend, isArray } from '@vue/shared'
 import {
   type Block,
-  DynamicFragment,
   type TransitionBlock,
   type VaporTransitionHooks,
   insert,
-  isFragment,
 } from '../block'
 import {
   resolveTransitionHooks,
@@ -37,6 +35,7 @@ import {
 import { isForBlock } from '../apiCreateFor'
 import { renderEffect } from '../renderEffect'
 import { createElement } from '../dom/node'
+import { DynamicFragment, isFragment } from '../fragment'
 
 const positionMap = new WeakMap<TransitionBlock, DOMRect>()
 const newPositionMap = new WeakMap<TransitionBlock, DOMRect>()
index bb94acf95c25364e1476cdbdb28c68f20a320d26..68888b0dde55f6e7f4969af2ce8026b3fb64aa97 100644 (file)
@@ -6,13 +6,9 @@ import {
 } from '@vue/runtime-dom'
 import { renderEffect } from '../renderEffect'
 import { isVaporComponent } from '../component'
-import {
-  type Block,
-  DynamicFragment,
-  type TransitionBlock,
-  VaporFragment,
-} from '../block'
+import type { Block, TransitionBlock } from '../block'
 import { isArray } from '@vue/shared'
+import { DynamicFragment, VaporFragment } from '../fragment'
 
 export function applyVShow(target: Block, source: () => any): void {
   if (isVaporComponent(target)) {
diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts
new file mode 100644 (file)
index 0000000..3ac1e09
--- /dev/null
@@ -0,0 +1,189 @@
+import { EffectScope, setActiveSub } from '@vue/reactivity'
+import { createComment, createTextNode } from './dom/node'
+import {
+  type Block,
+  type BlockFn,
+  type TransitionOptions,
+  type VaporTransitionHooks,
+  insert,
+  isValidBlock,
+  remove,
+} from './block'
+import {
+  type TransitionHooks,
+  type VNode,
+  currentInstance,
+  isKeepAlive,
+} from '@vue/runtime-dom'
+import type { VaporComponentInstance } from './component'
+import type { NodeRef } from './apiTemplateRef'
+import type { KeepAliveInstance } from './components/KeepAlive'
+import {
+  applyTransitionHooks,
+  applyTransitionLeaveHooks,
+} from './components/Transition'
+
+export class VaporFragment<T extends Block = Block>
+  implements TransitionOptions
+{
+  nodes: T
+  vnode?: VNode | null = null
+  anchor?: Node
+  setRef?: (
+    instance: VaporComponentInstance,
+    ref: NodeRef,
+    refFor: boolean,
+    refKey: string | undefined,
+  ) => void
+  fallback?: BlockFn
+  $key?: any
+  $transition?: VaporTransitionHooks | undefined
+  insert?: (
+    parent: ParentNode,
+    anchor: Node | null,
+    transitionHooks?: TransitionHooks,
+  ) => void
+  remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void
+
+  constructor(nodes: T) {
+    this.nodes = nodes
+  }
+}
+
+export class ForFragment extends VaporFragment<Block[]> {
+  constructor(nodes: Block[]) {
+    super(nodes)
+  }
+}
+
+export class DynamicFragment extends VaporFragment {
+  anchor: Node
+  scope: EffectScope | undefined
+  current?: BlockFn
+
+  constructor(anchorLabel?: string) {
+    super([])
+    this.anchor =
+      __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode()
+  }
+
+  update(render?: BlockFn, key: any = render): void {
+    if (key === this.current) {
+      return
+    }
+    this.current = key
+
+    const prevSub = setActiveSub()
+    const parent = this.anchor.parentNode
+    const transition = this.$transition
+    const renderBranch = () => {
+      if (render) {
+        this.scope = new EffectScope()
+        this.nodes = this.scope.run(render) || []
+        if (isKeepAlive(instance)) {
+          ;(instance as KeepAliveInstance).process(this.nodes)
+        }
+        if (transition) {
+          this.$transition = applyTransitionHooks(this.nodes, transition)
+        }
+        if (parent) insert(this.nodes, parent, this.anchor)
+      } else {
+        this.scope = undefined
+        this.nodes = []
+      }
+    }
+    const instance = currentInstance!
+    // teardown previous branch
+    if (this.scope) {
+      if (isKeepAlive(instance)) {
+        ;(instance as KeepAliveInstance).process(this.nodes)
+      } else {
+        this.scope.stop()
+      }
+      const mode = transition && transition.mode
+      if (mode) {
+        applyTransitionLeaveHooks(this.nodes, transition, renderBranch)
+        parent && remove(this.nodes, parent)
+        if (mode === 'out-in') {
+          setActiveSub(prevSub)
+          return
+        }
+      } else {
+        parent && remove(this.nodes, parent)
+      }
+    }
+
+    renderBranch()
+
+    if (this.fallback) {
+      // set fallback for nested fragments
+      const hasNestedFragment = isFragment(this.nodes)
+      if (hasNestedFragment) {
+        setFragmentFallback(this.nodes as VaporFragment, this.fallback)
+      }
+
+      const invalidFragment = findInvalidFragment(this)
+      if (invalidFragment) {
+        parent && remove(this.nodes, parent)
+        const scope = this.scope || (this.scope = new EffectScope())
+        scope.run(() => {
+          // for nested fragments, render invalid fragment's fallback
+          if (hasNestedFragment) {
+            renderFragmentFallback(invalidFragment)
+          } else {
+            this.nodes = this.fallback!() || []
+          }
+        })
+        parent && insert(this.nodes, parent, this.anchor)
+      }
+    }
+
+    setActiveSub(prevSub)
+  }
+}
+
+export function setFragmentFallback(
+  fragment: VaporFragment,
+  fallback: BlockFn,
+): void {
+  if (fragment.fallback) {
+    const originalFallback = fragment.fallback
+    // if the original fallback also renders invalid blocks,
+    // this ensures proper fallback chaining
+    fragment.fallback = () => {
+      const fallbackNodes = originalFallback()
+      if (isValidBlock(fallbackNodes)) {
+        return fallbackNodes
+      }
+      return fallback()
+    }
+  } else {
+    fragment.fallback = fallback
+  }
+
+  if (isFragment(fragment.nodes)) {
+    setFragmentFallback(fragment.nodes, fragment.fallback)
+  }
+}
+
+function renderFragmentFallback(fragment: VaporFragment): void {
+  if (fragment instanceof ForFragment) {
+    fragment.nodes[0] = [fragment.fallback!() || []] as Block[]
+  } else if (fragment instanceof DynamicFragment) {
+    fragment.update(fragment.fallback)
+  } else {
+    // vdom slots
+  }
+}
+
+function findInvalidFragment(fragment: VaporFragment): VaporFragment | null {
+  if (isValidBlock(fragment.nodes)) return null
+
+  return isFragment(fragment.nodes)
+    ? findInvalidFragment(fragment.nodes) || fragment
+    : fragment
+}
+
+export function isFragment(val: NonNullable<unknown>): val is VaporFragment {
+  return val instanceof VaporFragment
+}
index 1a88ec7dc22a659db7ae7e2227d239f21744927d..4e9927ee9ab980b829c32a25aaf89677ebe60ae4 100644 (file)
@@ -20,6 +20,10 @@ export function hmrRerender(instance: VaporComponentInstance): void {
   const anchor = normalized[normalized.length - 1].nextSibling
   remove(instance.block, parent)
   const prev = setCurrentInstance(instance)
+  if (instance.renderEffects) {
+    instance.renderEffects.forEach(e => e.stop())
+    instance.renderEffects = []
+  }
   pushWarningContext(instance)
   devRender(instance)
   popWarningContext()
@@ -47,6 +51,7 @@ export function hmrReload(
   mountComponent(newInstance, parent, anchor)
 
   updateParentBlockOnHmrReload(parentInstance, instance, newInstance)
+  updateParentTeleportOnHmrReload(instance, newInstance)
 }
 
 /**
@@ -73,3 +78,30 @@ function updateParentBlockOnHmrReload(
     }
   }
 }
+
+/**
+ * dev only
+ * during root component HMR reload, since the old component will be unmounted
+ * and a new one will be mounted, we need to update the teleport's nodes
+ * to ensure that the correct parent and anchor are found during parentInstance
+ * HMR rerender/reload, as `normalizeBlock` relies on the current instance.block
+ */
+export function updateParentTeleportOnHmrReload(
+  instance: VaporComponentInstance,
+  newInstance: VaporComponentInstance,
+): void {
+  const teleport = instance.parentTeleport
+  if (teleport) {
+    newInstance.parentTeleport = teleport
+    if (teleport.nodes === instance) {
+      teleport.nodes = newInstance
+    } else if (isArray(teleport.nodes)) {
+      for (let i = 0; i < teleport.nodes.length; i++) {
+        if (teleport.nodes[i] === instance) {
+          teleport.nodes[i] = newInstance
+          break
+        }
+      }
+    }
+  }
+}
index 61128da8f5a0e7f5535abeada00b50d071f79cd3..f98c7477baab8285d74cd8d10e17e7f73cad939a 100644 (file)
@@ -4,10 +4,11 @@ export { defineVaporComponent } from './apiDefineComponent'
 export { defineVaporAsyncComponent } from './apiDefineAsyncComponent'
 export { vaporInteropPlugin } from './vdomInterop'
 export type { VaporDirective } from './directives/custom'
+export { VaporTeleportImpl as VaporTeleport } from './components/Teleport'
 export { VaporKeepAliveImpl as VaporKeepAlive } from './components/KeepAlive'
 
 // compiler-use only
-export { insert, prepend, remove, isFragment, VaporFragment } from './block'
+export { insert, prepend, remove } from './block'
 export { setInsertionState } from './insertionState'
 export {
   createComponent,
@@ -52,5 +53,6 @@ export {
   applyDynamicModel,
 } from './directives/vModel'
 export { withVaporDirectives } from './directives/custom'
+export { isFragment, VaporFragment } from './fragment'
 export { VaporTransition } from './components/Transition'
 export { VaporTransitionGroup } from './components/TransitionGroup'
index ac34e8863d2ab8aada626553ef75863480887014..d41ee357191fb39483f854aad149795bfc478724 100644 (file)
@@ -11,7 +11,7 @@ import {
 import { type VaporComponentInstance, isVaporComponent } from './component'
 import { invokeArrayFns } from '@vue/shared'
 
-class RenderEffect extends ReactiveEffect {
+export class RenderEffect extends ReactiveEffect {
   i: VaporComponentInstance | null
   job: SchedulerJob
   updateJob: SchedulerJob
@@ -71,6 +71,11 @@ class RenderEffect extends ReactiveEffect {
     setCurrentInstance(...prev)
     if (__DEV__ && instance) {
       startMeasure(instance, `renderEffect`)
+
+      if (instance.renderEffects) {
+        instance.renderEffects.forEach(e => e.stop())
+        instance.renderEffects = []
+      }
     }
   }
 
index dcd234ef578d418d1688f03bff5d79872f699954..3d6fba608dae121d0aaf27efff72f6673af9984c 100644 (file)
@@ -42,15 +42,7 @@ import {
   mountComponent,
   unmountComponent,
 } from './component'
-import {
-  type Block,
-  VaporFragment,
-  type VaporTransitionHooks,
-  insert,
-  isFragment,
-  remove,
-  setFragmentFallback,
-} from './block'
+import { type Block, type VaporTransitionHooks, insert, remove } from './block'
 import {
   EMPTY_OBJ,
   ShapeFlags,
@@ -64,6 +56,7 @@ import type { RawSlots, VaporSlot } from './componentSlots'
 import { renderEffect } from './renderEffect'
 import { createTextNode } from './dom/node'
 import { optimizePropertyLookup } from './dom/prop'
+import { VaporFragment, isFragment, setFragmentFallback } from './fragment'
 import type { NodeRef } from './apiTemplateRef'
 import { setTransitionHooks as setVaporTransitionHooks } from './components/Transition'
 import {