@barefootjs/jsx 0.33.2 → 0.33.4

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 (61) hide show
  1. package/dist/analyzer.d.ts +17 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/compiler.d.ts +21 -5
  4. package/dist/compiler.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +804 -445
  8. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +2 -4
  12. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/stringify/component-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/imports.d.ts +60 -2
  18. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
  20. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +26 -2
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/jsx-to-ir.d.ts.map +1 -1
  24. package/dist/props-binding.d.ts +35 -0
  25. package/dist/props-binding.d.ts.map +1 -1
  26. package/dist/types.d.ts +63 -13
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
  30. package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
  31. package/src/__tests__/fragment-body-loop-key.test.ts +95 -0
  32. package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
  33. package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
  34. package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
  35. package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
  36. package/src/__tests__/merge-template-imports.test.ts +41 -1
  37. package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
  38. package/src/__tests__/multi-root-loop-body.test.ts +7 -3
  39. package/src/__tests__/preamble-declarations.test.ts +42 -0
  40. package/src/__tests__/root-key-relay.test.ts +170 -0
  41. package/src/__tests__/signal-getter-not-called.test.ts +149 -0
  42. package/src/__tests__/state-only-file-default-import.test.ts +47 -0
  43. package/src/analyzer.ts +36 -0
  44. package/src/compiler.ts +94 -104
  45. package/src/index.ts +1 -1
  46. package/src/ir-to-client-js/build-references.ts +7 -0
  47. package/src/ir-to-client-js/collect-elements.ts +27 -5
  48. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +19 -1
  49. package/src/ir-to-client-js/control-flow/plan/loop.ts +2 -4
  50. package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +7 -0
  51. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
  52. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
  53. package/src/ir-to-client-js/control-flow.ts +12 -0
  54. package/src/ir-to-client-js/html-template.ts +174 -23
  55. package/src/ir-to-client-js/imports.ts +178 -5
  56. package/src/ir-to-client-js/index.ts +5 -0
  57. package/src/ir-to-client-js/prop-handling.ts +6 -17
  58. package/src/ir-to-client-js/utils.ts +30 -2
  59. package/src/jsx-to-ir.ts +592 -58
  60. package/src/props-binding.ts +51 -0
  61. package/src/types.ts +60 -13
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Regression tests for #2749: a `ref` on a CHILD-COMPONENT call site must not
3
+ * be mirrored onto the child's root element as a DOM attribute.
4
+ *
5
+ * Measured before the fix: `collectReactiveChildProps` (collect-elements.ts)
6
+ * hand-rolled an `on[A-Z]` event check and had no `ref` case, so a reactive
7
+ * `ref` prop fell through to the generic dynamic-prop mirror and emitted
8
+ *
9
+ * if (__v != null) __scope.setAttribute('ref', String(__v))
10
+ *
11
+ * i.e. the callback's SOURCE TEXT as an attribute value. SSR never emits a
12
+ * `ref` attribute, so only the hydrate leg grew it and the SSR-vs-hydrated
13
+ * snapshot diverged. The same prop was — and still is — passed correctly to
14
+ * `initChild` as `get ref() { … }`; the runtime child then routes it through
15
+ * `applyRestAttrs`, whose `classifyDOMProp` read already returns `kind: 'ref'`
16
+ * and invokes the callback instead of setting an attribute.
17
+ *
18
+ * The fix makes the compile-time mirror read the same classifier. These tests
19
+ * pin BOTH directions: `ref` (and `on*`) must not reach the mirror, and an
20
+ * ordinary reactive attribute prop must still reach it.
21
+ */
22
+
23
+ import { describe, test, expect } from 'bun:test'
24
+ import { compileJSX } from '../compiler'
25
+ import { TestAdapter } from '../adapters/test-adapter'
26
+
27
+ const adapter = new TestAdapter()
28
+
29
+ function getClientJs(source: string, filename: string): string {
30
+ const result = compileJSX(source, filename, { adapter })
31
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
32
+ const clientJs = result.files.find(f => f.type === 'clientJs')
33
+ expect(clientJs).toBeDefined()
34
+ return clientJs!.content
35
+ }
36
+
37
+ const REF_ON_CHILD = `
38
+ 'use client'
39
+ import { createSignal } from '@barefootjs/client'
40
+ import { Row } from './Row'
41
+
42
+ export function Case() {
43
+ const [val, setVal] = createSignal(0)
44
+ const handleMount = (el: Element) => {
45
+ el.setAttribute('data-mounted', String(val()))
46
+ }
47
+ return <Row ref={handleMount} data-x="1"><span>{val()}</span></Row>
48
+ }
49
+ `
50
+
51
+ describe('ref on a child-component call site (#2749)', () => {
52
+ test('is never mirrored into a setAttribute(\'ref\', …) update', () => {
53
+ const js = getClientJs(REF_ON_CHILD, 'Case.tsx')
54
+ expect(js).not.toContain(`setAttribute('ref'`)
55
+ expect(js).not.toContain(`removeAttribute('ref'`)
56
+ })
57
+
58
+ test('still reaches the child through initChild as a getter', () => {
59
+ const js = getClientJs(REF_ON_CHILD, 'Case.tsx')
60
+ expect(js).toContain('initChild(')
61
+ expect(js).toContain('get ref()')
62
+ })
63
+
64
+ test('does not appear as an attribute in the CSR template either', () => {
65
+ const js = getClientJs(REF_ON_CHILD, 'Case.tsx')
66
+ const template = js.slice(js.indexOf('template:'))
67
+ expect(template).not.toContain('ref=')
68
+ })
69
+
70
+ test('an ordinary reactive attribute prop on the same call site is still mirrored', () => {
71
+ // Reverse direction: the fix must not silence the generic mirror. `title`
72
+ // classifies as `attr`, so it keeps its setAttribute update.
73
+ const js = getClientJs(
74
+ `
75
+ 'use client'
76
+ import { createSignal } from '@barefootjs/client'
77
+ import { Row } from './Row'
78
+
79
+ export function Case() {
80
+ const [val, setVal] = createSignal(0)
81
+ const onMount = (el: Element) => { el.setAttribute('data-mounted', '1') }
82
+ return <Row ref={onMount} title={String(val())}>x</Row>
83
+ }
84
+ `,
85
+ 'Case.tsx',
86
+ )
87
+ expect(js).toContain(`setAttribute('title'`)
88
+ expect(js).not.toContain(`setAttribute('ref'`)
89
+ })
90
+ })
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Fragment-bodied `.map()` row key extraction (#2763).
3
+ *
4
+ * A `.map()` callback whose body is a fragment (`<><li key={..}/><li/></>`)
5
+ * used to make `extractLoopKey` (`jsx-to-ir.ts`) return `null` — it handled
6
+ * `element`, `component`, and `conditional`, but not `fragment`. That made
7
+ * every SSR adapter silently drop the row-key attribute AND left
8
+ * `mapArray`'s `keyFn` unset (positional reconciliation), while
9
+ * `html-template.ts`'s client row builder kept baking `data-key` from the
10
+ * raw `key` JSX attribute — a silent SSR/CSR divergence with no diagnostic.
11
+ *
12
+ * The fix adds a `fragment` case to both `extractLoopKey` and its write-side
13
+ * twin `applyLoopKeyAttr`, reading/stamping the key on the fragment's FIRST
14
+ * ELEMENT child (skipping whitespace-only text), matching the "first
15
+ * element, not first node" rule `IRElement.keyAttr`'s docstring already
16
+ * documents for the render-root relay case. `html-template.ts`'s client
17
+ * template builders were also changed to read `IRElement.keyAttr` instead
18
+ * of the raw attribute (see `resolvedKeyAttrName` in that file) — this test
19
+ * covers only the SSR/IR-resolution half.
20
+ */
21
+ import { describe, test, expect } from 'bun:test'
22
+ import { analyzeComponent } from '../analyzer'
23
+ import { jsxToIR } from '../jsx-to-ir'
24
+ import { compileJSX } from '../compiler'
25
+ import { TestAdapter } from '../adapters/test-adapter'
26
+ import type { IRElement, IRLoop, IRNode } from '../types'
27
+
28
+ function compile(source: string, name = 'Test'): IRNode {
29
+ const ir = jsxToIR(analyzeComponent(source, `${name}.tsx`))
30
+ expect(ir).not.toBeNull()
31
+ return ir!
32
+ }
33
+
34
+ /** The single `loop` node anywhere in the tree (depth-first), or null. */
35
+ function findLoop(node: IRNode): IRLoop | null {
36
+ if (node.type === 'loop') return node
37
+ const children: IRNode[] =
38
+ 'children' in node && Array.isArray((node as { children?: unknown }).children)
39
+ ? (node as { children: IRNode[] }).children
40
+ : []
41
+ for (const c of children) {
42
+ const found = findLoop(c)
43
+ if (found) return found
44
+ }
45
+ return null
46
+ }
47
+
48
+ const SOURCE = `
49
+ 'use client'
50
+ import { createSignal } from '@barefootjs/client'
51
+ type Item = { id: number; label: string }
52
+ export function KeyFrag(props: { items: Item[] }) {
53
+ const [items] = createSignal<Item[]>(props.items)
54
+ return (
55
+ <ul>
56
+ {items().map(item => (
57
+ <>
58
+ <li key={item.id}>{item.label}</li>
59
+ <li>x</li>
60
+ </>
61
+ ))}
62
+ </ul>
63
+ )
64
+ }
65
+ `
66
+
67
+ describe('fragment-bodied .map() row key extraction (#2763)', () => {
68
+ test('IRElement.keyAttr lands on the fragment\'s first element, not the second', () => {
69
+ const ir = compile(SOURCE, 'KeyFrag')
70
+ const loop = findLoop(ir)
71
+ expect(loop).not.toBeNull()
72
+ const fragment = loop!.children[0]
73
+ expect(fragment.type).toBe('fragment')
74
+ if (fragment.type !== 'fragment') return
75
+ const [firstLi, secondLi] = fragment.children.filter(
76
+ (c): c is IRElement => c.type === 'element',
77
+ )
78
+ expect(firstLi.keyAttr).toEqual({ name: 'data-key', value: 'item.id' })
79
+ expect(secondLi.keyAttr).toBeUndefined()
80
+ })
81
+
82
+ test('mapArray receives a real keyFn instead of reconciling positionally', () => {
83
+ const result = compileJSX(SOURCE, 'KeyFrag.tsx', { adapter: new TestAdapter() })
84
+ expect(result.errors.filter((e) => (e as { severity?: string }).severity === 'error')).toHaveLength(0)
85
+ const cjs = result.files.find((f) => f.type === 'clientJs')
86
+ expect(cjs).toBeDefined()
87
+ const calls = cjs!.content
88
+ .split('\n')
89
+ .map((ln) => ln.trim())
90
+ .filter((ln) => ln.startsWith('mapArray(') || ln.startsWith('mapArrayLazy('))
91
+ expect(calls).toHaveLength(1)
92
+ expect(calls[0]).toContain('String(item.id)')
93
+ expect(calls[0]).not.toMatch(/_s\d+, null,/)
94
+ })
95
+ })
@@ -71,6 +71,29 @@ function makeSideEffectImport(source: string): ImportInfo {
71
71
  }
72
72
  }
73
73
 
74
+ /** `import <localName> from 'source'`, optionally alongside named specifiers. */
75
+ function makeDefaultImport(source: string, localName: string, namedSpecifiers: string[] = []): ImportInfo {
76
+ return {
77
+ source,
78
+ specifiers: [
79
+ { name: localName, alias: null, isDefault: true, isNamespace: false },
80
+ ...namedSpecifiers.map(name => ({ name, alias: null, isDefault: false, isNamespace: false })),
81
+ ],
82
+ isTypeOnly: false,
83
+ loc: dummyLoc,
84
+ }
85
+ }
86
+
87
+ /** `import * as localName from 'source'`. */
88
+ function makeNamespaceImport(source: string, localName: string): ImportInfo {
89
+ return {
90
+ source,
91
+ specifiers: [{ name: localName, alias: null, isDefault: false, isNamespace: true }],
92
+ isTypeOnly: false,
93
+ loc: dummyLoc,
94
+ }
95
+ }
96
+
74
97
  describe('collectExternalImports', () => {
75
98
  test('preserves third-party library imports used in generated code', () => {
76
99
  const ir = makeIR([makeImport('zod', ['z'])])
@@ -79,6 +102,90 @@ describe('collectExternalImports', () => {
79
102
  expect(result).toEqual(["import { z } from 'zod'"])
80
103
  })
81
104
 
105
+ // #2767 follow-up: a default-imported binding (e.g. a JSON module's
106
+ // `import lock from '...json' with { type: 'json' }`) was previously
107
+ // always re-emitted as a NAMED import (`import { lock } from '...'`),
108
+ // which is a real, silently-wrong ESM import — the module has no such
109
+ // named export — that only failed once a bundler actually resolved it.
110
+ test('preserves a default import with correct default-import syntax, not named braces', () => {
111
+ const ir = makeIR([makeDefaultImport('../data.json', 'lock')])
112
+ const code = 'lock.adapters'
113
+ const result = collectExternalImports(ir, code)
114
+ expect(result).toEqual(["import lock from '../data.json'"])
115
+ })
116
+
117
+ test('preserves a namespace import with correct namespace-import syntax', () => {
118
+ const ir = makeIR([makeNamespaceImport('./ns-module', 'NS')])
119
+ const code = 'NS.helper()'
120
+ const result = collectExternalImports(ir, code)
121
+ expect(result).toEqual(["import * as NS from './ns-module'"])
122
+ })
123
+
124
+ test('combines a used default specifier with used named specifiers on one declaration', () => {
125
+ const ir = makeIR([makeDefaultImport('./mixed', 'Default', ['helper', 'other'])])
126
+ const code = 'Default(); helper()'
127
+ const result = collectExternalImports(ir, code)
128
+ expect(result).toEqual(["import Default, { helper } from './mixed'"])
129
+ })
130
+
131
+ test('drops an unused default specifier but keeps a used named sibling', () => {
132
+ const ir = makeIR([makeDefaultImport('./mixed', 'Default', ['helper'])])
133
+ const code = 'helper()'
134
+ const result = collectExternalImports(ir, code)
135
+ expect(result).toEqual(["import { helper } from './mixed'"])
136
+ })
137
+
138
+ // `import Foo, * as NS from 'x'` is legal JS, but never emitted as one
139
+ // line — a namespace specifier always gets its own declaration (see
140
+ // `renderUsedImportLines`'s docstring). Pin the two-line output so a
141
+ // future refactor can't silently collapse or drop one of them.
142
+ test('a used default specifier and a used namespace specifier from the same source emit two separate lines', () => {
143
+ const ir = makeIR([
144
+ {
145
+ source: './both',
146
+ specifiers: [
147
+ { name: 'Foo', alias: null, isDefault: true, isNamespace: false },
148
+ { name: 'NS', alias: null, isDefault: false, isNamespace: true },
149
+ ],
150
+ isTypeOnly: false,
151
+ loc: dummyLoc,
152
+ },
153
+ ])
154
+ const code = 'Foo(); NS.helper()'
155
+ const result = collectExternalImports(ir, code)
156
+ expect(result).toEqual(["import Foo from './both'", "import * as NS from './both'"])
157
+ })
158
+
159
+ test('a default-imported COMPONENT is skipped, same as a named-imported one', () => {
160
+ const ir = makeIR([makeDefaultImport('./button', 'Button')], ['Button'])
161
+ const code = '<Button/>'
162
+ const result = collectExternalImports(ir, code)
163
+ expect(result).toEqual([])
164
+ })
165
+
166
+ test('rewrites a default import to the .client.js sibling when its source is a client-signal import', () => {
167
+ const ir = makeIR([makeDefaultImport('./state', 'store')])
168
+ ir.metadata.clientSignalImportSources = new Set(['./state'])
169
+ const code = 'store.count'
170
+ const result = collectExternalImports(ir, code)
171
+ expect(result).toEqual(["import store from './state.client.js'"])
172
+ })
173
+
174
+ test('rewrites a namespace import to the .client.js sibling when its source is a client-signal import', () => {
175
+ const ir = makeIR([makeNamespaceImport('./state', 'NS')])
176
+ ir.metadata.clientSignalImportSources = new Set(['./state'])
177
+ const code = 'NS.count'
178
+ const result = collectExternalImports(ir, code)
179
+ expect(result).toEqual(["import * as NS from './state.client.js'"])
180
+ })
181
+
182
+ test('drops an unused namespace specifier entirely', () => {
183
+ const ir = makeIR([makeNamespaceImport('./ns-module', 'NS')])
184
+ const code = 'somethingElse()'
185
+ const result = collectExternalImports(ir, code)
186
+ expect(result).toEqual([])
187
+ })
188
+
82
189
  test('skips @barefootjs/client imports', () => {
83
190
  const ir = makeIR([makeImport('@barefootjs/client', ['createSignal'])])
84
191
  const code = 'createSignal(0)'
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `mergeCompiledClientJsImports` merges sibling components' compiled
3
+ * client-JS blobs (`compileMultipleComponents`'s two call sites) via a
4
+ * real `ts.createSourceFile` AST walk — never a text/regex line scan — so
5
+ * a string or template-literal VALUE that merely contains a line starting
6
+ * with `import ` can never be torn out of its literal and hoisted into the
7
+ * imports block. Mirrors `combine-client-js.test.ts`'s `#1702` regression
8
+ * test for `parseAndMerge`, the established precedent this function
9
+ * follows (see its own docstring).
10
+ */
11
+ import { describe, test, expect } from 'bun:test'
12
+ import { mergeCompiledClientJsImports } from '../../ir-to-client-js/imports'
13
+
14
+ describe('mergeCompiledClientJsImports', () => {
15
+ test('does not treat an import-shaped line inside a string/template literal as a real import (#1702-class)', () => {
16
+ // A docs component embeds a code sample whose CONTENTS contain a
17
+ // line that starts with `import `. A line-based scan would tear that
18
+ // fake import out of the literal and hoist it into the imports
19
+ // block, leaving `hydrate` undefined and corrupting the literal.
20
+ const sample = [
21
+ '`Example usage:',
22
+ '',
23
+ "import { createSignal } from '@barefootjs/client'",
24
+ '',
25
+ 'export function Counter() {}`',
26
+ ].join('\n')
27
+
28
+ const componentA = [
29
+ "import { hydrate, createSignal } from '@barefootjs/client/runtime'",
30
+ `const SAMPLE = ${sample}`,
31
+ "hydrate('DocsExample', (el) => {})",
32
+ ].join('\n')
33
+
34
+ const merged = mergeCompiledClientJsImports([componentA])
35
+
36
+ // The real runtime import survives as its own top-level declaration,
37
+ // exactly once — not duplicated by the fake import line inside the
38
+ // string literal.
39
+ const realImportOccurrences = (merged.match(/^import \{ hydrate, createSignal \} from '@barefootjs\/client\/runtime'$/gm) ?? []).length
40
+ expect(realImportOccurrences).toBe(1)
41
+ // The sample string is untouched — the fake import line inside it is
42
+ // still there, still nested inside the backtick literal, not hoisted
43
+ // out as a separate top-level statement.
44
+ expect(merged).toContain('const SAMPLE = `Example usage:')
45
+ expect(merged).toContain("import { createSignal } from '@barefootjs/client'\n\nexport function Counter() {}`")
46
+ })
47
+
48
+ // #2767 follow-up: the same duplicate-binding SyntaxError F1 pinned for
49
+ // the old regex-based merge, now exercised through the AST-based path.
50
+ test('folds a default import shared across sibling components instead of redeclaring the binding', () => {
51
+ const componentA = [
52
+ "import cfg from './config'",
53
+ "hydrate('CompA', (el) => {})",
54
+ ].join('\n')
55
+ const componentB = [
56
+ "import cfg, { helper } from './config'",
57
+ "hydrate('CompB', (el) => {})",
58
+ ].join('\n')
59
+
60
+ const merged = mergeCompiledClientJsImports([componentA, componentB])
61
+
62
+ expect(merged).toContain("import cfg, { helper } from './config'")
63
+ // Exactly one declaration — not one per sibling component.
64
+ expect((merged.match(/\bcfg\b/g) ?? []).length).toBe(1)
65
+ })
66
+
67
+ test('keeps an unresolved @bf-child: placeholder import (does not drop it, unlike parent-child inlining)', () => {
68
+ const componentA = [
69
+ "import { hydrate, initChild } from '@barefootjs/client/runtime'",
70
+ "import '/* @bf-child:Child */'",
71
+ "hydrate('Parent', (el) => { initChild('Child', el, {}) })",
72
+ ].join('\n')
73
+
74
+ const merged = mergeCompiledClientJsImports([componentA])
75
+
76
+ expect(merged).toContain("import '/* @bf-child:Child */'")
77
+ })
78
+
79
+ test('dedupes an identical @bf-child: placeholder shared by two sibling components', () => {
80
+ const componentA = [
81
+ "import '/* @bf-child:Shared */'",
82
+ "hydrate('A', (el) => {})",
83
+ ].join('\n')
84
+ const componentB = [
85
+ "import '/* @bf-child:Shared */'",
86
+ "hydrate('B', (el) => {})",
87
+ ].join('\n')
88
+
89
+ const merged = mergeCompiledClientJsImports([componentA, componentB])
90
+
91
+ expect((merged.match(/@bf-child:Shared/g) ?? []).length).toBe(1)
92
+ })
93
+
94
+ // pullfrog[bot] review of #2769: a combined `import Default, * as NS
95
+ // from '…'` line has a default clause AND a namespace binding — a
96
+ // naive "has a default clause" check would route it into the fold
97
+ // branch and silently drop the namespace half, since only named
98
+ // bindings are read there. No current producer emits this combined
99
+ // shape (`renderUsedImportLines` always splits a used default+namespace
100
+ // pair into two lines), but the classification must stay correct
101
+ // independent of that invariant.
102
+ test('keeps a combined default+namespace import verbatim, not silently dropping the namespace half', () => {
103
+ const componentA = [
104
+ "import Default, * as NS from './combined'",
105
+ "hydrate('A', (el) => {})",
106
+ ].join('\n')
107
+
108
+ const merged = mergeCompiledClientJsImports([componentA])
109
+
110
+ expect(merged).toContain("import Default, * as NS from './combined'")
111
+ })
112
+
113
+ test('preserves a namespace import on its own line, deduped by exact source+name across components', () => {
114
+ const componentA = [
115
+ "import * as util from './util'",
116
+ "hydrate('A', (el) => {})",
117
+ ].join('\n')
118
+ const componentB = [
119
+ "import * as util from './util'",
120
+ "hydrate('B', (el) => {})",
121
+ ].join('\n')
122
+
123
+ const merged = mergeCompiledClientJsImports([componentA, componentB])
124
+
125
+ expect((merged.match(/import \* as util from '\.\/util'/g) ?? []).length).toBe(1)
126
+ })
127
+
128
+ test('preserves both components\' code sections after import extraction', () => {
129
+ const componentA = "import { hydrate } from '@barefootjs/client/runtime'\nhydrate('A', (el) => {})"
130
+ const componentB = "import { hydrate } from '@barefootjs/client/runtime'\nhydrate('B', (el) => {})"
131
+
132
+ const merged = mergeCompiledClientJsImports([componentA, componentB])
133
+
134
+ expect(merged).toContain("hydrate('A', (el) => {})")
135
+ expect(merged).toContain("hydrate('B', (el) => {})")
136
+ expect((merged.match(/^import \{ hydrate \}/gm) ?? []).length).toBe(1)
137
+ })
138
+ })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * #2754 — a `{...props}` / `{...rest}` forward is the one attribute source
3
+ * no template can carry (its keys are unknown at compile time), so the
4
+ * runtime's `applyRestAttrs` is the only thing that can apply it. That
5
+ * call is addressed by slot id and lives in `init`, and BOTH gates used to
6
+ * miss the stateless case:
7
+ *
8
+ * - Phase 1 gave the host element no slot id, because a spread trips
9
+ * none of the reactivity heuristics; and
10
+ * - `needsClientJs` did not count `restAttrElements`, so even with a
11
+ * slot the component fell to the template-only mount with `init` empty.
12
+ *
13
+ * SSR and hydration hid both: the SSR markup already carries the caller's
14
+ * attributes. Only a pure `createComponent` mount showed the drop.
15
+ */
16
+ import { describe, test, expect } from 'bun:test'
17
+ import { compileJSX } from '../compiler'
18
+ import { TestAdapter } from '../adapters/test-adapter'
19
+
20
+ const adapter = new TestAdapter()
21
+
22
+ function compile(source: string) {
23
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
24
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
25
+ return {
26
+ clientJs: result.files.find(f => f.type === 'clientJs')!.content,
27
+ template: result.files.find(f => f.type === 'markedTemplate')!.content,
28
+ }
29
+ }
30
+
31
+ describe('#2754 — a caller-props forward earns a slot and an init', () => {
32
+ test('a stateless `{ children, ...props }` forwarder emits applyRestAttrs against a slot', () => {
33
+ const { clientJs, template } = compile(`
34
+ "use client";
35
+ export function Plain({ children, ...props }: { children?: unknown; [k: string]: unknown }) {
36
+ return <span className="plain" {...props}>{children}</span>
37
+ }
38
+ `)
39
+ expect(clientJs).toContain('applyRestAttrs')
40
+ // The slot the call addresses must exist in the SSR markup too, or
41
+ // hydration's `$(__scope, 's0')` finds nothing.
42
+ expect(clientJs).toMatch(/const \[_s\d\] = \$\(__scope, 's\d'\)/)
43
+ expect(template).toMatch(/bf="s\d"/)
44
+ // `children` and the statically-set `class` stay excluded so the
45
+ // forward neither double-renders children nor re-emits `class`.
46
+ expect(clientJs).toMatch(/applyRestAttrs\(_s\d, _p, \["children","class"\]\)/)
47
+ })
48
+
49
+ test('a whole undestructured props object spread gets the same treatment', () => {
50
+ const { clientJs } = compile(`
51
+ "use client";
52
+ export function Whole(props: { [k: string]: unknown }) {
53
+ return <span className="plain" {...props} />
54
+ }
55
+ `)
56
+ expect(clientJs).toContain('applyRestAttrs')
57
+ })
58
+
59
+ test('an alias hop onto the rest binding resolves the same way (#2723 shape)', () => {
60
+ const { clientJs } = compile(`
61
+ "use client";
62
+ export function Aliased({ children, ...props }: { children?: unknown; [k: string]: unknown }) {
63
+ const props__alias = props
64
+ return <span className="plain" {...props__alias}>{children}</span>
65
+ }
66
+ `)
67
+ expect(clientJs).toContain('applyRestAttrs')
68
+ })
69
+
70
+ test('a spread of an ordinary object still inlines into the template and earns no slot', () => {
71
+ // Reverse direction: only the caller-props forward is unknowable at
72
+ // compile time. An ordinary object spread is fully emitted by both
73
+ // templates and must not start allocating slot ids.
74
+ const { clientJs, template } = compile(`
75
+ "use client";
76
+ const extra = { title: 'x' }
77
+ export function Ordinary() {
78
+ return <span className="plain" {...extra} />
79
+ }
80
+ `)
81
+ expect(clientJs).toContain('spreadAttrs')
82
+ expect(clientJs).not.toContain('applyRestAttrs')
83
+ expect(template).not.toMatch(/bf="s\d"/)
84
+ })
85
+ })