diff --git a/packages/kit/src/runtime/registry.ts b/packages/kit/src/runtime/registry.ts
index 3cc427005..0e04e8a03 100644
--- a/packages/kit/src/runtime/registry.ts
+++ b/packages/kit/src/runtime/registry.ts
@@ -4,7 +4,15 @@ import type {
ComponentTreeNodeTag,
ComponentTreeNodeSnapshot,
} from '../protocol/messages'
-import { isVaporApp } from './vapor'
+import {
+ isForBlock,
+ isForFragment,
+ isInteropFragment,
+ isTeleportFragment,
+ isVaporApp,
+ isVaporFragment,
+ isVaporInstance,
+} from './vapor'
import type { AppId, AppRef, ComponentId, InstanceRef, RuntimeEvent, VueTypeMap } from './types'
export interface ComponentRecord {
@@ -51,6 +59,8 @@ export type { AppSnapshot, ComponentTreeNodeTag } from '../protocol/messages'
export type ComponentSnapshot = ComponentTreeNodeSnapshot
interface ComponentRecordOverrides {
+ // Vapor `v-for` keys live on the enclosing `ForBlock`, not on the instance.
+ renderKey?: unknown
inactive?: boolean
tags?: ComponentTreeNodeTag[]
domOrder?: number[]
@@ -62,6 +72,7 @@ export interface WalkedComponentChild {
instance: InstanceRef
tags?: ComponentTreeNodeTag[]
inactive?: boolean
+ renderKey?: unknown
}
// Cross-task caches must observe child-list mutations, including equal-length
@@ -219,7 +230,9 @@ export class RuntimeRegistry {
existing.parentId = parentId
existing.name = readComponentName(instance)
existing.file = readComponentFile(instance)
- existing.renderKey = readComponentRenderKey(instance)
+ existing.renderKey =
+ readComponentRenderKey(instance, overrides.renderKey) ??
+ (isVaporInstance(instance) ? existing.renderKey : undefined)
existing.inactive = overrides.inactive ?? isComponentInactive(instance)
existing.isFragment = isFragmentComponent(instance, app)
existing.hidden = isComponentHidden(instance)
@@ -239,7 +252,7 @@ export class RuntimeRegistry {
parentId,
name: readComponentName(instance),
file: readComponentFile(instance),
- renderKey: readComponentRenderKey(instance),
+ renderKey: readComponentRenderKey(instance, overrides.renderKey),
inactive: overrides.inactive ?? isComponentInactive(instance),
isFragment: isFragmentComponent(instance, app),
hidden: isComponentHidden(instance),
@@ -296,6 +309,7 @@ export class RuntimeRegistry {
walk(child.instance, instance, depth + 1, {
inactive: overrides.inactive || child.inactive,
tags: child.tags,
+ renderKey: child.renderKey,
})
}
@@ -413,6 +427,7 @@ export class RuntimeRegistry {
{
inactive: parent.inactive || child.inactive,
tags: child.tags,
+ renderKey: child.renderKey,
},
cache,
)
@@ -484,11 +499,17 @@ function readComponentFile(instance: object): string | undefined {
return type ? readStringProperty(type, '__file') : undefined
}
-function readComponentRenderKey(instance: object): string | number | undefined {
- const vnode = readObjectProperty(instance, 'vnode')
- if (!vnode) return
-
- const value = readUnknownProperty(vnode, 'key')
+function readComponentRenderKey(
+ instance: object,
+ walkedKey?: unknown,
+): string | number | undefined {
+ let value = walkedKey
+ if (value == null) {
+ const vapor = isVaporInstance(instance)
+ const keyOwner = vapor ? instance : readObjectProperty(instance, 'vnode')
+ if (!keyOwner) return
+ value = readUnknownProperty(keyOwner, vapor ? '$key' : 'key')
+ }
if (value == null) return
if (typeof value === 'number') return value
if (typeof value === 'string') return `'${value}'`
@@ -508,6 +529,8 @@ function isComponentInactive(instance: object): boolean {
}
function isFragmentComponent(instance: object, app: AppRecord): boolean {
+ if (isVaporInstance(instance)) return isVaporFragmentRoot(readUnknownProperty(instance, 'block'))
+
const subtree = readObjectProperty(instance, 'subTree')
if (!subtree) return false
return readUnknownProperty(subtree, 'type') === app.vueTypes?.Fragment
@@ -585,11 +608,56 @@ function readComponentDomOrder(
return indexList.reverse()
}
+// Mirrors VDOM: a root renders as a fragment when it is a list or a `v-for`.
+function isVaporFragmentRoot(block: unknown): boolean {
+ if (Array.isArray(block)) return block.length > 1 || isVaporFragmentRoot(block[0])
+ if (!isVaporFragment(block)) return false
+ if (isInteropFragment(block)) return false
+ return isForFragment(block) || isVaporFragmentRoot(readUnknownProperty(block, 'nodes'))
+}
+
function getInternalInstanceChildren(instance: object): WalkedComponentChild[] {
+ if (isVaporInstance(instance)) {
+ const block =
+ readUnknownProperty(instance, 'pendingBlock') ?? readUnknownProperty(instance, 'block')
+ return getInternalInstanceChildrenFromBlock(block)
+ }
const subtree = readObjectProperty(instance, 'subTree')
return subtree ? getInternalInstanceChildrenFromVNode(subtree) : []
}
+const TELEPORT_TAG: ComponentTreeNodeTag = {
+ label: 'teleport',
+ textColor: 0xffffff,
+ backgroundColor: 0x8b5cf6,
+}
+
+// Walks a Vapor `Block` (Node | VaporComponentInstance | VaporFragment | Block[]).
+function getInternalInstanceChildrenFromBlock(
+ block: unknown,
+ tags: ComponentTreeNodeTag[] = [],
+ renderKey?: unknown,
+): WalkedComponentChild[] {
+ if (block == null || typeof block !== 'object') return []
+ if (Array.isArray(block)) {
+ return block.flatMap((child) => getInternalInstanceChildrenFromBlock(child, tags, renderKey))
+ }
+ if (isVaporInstance(block)) {
+ return !isBeingDestroyed(block) && !isComponentHidden(block)
+ ? [{ instance: block, tags, renderKey }]
+ : []
+ }
+ if (!isVaporFragment(block)) return []
+ if (isInteropFragment(block)) {
+ return block.vnode ? getInternalInstanceChildrenFromVNode(block.vnode, tags) : []
+ }
+ const nextTags =
+ isTeleportFragment(block) && !tags.includes(TELEPORT_TAG) ? [...tags, TELEPORT_TAG] : tags
+ // Only a component directly inside a `v-for` item takes the item key.
+ const nextKey = isForBlock(block) ? readUnknownProperty(block, 'key') : undefined
+ return getInternalInstanceChildrenFromBlock(block.nodes, nextTags, nextKey)
+}
+
function getInternalInstanceChildrenFromVNode(
vnode: object,
tags: ComponentTreeNodeTag[] = [],
@@ -639,12 +707,14 @@ function getKeepAliveCachedInstances(instance: object): InstanceRef[] {
const cache = readUnknownProperty(instance, '__v_cache')
if (!(cache instanceof Map)) return []
+ // VDOM caches vnodes; Vapor caches instances or interop fragments wrapping a vnode.
return Array.from(cache.values())
- .map((vnode) =>
- vnode != null && typeof vnode === 'object'
- ? readObjectProperty(vnode, 'component')
- : undefined,
- )
+ .map((entry) => {
+ if (entry == null || typeof entry !== 'object') return
+ if (isVaporInstance(entry)) return entry
+ const vnode = isInteropFragment(entry) ? entry.vnode : entry
+ return vnode ? readObjectProperty(vnode, 'component') : undefined
+ })
.filter((component): component is InstanceRef => !!component)
}
diff --git a/packages/kit/src/runtime/vapor.ts b/packages/kit/src/runtime/vapor.ts
index ab45d1546..88110dfcb 100644
--- a/packages/kit/src/runtime/vapor.ts
+++ b/packages/kit/src/runtime/vapor.ts
@@ -54,6 +54,10 @@ export function isForFragment(value: unknown): value is VaporFragmentLike {
return hasFragmentBit(value, VF.FOR)
}
+export function isForBlock(value: unknown): value is VaporFragmentLike {
+ return hasFragmentBit(value, VF.FOR_BLOCK)
+}
+
function hasFragmentBit(value: unknown, bit: number): boolean {
return isVaporFragment(value) && (value.__vf & bit) !== 0
}
diff --git a/plans/vapor-support.md b/plans/vapor-support.md
index 4cc07b384..bc1855dac 100644
--- a/plans/vapor-support.md
+++ b/plans/vapor-support.md
@@ -95,7 +95,7 @@ Tick each item when it lands. Phase 0 landed in #1174.
### Phase 1 — Component tree (core feature)
-- [ ] Generalise `getInternalInstanceChildren(instance)`:
+- [x] Generalise `getInternalInstanceChildren(instance)`:
- VDOM: unchanged (`subTree`).
- Vapor: `walkBlockForChildren(instance.block)`:
- `Node` → skip
@@ -104,15 +104,17 @@ Tick each item when it lands. Phase 0 landed in #1174.
- fragment with bit 64 → `getInternalInstanceChildrenFromVNode(frag.vnode)`
- other fragment → recurse `nodes` (teleport included)
- pending suspense → `instance.pendingBlock` if present
-- [ ] In `getInternalInstanceChildrenFromVNode`, a `vnode.component` that is Vapor already
+- [x] In `getInternalInstanceChildrenFromVNode`, a `vnode.component` that is Vapor already
works (it's returned as the child) — the Vapor walk above then descends from it.
-- [ ] Skip the interop wrapper on `vnode.vi`; collapse `VaporAsyncComponentWrapper` the same
- way VDOM async wrappers are treated.
-- [ ] KeepAlive: relax `isKeepAlive` to accept `type.__isKeepAlive` on Vapor instances;
+- [x] Skip the interop wrapper on `vnode.vi`; collapse `VaporAsyncComponentWrapper` the same
+ way VDOM async wrappers are treated. Nothing needed: `vnode.vi` is a factory function, so
+ the wrapper is never reached, and VDOM does not collapse async wrappers either.
+- [x] KeepAlive: relax `isKeepAlive` to accept `type.__isKeepAlive` on Vapor instances;
for cache entries use `entry.vapor ? entry : entry.vnode?.component`.
-- [ ] Fragment flag for Vapor: `Array.isArray(block)` or a multi-node fragment root.
- Render key: `instance.$key` if present.
-- [ ] Tags: add `teleport` tag for children found under a teleport fragment, and
+- [x] Fragment flag for Vapor: `Array.isArray(block)` or a multi-node fragment root.
+ Render key: `instance.$key` if present. `v-for` keys live on the `ForBlock`, so the
+ walk passes them down as `WalkedComponentChild.renderKey`.
+- [x] Tags: add `teleport` tag for children found under a teleport fragment, and
`v-for`/`v-if` are not shown (same as VDOM).
### Phase 2 — Tree liveness (no add/remove events)
diff --git a/tests/integration/runtime/vapor-component-tree.test.ts b/tests/integration/runtime/vapor-component-tree.test.ts
new file mode 100644
index 000000000..b47b0a776
--- /dev/null
+++ b/tests/integration/runtime/vapor-component-tree.test.ts
@@ -0,0 +1,194 @@
+// @vitest-environment happy-dom
+
+import { afterEach, describe, expect, it } from 'vitest'
+// The package root resolves to a Node build without Vapor; the browser bundle includes it.
+import {
+ createApp,
+ createComponent,
+ createFor,
+ createIf,
+ createVaporApp,
+ defineComponent,
+ defineVaporComponent,
+ h,
+ nextTick,
+ ref,
+ template,
+ VaporKeepAlive,
+ VaporTeleport,
+ vaporInteropPlugin,
+} from 'vue-3.6/dist/vue.runtime-with-vapor.esm-browser.js'
+import { RuntimeRegistry } from '../../../packages/kit/src/runtime/registry'
+
+const cleanups: (() => void)[] = []
+afterEach(() => {
+ for (const cleanup of cleanups.splice(0)) cleanup()
+ document.body.replaceChildren()
+})
+
+function mount(app: { mount: (el: Element) => unknown; unmount: () => void }) {
+ const el = document.createElement('div')
+ document.body.append(el)
+ app.mount(el)
+ cleanups.push(() => app.unmount())
+ return app
+}
+
+function snapshot(app: object) {
+ const registry = new RuntimeRegistry()
+ const record = registry.addApp(app, '3.6.0', {}, 1)
+ registry.refreshComponentTree(record.id)
+ const byId = new Map([...record.components.values()].map((c) => [c.id, c]))
+ const tree = (id: string): unknown => {
+ const node = byId.get(id)!
+ const children = [...byId.values()].filter((c) => c.parentId === id)
+ return {
+ name: node.name,
+ ...(node.isFragment ? { fragment: true } : {}),
+ ...(node.renderKey !== undefined ? { key: node.renderKey } : {}),
+ ...(node.inactive ? { inactive: true } : {}),
+ ...(node.tags?.length ? { tags: node.tags.map((tag) => tag.label) } : {}),
+ ...(children.length ? { children: children.map((c) => tree(c.id)) } : {}),
+ }
+ }
+ const root = [...byId.values()].find((c) => !c.parentId)!
+ return { registry, record, tree: tree(root.id) }
+}
+
+const Leaf = defineVaporComponent({
+ name: 'Leaf',
+ setup: () => template('leaf')(),
+})
+
+describe('Vapor component tree', () => {
+ it('walks arrays, v-if, v-for, teleport and KeepAlive blocks', async () => {
+ const show = ref(true)
+ const items = ref([1, 2])
+ const target = document.createElement('div')
+ document.body.append(target)
+ const Item = defineVaporComponent({ name: 'Item', setup: () => template('')() })
+ const Tab = defineVaporComponent({ name: 'Tab', setup: () => template('')() })
+ const Other = defineVaporComponent({ name: 'Other', setup: () => template('')() })
+ const tab = ref