@barefootjs/xyflow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +117 -0
  2. package/dist/classes.d.ts +31 -0
  3. package/dist/classes.d.ts.map +1 -0
  4. package/dist/compat.d.ts +67 -0
  5. package/dist/compat.d.ts.map +1 -0
  6. package/dist/connection.d.ts +20 -0
  7. package/dist/connection.d.ts.map +1 -0
  8. package/dist/constants.d.ts +4 -0
  9. package/dist/constants.d.ts.map +1 -0
  10. package/dist/context.d.ts +7 -0
  11. package/dist/context.d.ts.map +1 -0
  12. package/dist/edge-path.d.ts +14 -0
  13. package/dist/edge-path.d.ts.map +1 -0
  14. package/dist/flow-subsystems.d.ts +28 -0
  15. package/dist/flow-subsystems.d.ts.map +1 -0
  16. package/dist/hooks.d.ts +34 -0
  17. package/dist/hooks.d.ts.map +1 -0
  18. package/dist/index.d.ts +19 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +6572 -0
  21. package/dist/node-resizer.d.ts +52 -0
  22. package/dist/node-resizer.d.ts.map +1 -0
  23. package/dist/node-type-dispatch.d.ts +34 -0
  24. package/dist/node-type-dispatch.d.ts.map +1 -0
  25. package/dist/selection.d.ts +32 -0
  26. package/dist/selection.d.ts.map +1 -0
  27. package/dist/store.d.ts +8 -0
  28. package/dist/store.d.ts.map +1 -0
  29. package/dist/types.d.ts +214 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/utils.d.ts +6 -0
  32. package/dist/utils.d.ts.map +1 -0
  33. package/dist/xyflow.browser.min.js +95 -0
  34. package/dist/xyflow.browser.min.js.map +129 -0
  35. package/package.json +58 -0
  36. package/src/__tests__/clamp-drag-position.test.ts +111 -0
  37. package/src/__tests__/compat.test.ts +157 -0
  38. package/src/__tests__/host-element-store.test.ts +33 -0
  39. package/src/__tests__/jsx-smoke.test.ts +33 -0
  40. package/src/__tests__/jsx-smoke.tsx +23 -0
  41. package/src/__tests__/node-type-dispatch.test.ts +104 -0
  42. package/src/__tests__/store.test.ts +399 -0
  43. package/src/__tests__/tsconfig.json +23 -0
  44. package/src/classes.ts +41 -0
  45. package/src/compat.ts +237 -0
  46. package/src/connection.ts +459 -0
  47. package/src/constants.ts +8 -0
  48. package/src/context.ts +8 -0
  49. package/src/edge-path.ts +89 -0
  50. package/src/flow-subsystems.ts +506 -0
  51. package/src/hooks.ts +72 -0
  52. package/src/index.ts +134 -0
  53. package/src/node-resizer.ts +276 -0
  54. package/src/node-type-dispatch.ts +46 -0
  55. package/src/selection.ts +407 -0
  56. package/src/store.ts +526 -0
  57. package/src/types.ts +329 -0
  58. package/src/utils.ts +13 -0
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@barefootjs/xyflow",
3
+ "version": "0.1.0",
4
+ "description": "Signal-based xyflow wrapper for BarefootJS",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "unpkg": "./dist/xyflow.browser.min.js",
9
+ "jsdelivr": "./dist/xyflow.browser.min.js",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "umd": "./dist/xyflow.browser.min.js",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "scripts": {
22
+ "build": "bun run build:js && bun run build:browser && bun run build:types",
23
+ "build:js": "bun build ./src/index.ts --outdir ./dist --format esm --external '@barefootjs/client' --external '@barefootjs/client/runtime' --external '@barefootjs/client/reactive'",
24
+ "build:browser": "bun build ./src/index.ts --outdir ./dist --entry-naming 'xyflow.browser.min.[ext]' --format esm --minify --sourcemap --external '@barefootjs/client' --external '@barefootjs/client/runtime' --external '@barefootjs/client/reactive'",
25
+ "build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
26
+ "test": "bun test src/ && bun run typecheck:tests",
27
+ "typecheck:tests": "tsgo --noEmit -p src/__tests__/tsconfig.json",
28
+ "clean": "rm -rf dist"
29
+ },
30
+ "keywords": [
31
+ "xyflow",
32
+ "react-flow",
33
+ "graph",
34
+ "node-editor",
35
+ "signals",
36
+ "reactive",
37
+ "barefoot"
38
+ ],
39
+ "author": "kobaken <kentafly88@gmail.com>",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/piconic-ai/barefootjs",
44
+ "directory": "packages/xyflow"
45
+ },
46
+ "peerDependencies": {
47
+ "@barefootjs/client": ">=0.0.1",
48
+ "@barefootjs/jsx": ">=0.0.1"
49
+ },
50
+ "dependencies": {
51
+ "@xyflow/system": "^0.0.76"
52
+ },
53
+ "devDependencies": {
54
+ "@barefootjs/jsx": "workspace:*",
55
+ "@barefootjs/test": "workspace:*",
56
+ "typescript": "^5.0.0"
57
+ }
58
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { InternalNodeBase, NodeLookup } from '@xyflow/system'
3
+ import { clampDragPositionToParent } from '../flow-subsystems'
4
+ import type { NodeBase } from '../types'
5
+
6
+ // Build a minimal NodeLookup that satisfies the bits clampDragPositionToParent
7
+ // reads (`internal.measured.width/height`, `internal.internals.userNode`).
8
+ // adoptUserNodes-produced internals carry many more fields, but the clamp
9
+ // helper is intentionally narrow: anything outside its read set goes
10
+ // untouched at runtime.
11
+ function makeLookup(
12
+ nodes: Array<Partial<NodeBase> & { id: string; measured?: { width: number; height: number } }>,
13
+ ): NodeLookup<InternalNodeBase<NodeBase>> {
14
+ const m = new Map<string, InternalNodeBase<NodeBase>>()
15
+ for (const n of nodes) {
16
+ const userNode = { position: { x: 0, y: 0 }, data: {}, ...n } as NodeBase
17
+ const internal = {
18
+ ...userNode,
19
+ measured: n.measured,
20
+ internals: {
21
+ positionAbsolute: { x: 0, y: 0 },
22
+ z: 0,
23
+ userNode,
24
+ },
25
+ } as unknown as InternalNodeBase<NodeBase>
26
+ m.set(n.id, internal)
27
+ }
28
+ return m as NodeLookup<InternalNodeBase<NodeBase>>
29
+ }
30
+
31
+ describe("clampDragPositionToParent — extent: 'parent'", () => {
32
+ const parent = {
33
+ id: 'p',
34
+ position: { x: 0, y: 0 },
35
+ measured: { width: 200, height: 100 },
36
+ }
37
+
38
+ test('clamps a child whose proposed relative position would overflow on +x', () => {
39
+ const lookup = makeLookup([
40
+ parent,
41
+ { id: 'c', parentId: 'p', extent: 'parent', measured: { width: 40, height: 30 } },
42
+ ])
43
+ // parent 200×100, child 40×30 → max relative x = 160, max y = 70
44
+ const clamped = clampDragPositionToParent({ x: 500, y: 500 }, 'c', lookup)
45
+ expect(clamped).toEqual({ x: 160, y: 70 })
46
+ })
47
+
48
+ test('clamps to 0 on the negative side', () => {
49
+ const lookup = makeLookup([
50
+ parent,
51
+ { id: 'c', parentId: 'p', extent: 'parent', measured: { width: 40, height: 30 } },
52
+ ])
53
+ const clamped = clampDragPositionToParent({ x: -50, y: -10 }, 'c', lookup)
54
+ expect(clamped).toEqual({ x: 0, y: 0 })
55
+ })
56
+
57
+ test('passes through when child fits and is inside the rect', () => {
58
+ const lookup = makeLookup([
59
+ parent,
60
+ { id: 'c', parentId: 'p', extent: 'parent', measured: { width: 40, height: 30 } },
61
+ ])
62
+ const clamped = clampDragPositionToParent({ x: 80, y: 40 }, 'c', lookup)
63
+ expect(clamped).toEqual({ x: 80, y: 40 })
64
+ })
65
+
66
+ test('returns the input unchanged for nodes without parentId', () => {
67
+ const lookup = makeLookup([{ id: 'c', measured: { width: 40, height: 30 } }])
68
+ const proposed = { x: 1234, y: -5 }
69
+ expect(clampDragPositionToParent(proposed, 'c', lookup)).toEqual(proposed)
70
+ })
71
+
72
+ test('returns the input unchanged when the node has parentId but extent is not "parent"', () => {
73
+ const lookup = makeLookup([
74
+ parent,
75
+ // No extent set → drag should NOT be clamped (xyflow contract).
76
+ { id: 'c', parentId: 'p', measured: { width: 40, height: 30 } },
77
+ ])
78
+ const proposed = { x: 999, y: 999 }
79
+ expect(clampDragPositionToParent(proposed, 'c', lookup)).toEqual(proposed)
80
+ })
81
+
82
+ test('returns the input unchanged when the parent is missing from the lookup', () => {
83
+ const lookup = makeLookup([
84
+ // Parent intentionally absent — drag handler must not throw.
85
+ { id: 'c', parentId: 'missing', extent: 'parent', measured: { width: 40, height: 30 } },
86
+ ])
87
+ const proposed = { x: 999, y: 999 }
88
+ expect(clampDragPositionToParent(proposed, 'c', lookup)).toEqual(proposed)
89
+ })
90
+
91
+ test('returns the input unchanged when measurements are still pending', () => {
92
+ const lookup = makeLookup([
93
+ // No `measured` on the parent. Drag begins before ResizeObserver
94
+ // has fired — clamp would otherwise pin to 0/0 and prevent any
95
+ // drag motion.
96
+ { id: 'p', position: { x: 0, y: 0 } },
97
+ { id: 'c', parentId: 'p', extent: 'parent', measured: { width: 40, height: 30 } },
98
+ ])
99
+ const proposed = { x: 999, y: 999 }
100
+ expect(clampDragPositionToParent(proposed, 'c', lookup)).toEqual(proposed)
101
+ })
102
+
103
+ test('clamps to 0 on each axis when the child is bigger than the parent', () => {
104
+ const lookup = makeLookup([
105
+ parent,
106
+ { id: 'c', parentId: 'p', extent: 'parent', measured: { width: 999, height: 999 } },
107
+ ])
108
+ // pw - myW < 0 → max{0, …} = 0; same for y.
109
+ expect(clampDragPositionToParent({ x: 50, y: 50 }, 'c', lookup)).toEqual({ x: 0, y: 0 })
110
+ })
111
+ })
@@ -0,0 +1,157 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { createRoot } from '@barefootjs/client'
3
+ import { createFlowStore } from '../store'
4
+
5
+ /**
6
+ * Tests for applyNodeChanges / applyEdgeChanges logic
7
+ * used inside compat.ts (useNodesState, useEdgesState).
8
+ *
9
+ * We test the store actions directly since compat hooks require
10
+ * context which needs DOM.
11
+ */
12
+ describe('Store actions (compat foundation)', () => {
13
+ test('addEdge creates a new edge', () => {
14
+ createRoot(() => {
15
+ const store = createFlowStore({
16
+ nodes: [
17
+ { id: '1', position: { x: 0, y: 0 }, data: {} },
18
+ { id: '2', position: { x: 100, y: 0 }, data: {} },
19
+ ],
20
+ })
21
+
22
+ store.addEdge({ id: 'e1-2', source: '1', target: '2' })
23
+ expect(store.edges()).toHaveLength(1)
24
+ expect(store.edges()[0]).toEqual({ id: 'e1-2', source: '1', target: '2' })
25
+ })
26
+ })
27
+
28
+ test('addEdge appends to existing edges', () => {
29
+ createRoot(() => {
30
+ const store = createFlowStore({
31
+ edges: [{ id: 'e1', source: '1', target: '2' }],
32
+ })
33
+
34
+ store.addEdge({ id: 'e2', source: '2', target: '3' })
35
+ expect(store.edges()).toHaveLength(2)
36
+ expect(store.edges().map(e => e.id)).toEqual(['e1', 'e2'])
37
+ })
38
+ })
39
+
40
+ test('deleteElements removes specific edges only', () => {
41
+ createRoot(() => {
42
+ const store = createFlowStore({
43
+ edges: [
44
+ { id: 'e1', source: '1', target: '2' },
45
+ { id: 'e2', source: '2', target: '3' },
46
+ { id: 'e3', source: '3', target: '4' },
47
+ ],
48
+ })
49
+
50
+ store.deleteElements({ edges: [{ id: 'e2', source: '2', target: '3' }] })
51
+ expect(store.edges()).toHaveLength(2)
52
+ expect(store.edges().map(e => e.id)).toEqual(['e1', 'e3'])
53
+ })
54
+ })
55
+
56
+ test('deleteElements removes nodes and their connected edges', () => {
57
+ createRoot(() => {
58
+ const store = createFlowStore({
59
+ nodes: [
60
+ { id: 'a', position: { x: 0, y: 0 }, data: {} },
61
+ { id: 'b', position: { x: 100, y: 0 }, data: {} },
62
+ { id: 'c', position: { x: 200, y: 0 }, data: {} },
63
+ ],
64
+ edges: [
65
+ { id: 'ab', source: 'a', target: 'b' },
66
+ { id: 'bc', source: 'b', target: 'c' },
67
+ { id: 'ac', source: 'a', target: 'c' },
68
+ ],
69
+ })
70
+
71
+ // Remove node b
72
+ store.deleteElements({ nodes: [{ id: 'b', position: { x: 100, y: 0 }, data: {} }] })
73
+
74
+ expect(store.nodes().map(n => n.id)).toEqual(['a', 'c'])
75
+ // ab and bc are connected to b, should be removed. ac survives.
76
+ expect(store.edges().map(e => e.id)).toEqual(['ac'])
77
+ })
78
+ })
79
+
80
+ test('unselectNodesAndEdges clears all selection', () => {
81
+ createRoot(() => {
82
+ const store = createFlowStore({
83
+ nodes: [
84
+ { id: '1', position: { x: 0, y: 0 }, data: {}, selected: true },
85
+ { id: '2', position: { x: 100, y: 0 }, data: {}, selected: false },
86
+ { id: '3', position: { x: 200, y: 0 }, data: {}, selected: true },
87
+ ],
88
+ edges: [
89
+ { id: 'e1', source: '1', target: '2', selected: true },
90
+ ],
91
+ })
92
+
93
+ store.unselectNodesAndEdges()
94
+
95
+ expect(store.nodes().filter(n => n.selected)).toHaveLength(0)
96
+ expect(store.edges().filter(e => e.selected)).toHaveLength(0)
97
+ })
98
+ })
99
+
100
+ test('unselectNodesAndEdges with specific nodes', () => {
101
+ createRoot(() => {
102
+ const store = createFlowStore({
103
+ nodes: [
104
+ { id: '1', position: { x: 0, y: 0 }, data: {}, selected: true },
105
+ { id: '2', position: { x: 100, y: 0 }, data: {}, selected: true },
106
+ ],
107
+ })
108
+
109
+ // Only deselect node 1
110
+ store.unselectNodesAndEdges({
111
+ nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {}, selected: true }],
112
+ })
113
+
114
+ expect(store.nodes().find(n => n.id === '1')?.selected).toBeFalsy()
115
+ expect(store.nodes().find(n => n.id === '2')?.selected).toBeTruthy()
116
+ })
117
+ })
118
+
119
+ test('setNodes with updater function', () => {
120
+ createRoot(() => {
121
+ const store = createFlowStore({
122
+ nodes: [
123
+ { id: '1', position: { x: 0, y: 0 }, data: { label: 'A' } },
124
+ ],
125
+ })
126
+
127
+ store.setNodes(prev => [
128
+ ...prev,
129
+ { id: '2', position: { x: 100, y: 0 }, data: { label: 'B' } },
130
+ ])
131
+
132
+ expect(store.nodes()).toHaveLength(2)
133
+ expect(store.nodes()[1].id).toBe('2')
134
+ })
135
+ })
136
+
137
+ test('setEdges with updater function', () => {
138
+ createRoot(() => {
139
+ const store = createFlowStore({
140
+ edges: [{ id: 'e1', source: '1', target: '2' }],
141
+ })
142
+
143
+ store.setEdges(prev => prev.filter(e => e.id !== 'e1'))
144
+ expect(store.edges()).toHaveLength(0)
145
+ })
146
+ })
147
+
148
+ test('viewport can be updated', () => {
149
+ createRoot(() => {
150
+ const store = createFlowStore()
151
+
152
+ store.setViewport({ x: 100, y: 200, zoom: 2.5 })
153
+ expect(store.viewport()).toEqual({ x: 100, y: 200, zoom: 2.5 })
154
+ expect(store.getTransform()).toEqual([100, 200, 2.5])
155
+ })
156
+ })
157
+ })
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `<Flow renderNode={Fn}>` hydrates the rendered bridge as a top-level
3
+ * scope outside Flow's `FlowContext.Provider`, so consumers that look up
4
+ * the store via `useFlow()` get back `undefined`. As an escape hatch,
5
+ * `attachFlowSubsystems` stamps the host `<div class="bf-flow">` with
6
+ * `__bfFlowStore` so children can walk up the DOM and read the store
7
+ * without going through the context system.
8
+ */
9
+ import { beforeAll, describe, expect, test } from 'bun:test'
10
+ import { GlobalRegistrator } from '@happy-dom/global-registrator'
11
+
12
+ beforeAll(() => {
13
+ if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register()
14
+ })
15
+
16
+ describe('attachFlowSubsystems exposes the store on the host element', () => {
17
+ test('sets `__bfFlowStore` on the element it attaches to', async () => {
18
+ // Lazy import so happy-dom globals are in place before xyflow loads.
19
+ const { attachFlowSubsystems } = await import('../flow-subsystems')
20
+ const { createFlowStore } = await import('../store')
21
+
22
+ const el = document.createElement('div')
23
+ el.className = 'bf-flow'
24
+ document.body.appendChild(el)
25
+
26
+ // biome-ignore lint/suspicious/noExplicitAny: minimal props for unit test
27
+ const store = createFlowStore({} as any)
28
+ // biome-ignore lint/suspicious/noExplicitAny: minimal props for unit test
29
+ attachFlowSubsystems(el, store as any, {} as any)
30
+
31
+ expect((el as HTMLElement & { __bfFlowStore?: unknown }).__bfFlowStore).toBe(store)
32
+ })
33
+ })
@@ -0,0 +1,33 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { renderToTest } from '@barefootjs/test'
5
+
6
+ // Smoke test for #1081 step 1: JSX infra wired into @barefootjs/xyflow.
7
+ // We feed `jsx-smoke.tsx` (a real JSX file living inside this package's
8
+ // source tree) through the JSX → IR pipeline and assert the analyzer
9
+ // accepts it. If the package's tsconfig or `@barefootjs/jsx` JSX runtime
10
+ // resolution regresses, this test fails.
11
+ const smokeSource = readFileSync(resolve(__dirname, 'jsx-smoke.tsx'), 'utf-8')
12
+
13
+ describe('JSX infra smoke (#1081 step 1)', () => {
14
+ const result = renderToTest(smokeSource, 'jsx-smoke.tsx', 'JsxSmoke')
15
+
16
+ test('JSX → IR pipeline reports no compiler errors', () => {
17
+ expect(result.errors).toEqual([])
18
+ })
19
+
20
+ test('component is recognized as a client component', () => {
21
+ expect(result.isClient).toBe(true)
22
+ })
23
+
24
+ test('createSignal is tracked as a reactive signal', () => {
25
+ expect(result.signals).toContain('count')
26
+ })
27
+
28
+ test('IR exposes the rendered <button>', () => {
29
+ const button = result.find({ tag: 'button' })
30
+ expect(button).not.toBeNull()
31
+ expect(button!.props['type']).toBe('button')
32
+ })
33
+ })
@@ -0,0 +1,23 @@
1
+ "use client"
2
+
3
+ // JSX infra smoke component for the @barefootjs/xyflow package.
4
+ //
5
+ // This file is the proof that the package's tsconfig accepts JSX with
6
+ // `@barefootjs/jsx` as the JSX import source — see #1081 step 1.
7
+ // It is not exported from `src/index.ts`; it exists only so `jsx-smoke.test.ts`
8
+ // can feed it through `renderToTest()` and assert the JSX → IR pipeline
9
+ // accepts a "use client" component declared inside this package.
10
+ //
11
+ // Real renderer migrations (Flow, Background, MiniMap, Controls, Handle,
12
+ // Edge, NodeWrapper) land in subsequent steps of #1081.
13
+
14
+ import { createSignal } from '@barefootjs/client'
15
+
16
+ export function JsxSmoke() {
17
+ const [count, setCount] = createSignal(0)
18
+ return (
19
+ <button type="button" onClick={() => setCount(count() + 1)}>
20
+ count: {count()}
21
+ </button>
22
+ )
23
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Boundary contract tests for `dispatchNodeType` — the helper that
3
+ * `FlowNodeTypeBridge` funnels imperative and JSX-component shim
4
+ * `nodeTypes` entries through. Without these tests, the IR test in
5
+ * `ui/components/ui/xyflow/index.test.tsx` only verifies the bridge's
6
+ * structural shape (signals, memos, effect count) — the `instanceof Node`
7
+ * branch lives inside the effect body where IR analysis can't see it.
8
+ *
9
+ * Regression guard for piconic-ai/barefootjs#1236 (xyflow: nodeTypes
10
+ * map can't accept 'use client' JSX components).
11
+ */
12
+
13
+ import { beforeAll, describe, expect, test } from 'bun:test'
14
+ import { GlobalRegistrator } from '@happy-dom/global-registrator'
15
+
16
+ beforeAll(() => {
17
+ if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register()
18
+ })
19
+
20
+ describe('dispatchNodeType', () => {
21
+ test('imperative entry: mutates the host via `this`, returns void', async () => {
22
+ const { dispatchNodeType } = await import('../node-type-dispatch')
23
+
24
+ const host = document.createElement('div')
25
+
26
+ const imperative: (this: HTMLElement, props: { id: string }) => void = function (props) {
27
+ const child = document.createElement('span')
28
+ child.textContent = `imperative:${props.id}`
29
+ this.appendChild(child)
30
+ }
31
+
32
+ dispatchNodeType(host, imperative as never, { id: 'a' } as never)
33
+
34
+ expect(host.children).toHaveLength(1)
35
+ expect(host.firstChild?.textContent).toBe('imperative:a')
36
+ })
37
+
38
+ test("JSX-component shim entry: returned Node is appended to the host (the #1236 contract)", async () => {
39
+ const { dispatchNodeType } = await import('../node-type-dispatch')
40
+
41
+ const host = document.createElement('div')
42
+
43
+ // Mirrors the bundler-emitted shim shape for a `'use client'` `.tsx`
44
+ // component: ignores `this`, returns a DOM `Node`.
45
+ const jsxShim: (this: HTMLElement, props: { id: string }) => Node = (props) => {
46
+ const node = document.createElement('div')
47
+ node.textContent = `jsx:${props.id}`
48
+ return node
49
+ }
50
+
51
+ dispatchNodeType(host, jsxShim as never, { id: 'b' } as never)
52
+
53
+ expect(host.children).toHaveLength(1)
54
+ expect(host.firstChild?.textContent).toBe('jsx:b')
55
+ })
56
+
57
+ test('JSX-component shim returning a DocumentFragment unwraps into the host', async () => {
58
+ const { dispatchNodeType } = await import('../node-type-dispatch')
59
+
60
+ const host = document.createElement('div')
61
+
62
+ // `createComponent` shims that produce multiple top-level nodes
63
+ // wrap them in a DocumentFragment. `appendChild` of a fragment
64
+ // unwraps its children into the host — this exercise pins that
65
+ // behaviour so a future "only HTMLElement" tightening of the
66
+ // guard is caught.
67
+ const fragmentShim: () => Node = () => {
68
+ const frag = document.createDocumentFragment()
69
+ const a = document.createElement('span')
70
+ a.textContent = 'one'
71
+ const b = document.createElement('span')
72
+ b.textContent = 'two'
73
+ frag.appendChild(a)
74
+ frag.appendChild(b)
75
+ return frag
76
+ }
77
+
78
+ dispatchNodeType(host, fragmentShim as never, {} as never)
79
+
80
+ expect(host.children).toHaveLength(2)
81
+ expect(host.children[0]?.textContent).toBe('one')
82
+ expect(host.children[1]?.textContent).toBe('two')
83
+ })
84
+
85
+ test('non-Node return value is ignored (imperative entries that accidentally return a primitive)', async () => {
86
+ const { dispatchNodeType } = await import('../node-type-dispatch')
87
+
88
+ const host = document.createElement('div')
89
+
90
+ // Defensive: an imperative entry that accidentally returns a
91
+ // non-Node value (e.g. a number from a `.forEach` chain) MUST NOT
92
+ // trigger an `appendChild` — the `instanceof Node` guard is the
93
+ // only thing keeping the imperative path safe.
94
+ const stray: (this: HTMLElement) => unknown = function () {
95
+ this.dataset.touched = '1'
96
+ return 42
97
+ }
98
+
99
+ dispatchNodeType(host, stray as never, {} as never)
100
+
101
+ expect(host.dataset.touched).toBe('1')
102
+ expect(host.children).toHaveLength(0)
103
+ })
104
+ })