@barefootjs/jsx 0.31.7 → 0.31.9

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 (31) hide show
  1. package/dist/index.js +136 -59
  2. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  3. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  4. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  5. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +66 -38
  6. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/html-template.d.ts +15 -1
  10. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/imports.d.ts +2 -2
  12. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  13. package/package.json +2 -2
  14. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +210 -210
  15. package/src/__tests__/aliased-destructured-prop-csr.test.ts +8 -2
  16. package/src/__tests__/csr-template-loop-shadowing.test.ts +5 -2
  17. package/src/__tests__/env-signal-template-prelude.test.ts +89 -0
  18. package/src/__tests__/inner-loop-mathml-namespace.test.ts +135 -0
  19. package/src/__tests__/markup-prop-brand.test.ts +161 -0
  20. package/src/__tests__/mathml-mapArray-namespace.test.ts +230 -0
  21. package/src/__tests__/text-slot-escaping.test.ts +21 -12
  22. package/src/ir-to-client-js/collect-elements.ts +38 -5
  23. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +10 -10
  24. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +3 -4
  25. package/src/ir-to-client-js/control-flow/stringify/loop.ts +18 -14
  26. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +142 -78
  27. package/src/ir-to-client-js/csr-substitute.ts +3 -2
  28. package/src/ir-to-client-js/emit-registration.ts +56 -4
  29. package/src/ir-to-client-js/html-template.ts +103 -12
  30. package/src/ir-to-client-js/imports.ts +4 -0
  31. package/src/ir-to-client-js/index.ts +6 -1
@@ -4,7 +4,7 @@
4
4
  * and the final hydrate() call emission.
5
5
  */
6
6
 
7
- import type { ComponentIR, IRFragment, IRNode, ReferencesGraph } from '../types.ts'
7
+ import type { ComponentIR, IRFragment, IRNode, ReferencesGraph, SignalInfo } from '../types.ts'
8
8
  import type { ClientJsContext } from './types.ts'
9
9
  import { PROPS_PARAM } from './utils.ts'
10
10
  import { computeInlinability, toLegacyInlinability } from './compute-inlinability.ts'
@@ -115,6 +115,54 @@ export function csrInlinableConstantsFromCtx(ctx: ClientJsContext): Map<string,
115
115
  return out
116
116
  }
117
117
 
118
+ /**
119
+ * Build the `template:` ComponentDef entry text for a generated
120
+ * `templateHtml` string.
121
+ *
122
+ * When the component holds one or more env signals (`createSearchParams()`,
123
+ * #2057), the template lambda destructures its own copy of each getter in
124
+ * a block-body prelude before returning the template literal:
125
+ *
126
+ * template: (_p) => { const [sp] = createSearchParams(); return `...` }
127
+ *
128
+ * instead of the plain expression-body form:
129
+ *
130
+ * template: (_p) => `...`
131
+ *
132
+ * The template lambda runs at module scope (`render()` / `renderChild()`),
133
+ * but an env-signal getter is otherwise only ever destructured inside
134
+ * `init...` — so a template that calls the getter directly (`sp()`,
135
+ * `searchParams()`) ReferenceErrors the moment it runs (#2654). Before
136
+ * #2057, `searchParams` was a bare module-scope import and the same
137
+ * template-body call worked by accident; #2057 moved it behind
138
+ * `const [sp] = createSearchParams()` without updating template emission.
139
+ *
140
+ * The prelude is emitted whenever the component HAS an env signal —
141
+ * never gated on whether `templateHtml` textually mentions the getter.
142
+ * Scanning already-emitted template HTML for a getter name would be a
143
+ * string/regex parse of emitted JS, which CLAUDE.md's parse rule forbids.
144
+ * Unconditional emission is safe because `createSearchParams()`
145
+ * (`packages/client/src/reactive.ts`) only returns the shared
146
+ * `searchParamsTuple` module singleton — no side effect — so declaring it
147
+ * in a prelude the template body doesn't end up using costs nothing.
148
+ *
149
+ * `envFactory` is expected to always be set alongside `envReader` (the
150
+ * analyzer sets both together, #2057) — the `undefined` branch is a
151
+ * defensive fallback: if it's ever missing, skip that signal's prelude
152
+ * line entirely rather than guessing a canonical factory name, leaving
153
+ * that one signal's template reference exactly as unsound as before this
154
+ * fix (never worse).
155
+ */
156
+ function buildTemplateDefPart(ctx: ClientJsContext, templateHtml: string): string {
157
+ const envDecls = ctx.signals
158
+ .filter((s): s is SignalInfo & { envFactory: string } => Boolean(s.envReader) && Boolean(s.envFactory))
159
+ .map((s) => `const [${s.getter}] = ${s.envFactory}()`)
160
+ if (envDecls.length === 0) {
161
+ return `template: (${PROPS_PARAM}) => \`${templateHtml}\``
162
+ }
163
+ return `template: (${PROPS_PARAM}) => { ${envDecls.join('; ')}; return \`${templateHtml}\` }`
164
+ }
165
+
118
166
  /** Emit hydrate() call that registers component, template, and hydrates. */
119
167
  /**
120
168
  * Generate the closing brace for the init function and the hydrate() call.
@@ -155,9 +203,13 @@ export function emitRegistrationAndHydration(
155
203
  // Build ComponentDef object for hydrate()
156
204
  const defParts: string[] = [`init: init${name}`]
157
205
  if (canGenerateStaticTemplate(_ir.root, propNamesForStaticCheck, inlinableConstants, unsafeLocalNames)) {
158
- const templateHtml = irToComponentTemplate(_ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName)
206
+ // `ctx.dynamicElements` is the claim-plan-'markup' membership
207
+ // (#2651) — see `generateCsrTemplate`'s identical derivation below
208
+ // for why this is reused as-is rather than re-derived.
209
+ const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
210
+ const templateHtml = irToComponentTemplate(_ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName, markupSlotIds)
159
211
  if (templateHtml) {
160
- defParts.push(`template: (${PROPS_PARAM}) => \`${templateHtml}\``)
212
+ defParts.push(buildTemplateDefPart(ctx, templateHtml))
161
213
  }
162
214
  } else {
163
215
  // CSR fallback: emit for all components that can't generate static templates.
@@ -173,7 +225,7 @@ export function emitRegistrationAndHydration(
173
225
  _ir.root, csrInlinableConstants, ctx, restSpreadNames, ctx.propsObjectName, unsafeLocalNames, ctx.deferredChildSlots
174
226
  )
175
227
  if (templateHtml) {
176
- defParts.push(`template: (${PROPS_PARAM}) => \`${templateHtml}\``)
228
+ defParts.push(buildTemplateDefPart(ctx, templateHtml))
177
229
  }
178
230
  }
179
231
  // No else: top-level-only components skip template entirely (save bytes)
@@ -321,9 +321,22 @@ function escapeAttrValueExpr(valExpr: string): string {
321
321
  * escaped, so this is applied only at the four text-marker emit sites.
322
322
  * Hono escapes text content with the same set as attribute values
323
323
  * (`& " ' < >`), so `escapeText` delegates to the same operation.
324
+ *
325
+ * `isMarkup` (#2651) switches to `escapeTextOrMarkup` — `escapeText`'s
326
+ * strict superset that additionally unwraps a `bfMarkup()`-branded value
327
+ * raw instead of escaping it. Two of the four text-marker call sites
328
+ * (`irToComponentTemplateWithOpts`, `generateCsrTemplateWithOpts`) pass
329
+ * `true` for a slot `ctx.dynamicElements` also claims — the same
330
+ * membership `emit-reactive.ts` uses to pick the 'markup' writer kind for
331
+ * this slot's REACTIVE update, so the initial-render escape and the
332
+ * reactive-update escape agree on whether the slot may carry raw markup.
333
+ * The other two (`irToHtmlTemplate`, `irToPlaceholderTemplate`) build
334
+ * loop-item / conditional-branch HTML, which the reactive side always
335
+ * treats as plain text (`__bfText`) regardless — they never pass `true`,
336
+ * so they keep calling `escapeText` byte-for-byte as before.
324
337
  */
325
- function escapeTextSlotExpr(innerExpr: string): string {
326
- return `escapeText(${innerExpr})`
338
+ function escapeTextSlotExpr(innerExpr: string, isMarkup = false): string {
339
+ return `${isMarkup ? 'escapeTextOrMarkup' : 'escapeText'}(${innerExpr})`
327
340
  }
328
341
 
329
342
  /**
@@ -901,7 +914,20 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
901
914
  case 'jsx-children': {
902
915
  const hoistedRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, true)
903
916
  const childHtml = p.value.children.map(c => hoistedRecurse(c)).join('')
904
- return `${quotePropName(p.name)}: \`${childHtml}\``
917
+ // Brand the assembled HTML (#2651) — every text segment inside
918
+ // `childHtml` was already escaped node-by-node during
919
+ // `hoistedRecurse`'s own build, so the concatenated result is
920
+ // safe to splice raw; the child's own template evaluates this
921
+ // prop through `escapeTextOrMarkup`/`escapeTextOrNode`, which
922
+ // trust the brand and skip re-escaping it. EXCEPT an explicit
923
+ // `children={<jsx/>}` prop (out of scope, unchanged): the
924
+ // child's `{children}` interpolation is the bare-passthrough
925
+ // door (`escapeTextSlotExpr`'s own docstring), never routed
926
+ // through an escape/unwrap call, so a branded object here
927
+ // would stringify to `[object Object]` instead of unwrapping.
928
+ return p.name === 'children'
929
+ ? `${quotePropName(p.name)}: \`${childHtml}\``
930
+ : `${quotePropName(p.name)}: bfMarkup(\`${childHtml}\`)`
905
931
  }
906
932
  case 'literal':
907
933
  return `${quotePropName(p.name)}: ${JSON.stringify(p.value.value)}`
@@ -1478,6 +1504,25 @@ export function irChildrenToJsExpr(children: IRNode[]): string {
1478
1504
  return `[${exprs.join(', ')}]`
1479
1505
  }
1480
1506
 
1507
+ /**
1508
+ * Narrow gate for branding a nested `jsx-children` getter's value with
1509
+ * `bfMarkup()` (#2651) — the `irNodeToJsExprs` counterpart of
1510
+ * `collect-elements.ts`'s `isSingleElementJsxChildren` (duplicated rather
1511
+ * than imported: `collect-elements.ts` already imports FROM this file, so
1512
+ * the reverse import would cycle). Same shape, same reasoning: a
1513
+ * `jsx-children` prop's value is always a single stored node
1514
+ * (`AttrValueOf.jsxChildren([...])`); only when that node is a lone
1515
+ * `'element'` does `irChildrenToJsExpr` reduce it to ONE HTML-string
1516
+ * template literal — the shape `bfMarkup` + `escapeTextOrNode` /
1517
+ * `escapeTextOrMarkup` have a proven contract for. A `'fragment'` (multiple
1518
+ * children) or `'conditional'` node reduces to an array literal or a
1519
+ * nested ternary instead — left unbranded here for the same reason as the
1520
+ * `collect-elements.ts` twin.
1521
+ */
1522
+ function isSingleElementJsxChildren(nodes: IRNode[]): boolean {
1523
+ return nodes.length === 1 && nodes[0].type === 'element'
1524
+ }
1525
+
1481
1526
  function irNodeToJsExprs(node: IRNode): string[] {
1482
1527
  switch (node.type) {
1483
1528
  case 'component': {
@@ -1488,8 +1533,19 @@ function irNodeToJsExprs(node: IRNode): string[] {
1488
1533
  return `${quotePropName(p.name)}: ${attrValueToString(p.value) ?? 'undefined'}`
1489
1534
  }
1490
1535
  switch (p.value.kind) {
1491
- case 'jsx-children':
1492
- return `get ${quotePropName(p.name)}() { return ${irChildrenToJsExpr(p.value.children)} }`
1536
+ case 'jsx-children': {
1537
+ // Brand (#2651) only the lone-'element' shape, and never an
1538
+ // explicit `children={<jsx/>}` prop — see
1539
+ // `isSingleElementJsxChildren` (this file) for the shape
1540
+ // argument and the `collect-elements.ts` twin for the
1541
+ // `children`-exclusion argument (bare-passthrough consumer,
1542
+ // no unwrap call).
1543
+ const jsxExpr = irChildrenToJsExpr(p.value.children)
1544
+ const wrapped = (p.name !== 'children' && isSingleElementJsxChildren(p.value.children))
1545
+ ? `bfMarkup(${jsxExpr})`
1546
+ : jsxExpr
1547
+ return `get ${quotePropName(p.name)}() { return ${wrapped} }`
1548
+ }
1493
1549
  case 'literal':
1494
1550
  return `get ${quotePropName(p.name)}() { return ${JSON.stringify(p.value.value)} }`
1495
1551
  case 'boolean-shorthand':
@@ -1644,6 +1700,20 @@ export interface TemplateOptions {
1644
1700
  * the template phase agree on which children defer (dropped-prop fix).
1645
1701
  */
1646
1702
  deferredChildSlots?: ReadonlySet<string>
1703
+ /**
1704
+ * Slot ids of top-level, non-conditional dynamic expressions
1705
+ * (`ctx.dynamicElements`, populated by `collectElements` — the exact set
1706
+ * `emit-reactive.ts`'s `emitDynamicTextUpdates` claims `kind: 'markup'`
1707
+ * for on the REACTIVE side, #2651). When a text-marker expression's
1708
+ * `slotId` is a member, `escapeTextSlotExpr` emits `escapeTextOrMarkup`
1709
+ * instead of `escapeText` so a `bfMarkup()`-branded prop value (a JSX
1710
+ * element passed at a non-`children` component prop position) reaches
1711
+ * the initial-render template raw, matching what the reactive writer
1712
+ * already does via `escapeTextOrNode`. Computed once per component from
1713
+ * `ctx.dynamicElements` by `irToComponentTemplate` / `generateCsrTemplate`
1714
+ * — never re-derived here.
1715
+ */
1716
+ markupSlotIds?: ReadonlySet<string>
1647
1717
  }
1648
1718
 
1649
1719
  /**
@@ -1659,9 +1729,10 @@ export function irToComponentTemplate(
1659
1729
  node: IRNode,
1660
1730
  inlinableConstants?: Map<string, string>,
1661
1731
  restSpreadNames?: Set<string>,
1662
- propsObjectName?: string | null
1732
+ propsObjectName?: string | null,
1733
+ markupSlotIds?: ReadonlySet<string>
1663
1734
  ): string {
1664
- return irToComponentTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, loopDepth: -1 })
1735
+ return irToComponentTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, loopDepth: -1, markupSlotIds })
1665
1736
  }
1666
1737
 
1667
1738
  function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): string {
@@ -1829,7 +1900,8 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1829
1900
  ? `Array.isArray(${wrapped}) ? ${wrapped}.join('') : (${wrapped} ?? '')`
1830
1901
  : wrapped
1831
1902
  if (node.slotId) {
1832
- return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
1903
+ const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
1904
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped, isMarkup)}}<!--/-->`
1833
1905
  }
1834
1906
  return `\${${value}}`
1835
1907
  }
@@ -1873,7 +1945,12 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1873
1945
  case 'jsx-children': {
1874
1946
  const hoistedRecurse = (n: IRNode): string => irToComponentTemplateWithOpts(n, { ...opts, inHoistedChildren: true })
1875
1947
  const childHtml = p.value.children.map(c => hoistedRecurse(c)).join('')
1876
- return `${quotePropName(p.name)}: \`${childHtml}\``
1948
+ // Brand the assembled HTML (#2651), except an explicit
1949
+ // `children={<jsx/>}` prop — see the identical
1950
+ // `irToHtmlTemplate` case above for both arguments.
1951
+ return p.name === 'children'
1952
+ ? `${quotePropName(p.name)}: \`${childHtml}\``
1953
+ : `${quotePropName(p.name)}: bfMarkup(\`${childHtml}\`)`
1877
1954
  }
1878
1955
  case 'literal':
1879
1956
  return `${quotePropName(p.name)}: ${JSON.stringify(p.value.value)}`
@@ -2075,7 +2152,14 @@ export function generateCsrTemplate(
2075
2152
  }
2076
2153
  }
2077
2154
  const effectiveUnsafeLocalNames = mergeCsrNullUnsafe(ctx, unsafeLocalNames)
2078
- return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, unsafeLocalNames: effectiveUnsafeLocalNames, deferredChildSlots, loopDepth: -1 })
2155
+ // `ctx.dynamicElements` (populated by `collectElements`, before any
2156
+ // template-string build runs) IS the claim-plan-'markup' membership
2157
+ // `emit-reactive.ts` claims for these same slot ids on the reactive
2158
+ // side (#2651) — every entry is already non-conditional / non-`@client`
2159
+ // by construction (`collectElements`'s `expression` visitor only pushes
2160
+ // here). Reused as-is, not re-derived.
2161
+ const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
2162
+ return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, unsafeLocalNames: effectiveUnsafeLocalNames, deferredChildSlots, loopDepth: -1, markupSlotIds })
2079
2163
  }
2080
2164
 
2081
2165
  /**
@@ -2440,7 +2524,8 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2440
2524
  ? `Array.isArray(${expr}) ? ${expr}.join('') : (${expr} ?? '')`
2441
2525
  : expr
2442
2526
  if (node.slotId) {
2443
- return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr)}}<!--/-->`
2527
+ const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
2528
+ return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr, isMarkup)}}<!--/-->`
2444
2529
  }
2445
2530
  return `\${${value}}`
2446
2531
  }
@@ -2499,7 +2584,13 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2499
2584
  case 'jsx-children': {
2500
2585
  const hoistedRecurse = (n: IRNode): string => generateCsrTemplateWithOpts(n, { ...opts, inHoistedChildren: true })
2501
2586
  const childHtml = p.value.children.map(c => hoistedRecurse(c)).join('')
2502
- return `${quotePropName(p.name)}: \`${childHtml}\``
2587
+ // Brand the assembled HTML (#2651), except an explicit
2588
+ // `children={<jsx/>}` prop — see the identical
2589
+ // `irToHtmlTemplate` case (top of this file) for both
2590
+ // arguments.
2591
+ return p.name === 'children'
2592
+ ? `${quotePropName(p.name)}: \`${childHtml}\``
2593
+ : `${quotePropName(p.name)}: bfMarkup(\`${childHtml}\`)`
2503
2594
  }
2504
2595
  case 'literal':
2505
2596
  return `${quotePropName(p.name)}: ${JSON.stringify(p.value.value)}`
@@ -20,6 +20,10 @@ export const RUNTIME_IMPORT_CANDIDATES = [
20
20
  'createPortal',
21
21
  'provideContext', 'createContext', 'useContext',
22
22
  'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText', 'escapeTextOrNode',
23
+ // JSX-element-as-non-children-prop markup brand (#2651) — `bfMarkup` wraps
24
+ // the compiler-built HTML at the producer (renderChild / initChild props);
25
+ // `escapeTextOrMarkup` unwraps it at the claim-plan-'markup' template slot.
26
+ 'bfMarkup', 'escapeTextOrMarkup',
23
27
  'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
24
28
  // Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
25
29
  // — the "one claim mechanism" that replaced `patchSlotRange` and
@@ -259,7 +259,12 @@ function generateTemplateOnlyMount(ir: ComponentIR, ctx: ClientJsContext): strin
259
259
  let templateHtml: string | undefined
260
260
 
261
261
  if (canGenerateStaticTemplate(ir.root, propNamesForStaticCheck, inlinableConstants, unsafeLocalNames)) {
262
- templateHtml = irToComponentTemplate(ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName)
262
+ // `ctx.dynamicElements` is always empty on this template-only path
263
+ // (`needsClientJs(ctx)` gates it) — passed through anyway so this
264
+ // matches `emitRegistrationAndHydration`'s derivation byte-for-byte
265
+ // rather than special-casing "no markup slots here" (#2651).
266
+ const markupSlotIds = new Set(ctx.dynamicElements.map(e => e.slotId))
267
+ templateHtml = irToComponentTemplate(ir.root, inlinableConstants, restSpreadNames, ctx.propsObjectName, markupSlotIds)
263
268
  }
264
269
 
265
270
  // CSR fallback: when static template generation fails (e.g., components with