Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/kit/src/runtime/component-inspector.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -419,6 +420,9 @@ export function getRootNodesFromInstance(instance: InstanceRef, seen = new Set<o
if (seen.has(instance)) return []
seen.add(instance)

if (isVaporInstance(instance))
return getRootNodesFromBlock(readUnknownProperty(instance, 'block'), seen)

const subtree = readObjectProperty(instance, 'subTree') ?? readObjectProperty(instance, 'vnode')
return subtree ? getRootNodesFromVNode(subtree, seen) : []
}
Expand Down Expand Up @@ -450,6 +454,17 @@ function getRootNodesFromVNode(vnode: object, seen: Set<object>): 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<object>): 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()
Expand Down Expand Up @@ -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
}
Expand Down
38 changes: 26 additions & 12 deletions plans/vapor-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<!--if-->`, 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 (`<!--if-->`, 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

Expand Down Expand Up @@ -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

Expand Down
97 changes: 97 additions & 0 deletions tests/integration/runtime/vapor-dom.test.ts
Original file line number Diff line number Diff line change
@@ -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('<b>child</b>')() })
const root = mount(
createVaporApp(
defineVaporComponent({
setup: () => [
template('<p>first</p>')(),
createIf(
() => show.value,
() => template('<i>maybe</i>')(),
),
createComponent(Child),
],
}),
),
)
expect(outerHTML(getRootNodesFromInstance(root))).toEqual([
'<p>first</p>',
'<i>maybe</i>',
'<b>child</b>',
])

show.value = false
await nextTick()
expect(outerHTML(getRootNodesFromInstance(root))).toEqual(['<p>first</p>', '<b>child</b>'])
})

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('<span>moved</span>')() },
),
}),
),
)
const nodes = getRootNodesFromInstance(root)
expect(outerHTML(nodes)).toEqual(['<span>moved</span>'])
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('<u>vapor</u>')(), createComponent(VdomLeaf as never)],
})
const app = createApp(defineComponent({ render: () => h(VaporMiddle) }))
app.use(vaporInteropPlugin)
const root = mount(app)
expect(outerHTML(getRootNodesFromInstance(root))).toEqual(['<u>vapor</u>', '<em>vdom</em>'])
})
})
26 changes: 26 additions & 0 deletions tests/integration/runtime/vapor-tree-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<i></i>')() })
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'"])
})
})
Loading