From 0ffb1d9a3a075694d374654afd22231a5368e676 Mon Sep 17 00:00:00 2001 From: arlo Date: Wed, 21 Oct 2026 14:19:55 +0800 Subject: [PATCH] feat(kit): walk vapor component trees --- packages/kit/src/runtime/registry.ts | 96 +++++++-- packages/kit/src/runtime/vapor.ts | 4 + plans/vapor-support.md | 18 +- .../runtime/vapor-component-tree.test.ts | 194 ++++++++++++++++++ 4 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 tests/integration/runtime/vapor-component-tree.test.ts 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(Tab) + + const Root = defineVaporComponent({ + name: 'Root', + setup: () => [ + createComponent(Leaf), + createIf( + () => show.value, + () => createComponent(Leaf), + ), + createFor( + () => items.value, + () => createComponent(Item), + (item: number) => item, + ), + createComponent( + VaporTeleport, + { to: () => target }, + { default: () => createComponent(Leaf) }, + ), + createComponent(VaporKeepAlive, null, { + default: () => + createIf( + () => tab.value === Tab, + () => createComponent(Tab), + () => createComponent(Other), + ), + }), + ], + }) + + const app = mount(createVaporApp(Root)) + tab.value = Other + await nextTick() + + const { tree } = snapshot(app) + expect(tree).toMatchObject({ + name: 'Root', + fragment: true, + children: [ + { name: 'Leaf' }, + { name: 'Leaf' }, + { name: 'Item', key: 1 }, + { name: 'Item', key: 2 }, + // Teleport is a fragment, not an instance: its content is tagged instead. + { name: 'Leaf', tags: ['teleport'] }, + { name: 'VaporKeepAlive', children: [{ name: 'Other' }] }, + ], + }) + }) + + it('walks VDOM children of Vapor parents and Vapor children of VDOM parents', () => { + const VdomChild = defineComponent({ + name: 'VdomChild', + render: () => h(Leaf), + }) + const VaporMiddle = defineVaporComponent({ + name: 'VaporMiddle', + setup: () => createComponent(VdomChild as never), + }) + const Root = defineComponent({ name: 'Root', render: () => h('div', [h(VaporMiddle)]) }) + + const app = createApp(Root) + app.use(vaporInteropPlugin) + mount(app) + + expect(snapshot(app).tree).toEqual({ + name: 'Root', + children: [ + { + name: 'VaporMiddle', + children: [{ name: 'VdomChild', children: [{ name: 'Leaf' }] }], + }, + ], + }) + }) + + it('reports Vapor child counts', () => { + const Root = defineVaporComponent({ + name: 'Root', + setup: () => [createComponent(Leaf), template('

')(), createComponent(Leaf)], + }) + const app = mount(createVaporApp(Root)) + const { registry, record } = snapshot(app) + const root = [...record.components.values()].find((c) => !c.parentId)! + expect(registry.getComponentChildCount(root)).toBe(2) + }) + + it('lists Vapor KeepAlive cache entries, including interop fragments', () => { + const vdomCached = { uid: 30, type: { name: 'VdomCached' }, subTree: { type: 'div' } } + const keepAlive: Record = { + uid: 2, + vapor: true, + type: { name: 'VaporKeepAlive', __isKeepAlive: true }, + block: [], + } + const active = { uid: 3, vapor: true, type: { name: 'Active' }, parent: keepAlive, block: [] } + const cached = { uid: 4, vapor: true, type: { name: 'Cached' }, parent: keepAlive, block: [] } + Object.assign(vdomCached, { parent: keepAlive }) + keepAlive.block = active + keepAlive.__v_cache = new Map([ + ['a', active], + ['b', cached], + ['c', { __vf: 64 | 1, nodes: [], vnode: { component: vdomCached } }], + ]) + const root = { uid: 1, vapor: true, type: { name: 'Root' }, block: keepAlive } + keepAlive.parent = root + + expect(snapshot({ vapor: true, _instance: root }).tree).toEqual({ + name: 'Root', + children: [ + { + name: 'VaporKeepAlive', + children: [ + { name: 'Active' }, + { name: 'Cached', inactive: true }, + { name: 'VdomCached', inactive: true }, + ], + }, + ], + }) + }) +})