@barefootjs/jsx 0.28.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/adapters/child-scope.d.ts +17 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +890 -357
  9. package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
  15. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  21. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +14 -0
  25. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +2 -3
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/imports.d.ts +2 -2
  30. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  32. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/reactivity.d.ts +1 -10
  34. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/types.d.ts +12 -0
  36. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  37. package/dist/jsx-to-ir.d.ts.map +1 -1
  38. package/dist/rich-type-evidence.d.ts.map +1 -1
  39. package/dist/ssr-defaults.d.ts.map +1 -1
  40. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  41. package/dist/types.d.ts +78 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/value-references.d.ts +17 -4
  44. package/dist/value-references.d.ts.map +1 -1
  45. package/package.json +2 -2
  46. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  47. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  48. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  49. package/src/__tests__/csr-template-scope-soundness.test.ts +96 -0
  50. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  51. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  52. package/src/__tests__/ir-to-client-js/imports.test.ts +18 -0
  53. package/src/__tests__/lazy-conditional.test.ts +190 -0
  54. package/src/__tests__/lazy-preamble.test.ts +297 -0
  55. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  56. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  57. package/src/__tests__/loop-row-creation-paths.test.ts +169 -0
  58. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  59. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  60. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  61. package/src/__tests__/preamble-declarations.test.ts +207 -0
  62. package/src/__tests__/ssr-defaults.test.ts +44 -0
  63. package/src/__tests__/value-references.test.ts +88 -0
  64. package/src/adapters/child-scope.ts +22 -0
  65. package/src/adapters/loop-bound-names.ts +6 -0
  66. package/src/analyzer-context.ts +43 -0
  67. package/src/analyzer.ts +33 -3
  68. package/src/index.ts +1 -0
  69. package/src/ir-to-client-js/collect-elements.ts +51 -10
  70. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  71. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  72. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  73. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  74. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  75. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  76. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  77. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  78. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  79. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  80. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  81. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +4 -0
  82. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  83. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  84. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +23 -4
  85. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  86. package/src/ir-to-client-js/emit-registration.ts +1 -1
  87. package/src/ir-to-client-js/html-template.ts +51 -40
  88. package/src/ir-to-client-js/imports.ts +5 -0
  89. package/src/ir-to-client-js/index.ts +1 -1
  90. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  91. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  92. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  93. package/src/ir-to-client-js/reactivity.ts +25 -0
  94. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  95. package/src/ir-to-client-js/types.ts +12 -0
  96. package/src/jsx-to-ir.ts +400 -24
  97. package/src/rich-type-evidence.ts +6 -7
  98. package/src/ssr-defaults.ts +7 -2
  99. package/src/to-locale-date-lowering.ts +8 -2
  100. package/src/types.ts +80 -0
  101. package/src/value-references.ts +26 -4
@@ -12,6 +12,7 @@ import { buildSignalMemoEnv, csrSubstitute, applyPropsRewrite, type CsrEnv } fro
12
12
  import type { ClientJsContext } from './types.ts'
13
13
  import { BF_PARENT_SCOPE_PLACEHOLDER, BF_SCOPE, escapeHtml } from '@barefootjs/shared'
14
14
  import { buildLoopChainExpr } from '../loop-chain.ts'
15
+ import { derivesScopeFromSlot } from '../adapters/child-scope.ts'
15
16
 
16
17
  /**
17
18
  * Protect string literals from regex-based replacements.
@@ -547,12 +548,11 @@ function buildSpreadAttrsMergeCall(args: {
547
548
  * are wrapped with `__bfSlot(EXPR, <branchSlotsVar>)` so the runtime can
548
549
  * splice live `Node` returns into the parsed fragment instead of
549
550
  * stringifying them via the template literal (#1213).
550
- * @param insideLoop - When true, component nodes omit their `slotId` from the
551
- * `renderChild` call. Each loop iteration produces its own scope (identified
552
- * by `data-key`), so the parent-slot suffix would create scopes that don't
553
- * match the SSR shape see #1268 and the matching `insideLoop` guard in
554
- * `generateCsrTemplate` (case `'component'`). Set to `true` when generating
555
- * the per-iteration `staticItemTemplate` for static loops.
551
+ * Component nodes omit their `slotId` from the `renderChild` call exactly
552
+ * when `derivesScopeFromSlot()` says so a loop-item-root component (its
553
+ * own scope, identified by `data-key`) never derives from the parent slot;
554
+ * a component nested below the row root does (#2444). See
555
+ * `IRComponent.loopItemRoot` / `./adapters/child-scope.ts`.
556
556
  */
557
557
  /**
558
558
  * Build the per-item `<!--bf-loop-i:KEY-->` anchor comment for a whole-item
@@ -660,7 +660,7 @@ export function renderFlatMapClientBody(
660
660
  rawLeaf: true,
661
661
  renderLeaf: (ir) => {
662
662
  const key = flatMapLeafKeyExpr(ir)
663
- const html = irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, 1, undefined, undefined, true)
663
+ const html = irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, 1, undefined, undefined)
664
664
  return `({ k: ${key ?? 'undefined'}, h: \`${html}\` })`
665
665
  },
666
666
  })
@@ -691,7 +691,7 @@ export function renderFlatMapProjectionClientBody(
691
691
  const params = inner.index ? `(${inner.param}, ${inner.index})` : `(${inner.param})`
692
692
  const key = inner.key ? `(${inner.key})` : 'undefined'
693
693
  const html = inner.children
694
- .map((c) => irToHtmlTemplate(escapeLeafTextExpressions(c), restSpreadNames, 1, undefined, undefined, true))
694
+ .map((c) => irToHtmlTemplate(escapeLeafTextExpressions(c), restSpreadNames, 1, undefined, undefined))
695
695
  .join('')
696
696
  return `${chained}.map(${params} => ({ k: ${key}, h: \`${html}\` }))`
697
697
  }
@@ -728,8 +728,8 @@ function escapeLeafTextExpressions(ir: IRNode): IRNode {
728
728
  }
729
729
  }
730
730
 
731
- export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, insideLoop = false, inHoistedChildren = false): string {
732
- const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, inHoistedChildren)
731
+ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, inHoistedChildren = false): string {
732
+ const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, inHoistedChildren)
733
733
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
734
734
  const wrapInterpolation = (expr: string): string => branchSlotsVar
735
735
  ? `__bfSlot(${expr}, ${branchSlotsVar})`
@@ -781,7 +781,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
781
781
  }
782
782
 
783
783
  const attrs = attrParts.join(' ')
784
- const childrenRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, false)
784
+ const childrenRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, false)
785
785
  const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(childrenRecurse).join('')
786
786
 
787
787
  // Non-void elements must use open+close tags (HTML parsers ignore self-closing on div, span, etc.)
@@ -853,13 +853,17 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
853
853
  // instead of emitting empty placeholders (#435).
854
854
  const propsEntries = node.props
855
855
  .filter(p => p.name !== '...' && !p.name.startsWith('...') && p.name !== 'key')
856
- .filter(p => !(p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()))
856
+ // `ref` joins the `on*` handlers here: a function prop cannot run
857
+ // during module-scope string rendering, and a ref closing over an
858
+ // init-scope setter would leak it into the template (#2468). The
859
+ // init path re-applies refs once real DOM exists.
860
+ .filter(p => p.name !== 'ref' && !(p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()))
857
861
  .map(p => {
858
862
  // `/* @client */` defers the prop to hydrate via initChild.
859
863
  if (p.clientOnly) return null
860
864
  switch (p.value.kind) {
861
865
  case 'jsx-children': {
862
- const hoistedRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, true)
866
+ const hoistedRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, true)
863
867
  const childHtml = p.value.children.map(c => hoistedRecurse(c)).join('')
864
868
  return `${quotePropName(p.name)}: \`${childHtml}\``
865
869
  }
@@ -872,7 +876,15 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
872
876
  case 'expression':
873
877
  case 'template':
874
878
  case 'spread': {
875
- const expr = attrValueToString(p.value) ?? 'undefined'
879
+ // Template-variant form (`useTemplate`): this props object
880
+ // lands inside the module-scope registration template, which
881
+ // is not a closure over init's destructured-prop extractions
882
+ // — the raw form leaked bare refs like `${className}` into
883
+ // `renderChild(...)` (#2468). Mirrors the sibling component
884
+ // emit sites (`irToComponentTemplateWithOpts`,
885
+ // `generateCsrTemplateWithOpts`), which already pick the
886
+ // prop-rewritten variant.
887
+ const expr = attrValueToString(p.value, { useTemplate: true }) ?? 'undefined'
876
888
  return `${quotePropName(p.name)}: ${expr}`
877
889
  }
878
890
  }
@@ -884,11 +896,12 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
884
896
  const keyProp = node.props.find(p => p.name === 'key')
885
897
  const keyArg = keyProp ? `, ${attrValueToString(keyProp.value) ?? 'undefined'}` : ''
886
898
  // Pass slotId as suffix so $c() can find the child component by slot
887
- // after branch swap. Inside a loop body each iteration owns a
888
- // separate scope (identified by `data-key`), so the parent-slot
889
- // suffix is dropped to avoid anchoring a deeply-nested child to a
890
- // wrong component-wrapper scope (#1268).
891
- const slotArg = (!insideLoop && node.slotId) ? `, '${node.slotId}'` : ''
899
+ // after branch swap. A loop-item-root component owns a separate scope
900
+ // per iteration (identified by `data-key`), so the parent-slot suffix
901
+ // is dropped to avoid anchoring it to a wrong component-wrapper scope
902
+ // (#1268) — a component nested BELOW the row root still derives from
903
+ // its slot, matching the Hono reference (#2444).
904
+ const slotArg = derivesScopeFromSlot(node) ? `, '${node.slotId}'` : ''
892
905
  return `\${renderChild('${nameForRegistryRef(node.name)}', ${propsExpr}${keyArg || (slotArg ? ', undefined' : '')}${slotArg})}`
893
906
  }
894
907
 
@@ -897,13 +910,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
897
910
  // Increment loopDepth so inner key attrs become data-key-N
898
911
  // Forward loopParams so expressions referencing outer/inner loop params
899
912
  // get wrapped as signal accessors (e.g., task.title → task().title).
900
- // `insideLoop` is preserved (not forced to `true`): downstream branch /
901
- // inner-loop templates depend on the legacy slot-suffix-keeping shape
902
- // to find scopes via `findSsrScopeBySlotIn`. The opt-in at the entry
903
- // call site is the only place that wants the suffix dropped (#1268
904
- // Case 1 — childComponent body materialize); propagating it through
905
- // every nested loop regressed form-builder's inner-loop Select wiring.
906
- const innerRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop)
913
+ const innerRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar)
907
914
  let childTemplate = node.children.map(innerRecurse).join('')
908
915
  // Whole-item conditional loops (#1665): prepend an always-present
909
916
  // `<!--bf-loop-i:KEY-->` anchor before each item's (possibly empty)
@@ -938,13 +945,13 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
938
945
  // identity is stamped by mapArray via setAttribute — emitting it
939
946
  // here was the CSR/SSR data-key asymmetry (unescaped, client-only).
940
947
  const body = renderPreamble(node.flatMapCallback, {
941
- renderLeaf: (ir) => irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop),
948
+ renderLeaf: (ir) => irToHtmlTemplate(stripLeafKeyAttr(ir), restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar),
942
949
  })
943
950
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
944
951
  } else if (node.preamble) {
945
952
  // Stage 3 / D4 — render JSX leaves in an arbitrary array-builder
946
953
  // preamble (the hydrate-template context uses the bare loop param).
947
- const preamble = renderPreamble(node.preamble, { textVariant: 'client', renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar, insideLoop) })
954
+ const preamble = renderPreamble(node.preamble, { textVariant: 'client', renderLeaf: (ir) => irToHtmlTemplate(ir, restSpreadNames, loopDepth + 1, loopParams, branchSlotsVar) })
948
955
  mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
949
956
  } else {
950
957
  mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
@@ -1563,7 +1570,6 @@ export interface TemplateOptions {
1563
1570
  * `compute-inlinability` (#1277).
1564
1571
  */
1565
1572
  csrEnv?: CsrEnv
1566
- insideLoop?: boolean
1567
1573
  loopDepth?: number
1568
1574
  /**
1569
1575
  * Names bound by the ENCLOSING loops' callback params (item / index /
@@ -1799,7 +1805,11 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1799
1805
  // Use renderChild() to render child component's template at runtime (#435)
1800
1806
  const propsEntries = node.props
1801
1807
  .filter(p => p.name !== '...' && !p.name.startsWith('...') && p.name !== 'key')
1802
- .filter(p => !(p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()))
1808
+ // `ref` joins the `on*` handlers here: a function prop cannot run
1809
+ // during module-scope string rendering, and a ref closing over an
1810
+ // init-scope setter would leak it into the template (#2468). The
1811
+ // init path re-applies refs once real DOM exists.
1812
+ .filter(p => p.name !== 'ref' && !(p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()))
1803
1813
  .map(p => {
1804
1814
  // `/* @client */` defers the prop to hydrate via initChild.
1805
1815
  if (p.clientOnly) return null
@@ -1990,7 +2000,6 @@ export function generateCsrTemplate(
1990
2000
  node: IRNode,
1991
2001
  inlinableConstants: Map<string, string> | undefined,
1992
2002
  ctx: ClientJsContext,
1993
- insideLoop?: boolean,
1994
2003
  restSpreadNames?: Set<string>,
1995
2004
  propsObjectName?: string | null,
1996
2005
  unsafeLocalNames?: Set<string>,
@@ -2010,7 +2019,7 @@ export function generateCsrTemplate(
2010
2019
  }
2011
2020
  }
2012
2021
  const effectiveUnsafeLocalNames = mergeCsrNullUnsafe(ctx, unsafeLocalNames)
2013
- return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, insideLoop, unsafeLocalNames: effectiveUnsafeLocalNames, deferredChildSlots, loopDepth: -1 })
2022
+ return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, unsafeLocalNames: effectiveUnsafeLocalNames, deferredChildSlots, loopDepth: -1 })
2014
2023
  }
2015
2024
 
2016
2025
  /**
@@ -2171,6 +2180,7 @@ export function computeDeferredChildSlots(
2171
2180
  // filter set MUST mirror the `propsEntries` filter in the CSR
2172
2181
  // `component` emit below so the deferral decision matches output.
2173
2182
  if (p.name === '...' || p.name.startsWith('...') || p.name === 'key') return false
2183
+ if (p.name === 'ref') return false
2174
2184
  if (p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()) return false
2175
2185
  if (p.clientOnly) return false
2176
2186
  return propResolvesUnsafe(p, env, unsafeLocalNames)
@@ -2204,7 +2214,7 @@ export function computeDeferredChildSlots(
2204
2214
  }
2205
2215
 
2206
2216
  function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): string {
2207
- const { restSpreadNames, propsObjectName, csrEnv, insideLoop, unsafeLocalNames, loopDepth = 0 } = opts
2217
+ const { restSpreadNames, propsObjectName, csrEnv, unsafeLocalNames, loopDepth = 0 } = opts
2208
2218
  const env: CsrEnv = csrEnv ?? { substitutions: new Map(), propsObjectName: propsObjectName ?? null }
2209
2219
  const transformExpr = (expr: string, templateExpr?: string): string => {
2210
2220
  // Single AST substitution pass: replaces signal getter calls
@@ -2241,12 +2251,10 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2241
2251
  }
2242
2252
 
2243
2253
  // `recurse` preserves `inHoistedChildren` for structural pass-through
2244
- // (fragment / conditional); `childrenRecurse` and `recurseInLoop`
2245
- // clear it — a new element root or loop iteration starts a fresh
2246
- // scope. (#1320)
2254
+ // (fragment / conditional); `childrenRecurse` clears it — a new element
2255
+ // root starts a fresh scope. (#1320)
2247
2256
  const recurse = (n: IRNode): string => generateCsrTemplateWithOpts(n, opts)
2248
2257
  const childrenRecurse = (n: IRNode): string => generateCsrTemplateWithOpts(n, { ...opts, inHoistedChildren: false })
2249
- const recurseInLoop = (n: IRNode): string => generateCsrTemplateWithOpts(n, { ...opts, insideLoop: true, loopDepth: loopDepth + 1, inHoistedChildren: false })
2250
2258
 
2251
2259
  switch (node.type) {
2252
2260
  case 'element': {
@@ -2399,7 +2407,11 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2399
2407
 
2400
2408
  const propsEntries = node.props
2401
2409
  .filter(p => p.name !== '...' && !p.name.startsWith('...') && p.name !== 'key')
2402
- .filter(p => !(p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()))
2410
+ // `ref` joins the `on*` handlers here: a function prop cannot run
2411
+ // during module-scope string rendering, and a ref closing over an
2412
+ // init-scope setter would leak it into the template (#2468). The
2413
+ // init path re-applies refs once real DOM exists.
2414
+ .filter(p => p.name !== 'ref' && !(p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()))
2403
2415
  .map(p => {
2404
2416
  // `/* @client */` defers the prop to hydrate. Drop from the
2405
2417
  // SSR `renderChild` props — `initChild`'s `propsExpr` getter
@@ -2441,7 +2453,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2441
2453
  const propsExpr = propsEntries.length > 0 ? `{${propsEntries.join(', ')}}` : '{}'
2442
2454
  const keyProp = node.props.find(p => p.name === 'key')
2443
2455
  const keyArg = keyProp ? `, ${transformKeyValue(keyProp.value, transformExpr)}` : ''
2444
- const slotArg = (!insideLoop && node.slotId) ? `, '${node.slotId}'` : ''
2456
+ const slotArg = derivesScopeFromSlot(node) ? `, '${node.slotId}'` : ''
2445
2457
  return `\${renderChild('${nameForRegistryRef(node.name)}', ${propsExpr}${keyArg || (slotArg ? ', undefined' : '')}${slotArg})}`
2446
2458
  }
2447
2459
 
@@ -2469,7 +2481,6 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2469
2481
  }
2470
2482
  const recurseInLoopBody = (n: IRNode): string => generateCsrTemplateWithOpts(n, {
2471
2483
  ...opts,
2472
- insideLoop: true,
2473
2484
  loopDepth: loopDepth + 1,
2474
2485
  inHoistedChildren: false,
2475
2486
  loopBoundNames: boundHere,
@@ -11,6 +11,11 @@ export const RUNTIME_IMPORT_CANDIDATES = [
11
11
  'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
12
12
  'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'mapArrayLazy', 'patchLeaf', 'createDisposableEffect',
13
13
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
14
+ // Connects a template-clone loop row before the body's tail runs, so a child
15
+ // that inits inside it resolves context against real ancestors rather than
16
+ // falling through to the global store. The clone-root counterpart of the
17
+ // mount point `createComponent` consumes for component-root rows.
18
+ 'mountRowRoot',
14
19
  'createPortal',
15
20
  'provideContext', 'createContext', 'useContext',
16
21
  'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText', 'escapeTextOrNode',
@@ -267,7 +267,7 @@ function generateTemplateOnlyMount(ir: ComponentIR, ctx: ClientJsContext): strin
267
267
  if (!templateHtml) {
268
268
  const csrInlinableConstants = csrInlinableConstantsFromCtx(ctx)
269
269
  templateHtml = generateCsrTemplate(
270
- ir.root, csrInlinableConstants, ctx, undefined, restSpreadNames, ctx.propsObjectName, unsafeLocalNames
270
+ ir.root, csrInlinableConstants, ctx, restSpreadNames, ctx.propsObjectName, unsafeLocalNames
271
271
  )
272
272
  }
273
273
 
@@ -95,6 +95,7 @@ function buildSignalPlan(
95
95
  kind: 'signal',
96
96
  getter: signal.getter,
97
97
  setter: signal.setter,
98
+ getterElided: signal.getterElided,
98
99
  initialValueExpr: '',
99
100
  controlledEffect: null,
100
101
  initializerOverride: `${factory}()`,
@@ -109,6 +110,7 @@ function buildSignalPlan(
109
110
  kind: 'signal',
110
111
  getter: signal.getter,
111
112
  setter: signal.setter,
113
+ getterElided: signal.getterElided,
112
114
  initialValueExpr: resolveSignalInitialValue(signal, ctx, lookups),
113
115
  controlledEffect,
114
116
  branchCondition: signal.branchCondition,
@@ -30,7 +30,7 @@ import { irChildrenToJsExpr, renderPreamble, irToHtmlTemplate } from '../html-te
30
30
  function staticPreludeStatements(preamble: MapCallbackPreamble | undefined): string[] {
31
31
  return preamble
32
32
  ? [renderPreamble(preamble, {
33
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
33
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
34
34
  })]
35
35
  : []
36
36
  }
@@ -27,6 +27,12 @@ export interface SignalEmitPlan {
27
27
  getter: string
28
28
  /** Setter identifier — `null` for read-only signals. */
29
29
  setter: string | null
30
+ /**
31
+ * Source used the getter-elided form (`const [, setX] = ...`). `getter`
32
+ * holds the synthesized internal name (see `SignalInfo.getterElided`);
33
+ * stringify reproduces the source's hole instead of emitting it.
34
+ */
35
+ getterElided?: boolean
30
36
  /**
31
37
  * Fully-resolved initial value expression. Prop rewrites
32
38
  * (`<propsName>.X` → `_p.X`) and `?? <default>` insertions for controlled
@@ -16,6 +16,7 @@ import type {
16
16
  } from './types.ts'
17
17
  import { attrValueToString, freeIdsFromRefs, tokenContainsIdent } from './utils.ts'
18
18
  import { expandConstantForReactivity } from './prop-handling.ts'
19
+ import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
19
20
  import { walkIR, stopAt } from './walker.ts'
20
21
 
21
22
  /**
@@ -621,12 +622,20 @@ export function collectLoopChildReactiveTexts(
621
622
  * same semantics. Pass true only when the caller also separately collects
622
623
  * nested reactive conditionals and gives each its own `insert()`.
623
624
  */
625
+ /** Does any name in `names` appear in `set`? Iterates rather than
626
+ * spreading — this runs per attribute (Copilot review). */
627
+ function anyNameIn(names: Iterable<string>, set: ReadonlySet<string>): boolean {
628
+ for (const n of names) if (set.has(n)) return true
629
+ return false
630
+ }
631
+
624
632
  export function collectLoopChildReactiveAttrs(
625
633
  node: IRNode,
626
634
  ctx: ClientJsContext,
627
635
  loopParam?: string,
628
636
  loopParamBindings?: readonly LoopParamBinding[],
629
637
  stopAtReactiveConditionals = false,
638
+ preambleNames?: ReadonlySet<string>,
630
639
  ): LoopChildReactiveAttr[] {
631
640
  const attrs: LoopChildReactiveAttr[] = []
632
641
  traverseElements(node, (el) => {
@@ -664,8 +673,23 @@ export function collectLoopChildReactiveAttrs(
664
673
  // / `hasFunctionCalls` fallback so the loop-child path matches the
665
674
  // top-level one — a harmless over-wrap at worst (an effect that
666
675
  // subscribes to nothing runs once).
676
+ // A `.map()` callback preamble local (#2447 follow-up). The
677
+ // classifier cannot see it: `const cls = row.done ? …` makes `cls`
678
+ // neither a signal read nor a loop-param read, so `class={cls}`
679
+ // scored 'none' and the value froze at its row-construction value —
680
+ // a same-key item update rewrote the sibling `{row.label}` text and
681
+ // left the class stale. Its reactivity is the PREAMBLE's: whatever
682
+ // the initializer read. Flag it and let the emitter re-run the
683
+ // preamble ahead of the write (`stringifyReactiveEffects`), which is
684
+ // the child-position `preambleRegions` treatment (#2389) applied to
685
+ // the attribute position.
686
+ const readsPreamble =
687
+ preambleNames !== undefined &&
688
+ preambleNames.size > 0 &&
689
+ anyNameIn(expanded.freeIds ?? extractFreeIdentifiersFromText(expanded.expr), preambleNames)
667
690
  const reactive =
668
691
  classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
692
+ || readsPreamble
669
693
  || attr.callsReactiveGetters
670
694
  || attr.hasFunctionCalls
671
695
  if (!attr.clientOnly && !reactive) continue
@@ -675,6 +699,7 @@ export function collectLoopChildReactiveAttrs(
675
699
  expression: expanded.expr,
676
700
  ...pickAttrMetaFromIR(attr),
677
701
  ...(expanded.freeIds !== undefined && { freeIdentifiers: expanded.freeIds }),
702
+ ...(readsPreamble && { readsPreamble: true }),
678
703
  })
679
704
  }
680
705
  }
@@ -65,12 +65,17 @@ function bfIdArg(bfId: string | undefined): string {
65
65
  }
66
66
 
67
67
  function emitSignal(lines: string[], plan: SignalEmitPlan): void {
68
+ // Getter-elided source form (`const [, setX] = createSignal(...)`):
69
+ // reproduce the hole instead of introducing the synthesized internal
70
+ // getter name into output (#2468 sweep — dropping the declaration
71
+ // entirely left the setter as a guaranteed ReferenceError).
72
+ const getterSlot = plan.getterElided ? '' : plan.getter
68
73
  // Env signal (#2057): emit its own factory call verbatim (`createSearchParams()`)
69
74
  // — a stable request-scoped view, so no baked initial value, profile id,
70
75
  // controlled effect, or branch condition applies.
71
76
  if (plan.initializerOverride) {
72
77
  if (plan.setter) {
73
- lines.push(` const [${plan.getter}, ${plan.setter}] = ${plan.initializerOverride}`)
78
+ lines.push(` const [${getterSlot}, ${plan.setter}] = ${plan.initializerOverride}`)
74
79
  } else {
75
80
  lines.push(` const [${plan.getter}] = ${plan.initializerOverride}`)
76
81
  }
@@ -85,9 +90,9 @@ function emitSignal(lines: string[], plan: SignalEmitPlan): void {
85
90
  // runs when the branch is taken. Sibling branches with different
86
91
  // signals coexist because each gets its own `let` + guarded assign.
87
92
  if (plan.setter) {
88
- lines.push(` let ${plan.getter}, ${plan.setter}`)
93
+ lines.push(plan.getterElided ? ` let ${plan.setter}` : ` let ${plan.getter}, ${plan.setter}`)
89
94
  lines.push(` if (${plan.branchCondition}) {`)
90
- lines.push(` ;[${plan.getter}, ${plan.setter}] = createSignal(${plan.initialValueExpr}${id})`)
95
+ lines.push(` ;[${getterSlot}, ${plan.setter}] = createSignal(${plan.initialValueExpr}${id})`)
91
96
  lines.push(` }`)
92
97
  } else {
93
98
  lines.push(` let ${plan.getter}`)
@@ -101,7 +106,7 @@ function emitSignal(lines: string[], plan: SignalEmitPlan): void {
101
106
  return
102
107
  }
103
108
  if (plan.setter) {
104
- lines.push(` const [${plan.getter}, ${plan.setter}] = createSignal(${plan.initialValueExpr}${id})`)
109
+ lines.push(` const [${getterSlot}, ${plan.setter}] = createSignal(${plan.initialValueExpr}${id})`)
105
110
  } else {
106
111
  lines.push(` const [${plan.getter}] = createSignal(${plan.initialValueExpr}${id})`)
107
112
  }
@@ -494,6 +494,18 @@ export interface LoopChildReactiveAttr extends AttrMeta {
494
494
  childSlotId: string // bf slot ID of the element with reactive attr
495
495
  attrName: string // 'className', 'disabled', etc.
496
496
  expression: string // Expression that reads signals
497
+ /**
498
+ * The expression reads a `.map()` callback preamble local (#2447
499
+ * follow-up), so it is reactive by way of whatever the preamble read — the
500
+ * classifier cannot see through the declaration, and the local is not in
501
+ * scope in the update body until the preamble is re-run there.
502
+ *
503
+ * Two consequences, both handled by the emitter rather than here: the row
504
+ * effect runs `mapPreambleWrapped` BEFORE the attr writes, and the lazy row
505
+ * graph refuses the loop (`lazyRowEligibility`'s `readsPreamble` gate), so
506
+ * such a row keeps the eager per-row effect.
507
+ */
508
+ readsPreamble?: boolean
497
509
  }
498
510
 
499
511
  export interface LoopChildReactiveText {