@barefootjs/jsx 0.31.8 → 0.31.10

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 (43) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/expression-parser.d.ts +20 -0
  3. package/dist/expression-parser.d.ts.map +1 -1
  4. package/dist/index.js +226 -66
  5. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +66 -38
  9. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/html-template.d.ts +15 -1
  12. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/imports.d.ts +2 -2
  14. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  15. package/dist/ssr-defaults.d.ts +10 -0
  16. package/dist/ssr-defaults.d.ts.map +1 -1
  17. package/dist/ssr-seed-plan.d.ts.map +1 -1
  18. package/package.json +2 -2
  19. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +210 -210
  20. package/src/__tests__/aliased-destructured-prop-csr.test.ts +8 -2
  21. package/src/__tests__/csr-template-loop-shadowing.test.ts +5 -2
  22. package/src/__tests__/date-lowering.test.ts +1 -1
  23. package/src/__tests__/env-signal-template-prelude.test.ts +4 -2
  24. package/src/__tests__/inner-loop-mathml-namespace.test.ts +135 -0
  25. package/src/__tests__/markup-prop-brand.test.ts +161 -0
  26. package/src/__tests__/mathml-mapArray-namespace.test.ts +230 -0
  27. package/src/__tests__/props-destructuring.test.ts +40 -3
  28. package/src/__tests__/ssr-defaults.test.ts +230 -0
  29. package/src/__tests__/ssr-seed-plan.test.ts +72 -0
  30. package/src/__tests__/text-slot-escaping.test.ts +21 -12
  31. package/src/analyzer.ts +83 -26
  32. package/src/expression-parser.ts +10 -1
  33. package/src/ir-to-client-js/collect-elements.ts +38 -5
  34. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +10 -10
  35. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +3 -4
  36. package/src/ir-to-client-js/control-flow/stringify/loop.ts +18 -14
  37. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +142 -78
  38. package/src/ir-to-client-js/emit-registration.ts +5 -1
  39. package/src/ir-to-client-js/html-template.ts +103 -12
  40. package/src/ir-to-client-js/imports.ts +4 -0
  41. package/src/ir-to-client-js/index.ts +6 -1
  42. package/src/ssr-defaults.ts +171 -6
  43. package/src/ssr-seed-plan.ts +82 -2
@@ -103,6 +103,211 @@ describe('extractSsrDefaults', () => {
103
103
  expect(defaults?.n).toEqual({ value: 0 })
104
104
  })
105
105
 
106
+ test('self-derived signal collision (#2669, shape A): entry stays a PROP entry, not the evaluated signal value', () => {
107
+ // `props.label` and the signal getter `label` share a name — the
108
+ // bare-props-arg form is the only shape where this collides (a
109
+ // destructured-arg `function C({ label })` + `const [label] = ...`
110
+ // is a JS redeclaration error, so it can't happen there). The
111
+ // emitted template reads the stash var `label` as the RAW caller
112
+ // prop and OVERWRITES it with the derived signal value under that
113
+ // SAME name (`{% set label = (label if label is defined else
114
+ // 'Default') %}`). Seeding the stash with the pre-fix `{ value:
115
+ // 'Default' }` (the EVALUATED signal value, discarding `propName`)
116
+ // means a caller-passed `label` can never win — production seeds
117
+ // 'Default', the template sees a non-none value, and keeps it. The
118
+ // fix: this entry must stay a PROP entry (`propName` set, `value:
119
+ // null`) so the template's own `?? 'Default'` guard supplies the
120
+ // real fallback, and a caller-supplied prop still wins via
121
+ // `propName`.
122
+ const metadata = metadataFor(`
123
+ 'use client'
124
+ import { createSignal } from '@barefootjs/client'
125
+ function C(props: { label?: string }) {
126
+ const [label, setLabel] = createSignal(props.label ?? 'Default')
127
+ return <span>{label()}</span>
128
+ }
129
+ `)
130
+
131
+ const defaults = extractSsrDefaults(metadata)
132
+ expect(defaults?.label).toEqual({ propName: 'label', value: null })
133
+ })
134
+
135
+ test('self-derived signal collision (#2669, shape B): non-idempotent derivation must not seed the pre-fix double-applied value', () => {
136
+ // `(props.count ?? 1) * 2` is NOT idempotent: seeding the stash with
137
+ // the pre-fix EVALUATED value (2, for no caller prop) makes the
138
+ // template's own recompute apply the `* 2` a second time (`2 * 2 =
139
+ // 4`) — wrong even with no caller props at all. Pin that the entry
140
+ // is the RAW-prop shape (`value: null`), not `{ value: 2 }`.
141
+ const metadata = metadataFor(`
142
+ 'use client'
143
+ import { createSignal } from '@barefootjs/client'
144
+ function C(props: { count?: number }) {
145
+ const [count, setCount] = createSignal((props.count ?? 1) * 2)
146
+ return <span>{count()}</span>
147
+ }
148
+ `)
149
+
150
+ const defaults = extractSsrDefaults(metadata)
151
+ expect(defaults?.count).toEqual({ propName: 'count', value: null })
152
+ })
153
+
154
+ test('self-derived memo collision (#2669): the same rule applies to a memo whose computation derives from a same-named prop', () => {
155
+ // Same collision, memo flavor: `createMemo(() => (props.label ??
156
+ // 'Default') + n())` — the memo's OWN name (`label`) is the
157
+ // template variable the emitted template both reads (raw prop) and
158
+ // overwrites (derived memo value). `n` is an unrelated ordinary
159
+ // signal and must be unaffected (plain `{ value }` entry, no
160
+ // `propName`).
161
+ const metadata = metadataFor(`
162
+ 'use client'
163
+ import { createSignal, createMemo } from '@barefootjs/client'
164
+ function C(props: { label?: string }) {
165
+ const [n, setN] = createSignal(0)
166
+ const label = createMemo(() => (props.label ?? 'Default') + n())
167
+ return <span>{label()}</span>
168
+ }
169
+ `)
170
+
171
+ const defaults = extractSsrDefaults(metadata)
172
+ expect(defaults?.label).toEqual({ propName: 'label', value: null })
173
+ expect(defaults?.n).toEqual({ value: 0 })
174
+ })
175
+
176
+ test('NON-self-derived collision (#2669, shape C — out of scope, must be unchanged): signal value still wins, no `propName`', () => {
177
+ // Same-named `label` getter and `label` prop, but the signal's OWN
178
+ // initializer does NOT derive from `props.label` (it's a plain
179
+ // string literal) — the JSX body separately reads `label()` AND
180
+ // `props.label`. That's a template-variable-ALIASING defect (both
181
+ // expressions lower to the same template variable, so no seeding
182
+ // choice can be right for both readers) — a different bug, tracked
183
+ // separately, and this fix must leave it byte-identical: the entry
184
+ // stays the plain evaluated-signal-value shape with no `propName`.
185
+ const metadata = metadataFor(`
186
+ 'use client'
187
+ import { createSignal } from '@barefootjs/client'
188
+ function C(props: { label?: string }) {
189
+ const [label, setLabel] = createSignal('sig')
190
+ return <span>{label()}{props.label}</span>
191
+ }
192
+ `)
193
+
194
+ const defaults = extractSsrDefaults(metadata)
195
+ expect(defaults?.label).toEqual({ value: 'sig' })
196
+ })
197
+
198
+ test('self-derived signal collision THROUGH a component-scope const (#2685 review): entry stays a PROP entry', () => {
199
+ // One hop of pure indirection past #2669's shape A: `referencesOwnProp`
200
+ // (built on `collectPropRefs`) only saw a DIRECT `props.<name>` access
201
+ // in the initializer — a `const mid = props.label` sitting between the
202
+ // prop read and `createSignal(mid ?? 'Default')` defeated the
203
+ // detection, so this entry regressed to the pre-#2669 `{ value:
204
+ // 'Default' }` shape (no `propName`) even after the #2669 fix landed.
205
+ // Same fix, widened: `collectPropRefsTransitive` now looks through
206
+ // component-scope const locals.
207
+ const metadata = metadataFor(`
208
+ 'use client'
209
+ import { createSignal } from '@barefootjs/client'
210
+ function C(props: { label?: string }) {
211
+ const mid = props.label
212
+ const [label, setLabel] = createSignal(mid ?? 'Default')
213
+ return <span>{label()}</span>
214
+ }
215
+ `)
216
+
217
+ const defaults = extractSsrDefaults(metadata)
218
+ expect(defaults?.label).toEqual({ propName: 'label', value: null })
219
+ })
220
+
221
+ test('self-derived memo collision THROUGH a component-scope const (#2685 review)', () => {
222
+ const metadata = metadataFor(`
223
+ 'use client'
224
+ import { createMemo } from '@barefootjs/client'
225
+ function C(props: { label?: string }) {
226
+ const mid = props.label
227
+ const label = createMemo(() => mid ?? 'Default')
228
+ return <span>{label()}</span>
229
+ }
230
+ `)
231
+
232
+ const defaults = extractSsrDefaults(metadata)
233
+ expect(defaults?.label).toEqual({ propName: 'label', value: null })
234
+ })
235
+
236
+ test('multi-hop const chain resolves transitively (#2685 review): `const a = props.x; const b = a`', () => {
237
+ // Two hops of indirection (`b` reads `a`, `a` reads `props.x`) — the
238
+ // signal here is named `count`, a DIFFERENT name from the prop `x`, so
239
+ // this exercises the bare-props safety net's transitive resolution
240
+ // (not the self-derivation `propName` collision path above): `x` must
241
+ // still be seeded, or the template-stash adapters' bare-scalar
242
+ // recompute for `x` aborts at render time (#1297/#2126).
243
+ const metadata = metadataFor(`
244
+ 'use client'
245
+ import { createSignal } from '@barefootjs/client'
246
+ function C(props: { x?: number }) {
247
+ const a = props.x
248
+ const b = a
249
+ const [count, setCount] = createSignal(b ?? 1)
250
+ return <span>{count()}</span>
251
+ }
252
+ `)
253
+
254
+ const defaults = extractSsrDefaults(metadata)
255
+ expect(defaults?.count).toEqual({ value: 1 })
256
+ expect(defaults?.x).toEqual({ propName: 'x', value: null })
257
+ })
258
+
259
+ test('safety-net seeding sees a prop through a component-scope const (#2685 review)', () => {
260
+ // The #1297/#2126 bare-props safety net (`collectPropRefs` at the
261
+ // bottom of `extractSsrDefaults`) only saw a DIRECT `props.X` access
262
+ // too — this is that same gap, single hop: `const mid = props.initial;
263
+ // createSignal(mid ?? 0)` misses seeding `initial`, which is a
264
+ // `Global symbol "$initial" requires explicit package name` render
265
+ // abort on Perl-strict backends. `count` (the signal's own name) is
266
+ // unrelated to `initial`, so this is NOT the self-collision path —
267
+ // purely the safety net.
268
+ const metadata = metadataFor(`
269
+ 'use client'
270
+ import { createSignal } from '@barefootjs/client'
271
+ function Counter(props: { initial?: number }) {
272
+ const mid = props.initial
273
+ const [count, setCount] = createSignal(mid ?? 0)
274
+ return <p>{count()}</p>
275
+ }
276
+ `)
277
+
278
+ const defaults = extractSsrDefaults(metadata)
279
+ expect(defaults?.count).toEqual({ value: 0 })
280
+ expect(defaults?.initial).toEqual({ propName: 'initial', value: null })
281
+ })
282
+
283
+ test('NEGATIVE (#2685 review): a const NOT derived from props keeps the signal-wins behavior', () => {
284
+ // Same-named `label` getter and `label` prop, wrapped in a const — but
285
+ // the const's OWN value doesn't read `props` at all, so
286
+ // `collectPropRefsTransitive` must NOT report a collision here. Must
287
+ // stay the plain evaluated-signal-value shape with no `propName` — the
288
+ // const-chain widening only ever ADDS prop references it finds by
289
+ // actually walking through `props.X`, never invents one.
290
+ //
291
+ // (The value resolves to `null`, not `'sig'`: `tryStaticEval` doesn't
292
+ // bind component-scope const locals into its evaluation `bindings` —
293
+ // see the signal loop's `bindings` comment above — so `mid` itself is
294
+ // unresolved for VALUE purposes independent of this fix; only the
295
+ // separate `propName`-detection walk this test is pinning follows the
296
+ // const chain.)
297
+ const metadata = metadataFor(`
298
+ 'use client'
299
+ import { createSignal } from '@barefootjs/client'
300
+ function C(props: { label?: string }) {
301
+ const mid = 'sig'
302
+ const [label, setLabel] = createSignal(mid)
303
+ return <span>{label()}{props.label}</span>
304
+ }
305
+ `)
306
+
307
+ const defaults = extractSsrDefaults(metadata)
308
+ expect(defaults?.label).toEqual({ value: null })
309
+ })
310
+
106
311
  test('aliased destructured prop (#2460): `propName` is the CALLER-facing key, not the local binding', () => {
107
312
  // `{ n: count }` — the caller passes `n`, the template variable (and
108
313
  // stash entry key) is the local binding `count`. The manifest
@@ -302,6 +507,31 @@ describe('deriveStashFromDefaults', () => {
302
507
  expect(deriveStashFromDefaults(defaults, { n: 0 })).toEqual({ count: 0 })
303
508
  })
304
509
 
510
+ test('self-derived signal collision (#2669): the caller-supplied prop now wins, and an absent prop falls back to null', () => {
511
+ // End-to-end proof of the fix's effect at the `deriveStashFromDefaults`
512
+ // layer: run the real `extractSsrDefaults` output for shape A through
513
+ // the seeding function. Pre-fix, this entry was `{ value: 'Default' }`
514
+ // (propName-less) so a caller's `label: 'Hello'` was IGNORED —
515
+ // `deriveStashFromDefaults` had no `propName` to resolve against and
516
+ // always used the static value. Post-fix the entry is a PROP entry, so
517
+ // the caller's value wins, and an absent prop resolves to the RAW
518
+ // fallback `null` (not the old evaluated `'Default'`) — the emitted
519
+ // template's own `?? 'Default'` guard supplies the real default from
520
+ // there.
521
+ const metadata = metadataFor(`
522
+ 'use client'
523
+ import { createSignal } from '@barefootjs/client'
524
+ function C(props: { label?: string }) {
525
+ const [label, setLabel] = createSignal(props.label ?? 'Default')
526
+ return <span>{label()}</span>
527
+ }
528
+ `)
529
+ const defaults = extractSsrDefaults(metadata)!
530
+
531
+ expect(deriveStashFromDefaults(defaults, { label: 'Hello' })).toEqual({ label: 'Hello' })
532
+ expect(deriveStashFromDefaults(defaults, {})).toEqual({ label: null })
533
+ })
534
+
305
535
  test('propName-less entry (signal / memo local): always uses the static value', () => {
306
536
  // A caller cannot override an internal signal/memo by construction —
307
537
  // even a same-named caller prop must not leak in.
@@ -209,4 +209,76 @@ describe('computeSsrSeedPlan', () => {
209
209
  expect(on.frees).toEqual(['props'])
210
210
  }
211
211
  })
212
+
213
+ test('prop-derived signal init THROUGH a component-scope const (#2685 review): still derived, resolves to the prop free', () => {
214
+ // Pre-fix, `mid` (a component-scope const, not part of `baseScope`)
215
+ // was a free identifier `classify` couldn't find in `available`, so
216
+ // this step fell to `opaque` — no adapter emits an in-template
217
+ // recompute for it at all (a DIFFERENT, worse-than-#2669 defect: even
218
+ // with `propName` restored on the manifest side, an absent caller prop
219
+ // would render permanently empty instead of the signal's own
220
+ // `'Default'`). `resolveThroughLocalConsts` inlines `mid` to
221
+ // `props.defaultOn` before classification, so this now derives exactly
222
+ // like the direct-access form above.
223
+ const plan = planFor(`
224
+ 'use client'
225
+ import { createSignal } from '@barefootjs/client'
226
+ function Toggle(props: { defaultOn?: boolean }) {
227
+ const mid = props.defaultOn
228
+ const [on, setOn] = createSignal(mid ?? false)
229
+ return <button aria-pressed={on()}>t</button>
230
+ }
231
+ `)
232
+
233
+ const on = step(plan, 'on')
234
+ expect(on.kind).toBe('derived')
235
+ if (on.kind === 'derived') {
236
+ expect(on.origin).toBe('signal')
237
+ // The RESOLVED free set names `props` (what the inlined form reads),
238
+ // never the intermediate const `mid` — a consumer emitting `on`'s
239
+ // seed line never needs to know `mid` existed.
240
+ expect(on.frees).toEqual(['props'])
241
+ }
242
+ })
243
+
244
+ test('multi-hop const chain THROUGH TWO component-scope consts (#2685 review): still derived', () => {
245
+ const plan = planFor(`
246
+ 'use client'
247
+ import { createSignal } from '@barefootjs/client'
248
+ function C(props: { x?: number }) {
249
+ const a = props.x
250
+ const b = a
251
+ const [count, setCount] = createSignal(b ?? 1)
252
+ return <p>{count()}</p>
253
+ }
254
+ `)
255
+
256
+ const count = step(plan, 'count')
257
+ expect(count.kind).toBe('derived')
258
+ if (count.kind === 'derived') {
259
+ expect(count.frees).toEqual(['props'])
260
+ }
261
+ })
262
+
263
+ test('const chain that bottoms out on an UNAVAILABLE name → opaque (fails safe, not a wrong substitution)', () => {
264
+ // `mid` inlines to `laterMemo`, a memo declared AFTER `on` in source —
265
+ // per the plan's ordering guarantee, `laterMemo` is not yet in
266
+ // `available` when `on` is classified, so this must stay `opaque`
267
+ // exactly like a direct forward-reference would (mirrors the existing
268
+ // "forward reference … → opaque" case above, one hop of const
269
+ // indirection removed).
270
+ const plan = planFor(`
271
+ 'use client'
272
+ import { createSignal, createMemo } from '@barefootjs/client'
273
+ function C() {
274
+ const mid = laterMemo()
275
+ const [on, setOn] = createSignal(mid ?? false)
276
+ const laterMemo = createMemo(() => true)
277
+ return <button aria-pressed={on()}>{laterMemo()}</button>
278
+ }
279
+ `)
280
+
281
+ const on = step(plan, 'on')
282
+ expect(on.kind).toBe('opaque')
283
+ })
212
284
  })
@@ -1,15 +1,23 @@
1
1
  /**
2
- * Text-slot HTML-escaping emit shape (#1694 + follow-up).
2
+ * Text-slot HTML-escaping emit shape (#1694 + follow-up, #2651).
3
3
  *
4
- * Pins which interpolations the client template wraps in `escapeText`:
5
- * - a plain text slot (`{stringValue}`) IS escaped — it becomes the
6
- * slot's text content under `innerHTML`;
4
+ * Pins which interpolations the client template wraps in an escape call:
5
+ * - a plain, non-conditional dynamic text slot (`{stringValue}`) IS
6
+ * escaped — it becomes the slot's text content under `innerHTML`. Since
7
+ * #2651 this goes through `escapeTextOrMarkup`, not bare `escapeText`:
8
+ * the slot is claim-plan `kind: 'markup'` (the value may be a live
9
+ * `Node`, or a `bfMarkup()`-branded JSX-element-prop value), and
10
+ * `escapeTextOrMarkup` is a strict superset of `escapeText` for every
11
+ * non-branded value — this pin's actual escaping behaviour for a plain
12
+ * string is unchanged, only the call name changed to match the
13
+ * REACTIVE side's existing `escapeTextOrNode` classification;
7
14
  * - a branch-slot expression (Child-position value inside a conditional
8
15
  * `template()` arrow) is routed through `__bfSlot` and must NOT be
9
- * wrapped in `escapeText`. `__bfSlot` returns raw `<!--bf-slot:N-->`
10
- * markers for live nodes; escaping the whole call corrupts them and
11
- * drops slotted content (the regression that broke `e2e-site-ui`).
12
- * `__bfSlot` escapes its own plain-string path internally instead.
16
+ * wrapped in either escape call. `__bfSlot` returns raw
17
+ * `<!--bf-slot:N-->` markers for live nodes; escaping the whole call
18
+ * corrupts them and drops slotted content (the regression that broke
19
+ * `e2e-site-ui`). `__bfSlot` escapes its own plain-string path
20
+ * internally instead.
13
21
  */
14
22
 
15
23
  import { describe, test, expect } from 'bun:test'
@@ -27,7 +35,7 @@ function getClientJs(source: string, filename: string): string {
27
35
  }
28
36
 
29
37
  describe('text-slot escaping', () => {
30
- test('a plain text slot is wrapped in escapeText', () => {
38
+ test('a plain text slot is wrapped in escapeTextOrMarkup (#2651)', () => {
31
39
  const clientJs = getClientJs(
32
40
  `'use client'
33
41
  export function Label({ text }: { text: string }) {
@@ -35,10 +43,10 @@ describe('text-slot escaping', () => {
35
43
  }`,
36
44
  'Label.tsx',
37
45
  )
38
- expect(clientJs).toMatch(/<!--bf:\w+-->\$\{escapeText\(_p\.text\)\}<!--\/-->/)
46
+ expect(clientJs).toMatch(/<!--bf:\w+-->\$\{escapeTextOrMarkup\(_p\.text\)\}<!--\/-->/)
39
47
  })
40
48
 
41
- test('a branch-slot expression is NOT wrapped in escapeText', () => {
49
+ test('a branch-slot expression is NOT wrapped in escapeTextOrMarkup or escapeText', () => {
42
50
  const clientJs = getClientJs(
43
51
  `'use client'
44
52
  import { createSignal } from '@barefootjs/client'
@@ -50,7 +58,8 @@ describe('text-slot escaping', () => {
50
58
  )
51
59
  // The branch value goes through __bfSlot (raw markers preserved)…
52
60
  expect(clientJs).toMatch(/\$\{__bfSlot\(/)
53
- // …and must never be double-wrapped by the text escape.
61
+ // …and must never be double-wrapped by either text-escape call.
62
+ expect(clientJs).not.toMatch(/escapeTextOrMarkup\(\s*__bfSlot/)
54
63
  expect(clientJs).not.toMatch(/escapeText\(\s*__bfSlot/)
55
64
  })
56
65
  })
package/src/analyzer.ts CHANGED
@@ -3429,6 +3429,82 @@ function collectKeysFromMembers(
3429
3429
  return keys
3430
3430
  }
3431
3431
 
3432
+ /**
3433
+ * Decide whether a member's `TypeInfo` is admissible for
3434
+ * `collectMemberTypes`'s destructured-parameter resolution (#2677 —
3435
+ * widened from the original string/number/boolean-only gate).
3436
+ *
3437
+ * #2150 originally restricted the gate to string/number/boolean, because a
3438
+ * non-primitive `TypeInfo` there used to mean "the typed adapters will emit
3439
+ * an unchecked scalar assertion (`in.X.(int)`) that panics" for a shape the
3440
+ * template layer had no representation for. That reasoning does NOT extend
3441
+ * to a rich type with a CATALOGUED lowering (#2274: `Date` → the `date`
3442
+ * helper): its propsParams `TypeInfo` is consumed only as call-site
3443
+ * evidence for `resolveReceiverType` (rich-type-evidence.ts) — never
3444
+ * emitted as a concrete field type (`typeInfoToGo`'s `interface` case falls
3445
+ * through to `interface{}` for an unbacked host name exactly as `unknown`
3446
+ * already did) — so there is no assertion to panic. Gating on
3447
+ * `CATALOGUED_RICH_TYPE_NAMES` specifically (not `HOST_RICH_TYPE_NAMES`
3448
+ * wholesale) keeps an un-catalogued rich type (`Map`, `Set`, …) at
3449
+ * `unknown`, i.e. avoids resurrecting the #2150 mistake for a shape no
3450
+ * lowering plugin exists for yet.
3451
+ *
3452
+ * Nor does it extend to a STRUCTURAL shape (`kind: 'array'` /
3453
+ * `kind: 'object'`) any more. `typeNodeToTypeInfo` already builds these
3454
+ * fully recursively (`elementType`, `properties`) — the same walk the
3455
+ * `props`-object form (`extractPropsFromTypeMembers`) always got — and
3456
+ * go-template's `emitSynthPropStructs` (#2674/#2676) synthesizes a real,
3457
+ * json-tagged Go struct for any anonymous object type it reaches through
3458
+ * `ir.metadata.propsParams[].type`, array-element positions included. A
3459
+ * structural `TypeInfo` is representable today, so admitting it here no
3460
+ * longer resurrects #2150 either — it lets `propsParams[].type` carry the
3461
+ * SAME shape the props-object form already resolved, closing the
3462
+ * asymmetry #2677 reports (`{ items }: { items: {...}[] }` degrading to
3463
+ * `unknown` while `(props: { items: {...}[] })` resolved fully).
3464
+ *
3465
+ * `array`/`object` admit only when EVERY reachable leaf resolves — an
3466
+ * array's element type and an object's every property type must pass this
3467
+ * same check, recursively. A union, a function, or an un-catalogued named
3468
+ * type reachable ANYWHERE inside the shape declines the WHOLE member (not
3469
+ * just that one leaf): `collectMemberTypes` is an all-or-nothing gate per
3470
+ * member, so a partially-resolvable structural type must still decline —
3471
+ * per-field graceful degradation is `typeInfoToGo`'s job downstream, not
3472
+ * this gate's.
3473
+ *
3474
+ * The `object` arm requires `properties` to be PRESENT, not merely falsy-
3475
+ * coalesced to `[]` — an absent-members claim and a zero-members claim are
3476
+ * different things, and `.every()` on a defaulted-to-`[]` array would admit
3477
+ * both identically (vacuously `true`), which is the #2150 failure mode
3478
+ * wearing a different hat: a concrete representation with no evidence
3479
+ * behind it. `typeNodeToTypeInfo`'s `object` arm DOES omit `properties`
3480
+ * entirely in its `synthetic` mode (the checker-driven `tsTypeToTypeInfo`
3481
+ * path, used for e.g. `createMemo` inference off a resolved `ts.Type`,
3482
+ * which has no source positions for `membersToProperties`' `getText()`).
3483
+ * This gate's only caller (`collectMemberTypes`'s `fromMembers`) always
3484
+ * calls the NON-synthetic 2-arg form, so `properties` is in practice always
3485
+ * at least `[]` here today — but `isResolvableMemberType` is a module-level
3486
+ * function, not a closure scoped to that one caller, so it guards the
3487
+ * general case rather than trust a caller-specific invariant. A genuinely
3488
+ * EMPTY object literal (`{}`, real zero members) DOES resolve — an empty
3489
+ * synthesized struct is exactly as faithful a representation as the type
3490
+ * itself claims, nothing is being guessed.
3491
+ */
3492
+ function isResolvableMemberType(info: TypeInfo): boolean {
3493
+ switch (info.kind) {
3494
+ case 'primitive':
3495
+ return info.primitive === 'string' || info.primitive === 'number' || info.primitive === 'boolean'
3496
+ case 'interface':
3497
+ return CATALOGUED_RICH_TYPE_NAMES.has(baseTypeName(info.raw))
3498
+ case 'array':
3499
+ return !!info.elementType && isResolvableMemberType(info.elementType)
3500
+ case 'object':
3501
+ return info.properties !== undefined && info.properties.every(prop => isResolvableMemberType(prop.type))
3502
+ // Unions, functions, and `unknown` all decline — see docstring.
3503
+ default:
3504
+ return false
3505
+ }
3506
+ }
3507
+
3432
3508
  /**
3433
3509
  * Build a property-name -> { type, optional } map from a props type
3434
3510
  * annotation, for destructured params (`{ value }: Props`) so they carry the
@@ -3443,36 +3519,17 @@ function collectKeysFromMembers(
3443
3519
  * degraded to `unknown` -> `interface{}` for attribute omission, because
3444
3520
  * #2252's nullish-flip machinery supplies the nillable representation
3445
3521
  * exactly where absence is semantically observable (#2259).
3446
- * - Only PRIMITIVE members (string/number/boolean) resolve a type. Those are
3447
- * the types that otherwise produce an unchecked scalar assertion (`in.X.(int)`)
3448
- * that panics. Arrays/objects/functions are left as `unknown` because typed
3449
- * adapters lower them through interface{}-based helpers (`bf_flat`, spread,
3450
- * `bf_json`); giving them concrete types would break that lowering and is a
3451
- * larger, separate change.
3522
+ * - A member resolves a type when it is a PRIMITIVE (string/number/boolean),
3523
+ * a CATALOGUED rich type (`Date`), or a STRUCTURAL shape (array/object)
3524
+ * built entirely out of those, recursively (#2677). Unions, functions, and
3525
+ * un-catalogued named types (`Map`, `Set`, …) are left as `unknown` — see
3526
+ * `isResolvableMemberType`'s own docstring for why the structural cases
3527
+ * are safe to admit and why those three still are not.
3452
3528
  */
3453
3529
  function collectMemberTypes(
3454
3530
  typeNode: ts.TypeNode,
3455
3531
  ctx: AnalyzerContext
3456
3532
  ): Map<string, { type: TypeInfo | null; optional: boolean }> | null {
3457
- // #2150 originally restricted this gate to string/number/boolean only,
3458
- // because a non-primitive TypeInfo here used to mean "the typed adapters
3459
- // will emit an unchecked scalar assertion (`in.X.(int)`) that panics" for
3460
- // a shape the template layer has no representation for. That reasoning
3461
- // does NOT extend to a rich type with a CATALOGUED lowering (#2274: `Date`
3462
- // → the `date` helper): its propsParams TypeInfo is consumed only as
3463
- // call-site evidence for `resolveReceiverType` (rich-type-evidence.ts) —
3464
- // never emitted as a concrete field type (`typeInfoToGo`'s `interface`
3465
- // case falls through to `interface{}` for an unbacked host name exactly
3466
- // as `unknown` already did) — so there is no assertion to panic. Gating on
3467
- // `CATALOGUED_RICH_TYPE_NAMES` specifically (not `HOST_RICH_TYPE_NAMES`
3468
- // wholesale) keeps an un-catalogued rich type (`Map`, `Set`, …) at
3469
- // `unknown`, i.e. avoids resurrecting the #2150 mistake for a shape no
3470
- // lowering plugin exists for yet.
3471
- const isResolvablePrimitive = (info: TypeInfo): boolean =>
3472
- (info.kind === 'primitive' &&
3473
- (info.primitive === 'string' || info.primitive === 'number' || info.primitive === 'boolean')) ||
3474
- (info.kind === 'interface' && CATALOGUED_RICH_TYPE_NAMES.has(baseTypeName(info.raw)))
3475
-
3476
3533
  const fromMembers = (
3477
3534
  members: ts.NodeArray<ts.TypeElement>
3478
3535
  ): Map<string, { type: TypeInfo | null; optional: boolean }> => {
@@ -3481,7 +3538,7 @@ function collectMemberTypes(
3481
3538
  if (ts.isPropertySignature(member) && member.name) {
3482
3539
  const info = member.type ? typeNodeToTypeInfo(member.type, ctx.sourceFile) : null
3483
3540
  map.set(member.name.getText(ctx.sourceFile), {
3484
- type: info && isResolvablePrimitive(info) ? info : null,
3541
+ type: info && isResolvableMemberType(info) ? info : null,
3485
3542
  optional: !!member.questionToken,
3486
3543
  })
3487
3544
  }
@@ -3037,8 +3037,17 @@ function usesPerPath(name: string, expr: ParsedExpr): { min: number; max: number
3037
3037
  * leaves that inner reference untouched (it is the parameter, not the binding).
3038
3038
  * Mirrors the structural walk of `substituteDestructuredFields`; every
3039
3039
  * `ParsedExpr` kind is handled so a new kind surfaces as a compile error here.
3040
+ *
3041
+ * Exported for `computeSsrSeedPlan` (`ssr-seed-plan.ts`), which reuses this
3042
+ * SAME let-inline step to resolve a signal/memo initializer through
3043
+ * component-scope `const` locals sitting between it and the prop it reads
3044
+ * (`const mid = props.label; createSignal(mid ?? 'Default')`) — the
3045
+ * structural (never string-splicing) analogue of what `foldBlockToExpr`
3046
+ * already does for a block-bodied memo's own internal `const`s, and of what
3047
+ * the CSR client-JS emitter's `csrSubstitute` does over TS source text for
3048
+ * the compiled JS domain (#2685 review).
3040
3049
  */
3041
- function inlineBinding(
3050
+ export function inlineBinding(
3042
3051
  expr: ParsedExpr,
3043
3052
  name: string,
3044
3053
  value: ParsedExpr,
@@ -7,7 +7,7 @@ import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranc
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings, buildLoopRowScope, anyNameIn } from './reactivity.ts'
9
9
  import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
10
- import { templateRootIsSvg } from './control-flow/stringify/template-parse.ts'
10
+ import { detectRootNamespaceWrapTag } from './control-flow/stringify/template-parse.ts'
11
11
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
12
12
  import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
13
13
  import { walkIR, stopAt } from './walker.ts'
@@ -463,6 +463,29 @@ function jsxChildrenContainComponent(nodes: IRNode[]): boolean {
463
463
  return false
464
464
  }
465
465
 
466
+ /**
467
+ * Narrow gate for branding a `jsx-children` getter's value with `bfMarkup()`
468
+ * (#2651). The constructor (`jsx-to-ir.ts`'s `processComponentProps`) always
469
+ * stores exactly one node here (`AttrValueOf.jsxChildren([...])`), so
470
+ * `nodes[0]` is the whole payload; `irChildrenToJsExpr` (`html-template.ts`)
471
+ * turns a single `'element'` node into ONE HTML-string template literal —
472
+ * the same shape the `renderChild` / `irToComponentTemplateWithOpts` /
473
+ * `generateCsrTemplateWithOpts` "jsx-children" doors always produce (they
474
+ * join every child into one string regardless of shape), and the shape
475
+ * this fixture's `header={<strong>Title</strong>}` exercises.
476
+ *
477
+ * Deliberately narrow — NOT a general "does this reduce to one string"
478
+ * check: a `'fragment'` node (`header={<>text<strong/></>}`, multiple
479
+ * children) or a `'conditional'` node reduces through `irChildrenToJsExpr`
480
+ * to an array literal or a nested ternary of un-escaped-vs-escaped parts,
481
+ * for which `escapeTextOrNode`/`escapeTextOrMarkup` have no (array) or an
482
+ * unproven (ternary) contract. Those shapes are left unbranded — see
483
+ * #2651's door inventory — rather than guessed at here.
484
+ */
485
+ function isSingleElementJsxChildren(nodes: IRNode[]): boolean {
486
+ return nodes.length === 1 && nodes[0].type === 'element'
487
+ }
488
+
466
489
  /** Build rest spread names from context (rest/props spreads handled by applyRestAttrs, not spreadAttrs). */
467
490
  function buildRestSpreadNames(ctx: ClientJsContext): Set<string> {
468
491
  const names = new Set<string>()
@@ -502,6 +525,15 @@ function buildComponentPropsExpr(props: IRProp[], ctx: ClientJsContext): string
502
525
  const jsxExpr = irChildrenToJsExpr(prop.value.children)
503
526
  if (jsxChildrenContainComponent(prop.value.children)) {
504
527
  propsForInit.push(`get ${quotePropName(prop.name)}() { return __slot(() => ${jsxExpr}) }`)
528
+ } else if (prop.name !== 'children' && isSingleElementJsxChildren(prop.value.children)) {
529
+ // Brand (#2651): `jsxExpr` is the single HTML-string template
530
+ // literal `irChildrenToJsExpr` builds for a lone 'element' node —
531
+ // see `isSingleElementJsxChildren`'s docstring for why only this
532
+ // shape is branded here. Excludes an explicit `children={<jsx/>}`
533
+ // prop (out of scope, unchanged) — the child's `{children}`
534
+ // interpolation is a bare passthrough with no unwrap call, so a
535
+ // branded object there would stringify to `[object Object]`.
536
+ propsForInit.push(`get ${quotePropName(prop.name)}() { return bfMarkup(${jsxExpr}) }`)
505
537
  } else {
506
538
  propsForInit.push(`get ${quotePropName(prop.name)}() { return ${jsxExpr} }`)
507
539
  }
@@ -761,10 +793,11 @@ export function collectElements(
761
793
  }
762
794
  skeletonTemplate = buildLoopSkeletonTemplate(l.children[0], skeletonSafeSlots) ?? undefined
763
795
  // Direct child-index paths (perf, #2143): only attempted when the
764
- // skeleton itself hoisted, and skipped for SVG roots for now (the
765
- // `<svg>`-wrap namespace fix-up is orthogonal and untested against
766
- // this path model — safe fallback to qsa/$t for those loops).
767
- if (skeletonTemplate && !templateRootIsSvg(skeletonTemplate)) {
796
+ // skeleton itself hoisted, and skipped for SVG/MathML roots for
797
+ // now (the `<svg>`/`<math>`-wrap namespace fix-up is orthogonal
798
+ // and untested against this path model — safe fallback to
799
+ // qsa/$t for those loops).
800
+ if (skeletonTemplate && !detectRootNamespaceWrapTag(skeletonTemplate)) {
768
801
  skeletonPaths = computeSkeletonSlotPaths(l.children[0], skeletonSafeSlots) ?? undefined
769
802
  }
770
803
  }
@@ -33,7 +33,7 @@
33
33
  import { keyAttrName, profileBindingId, varSlotId } from '../../utils.ts'
34
34
  import { emitComponentAndEventSetup } from '../shared.ts'
35
35
  import { emitAttrUpdate } from '../../emit-reactive.ts'
36
- import { emitMultiRootTemplateCloneLines, templateRootIsSvg } from './template-parse.ts'
36
+ import { emitMultiRootTemplateCloneLines, namespaceWrapForTemplate } from './template-parse.ts'
37
37
  import { emitLoopChildRefs } from './loop.ts'
38
38
  import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
39
39
  import type {
@@ -83,15 +83,15 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
83
83
  lines.push(`${innerIndent} __innerEl${uid}.__bfExtras = __innerExtras${uid}`)
84
84
  lines.push(`${indent} }`)
85
85
  } else {
86
- // SVG-rooted item templates must parse inside a synthetic `<svg>` wrap
87
- // (#2219): `template.innerHTML` parses in the HTML namespace, so a bare
88
- // `<line>`/`<circle>` root clones as an HTMLUnknownElement and the SVG
89
- // renderer silently draws nothing. Mirrors `templateRootIsSvg` handling
90
- // on the top-level (#135/#1088) and branch-arm paths; HTML-rooted
91
- // templates keep byte-identical output.
92
- const isSvg = templateRootIsSvg(emit.wrappedTemplate)
93
- const innerHtml = isSvg ? `<svg>${emit.wrappedTemplate}</svg>` : emit.wrappedTemplate
94
- const childPath = isSvg ? '.firstElementChild.firstElementChild' : '.firstElementChild'
86
+ // SVG/MathML-rooted item templates must parse inside a synthetic
87
+ // namespace wrap (#2219, #1096): `template.innerHTML` parses in the
88
+ // HTML namespace, so a bare `<line>`/`<circle>`/`<mrow>` root clones as
89
+ // an HTMLUnknownElement and the SVG/MathML renderer silently draws
90
+ // nothing. Mirrors `namespaceWrapForTemplate` handling on the top-level
91
+ // (#135/#1088) and branch-arm paths; HTML-rooted templates keep
92
+ // byte-identical output.
93
+ const { wrapTag, childPath } = namespaceWrapForTemplate(emit.wrappedTemplate)
94
+ const innerHtml = wrapTag ? `<${wrapTag}>${emit.wrappedTemplate}</${wrapTag}>` : emit.wrappedTemplate
95
95
  lines.push(`${indent} let __innerEl${uid} = __existing ?? (() => { const __t = document.createElement('template'); __t.innerHTML = \`${innerHtml}\`; return __t.content${childPath}.cloneNode(true) })()`)
96
96
  }
97
97
  if (emit.wrappedKey) {