From a46e118433a88999ecd495608f17691d7b038120 Mon Sep 17 00:00:00 2001 From: arlo Date: Fri, 30 Oct 2026 16:52:49 +0800 Subject: [PATCH] feat(kit): resolve vapor component root nodes --- .../kit/src/runtime/component-inspector.ts | 19 ++++ plans/vapor-support.md | 38 +++++--- tests/integration/runtime/vapor-dom.test.ts | 97 +++++++++++++++++++ .../runtime/vapor-tree-hooks.test.ts | 26 +++++ 4 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 tests/integration/runtime/vapor-dom.test.ts diff --git a/packages/kit/src/runtime/component-inspector.ts b/packages/kit/src/runtime/component-inspector.ts index 7f471ff38..f17abf638 100644 --- a/packages/kit/src/runtime/component-inspector.ts +++ b/packages/kit/src/runtime/component-inspector.ts @@ -1,4 +1,5 @@ import type { ComponentRecord } from './registry' +import { isInteropFragment, isVaporFragment, isVaporInstance } from './vapor' import type { AppId, ComponentId, InstanceRef } from './types' import type { DevtoolsRuntime } from './runtime' @@ -419,6 +420,9 @@ export function getRootNodesFromInstance(instance: InstanceRef, seen = new Set): Node[] { return children.flatMap((child) => (isObject(child) ? getRootNodesFromVNode(child, seen) : [])) } +// Walks a Vapor `Block` (Node | VaporComponentInstance | VaporFragment | Block[]). +// Fragment anchors are comments or empty text and are dropped as invisible nodes. +function getRootNodesFromBlock(block: unknown, seen: Set): Node[] { + if (isNode(block)) return isVisibleRootNode(block) && !isEmptyText(block) ? [block] : [] + if (Array.isArray(block)) return block.flatMap((child) => getRootNodesFromBlock(child, seen)) + if (isVaporInstance(block)) return getRootNodesFromInstance(block as InstanceRef, seen) + if (!isVaporFragment(block)) return [] + if (isInteropFragment(block)) return block.vnode ? getRootNodesFromVNode(block.vnode, seen) : [] + return getRootNodesFromBlock(block.nodes, seen) +} + function getNodeRect(node: Node): ComponentBounds | undefined { if (node.nodeType === Node.ELEMENT_NODE && 'getBoundingClientRect' in node) { const rect = (node as Element).getBoundingClientRect() @@ -504,6 +519,10 @@ async function restoreDockPanelSafely(restore: ComponentInspectorDockRestore | u } } +function isEmptyText(node: Node): boolean { + return node.nodeType === Node.TEXT_NODE && !node.textContent +} + function isNode(value: unknown): value is Node { return typeof Node !== 'undefined' && value instanceof Node } diff --git a/plans/vapor-support.md b/plans/vapor-support.md index 65c0d806f..440a6fd51 100644 --- a/plans/vapor-support.md +++ b/plans/vapor-support.md @@ -133,32 +133,32 @@ Tick each item when it lands. Phase 0 landed in #1174. ### Phase 3 — DOM: bounds, highlight, scroll, ordering -- [ ] `getRootNodesFromInstance`: if Vapor, `collectBlockNodes(instance.block)`: +- [x] `getRootNodesFromInstance`: if Vapor, `collectBlockNodes(instance.block)`: - `Node` → itself - Vapor instance → recurse into its `block` - array → flatten - - teleport fragment → `nodes` (target side) only for bounds; `placeholder` for ordering + - teleport fragment → `nodes` (target side), same as VDOM teleported roots - fragment → `nodes`, ignore comment anchors for bounds - interop fragment → existing `getRootNodesFromVNode(frag.vnode)` -- [ ] Reuse the existing `isVisibleRootNode` filter so anchors (``, empty text) - are skipped. -- [ ] `readComponentDomOrder` then works unchanged. -- [ ] Regression: `v-for` reorder ordering. Today `readComponentDomOrder` returns `[-1]` - for every Vapor instance, so siblings sort by id. Repro: a Vapor `Root` renders +- [x] Reuse the existing `isVisibleRootNode` filter so anchors (``, empty text) + are skipped. Empty text is dropped on the Vapor path only. +- [x] `readComponentDomOrder` then works unchanged. +- [x] Regression: `v-for` reorder ordering. Before this phase `readComponentDomOrder` returned + `[-1]` for every Vapor instance, so siblings sorted by id. Repro: a Vapor `Root` renders `createFor(() => items.value, () => createComponent(Item), item => item)`, then `items.value` goes `['a', 'b', 'c']` → `['c', 'b', 'a']`: | | live (client applies patches) | `components.snapshot()` | | --- | --- | --- | | VDOM | `a b c` | `c b a` | - | Vapor | `a b c` | `a b c` (should be `c b a`) | + | Vapor | `a b c` | `c b a` (was `a b c` before this phase) | - Snapshot order: Vapor-specific, caused by the missing Vapor root nodes above. - Once this phase lands, add the reorder case to `vapor-tree-hooks.test.ts` - asserting `snapshot` keys are `'c', 'b', 'a'`. + Covered by the reorder case in `vapor-tree-hooks.test.ts`. - Live order: not Vapor-specific. `applyComponentTreePatches` ignores `domOrder`, - so VDOM is stale too until the next snapshot. Not caused by the missing - `component:updated`; out of scope here. + and the kit only refreshes the updated parent, not its children's `domOrder`, + so VDOM is stale too until the next snapshot. Fixed for VDOM in a separate PR + (`fix/component-tree-live-order`); the Vapor trigger is a Phase 6 item. ### Phase 4 — Element picker @@ -195,6 +195,20 @@ Vapor nodes have no back-reference. - [ ] Accept the `renderEffect` perf phase: map it to "update" for timing and `update-highlight` flashes (currently keyed on `patch`). - [ ] Emits timeline: verify — should work as-is. +- [ ] Live `v-for` reorder for Vapor. Needs Phase 3 (Vapor `domOrder`) and the + `fix/component-tree-live-order` PR, which makes a VDOM `component:updated` recompute + the visible children's `domOrder` and lets the client re-sort siblings when + `domOrder` changes. Vapor never emits `component:updated`, so that path never runs: + - Trigger the same child `domOrder` refresh from `perf:end` with phase `renderEffect`, + for the instance that owns the effect (`payload` instance), only when that instance + is visible and expanded in the tree. Debounce with `updateDebounceMs`, keyed like + `component:update`. + - Reuse the VDOM refresh function from the fix PR; do not add a Vapor-only path. + - Regression test in `vapor-tree-hooks.test.ts`: Vapor `Root` renders + `createFor(() => items.value, () => createComponent(Item), item => item)`; after + `items.value` goes `['a', 'b', 'c']` → `['c', 'b', 'a']`, the tree built by applying + `components:treePatched` patches (not a fresh snapshot) lists keys `'c', 'b', 'a'`. + Today it stays `'a', 'b', 'c'`. ### Phase 7 — Client / UI diff --git a/tests/integration/runtime/vapor-dom.test.ts b/tests/integration/runtime/vapor-dom.test.ts new file mode 100644 index 000000000..fb3ebfacc --- /dev/null +++ b/tests/integration/runtime/vapor-dom.test.ts @@ -0,0 +1,97 @@ +// @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, + createIf, + createVaporApp, + defineComponent, + defineVaporComponent, + h, + nextTick, + ref, + template, + VaporTeleport, + vaporInteropPlugin, +} from 'vue-3.6/dist/vue.runtime-with-vapor.esm-browser.js' +import { getRootNodesFromInstance } from '../../../packages/kit/src/runtime/component-inspector' + +const cleanups: (() => void)[] = [] +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup() + document.body.replaceChildren() +}) + +function mount(app: { mount: (el: Element) => unknown; unmount: () => void; _instance?: unknown }) { + const el = document.createElement('div') + document.body.append(el) + app.mount(el) + cleanups.push(() => app.unmount()) + return app._instance as object +} + +const outerHTML = (nodes: Node[]) => + nodes.map((node) => (node as Element).outerHTML ?? node.textContent) + +describe('Vapor root nodes', () => { + it('collects elements across arrays, v-if and nested components, skipping anchors', async () => { + const show = ref(true) + const Child = defineVaporComponent({ setup: () => template('child')() }) + const root = mount( + createVaporApp( + defineVaporComponent({ + setup: () => [ + template('

first

')(), + createIf( + () => show.value, + () => template('maybe')(), + ), + createComponent(Child), + ], + }), + ), + ) + expect(outerHTML(getRootNodesFromInstance(root))).toEqual([ + '

first

', + 'maybe', + 'child', + ]) + + show.value = false + await nextTick() + expect(outerHTML(getRootNodesFromInstance(root))).toEqual(['

first

', 'child']) + }) + + it('uses the teleport target content', () => { + const target = document.createElement('section') + document.body.append(target) + const root = mount( + createVaporApp( + defineVaporComponent({ + setup: () => + createComponent( + VaporTeleport, + { to: () => target }, + { default: () => template('moved')() }, + ), + }), + ), + ) + const nodes = getRootNodesFromInstance(root) + expect(outerHTML(nodes)).toEqual(['moved']) + expect(nodes[0]!.parentNode).toBe(target) + }) + + it('crosses the VDOM / Vapor boundary in both directions', () => { + const VdomLeaf = defineComponent({ render: () => h('em', 'vdom') }) + const VaporMiddle = defineVaporComponent({ + setup: () => [template('vapor')(), createComponent(VdomLeaf as never)], + }) + const app = createApp(defineComponent({ render: () => h(VaporMiddle) })) + app.use(vaporInteropPlugin) + const root = mount(app) + expect(outerHTML(getRootNodesFromInstance(root))).toEqual(['vapor', 'vdom']) + }) +}) diff --git a/tests/integration/runtime/vapor-tree-hooks.test.ts b/tests/integration/runtime/vapor-tree-hooks.test.ts index c0bcf58f9..a3a3a5674 100644 --- a/tests/integration/runtime/vapor-tree-hooks.test.ts +++ b/tests/integration/runtime/vapor-tree-hooks.test.ts @@ -87,4 +87,30 @@ describe('Vapor tree via devtools hooks', () => { await settle() expect(names().sort()).toEqual(['Item', 'Item', 'Item', 'Root']) }) + + it('orders v-for children by DOM position after a reorder', async () => { + const items = ref(['a', 'b', 'c']) + const Item = defineVaporComponent({ name: 'Item', setup: () => template('')() }) + await mount( + defineVaporComponent({ + name: 'Root', + setup: () => + createFor( + () => items.value, + () => createComponent(Item), + (item: string) => item, + ), + }), + ) + const keys = () => + runtime!.components + .snapshot() + .nodes.filter((node) => node.name === 'Item') + .map((node) => node.renderKey) + expect(keys()).toEqual(["'a'", "'b'", "'c'"]) + + items.value = ['c', 'b', 'a'] + await settle() + expect(keys()).toEqual(["'c'", "'b'", "'a'"]) + }) })