@barefootjs/jsx 0.4.0 → 0.5.1

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 (67) hide show
  1. package/dist/adapters/interface.d.ts +20 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/test-adapter.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +36 -19
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/import-map.d.ts +56 -0
  7. package/dist/import-map.d.ts.map +1 -0
  8. package/dist/import-map.js +18 -0
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +333 -199
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +14 -0
  15. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +0 -14
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/types.d.ts +7 -0
  24. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/utils.d.ts +2 -2
  26. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  27. package/dist/scanner/js-scanner.d.ts +10 -0
  28. package/dist/scanner/js-scanner.d.ts.map +1 -1
  29. package/dist/scanner/js-scanner.js +5 -0
  30. package/dist/types.d.ts +11 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +7 -3
  33. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +438 -190
  34. package/src/__tests__/adapter-output.test.ts +49 -0
  35. package/src/__tests__/child-components-in-map.test.ts +76 -0
  36. package/src/__tests__/client-js-generation.test.ts +5 -2
  37. package/src/__tests__/import-map.test.ts +75 -0
  38. package/src/__tests__/inline-jsx-callback.test.ts +95 -0
  39. package/src/__tests__/ir-jsx-props.test.ts +5 -2
  40. package/src/__tests__/ir-sort-comparator.test.ts +212 -9
  41. package/src/__tests__/loop-item-conditional-codegen.test.ts +81 -0
  42. package/src/__tests__/map-logical-jsx-helper.test.ts +159 -0
  43. package/src/__tests__/missing-key-in-list.test.ts +49 -0
  44. package/src/__tests__/reactive-attrs-in-map.test.ts +41 -0
  45. package/src/__tests__/token-contains-ident.test.ts +27 -0
  46. package/src/__tests__/unsupported-expression.test.ts +42 -13
  47. package/src/adapters/interface.ts +20 -0
  48. package/src/adapters/test-adapter.ts +16 -1
  49. package/src/expression-parser.ts +265 -50
  50. package/src/import-map.ts +72 -0
  51. package/src/index.ts +5 -1
  52. package/src/ir-to-client-js/collect-elements.ts +3 -0
  53. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +17 -0
  54. package/src/ir-to-client-js/control-flow/plan/loop.ts +14 -0
  55. package/src/ir-to-client-js/control-flow/stringify/insert.ts +7 -2
  56. package/src/ir-to-client-js/control-flow/stringify/loop.ts +60 -0
  57. package/src/ir-to-client-js/emit-reactive.ts +12 -3
  58. package/src/ir-to-client-js/html-template.ts +29 -3
  59. package/src/ir-to-client-js/imports.ts +2 -2
  60. package/src/ir-to-client-js/reactivity.ts +17 -1
  61. package/src/ir-to-client-js/stringify/static-array-child-init.ts +8 -4
  62. package/src/ir-to-client-js/types.ts +7 -0
  63. package/src/ir-to-client-js/utils.ts +31 -116
  64. package/src/jsx-to-ir.ts +161 -12
  65. package/src/preprocess-inline-jsx-callbacks.ts +28 -10
  66. package/src/scanner/js-scanner.ts +16 -1
  67. package/src/types.ts +12 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * BarefootJS Compiler — `.map()` callback whose body is a logical
3
+ * expression (`&&` / `||` / `??`) that renders JSX.
4
+ *
5
+ * Regression for #1665: calling a module-level JSX helper from inside a
6
+ * reactive `.map()` child (`THEMES.map(t => sel === t.id && themeLogo(t.id))`)
7
+ * threw `ReferenceError: themeLogo is not defined` at hydration.
8
+ *
9
+ * Root cause: `transformMapCall` only recognised JSX-element, ternary,
10
+ * parenthesized, flatMap-array, and block callback bodies. A logical-`&&`
11
+ * body fell through, leaving `children` empty, so the whole `.map(...)`
12
+ * was emitted verbatim as a reactive-text expression — the inline JSX was
13
+ * never compiled and the JSX helper was never inlined nor declared,
14
+ * producing a ReferenceError.
15
+ *
16
+ * The fix routes a JSX-rendering logical callback body through the shared
17
+ * JSX expression transformer, which lowers it into an IRConditional and
18
+ * inlines any JSX helper (the same path the ternary form already used).
19
+ * The routing is scoped to logical operators so the bare-call body
20
+ * (`map(t => renderItem(t))`, owned by #546) and scalar logical bodies
21
+ * (`t.active && t.label`) keep their existing reactive-text behaviour.
22
+ */
23
+
24
+ import { describe, test, expect } from 'bun:test'
25
+ import { compileJSX } from '../compiler'
26
+ import { TestAdapter } from '../adapters/test-adapter'
27
+
28
+ const adapter = new TestAdapter()
29
+
30
+ describe('.map() with logical / JSX-helper-call body (#1665)', () => {
31
+ test('module-level JSX helper in a `&&` map body is inlined, not left as a bare call', () => {
32
+ const source = `
33
+ 'use client'
34
+ const THEMES = [{ id: 'piconic' }, { id: 'hono' }]
35
+ function themeLogo(id: string) {
36
+ if (id === 'hono') return <span>hono</span>
37
+ return <span>piconic</span>
38
+ }
39
+ export function Header(props: { sel: string }) {
40
+ return <div>{THEMES.map(t => props.sel === t.id && themeLogo(t.id))}</div>
41
+ }
42
+ `
43
+ const result = compileJSX(source, 'Header.tsx', { adapter })
44
+ expect(result.errors).toHaveLength(0)
45
+
46
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
47
+
48
+ // The helper must NOT survive as a runtime call in the client bundle —
49
+ // it is inlined. A surviving `themeLogo(` call with no declaration is
50
+ // the exact ReferenceError the issue reported.
51
+ expect(clientJs).not.toMatch(/themeLogo\s*\(/)
52
+
53
+ // The map is compiled into a loop, and the helper's JSX is inlined.
54
+ expect(clientJs).toContain('<!--bf-loop')
55
+ expect(clientJs).toContain('hono')
56
+ expect(clientJs).toContain('piconic')
57
+ })
58
+
59
+ test('parenthesized `&&` with a single-return JSX helper is inlined', () => {
60
+ const source = `
61
+ 'use client'
62
+ const THEMES = [{ id: 'piconic' }, { id: 'hono' }]
63
+ function themeLogo(id: string) { return <span>{id}</span> }
64
+ export function Header(props: { sel: string }) {
65
+ return <div>{THEMES.map(t => (props.sel === t.id && themeLogo(t.id)))}</div>
66
+ }
67
+ `
68
+ const result = compileJSX(source, 'Header.tsx', { adapter })
69
+ expect(result.errors).toHaveLength(0)
70
+
71
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
72
+
73
+ expect(clientJs).not.toMatch(/themeLogo\s*\(/)
74
+ expect(clientJs).toContain('<!--bf-loop')
75
+ expect(clientJs).toContain('<span')
76
+ })
77
+
78
+ test('`??` JSX-helper fallback in a map body is inlined', () => {
79
+ const source = `
80
+ 'use client'
81
+ const THEMES = [{ id: 'piconic' }, { id: 'hono' }]
82
+ function themeLogo(id: string) { return <span>{id}</span> }
83
+ export function Header(props: { current?: string }) {
84
+ return <div>{THEMES.map(t => props.current ?? themeLogo(t.id))}</div>
85
+ }
86
+ `
87
+ const result = compileJSX(source, 'Header.tsx', { adapter })
88
+ expect(result.errors).toHaveLength(0)
89
+
90
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
91
+
92
+ // `transformJsxExpression` now routes a `||`/`??` whose right operand
93
+ // renders JSX via a tracked helper through the nullish transformer, so
94
+ // the helper is inlined rather than left as a verbatim call.
95
+ expect(clientJs).not.toMatch(/themeLogo\s*\(/)
96
+ expect(clientJs).toContain('<span')
97
+ })
98
+
99
+ test('bare JSX-helper call body keeps its #546 reactive-text behaviour', () => {
100
+ // Boundary guard: the narrowed fix must NOT re-route a bare call body
101
+ // into the conditional path — that remains #546 territory. A local
102
+ // arrow helper is declared in init scope, so the call survives.
103
+ const source = `
104
+ 'use client'
105
+ import { createSignal } from '@barefootjs/client'
106
+ export function List() {
107
+ const [items, _set] = createSignal([{ id: '1' }])
108
+ const renderItem = (item: any) => <li>{item.id}</li>
109
+ return <ul>{items().map(item => renderItem(item))}</ul>
110
+ }
111
+ `
112
+ const result = compileJSX(source, 'List.tsx', { adapter })
113
+ expect(result.errors).toHaveLength(0)
114
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
115
+ // The bare call is preserved (not inlined into a conditional loop).
116
+ expect(clientJs).toMatch(/renderItem\s*\(/)
117
+ })
118
+
119
+ test('inline JSX in a `&&` map body compiles instead of emitting raw JSX verbatim', () => {
120
+ // `key` is required on the `&&` JSX operand (#1665 / BF023): a whole-item
121
+ // conditional renders 0-or-1 element per iteration and needs a stable key
122
+ // for correct reconciliation, exactly like a ternary branch.
123
+ const source = `
124
+ 'use client'
125
+ const THEMES = [{ id: 'piconic' }, { id: 'hono' }]
126
+ export function Header(props: { sel: string }) {
127
+ return <div>{THEMES.map(t => props.sel === t.id && <span key={t.id}>{t.id}</span>)}</div>
128
+ }
129
+ `
130
+ const result = compileJSX(source, 'Header.tsx', { adapter })
131
+ expect(result.errors).toHaveLength(0)
132
+
133
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
134
+ // Compiled into a loop with a conditional branch — not the raw,
135
+ // un-compiled `<span>{t.id}</span>` JSX literal the buggy path left
136
+ // inside the client-bundle template string.
137
+ expect(clientJs).toContain('<!--bf-loop')
138
+ expect(clientJs).not.toContain('<span>{t.id}</span>')
139
+ })
140
+
141
+ test('scalar `&&` map body keeps its plain reactive-text behaviour', () => {
142
+ // Guard: a non-JSX logical body must NOT be re-routed into the
143
+ // conditional-control-flow path. It stays a verbatim map expression.
144
+ const source = `
145
+ 'use client'
146
+ import { createSignal } from '@barefootjs/client'
147
+ export function Labels() {
148
+ const [items, _set] = createSignal([{ active: true, name: 'A' }])
149
+ return <div>{items().map(t => t.active && t.name)}</div>
150
+ }
151
+ `
152
+ const result = compileJSX(source, 'Labels.tsx', { adapter })
153
+ expect(result.errors).toHaveLength(0)
154
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
155
+ // Still rendered as the raw map expression (no loop control flow).
156
+ expect(clientJs).not.toContain('<!--bf-loop')
157
+ expect(clientJs).toContain('.map(')
158
+ })
159
+ })
@@ -224,6 +224,55 @@ describe('BF023 — ternary .map() callback', () => {
224
224
  })
225
225
  })
226
226
 
227
+ // ---------------------------------------------------------------------------
228
+ // BF023 — logical (&&, ||, ??) .map() callback (#1665)
229
+ //
230
+ // A whole-item conditional (`cond && <li>`) renders 0-or-1 element per
231
+ // iteration. The same key reconciliation applies — without a key, toggling
232
+ // items collapses or misorders them — so the JSX element inside a logical
233
+ // expression must carry a key, just like a ternary branch.
234
+ // ---------------------------------------------------------------------------
235
+
236
+ describe('BF023 — logical && / || .map() callback (#1665)', () => {
237
+ test('&& body without key raises BF023', () => {
238
+ const source = `
239
+ interface Item { active: boolean; id: string }
240
+ export function List({ items }: { items: Item[] }) {
241
+ return <ul>{items.map(item => item.active && <li>{item.id}</li>)}</ul>
242
+ }
243
+ `
244
+ const errs = errorsFor(ErrorCodes.MISSING_KEY_IN_LIST, source)
245
+ expect(errs).toHaveLength(1)
246
+ expect(errs[0].severity).toBe('error')
247
+ })
248
+
249
+ test('&& body with key — no error', () => {
250
+ const source = `
251
+ interface Item { active: boolean; id: string }
252
+ export function List({ items }: { items: Item[] }) {
253
+ return <ul>{items.map(item => item.active && <li key={item.id}>{item.id}</li>)}</ul>
254
+ }
255
+ `
256
+ const result = compile(source)
257
+ const errs = result.errors.filter(
258
+ (e) => e.code === ErrorCodes.MISSING_KEY_IN_LIST || e.code === ErrorCodes.MISSING_KEY_IN_NESTED_LIST,
259
+ )
260
+ expect(errs).toHaveLength(0)
261
+ })
262
+
263
+ test('|| body without key raises BF023', () => {
264
+ const source = `
265
+ interface Item { hidden: boolean; id: string }
266
+ export function List({ items }: { items: Item[] }) {
267
+ return <ul>{items.map(item => item.hidden || <li>{item.id}</li>)}</ul>
268
+ }
269
+ `
270
+ const errs = errorsFor(ErrorCodes.MISSING_KEY_IN_LIST, source)
271
+ expect(errs).toHaveLength(1)
272
+ expect(errs[0].severity).toBe('error')
273
+ })
274
+ })
275
+
227
276
  // ---------------------------------------------------------------------------
228
277
  // BF024 — nested map
229
278
  // ---------------------------------------------------------------------------
@@ -236,6 +236,47 @@ describe('reactive attributes inside .map() callbacks', () => {
236
236
  expect(forEachMatches.length).toBe(1)
237
237
  })
238
238
 
239
+ test('per-item attr reading an outer signal via helper+index gets a createEffect (#1673)', () => {
240
+ // A per-item attribute whose value reads the source array signal through
241
+ // a helper indexed by position (`widthAt(i)` → `items()[i].w`) used to
242
+ // emit NO reactive effect — the value was baked into the item template
243
+ // once and froze at its SSR value. The identical binding on a top-level
244
+ // element works, because the top-level path wraps opaque function calls
245
+ // via the Solid-style AST-flag fallback (#940). This test pins that the
246
+ // loop-child path now applies the same fallback.
247
+ const source = `
248
+ 'use client'
249
+ import { createSignal } from '@barefootjs/client'
250
+
251
+ export function ReproLoopAttr() {
252
+ const [items, setItems] = createSignal([{ id: 'a', w: 20 }, { id: 'b', w: 50 }])
253
+ const widthAt = (i) => items()[i].w
254
+ return (
255
+ <ul>
256
+ {items().map((it, i) => (
257
+ <li key={it.id} data-b style={\`width:\${widthAt(i)}%\`}>{it.id}</li>
258
+ ))}
259
+ </ul>
260
+ )
261
+ }
262
+ `
263
+ const result = compileJSX(source, 'ReproLoopAttr.tsx', { adapter })
264
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
265
+
266
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
267
+ // The per-item style binding must be wired through a createEffect that
268
+ // re-reads the helper and writes the style attribute, so it tracks the
269
+ // `items()` signal and updates after hydration. Pin the helper call
270
+ // *inside* the createEffect alongside the style write — `widthAt(` also
271
+ // appears in the static template clone, so asserting it independently
272
+ // could pass even if the effect was missing (the exact regression here).
273
+ // The index resolves to the loop's renderItem index param, in scope
274
+ // inside the factory.
275
+ expect(clientJs).toMatch(
276
+ /createEffect\(\(\)\s*=>\s*\{[\s\S]*?widthAt\([\s\S]*?setAttribute\('style'/,
277
+ )
278
+ })
279
+
239
280
  test('keyed loop: `key` prop is not emitted as a reactive DOM attribute', () => {
240
281
  // Regression guard for the "key duplicate emission" bug:
241
282
  // `<li key={item.id}>` used to be both rendered as `data-key=` in the
@@ -129,6 +129,33 @@ describe('tokenContainsIdent', () => {
129
129
  })
130
130
  })
131
131
 
132
+ // Regex literals were invisible to the previous hand-rolled char scanner:
133
+ // a lone quote inside the regex flipped it into string state (swallowing
134
+ // real references), and an identifier inside the regex body was counted as
135
+ // a reference. The shared ts.createScanner-based lexer recognises regex
136
+ // literals, so both cases are now correct (#1370).
137
+ describe('regex literals', () => {
138
+ test('reference after a regex literal containing an apostrophe', () => {
139
+ expect(tokenContainsIdent("/it's/.test(className)", 'className')).toBe(true)
140
+ })
141
+
142
+ test('reference after a regex literal containing a quote', () => {
143
+ expect(tokenContainsIdent('/a"b/.test(className)', 'className')).toBe(true)
144
+ })
145
+
146
+ test('identifier inside a regex body is not a reference', () => {
147
+ expect(tokenContainsIdent('/className/.test(x)', 'className')).toBe(false)
148
+ })
149
+
150
+ test('regex with escaped slash does not leak into following code', () => {
151
+ expect(tokenContainsIdent('/a\\/b/.test(className)', 'className')).toBe(true)
152
+ })
153
+
154
+ test('division is not mistaken for a regex literal', () => {
155
+ expect(tokenContainsIdent('total / className', 'className')).toBe(true)
156
+ })
157
+ })
158
+
132
159
  describe('non-matches', () => {
133
160
  test('substring is not a match (word boundary)', () => {
134
161
  expect(tokenContainsIdent('myClassName', 'className')).toBe(false)
@@ -136,21 +136,22 @@ describe('Unsupported Expression Error (BF021)', () => {
136
136
  })
137
137
 
138
138
  describe('Unsupported Sort Comparator (BF021)', () => {
139
- test('emits BF021 for multi-key comparator (||-chained) — outside accepted catalogue', () => {
140
- // #1448 Tier B widened the accepted catalogue to include
141
- // `.localeCompare` and primitive `(a,b) => a - b`. Multi-key
142
- // shapes (`a.x - b.x || a.y - b.y`) are still out of scope —
143
- // they refuse here and must be `@client`-marked or rewritten
144
- // to a single-key sort.
139
+ test('emits BF021 for function-reference comparator — outside accepted catalogue', () => {
140
+ // #1448 Tier B follow-up widened the catalogue to include
141
+ // multi-key (`a.x - b.x || a.y - b.y`), relational ternary, and
142
+ // single-`return` block bodies. Function-reference comparators
143
+ // (`arr.sort(cmp)` where `cmp` is a named function) are still out
144
+ // of scope they need scope resolution and refuse here.
145
145
  const source = `
146
146
  'use client'
147
147
  import { createSignal } from '@barefootjs/client'
148
148
 
149
149
  export function TodoList() {
150
150
  const [items, setItems] = createSignal<any[]>([])
151
+ const cmp = (a, b) => a.priority - b.priority
151
152
  return (
152
153
  <ul>
153
- {items().sort((a, b) => a.priority - b.priority || a.id - b.id).map(t => (
154
+ {items().sort(cmp).map(t => (
154
155
  <li>{t.name}</li>
155
156
  ))}
156
157
  </ul>
@@ -162,7 +163,6 @@ describe('Unsupported Sort Comparator (BF021)', () => {
162
163
  const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
163
164
 
164
165
  expect(bf021).toHaveLength(1)
165
- expect(bf021[0].message).toContain('not a supported shape')
166
166
  })
167
167
 
168
168
  test('@client suppresses BF021 for unsupported sort comparator', () => {
@@ -172,9 +172,10 @@ describe('Unsupported Sort Comparator (BF021)', () => {
172
172
 
173
173
  export function TodoList() {
174
174
  const [items, setItems] = createSignal<any[]>([])
175
+ const cmp = (a, b) => a.priority - b.priority
175
176
  return (
176
177
  <ul>
177
- {/* @client */ items().sort((a, b) => a.priority - b.priority || a.id - b.id).map(t => (
178
+ {/* @client */ items().sort(cmp).map(t => (
178
179
  <li>{t.name}</li>
179
180
  ))}
180
181
  </ul>
@@ -188,9 +189,37 @@ describe('Unsupported Sort Comparator (BF021)', () => {
188
189
  expect(bf021).toHaveLength(0)
189
190
  })
190
191
 
191
- test('emits BF021 error for block body sort comparator', () => {
192
- // Block-body comparators are deferred to a Tier B follow-up
193
- // (the extractor only handles expression-body shapes for now).
192
+ test('emits BF021 for localeCompare with a locale/options argument', () => {
193
+ // The zero-arg `a.f.localeCompare(b.f)` form lowers, but the
194
+ // locale/options form needs per-adapter collation plumbing and
195
+ // stays refused (deferred #1448 Tier B follow-up).
196
+ const source = `
197
+ 'use client'
198
+ import { createSignal } from '@barefootjs/client'
199
+
200
+ export function TodoList() {
201
+ const [items, setItems] = createSignal<any[]>([])
202
+ return (
203
+ <ul>
204
+ {items().sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true })).map(t => (
205
+ <li>{t.name}</li>
206
+ ))}
207
+ </ul>
208
+ )
209
+ }
210
+ `
211
+
212
+ const { errors } = compileToIR(source)
213
+ const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
214
+
215
+ expect(bf021).toHaveLength(1)
216
+ expect(bf021[0].message).toContain('not a supported shape')
217
+ })
218
+
219
+ test('emits BF021 error for multi-statement block-body sort comparator', () => {
220
+ // Single-`return` block bodies now lower (#1448 Tier B follow-up),
221
+ // but multi-statement / local-var bodies stay refused — generalising
222
+ // over arbitrary statement sequences isn't tractable in a template.
194
223
  const source = `
195
224
  'use client'
196
225
  import { createSignal } from '@barefootjs/client'
@@ -199,7 +228,7 @@ describe('Unsupported Sort Comparator (BF021)', () => {
199
228
  const [items, setItems] = createSignal<any[]>([])
200
229
  return (
201
230
  <ul>
202
- {items().sort((a, b) => { return a.price - b.price }).map(t => (
231
+ {items().sort((a, b) => { const x = a.price; return x - b.price }).map(t => (
203
232
  <li>{t.name}</li>
204
233
  ))}
205
234
  </ul>
@@ -123,6 +123,26 @@ export interface TemplateAdapter {
123
123
  * Required for adapters that look up templates by filename (e.g. Mojolicious).
124
124
  */
125
125
  templatesPerComponent?: boolean
126
+ /**
127
+ * How the application author injects the externals importmap (and any
128
+ * `<link rel="modulepreload">` hints) into the page `<head>` when
129
+ * `externals` / `bundleEntries` are configured.
130
+ *
131
+ * - `'component'` — the adapter ships a render-time component (e.g. Hono's
132
+ * `BfImportMap`) that reads `barefoot-externals.json`; `bf build` emits no
133
+ * static snippet.
134
+ * - `'html-snippet'` — the adapter targets a template-string language (Go
135
+ * html/template, Mojolicious EP) with no component layer, so `bf build`
136
+ * writes a ready-to-include `barefoot-importmap.html` alongside
137
+ * `barefoot-externals.json` (via `renderImportMapHtml`).
138
+ *
139
+ * Optional only for backward compatibility (and internal-only adapters like
140
+ * the CSR test adapter). Every *shipping* adapter must set it — the
141
+ * adapter-tests importmap-injection contract enforces this so a new adapter
142
+ * cannot silently leave configured `externals` with no injection point.
143
+ * See issue #1644.
144
+ */
145
+ importMapInjection?: 'component' | 'html-snippet'
126
146
  /**
127
147
  * Module specifier of the SSR shim for `@barefootjs/client` (and
128
148
  * `/runtime`). When set, the compiler rewrites client-package imports in
@@ -138,11 +138,26 @@ export class TestAdapter extends JsxAdapter {
138
138
  }
139
139
  const fullPropsDestructure = `{ ${parts.join(', ')} }`
140
140
 
141
+ // Default the props param to `{}` when the component has no required
142
+ // props, so a bare no-arg call (`Foo()`) doesn't crash on destructuring
143
+ // `undefined`. This is what makes a JSX-returning arrow hoisted from an
144
+ // object-literal value (e.g. `THEME_LOGOS[id]()`) renderable at SSR
145
+ // (#1663). `hasRequiredProps` ignores props that carry a destructuring
146
+ // default, but the declared props type may still mark that field
147
+ // required — so a bare `= {}` would fail `tsc` ("Property 'x' is missing
148
+ // in type '{}'..."). Assert the default to the param's own annotated type
149
+ // (`{} as T`); the destructuring defaults supply the values at runtime.
150
+ const hasRequiredProps = ir.metadata.propsParams.some(
151
+ (p: ParamInfo) => !p.optional && p.defaultValue === undefined && !p.isRest,
152
+ )
153
+ const propsTypeExpr = typeAnnotation.replace(/^:\s*/, '')
154
+ const noArgDefault = hasRequiredProps ? '' : ` = {} as ${propsTypeExpr}`
155
+
141
156
  const lines: string[] = []
142
157
  // Module-export keyword belongs to the adapter: it knows the target language
143
158
  // and whether the source declared the component as exported.
144
159
  const exportPrefix = ir.metadata.isExported === false ? '' : 'export '
145
- lines.push(`${exportPrefix}function ${name}(${fullPropsDestructure}${typeAnnotation}) {`)
160
+ lines.push(`${exportPrefix}function ${name}(${fullPropsDestructure}${typeAnnotation}${noArgDefault}) {`)
146
161
 
147
162
  // Generate scope ID
148
163
  if (hasClientInteractivity) {