@barefootjs/jsx 0.31.3 → 0.31.5

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 (86) hide show
  1. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  2. package/dist/adapters/loop-bound-names.d.ts +18 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/adapters/template-imports.d.ts +27 -15
  5. package/dist/adapters/template-imports.d.ts.map +1 -1
  6. package/dist/adapters/test-adapter.d.ts.map +1 -1
  7. package/dist/augment-inherited-props.d.ts +12 -2
  8. package/dist/augment-inherited-props.d.ts.map +1 -1
  9. package/dist/compiler.d.ts.map +1 -1
  10. package/dist/debug.d.ts.map +1 -1
  11. package/dist/free-refs.d.ts +11 -2
  12. package/dist/free-refs.d.ts.map +1 -1
  13. package/dist/identifier-pattern.d.ts +62 -0
  14. package/dist/identifier-pattern.d.ts.map +1 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +968 -789
  18. package/dist/ir-to-client-js/collect-elements.d.ts +23 -2
  19. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  23. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/csr-substitute.d.ts +12 -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 +20 -12
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/prop-handling.d.ts +25 -2
  31. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/reactivity.d.ts +46 -2
  33. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  34. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/types.d.ts +16 -0
  36. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/utils.d.ts +15 -0
  38. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  39. package/dist/jsx-to-ir.d.ts.map +1 -1
  40. package/dist/module-exports.d.ts +64 -0
  41. package/dist/module-exports.d.ts.map +1 -1
  42. package/dist/relocate.d.ts.map +1 -1
  43. package/dist/scope/binding-scope.d.ts +1 -1
  44. package/dist/types.d.ts +41 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/__tests__/binding-scope-ratchet.test.ts +146 -21
  48. package/src/__tests__/component-type-parameters.test.ts +70 -0
  49. package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +85 -0
  50. package/src/__tests__/csr-substitute-enclosing-scope.test.ts +77 -0
  51. package/src/__tests__/free-refs.test.ts +1 -1
  52. package/src/__tests__/identifier-pattern.test.ts +170 -0
  53. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +107 -0
  54. package/src/__tests__/mutable-binding-writers.test.ts +133 -0
  55. package/src/__tests__/preamble-conditional-reactivity.test.ts +191 -0
  56. package/src/__tests__/rewrite-dynamic-imports.test.ts +98 -0
  57. package/src/__tests__/signal-setter-updater-type.test.ts +81 -0
  58. package/src/adapters/jsx-adapter.ts +31 -4
  59. package/src/adapters/loop-bound-names.ts +18 -0
  60. package/src/adapters/template-imports.ts +93 -0
  61. package/src/adapters/test-adapter.ts +4 -1
  62. package/src/augment-inherited-props.ts +13 -1
  63. package/src/compiler.ts +18 -0
  64. package/src/debug.ts +38 -24
  65. package/src/free-refs.ts +14 -5
  66. package/src/identifier-pattern.ts +79 -0
  67. package/src/index.ts +1 -1
  68. package/src/ir-to-client-js/collect-elements.ts +60 -16
  69. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +2 -1
  70. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  71. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  72. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +2 -1
  73. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +12 -2
  74. package/src/ir-to-client-js/csr-substitute.ts +19 -2
  75. package/src/ir-to-client-js/html-template.ts +42 -31
  76. package/src/ir-to-client-js/imports.ts +2 -1
  77. package/src/ir-to-client-js/prop-handling.ts +28 -1
  78. package/src/ir-to-client-js/reactivity.ts +58 -6
  79. package/src/ir-to-client-js/rewrite-props-object.ts +2 -1
  80. package/src/ir-to-client-js/types.ts +16 -0
  81. package/src/ir-to-client-js/utils.ts +24 -8
  82. package/src/jsx-to-ir.ts +135 -10
  83. package/src/module-exports.ts +140 -2
  84. package/src/relocate.ts +2 -1
  85. package/src/scope/binding-scope.ts +1 -1
  86. package/src/types.ts +41 -0
@@ -18,6 +18,41 @@ import { attrValueToString, freeIdsFromRefs, tokenContainsIdent } from './utils.
18
18
  import { expandConstantForReactivity } from './prop-handling.ts'
19
19
  import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
20
20
  import { walkIR, stopAt } from './walker.ts'
21
+ import { BindingScope } from '../scope/binding-scope.ts'
22
+ import { identifierCallPattern } from '../identifier-pattern.ts'
23
+
24
+ /**
25
+ * Build the `BindingScope` for one loop row's own bindings — item /
26
+ * destructured param / index names plus (when supplied) the `.map()`
27
+ * callback's preamble locals (#2447) — for the `expandConstantForReactivity`
28
+ * shadow guard (#2482 Stage 1b). `undefined` when there's no enclosing loop
29
+ * (`loopParam` unset), matching every caller's existing "no loop" shape.
30
+ *
31
+ * `loopIndex` (Copilot review on #2595): a `.map((item, i) => ...)`
32
+ * callback's second param is a real row-scoped binding exactly like
33
+ * `item` — an `i`-shadows-a-module-const attr/text (e.g.
34
+ * `data-idx={/* @client *\/ i}`) const-folds to the outer value exactly
35
+ * like an item-param shadow does when the index name isn't in scope.
36
+ * Only guarded when the CALLER passes it in, though — none of the
37
+ * `collectLoopChild*` collectors' own callers threaded the IR loop's
38
+ * `index` field through before this fix (see each call site's own
39
+ * `undefined`/omitted-arg default), so `undefined` here still means "not
40
+ * guarded," not "no index param."
41
+ */
42
+ export function buildLoopRowScope(
43
+ loopParam?: string,
44
+ loopParamBindings?: readonly LoopParamBinding[],
45
+ preambleNames?: ReadonlySet<string>,
46
+ loopIndex?: string | null,
47
+ ): BindingScope | undefined {
48
+ if (!loopParam) return undefined
49
+ return BindingScope.EMPTY.enterLoopRow({
50
+ param: loopParam,
51
+ paramBindings: loopParamBindings,
52
+ index: loopIndex,
53
+ preamble: preambleNames && preambleNames.size > 0 ? { declaredNames: [...preambleNames] } : undefined,
54
+ })
55
+ }
21
56
 
22
57
  /**
23
58
  * Phase 2 reactivity detection: determines if a code expression needs `createEffect`
@@ -146,13 +181,13 @@ export function needsEffectWrapper(
146
181
  freeIdentifiers?: ReadonlySet<string>,
147
182
  ): boolean {
148
183
  for (const signal of ctx.signals) {
149
- if (new RegExp(`\\b${signal.getter}\\s*\\(`).test(expr)) {
184
+ if (identifierCallPattern(signal.getter).test(expr)) {
150
185
  return true
151
186
  }
152
187
  }
153
188
 
154
189
  for (const memo of ctx.memos) {
155
- if (new RegExp(`\\b${memo.name}\\s*\\(`).test(expr)) {
190
+ if (identifierCallPattern(memo.name).test(expr)) {
156
191
  return true
157
192
  }
158
193
  }
@@ -555,6 +590,14 @@ function traverseForComponents(
555
590
  * Defaults to false: some callers (e.g. a plain nested `.map()`'s own
556
591
  * per-item bindings) have no sibling conditional collector, so a slotId'd
557
592
  * conditional's inlined content is only ever reachable by descending here.
593
+ *
594
+ * `preambleNames` (#2482 Stage 1b): the enclosing loop's `.map()` callback
595
+ * preamble locals (#2447), when known — folded into the `expandConstantForReactivity`
596
+ * shadow guard via `buildLoopRowScope` so a preamble local shadowing a
597
+ * component/module const doesn't get const-folded here.
598
+ *
599
+ * `loopIndex` (Copilot review on #2595): the loop's index param name
600
+ * (`.map((item, i) => ...)`'s `i`), when known — see `buildLoopRowScope`.
558
601
  */
559
602
  export function collectLoopChildReactiveTexts(
560
603
  node: IRNode,
@@ -562,8 +605,11 @@ export function collectLoopChildReactiveTexts(
562
605
  loopParam?: string,
563
606
  loopParamBindings?: readonly LoopParamBinding[],
564
607
  stopAtReactiveConditionals = false,
608
+ preambleNames?: ReadonlySet<string>,
609
+ loopIndex?: string | null,
565
610
  ): LoopChildReactiveText[] {
566
611
  const texts: LoopChildReactiveText[] = []
612
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
567
613
  walkIR(node, false, {
568
614
  // Skip loop/async/if-statement subtrees — the original walker omitted
569
615
  // them; they have their own scopes (inner-loop reconciliation, async
@@ -579,7 +625,7 @@ export function collectLoopChildReactiveTexts(
579
625
  // (a `joinArrayChild` region's value is raw HTML, not text).
580
626
  if (n.preambleRegion) return
581
627
  const originFreeIds = freeIdsFromRefs(n.origin?.freeRefs)
582
- const expanded = expandConstantForReactivity(n.expr, ctx, originFreeIds)
628
+ const expanded = expandConstantForReactivity(n.expr, ctx, originFreeIds, scope)
583
629
  // Include if expression reads signals OR references the loop parameter
584
630
  // (loop param becomes a signal accessor via per-item signals). Falls
585
631
  // back to the Solid-style AST-flag wrap decision — mirroring
@@ -621,10 +667,14 @@ export function collectLoopChildReactiveTexts(
621
667
  * `stopAtReactiveConditionals` (#2347): see `collectLoopChildReactiveTexts` —
622
668
  * same semantics. Pass true only when the caller also separately collects
623
669
  * nested reactive conditionals and gives each its own `insert()`.
670
+ *
671
+ * `loopIndex` (Copilot review on #2595): see `collectLoopChildReactiveTexts`.
624
672
  */
625
673
  /** 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 {
674
+ * spreading — this runs per attribute (Copilot review). Exported for
675
+ * `collectLoopChildConditionals`'s `readsPreamble` check (#2596), the
676
+ * condition-position twin of the attr check just below this uses it for. */
677
+ export function anyNameIn(names: Iterable<string>, set: ReadonlySet<string>): boolean {
628
678
  for (const n of names) if (set.has(n)) return true
629
679
  return false
630
680
  }
@@ -636,8 +686,10 @@ export function collectLoopChildReactiveAttrs(
636
686
  loopParamBindings?: readonly LoopParamBinding[],
637
687
  stopAtReactiveConditionals = false,
638
688
  preambleNames?: ReadonlySet<string>,
689
+ loopIndex?: string | null,
639
690
  ): LoopChildReactiveAttr[] {
640
691
  const attrs: LoopChildReactiveAttr[] = []
692
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
641
693
  traverseElements(node, (el) => {
642
694
  if (el.slotId) {
643
695
  for (const attr of el.attrs) {
@@ -653,7 +705,7 @@ export function collectLoopChildReactiveAttrs(
653
705
  if (attr.name === 'key') continue
654
706
  const valueStr = attrValueToString(attr.value)
655
707
  if (!valueStr) continue
656
- const expanded = expandConstantForReactivity(valueStr, ctx, attr.freeIdentifiers)
708
+ const expanded = expandConstantForReactivity(valueStr, ctx, attr.freeIdentifiers, scope)
657
709
  // `/* @client */` always defers via per-item createEffect
658
710
  // regardless of the reactivity classifier — matches the
659
711
  // top-level `collectElements.element` and the conditional
@@ -24,6 +24,7 @@
24
24
 
25
25
  import ts from 'typescript'
26
26
  import { PROPS_PARAM } from './utils.ts'
27
+ import { identifierPattern } from '../identifier-pattern.ts'
27
28
 
28
29
  /**
29
30
  * Rename every value-position reference to `propsObjectName` in `code`
@@ -36,7 +37,7 @@ export function rewritePropsObjectRef(code: string, propsObjectName: string | nu
36
37
  if (srcPropsName === PROPS_PARAM) return code
37
38
 
38
39
  // Quick exit when the name doesn't appear at all.
39
- if (!new RegExp(`\\b${srcPropsName}\\b`).test(code)) return code
40
+ if (!identifierPattern(srcPropsName).test(code)) return code
40
41
 
41
42
  const sourceFile = ts.createSourceFile(
42
43
  'init-body.ts',
@@ -578,6 +578,22 @@ export interface LoopChildConditional {
578
578
  * constants' own `freeIdentifiers`.
579
579
  */
580
580
  conditionFreeIdentifiers?: ReadonlySet<string>
581
+ /**
582
+ * `condition` references a `.map()` callback preamble-declared name
583
+ * (#2596, twin of `LoopChildReactiveAttr.readsPreamble` #2447) — the
584
+ * emitter must run the preamble ahead of evaluating the condition getter
585
+ * passed to `insert()`, since the local is not otherwise in scope there.
586
+ * Set whenever the condition mentions ANY preamble-declared name
587
+ * (`preambleNamesOf`), regardless of why the conditional was classified
588
+ * reactive — the scope obligation is the same either way. Whether the
589
+ * conditional is wired reactive AT ALL is the separate, stricter Phase-1
590
+ * question (`IRLoop.preamble.reactiveNames` via
591
+ * `markPreambleConditionalReactivity`, plus the ordinary signal/prop
592
+ * classifiers); `classifyReactivity` can't answer it from the token
593
+ * alone because `expandConstantForReactivity`'s shadow guard leaves a
594
+ * preamble-bound identifier unexpanded on purpose (#2482 Stage 1b).
595
+ */
596
+ readsPreamble?: boolean
581
597
  }
582
598
 
583
599
  export interface TopLevelLoop extends LoopCore {
@@ -8,6 +8,7 @@ import type { AttrValue, IRTemplatePart, LoopParamBinding, FreeReference, IRNode
8
8
  import type { TopLevelLoop, BranchLoop, LoopOffset } from './types.ts'
9
9
  import { buildLoopChainExpr } from '../loop-chain.ts'
10
10
  import { templatePartsToJsExpr } from '../template-parts.ts'
11
+ import { escapeIdentifierForRegex, ID_BOUNDARY_BEFORE, ID_BOUNDARY_AFTER } from '../identifier-pattern.ts'
11
12
  import {
12
13
  iterateJsTokens,
13
14
  isIdentifierLikeToken,
@@ -337,10 +338,6 @@ export function inferDefaultValue(type: { kind: string; primitive?: string }): s
337
338
  return 'undefined'
338
339
  }
339
340
 
340
- function escapeRegExp(s: string): string {
341
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
342
- }
343
-
344
341
  /**
345
342
  * Flatten an `OriginInfo.freeRefs` list to a plain `Set<string>` of free
346
343
  * identifier names (#1267). Skips `kind: 'reactive-brand'` entries whose
@@ -561,8 +558,12 @@ export function wrapLoopParamAsAccessor(expr: string, paramName: string, binding
561
558
  if (bindings && bindings.length > 0) {
562
559
  return rewriteLoopBindingRefs(expr, bindings, '__bfItem()')
563
560
  }
564
- const re = new RegExp(`\\b${escapeRegExp(paramName)}\\b(?!\\s*\\()(?!-)`, 'g')
565
- return replaceInExprContexts(expr, re, `${paramName}()`)
561
+ // `paramName` is a legal JS identifier and may itself start with `$`
562
+ // (`$1`, `$&`, ) — a plain string replacement would risk `String.replace`
563
+ // reading those as backreference/whole-match sequences, so use a replacer
564
+ // function to insert the accessor text literally (#2592).
565
+ const re = new RegExp(`${ID_BOUNDARY_BEFORE}${escapeIdentifierForRegex(paramName)}(?!\\s*\\()(?!-)${ID_BOUNDARY_AFTER}`, 'gu')
566
+ return replaceInExprContexts(expr, re, () => `${paramName}()`)
566
567
  }
567
568
 
568
569
  /**
@@ -588,8 +589,8 @@ function rewriteLoopBindingRefs(
588
589
  const byName = new Map<string, LoopParamBinding>()
589
590
  for (const b of bindings) byName.set(b.name, b)
590
591
  const preprocessed = expandShorthandBindings(expr, new Set(byName.keys()))
591
- const alt = bindings.map(b => escapeRegExp(b.name)).join('|')
592
- const re = new RegExp(`\\b(${alt})\\b`, 'g')
592
+ const alt = bindings.map(b => escapeIdentifierForRegex(b.name)).join('|')
593
+ const re = new RegExp(`${ID_BOUNDARY_BEFORE}(${alt})${ID_BOUNDARY_AFTER}`, 'gu')
593
594
  return replaceInExprContexts(preprocessed, re, (_m: string, name: string) =>
594
595
  renderLoopBindingAccess(byName.get(name)!, accessor),
595
596
  )
@@ -697,6 +698,21 @@ export interface LoopParamSpec {
697
698
  * avoiding post-hoc regex replacement on full template strings.
698
699
  *
699
700
  * Accepts either a bare param name or a spec carrying destructure bindings.
701
+ *
702
+ * #2482: this `loopParams` parameter (and the same-named param on
703
+ * `irToHtmlTemplate` / `irToPlaceholderTemplate` in `html-template.ts`,
704
+ * threaded through `collect-elements.ts` / `build-event-delegation.ts`) is
705
+ * NOT one of the ratchet's tracked ad-hoc scope devices, even though the
706
+ * ledger's textual pattern happens to match its spelling. `BindingScope`
707
+ * answers EXISTENCE/kind/depth queries about bound names; this ordered
708
+ * `ReadonlyArray<string | LoopParamSpec>` instead carries the ACCESSOR-
709
+ * REWRITE payload for outermost-to-innermost text substitution
710
+ * (`item` → `__bfItem().path`) — a rendering/codegen concern `BindingScope`
711
+ * has no field for by design, the client-JS-emitter twin of the Go
712
+ * adapter's `loopBindingStack` (see that field's docstring on
713
+ * `GoTemplateAdapter`). Order matters here (each nesting level's wrap
714
+ * composes over the previous), which is exactly what a scope EXISTENCE
715
+ * stack does not model.
700
716
  */
701
717
  export function wrapExprWithLoopParams(expr: string, loopParams?: ReadonlyArray<string | LoopParamSpec>): string {
702
718
  if (!loopParams) return expr
package/src/jsx-to-ir.ts CHANGED
@@ -63,6 +63,7 @@ import { reconstructAsSegments } from './strip-types.ts'
63
63
  import { templatePartsToJsExpr } from './template-parts.ts'
64
64
  import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
65
65
  import { BindingScope } from './scope/binding-scope.ts'
66
+ import { identifierPattern, identifierCallPattern } from './identifier-pattern.ts'
66
67
 
67
68
  // =============================================================================
68
69
  // Transform Context
@@ -681,19 +682,19 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
681
682
  patterns: {
682
683
  signals: analyzer.signals.map(s => ({
683
684
  getter: s.getter,
684
- pattern: new RegExp(`\\b${s.getter}\\s*\\(`),
685
+ pattern: identifierCallPattern(s.getter),
685
686
  })),
686
687
  memos: analyzer.memos.map(m => ({
687
688
  name: m.name,
688
- pattern: new RegExp(`\\b${m.name}\\s*\\(`),
689
+ pattern: identifierCallPattern(m.name),
689
690
  })),
690
691
  props: analyzer.propsParams
691
692
  .filter(p => p.name !== 'children')
692
- .map(p => ({ name: p.name, pattern: new RegExp(`\\b${p.name}\\b`) })),
693
+ .map(p => ({ name: p.name, pattern: identifierPattern(p.name) })),
693
694
  constants: analyzer.localConstants.map(c => ({
694
695
  name: c.name,
695
696
  value: c.value,
696
- pattern: new RegExp(`\\b${c.name}\\b`),
697
+ pattern: identifierPattern(c.name),
697
698
  })),
698
699
  },
699
700
  getJS(node: ts.Node): string {
@@ -844,7 +845,7 @@ function makeBindingEnv(ctx: TransformContext): BindingEnvironment {
844
845
  // mutated (cached on the immutable `BindingScope`) — a stable
845
846
  // snapshot even if `ctx.scope` is later reassigned by an enclosing
846
847
  // visitor frame, which swaps the instance rather than mutating it.
847
- loopParams: boundNames,
848
+ loopValueBoundNames: boundNames,
848
849
  checker: a.checker,
849
850
  }
850
851
  ctx._bindingEnv = env
@@ -2201,7 +2202,7 @@ function transformExpressionInner(
2201
2202
  // (#2482 Stage 1a Commit 2).
2202
2203
  const scopeValueNames = ctx.scope.valueBoundNames()
2203
2204
  const refsLoopParam = scopeValueNames.size > 0
2204
- && Array.from(scopeValueNames).some(p => new RegExp(`\\b${p}\\b`).test(exprText))
2205
+ && Array.from(scopeValueNames).some(p => identifierPattern(p).test(exprText))
2205
2206
 
2206
2207
  // Compute AST-derived flags. `callsReactive` recognises signal-getter / memo
2207
2208
  // calls even inside deeper expressions (e.g., `format(count())`); `hasCalls`
@@ -2267,7 +2268,11 @@ function transformJsxFunctionCall(
2267
2268
  const substitutedGetJS = (node: ts.Node) => {
2268
2269
  let text = baseGetJS(node)
2269
2270
  for (const [paramName, argExpr] of substitutions) {
2270
- text = text.replace(new RegExp(`\\b${paramName}\\b`, 'g'), argExpr)
2271
+ // Replacer function, not the string form: `argExpr` is arbitrary
2272
+ // caller-supplied JS text and may itself contain `$` sequences
2273
+ // (`$&`, `$1`, …) that `String.replace`'s string-replacement form
2274
+ // would interpret specially instead of inserting literally (#2592).
2275
+ text = text.replace(identifierPattern(paramName, 'g'), () => argExpr)
2271
2276
  }
2272
2277
  return text
2273
2278
  }
@@ -2319,7 +2324,11 @@ function transformMultiReturnJsxFunctionCall(
2319
2324
  const substitutedGetJS = (node: ts.Node) => {
2320
2325
  let text = baseGetJS(node)
2321
2326
  for (const [paramName, argExpr] of substitutions) {
2322
- text = text.replace(new RegExp(`\\b${paramName}\\b`, 'g'), argExpr)
2327
+ // Replacer function, not the string form: `argExpr` is arbitrary
2328
+ // caller-supplied JS text and may itself contain `$` sequences
2329
+ // (`$&`, `$1`, …) that `String.replace`'s string-replacement form
2330
+ // would interpret specially instead of inserting literally (#2592).
2331
+ text = text.replace(identifierPattern(paramName, 'g'), () => argExpr)
2323
2332
  }
2324
2333
  return text
2325
2334
  }
@@ -5012,6 +5021,13 @@ function transformMapCall(
5012
5021
  // the row effect re-runs the preamble ahead of the write.
5013
5022
  if (preamble && !isStaticArray) {
5014
5023
  markPreambleAttrSlots(children, new Set(preamble.declaredNames), ctx)
5024
+ // #2596 follow-up to the two passes above — the CONDITION-position twin.
5025
+ // See `markPreambleConditionalReactivity`'s docstring for why this one is
5026
+ // gated on `reactiveNames` (genuinely signal-derived) rather than the
5027
+ // blanket `declaredNames` the region/attr passes use.
5028
+ if (preamble.reactiveNames && preamble.reactiveNames.length > 0) {
5029
+ markPreambleConditionalReactivity(children, new Set(preamble.reactiveNames), ctx)
5030
+ }
5015
5031
  }
5016
5032
 
5017
5033
  // Collect nested components for both static and dynamic arrays.
@@ -5448,6 +5464,67 @@ function markPreambleAttrSlots(
5448
5464
  visit(nodes)
5449
5465
  }
5450
5466
 
5467
+ /**
5468
+ * Grant the IR `reactive` flag (and a slot id) to a loop-body CONDITIONAL
5469
+ * whose condition bare-references a preamble local that is itself reactive
5470
+ * (#2596) — `reactiveNames`, computed by `computePreambleReactiveNames`.
5471
+ *
5472
+ * Root cause this closes: a plain (non-preamble) condition's `reactive` flag
5473
+ * comes from `isReactiveExpression`/`isReactiveOrigin` run against the
5474
+ * condition's OWN text at `transformConditional`/`transformLogicalAnd`/the
5475
+ * if-else-chain builder — none of which know about an enclosing loop's
5476
+ * `.map()` callback preamble (that's assembled and only becomes available
5477
+ * on `IRLoop` after `children` is already built, same ordering constraint
5478
+ * `collectPreambleRegions`/`markPreambleAttrSlots` work around). A condition
5479
+ * that's just a bare reference to a preamble local (`label ? <A/> : <B/>`)
5480
+ * therefore falls through every one of those classifiers with `reactive:
5481
+ * false` even when `label`'s initializer reads a signal — the classifiers
5482
+ * only ever see the token `label`, never its declaration.
5483
+ *
5484
+ * Deliberately narrower than `collectPreambleRegions`/`markPreambleAttrSlots`:
5485
+ * those two mark ANY reference to `declaredNames` because their region-patch
5486
+ * effect re-runs the WHOLE preamble unconditionally on row update, so a
5487
+ * merely item-derived local (`const label = item.title`) still ends up
5488
+ * correctly tracked by ordinary signal auto-tracking inside that effect —
5489
+ * marking it reactive there costs nothing extra. A conditional is different:
5490
+ * `reactive: true` here means Phase 2 wraps it in an `insert()` that swaps
5491
+ * whole DOM subtrees, which is real cost and, for an item-only local, pure
5492
+ * waste — the row already renders fresh whenever it's (re)constructed. So
5493
+ * this only fires for `reactiveNames`, the subset PROVEN to depend on an
5494
+ * actual external signal/memo/prop.
5495
+ *
5496
+ * Mirrors the sibling passes' traversal shape (conditional arms included).
5497
+ */
5498
+ function markPreambleConditionalReactivity(
5499
+ nodes: IRNode[],
5500
+ reactiveNames: ReadonlySet<string>,
5501
+ ctx: TransformContext,
5502
+ ): void {
5503
+ if (reactiveNames.size === 0) return
5504
+ const visit = (list: IRNode[]): void => {
5505
+ for (const node of list) {
5506
+ switch (node.type) {
5507
+ case 'element':
5508
+ case 'fragment':
5509
+ visit(node.children)
5510
+ break
5511
+ case 'conditional': {
5512
+ if (!node.reactive) {
5513
+ const refs = extractFreeIdentifiersFromText(node.condition)
5514
+ if ([...refs].some((r) => reactiveNames.has(r))) {
5515
+ node.reactive = true
5516
+ if (!node.slotId) node.slotId = generateSlotId(ctx)
5517
+ }
5518
+ }
5519
+ visit([node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])])
5520
+ break
5521
+ }
5522
+ }
5523
+ }
5524
+ }
5525
+ visit(nodes)
5526
+ }
5527
+
5451
5528
  /**
5452
5529
  * Expression-bearing source text of an attribute value, for the
5453
5530
  * free-identifier scan. Only used as a FALLBACK — an attribute normally
@@ -5494,6 +5571,52 @@ function collectPreambleDeclaredNames(stmt: ts.Statement, out: Set<string>): voi
5494
5571
  }
5495
5572
  }
5496
5573
 
5574
+ /**
5575
+ * Which {@link MapCallbackPreamble.declaredNames} are themselves reactive —
5576
+ * their OWN initializer reads a signal / memo / reactive prop, directly or
5577
+ * transitively through an earlier declaration in the same preamble (#2596).
5578
+ *
5579
+ * Reuses the same Phase-1 classifier (`isReactiveExpression`) already applied
5580
+ * to every non-preamble condition/attribute — this doesn't invent a second
5581
+ * reactivity heuristic, it runs the existing one over one more declaration
5582
+ * list. `ctx.patterns.constants` (the component-level constant chain
5583
+ * `isSignalOrMemoReference`/`isPropsReference` already walk) doesn't reach
5584
+ * these names — they're scoped to the `.map()` callback body, never added to
5585
+ * `ctx.patterns` — so without this pass a preamble local reading a signal is
5586
+ * invisible to the classifier.
5587
+ *
5588
+ * Value-declaration statements only (`const`/`let x = expr`, including
5589
+ * destructuring targets, which mark every bound name reactive together since
5590
+ * they share one initializer). A non-declaration preamble statement
5591
+ * (assignment, loop, side-effecting call) contributes no reactive names —
5592
+ * consistent with `neutralPreambleDeclarations`'s all-or-nothing DSL gate
5593
+ * treating anything past that as un-lowerable, though this walk (client/JS-
5594
+ * runtime SSR only) doesn't require the same all-or-nothing: it simply skips
5595
+ * what it can't classify.
5596
+ */
5597
+ function computePreambleReactiveNames(
5598
+ statements: readonly ts.Statement[],
5599
+ ctx: TransformContext,
5600
+ ): Set<string> {
5601
+ const reactiveNames = new Set<string>()
5602
+ for (const stmt of statements) {
5603
+ if (!ts.isVariableStatement(stmt)) continue
5604
+ for (const decl of stmt.declarationList.declarations) {
5605
+ if (!decl.initializer) continue
5606
+ const boundNames = new Set<string>()
5607
+ collectBindingNames(decl.name, boundNames)
5608
+ const initText = ctx.getJS(decl.initializer)
5609
+ const initFreeRefs = extractFreeIdentifiersFromNode(decl.initializer)
5610
+ const readsEarlierReactive = [...initFreeRefs].some((r) => reactiveNames.has(r))
5611
+ const isReactive = readsEarlierReactive || isReactiveExpression(initText, ctx, decl.initializer)
5612
+ if (isReactive) {
5613
+ for (const n of boundNames) reactiveNames.add(n)
5614
+ }
5615
+ }
5616
+ }
5617
+ return reactiveNames
5618
+ }
5619
+
5497
5620
  /**
5498
5621
  * Build a {@link MapCallbackPreamble} from value-only (JSX-free) pre-return
5499
5622
  * statements — the Stage-2 fold preamble and the plain block-body preamble.
@@ -5517,6 +5640,7 @@ function preambleFromValueStatements(
5517
5640
  typedParts.push(raw0.endsWith(';') ? raw0 : raw0 + ';')
5518
5641
  segments.push(tjs !== js ? { kind: 'js', text: js, templateText: tjs } : { kind: 'js', text: js })
5519
5642
  }
5643
+ const reactiveNames = computePreambleReactiveNames(statements, ctx)
5520
5644
  return {
5521
5645
  segments: trimPreambleSegments(segments),
5522
5646
  ssrText: tsxSourceText(typedParts.join(' ')),
@@ -5524,6 +5648,7 @@ function preambleFromValueStatements(
5524
5648
  // Value-only preambles accumulate no JSX, so no child needs the array join.
5525
5649
  builderNames: [],
5526
5650
  declarations: neutralPreambleDeclarations(statements, ctx) ?? undefined,
5651
+ reactiveNames: reactiveNames.size > 0 ? [...reactiveNames] : undefined,
5527
5652
  }
5528
5653
  }
5529
5654
 
@@ -7104,7 +7229,7 @@ function referencesLoopParam(expr: string, ctx: TransformContext): boolean {
7104
7229
  const boundNames = ctx.scope.valueBoundNames()
7105
7230
  if (boundNames.size === 0) return false
7106
7231
  for (const p of boundNames) {
7107
- if (new RegExp(`\\b${p}\\b`).test(expr)) return true
7232
+ if (identifierPattern(p).test(expr)) return true
7108
7233
  }
7109
7234
  return false
7110
7235
  }
@@ -7238,7 +7363,7 @@ function hasReactiveAttributes(attrs: IRAttribute[], ctx: TransformContext): boo
7238
7363
  const scopeValueNames = ctx.scope.valueBoundNames()
7239
7364
  if (scopeValueNames.size > 0) {
7240
7365
  for (const p of scopeValueNames) {
7241
- if (new RegExp(`\\b${p}\\b`).test(valueToCheck)) return true
7366
+ if (identifierPattern(p).test(valueToCheck)) return true
7242
7367
  }
7243
7368
  }
7244
7369
  }
@@ -5,7 +5,9 @@
5
5
  * This is a compiler-layer concern, not adapter-specific.
6
6
  */
7
7
 
8
+ import ts from 'typescript'
8
9
  import type { ComponentIR, ParamInfo } from './types.ts'
10
+ import { identifierPattern } from './identifier-pattern.ts'
9
11
 
10
12
  /**
11
13
  * Emit module-level exports for local declarations and `export { ... } [from '...']`
@@ -140,7 +142,7 @@ export function findReachableNames(
140
142
  const queue: string[] = []
141
143
 
142
144
  for (const name of allNames) {
143
- if (new RegExp(`\\b${name}\\b`).test(primaryRefs)) {
145
+ if (identifierPattern(name).test(primaryRefs)) {
144
146
  reachable.add(name)
145
147
  queue.push(name)
146
148
  }
@@ -150,7 +152,7 @@ export function findReachableNames(
150
152
  const current = queue.shift()!
151
153
  const body = bodyMap.get(current) || ''
152
154
  for (const name of allNames) {
153
- if (!reachable.has(name) && new RegExp(`\\b${name}\\b`).test(body)) {
155
+ if (!reachable.has(name) && identifierPattern(name).test(body)) {
154
156
  reachable.add(name)
155
157
  queue.push(name)
156
158
  }
@@ -160,6 +162,142 @@ export function findReachableNames(
160
162
  return reachable
161
163
  }
162
164
 
165
+ /**
166
+ * Which of `candidates` does `bodyText` ASSIGN to?
167
+ *
168
+ * Reachability above answers "is this declaration referenced?", which is
169
+ * the right question for pruning SSR-irrelevant code. It is the wrong
170
+ * question for a MUTABLE binding: a surviving `let` whose only writer got
171
+ * pruned is left declared-and-read but never assigned, and TypeScript's
172
+ * control-flow analysis then narrows it to `never` at every guarded use
173
+ * (#2598). `closeOverWritersOfMutableBindings` uses this to restore the
174
+ * missing half of that pair.
175
+ *
176
+ * Recognizes the forms that actually write a local binding:
177
+ * `x = …`, `x += …` (and every other compound operator), `x++`, `--x`
178
+ * Destructuring assignment (`[x] = …`, `({ x } = …)`) is deliberately NOT
179
+ * recognized: it never appears in the ref/handler shapes this exists for,
180
+ * and a wrong guess here over-retains rather than fails loudly, so leaving
181
+ * it out keeps the retained set honest. If one shows up, it will present
182
+ * as this same `never` narrowing and can be added with a fixture.
183
+ *
184
+ * Parsed with the TS AST, not matched as text: `identifierPattern` (used
185
+ * for reference detection above) cannot tell a write from a read, and a
186
+ * regex for `name\s*=` would match `name == x`, a `name=` inside a string
187
+ * or JSX attribute, and a property write `obj.name = x` that assigns
188
+ * nothing of the sort.
189
+ */
190
+ export function findAssignedNames(
191
+ bodyText: string,
192
+ candidates: ReadonlySet<string>,
193
+ ): Set<string> {
194
+ const assigned = new Set<string>()
195
+ if (candidates.size === 0) return assigned
196
+
197
+ const sf = ts.createSourceFile(
198
+ 'bf-assignment-scan.tsx',
199
+ bodyText,
200
+ ts.ScriptTarget.Latest,
201
+ /* setParentNodes */ false,
202
+ ts.ScriptKind.TSX,
203
+ )
204
+
205
+ // A bare Identifier on the left of an assignment — `obj.x = …` is a
206
+ // PropertyAccessExpression and writes through the binding rather than to
207
+ // it, so it does not count.
208
+ const record = (target: ts.Node): void => {
209
+ if (ts.isIdentifier(target) && candidates.has(target.text)) {
210
+ assigned.add(target.text)
211
+ }
212
+ }
213
+
214
+ const visit = (node: ts.Node): void => {
215
+ if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) {
216
+ record(node.left)
217
+ } else if (
218
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
219
+ (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken)
220
+ ) {
221
+ record(node.operand)
222
+ }
223
+ ts.forEachChild(node, visit)
224
+ }
225
+
226
+ ts.forEachChild(sf, visit)
227
+ return assigned
228
+ }
229
+
230
+ /**
231
+ * `findReachableNames`, plus the invariant it cannot express on its own:
232
+ * **a mutable binding that survives keeps the declarations that write it.**
233
+ *
234
+ * Reachability is seeded from the RENDERED JSX, which has already had the
235
+ * client-only attributes stripped — `ref={setRef}` leaves no `setRef`
236
+ * behind, and `onClick={handleClick}` is rendered as `onClick={() => {}}`.
237
+ * That is deliberate: code reachable only from a handler is client-only
238
+ * and should not be emitted into an SSR template.
239
+ *
240
+ * It goes wrong when a `let` outlives its writer. The binding survives
241
+ * because some OTHER surviving declaration reads it, while its only
242
+ * assignment lived in a pruned handler — so the emitted template declares
243
+ * it, reads it, and never assigns it. TypeScript's control-flow analysis
244
+ * concludes it is permanently `null`, narrows every guarded use to `never`,
245
+ * and each member access on it fails:
246
+ *
247
+ * let highlightEl: HTMLElement | null = null // writer was pruned
248
+ * const syncScroll = () => {
249
+ * if (highlightEl && textareaEl) {
250
+ * highlightEl.scrollTop = textareaEl.scrollTop // TS2339 on `never`
251
+ * }
252
+ * }
253
+ *
254
+ * Pulling the writers back in restores the source's shape for exactly the
255
+ * bindings that survived — nothing else. The retained writer is dead code
256
+ * at SSR (it only ever runs from a hydrated event), which is the same
257
+ * harmless-unused-declaration trade `generateModuleScopeDeclarations`
258
+ * already makes deliberately.
259
+ *
260
+ * Iterates to a fixpoint because a newly retained writer can read further
261
+ * declarations, and can itself write another mutable binding. Bounded by
262
+ * the declaration count: each round either adds a name or stops.
263
+ */
264
+ export function closeOverWritersOfMutableBindings(
265
+ primaryRefs: string,
266
+ declarations: { name: string; body: string }[],
267
+ mutableNames: ReadonlySet<string>,
268
+ ): Set<string> {
269
+ let reachable = findReachableNames(primaryRefs, declarations)
270
+ if (mutableNames.size === 0) return reachable
271
+
272
+ let seedText = primaryRefs
273
+ for (let round = 0; round <= declarations.length; round++) {
274
+ const survivingMutables = new Set(
275
+ [...reachable].filter(name => mutableNames.has(name)),
276
+ )
277
+ if (survivingMutables.size === 0) return reachable
278
+
279
+ const added = declarations
280
+ .filter(d => !reachable.has(d.name))
281
+ .filter(d => findAssignedNames(d.body, survivingMutables).size > 0)
282
+ .map(d => d.name)
283
+ if (added.length === 0) return reachable
284
+
285
+ // Re-seed by NAME rather than merging sets directly, so each retained
286
+ // writer's own transitive dependencies come along through the same
287
+ // traversal instead of a second, divergent one.
288
+ seedText += '\n' + added.join('\n')
289
+ reachable = findReachableNames(seedText, declarations)
290
+ }
291
+ return reachable
292
+ }
293
+
294
+ function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
295
+ return (
296
+ kind >= ts.SyntaxKind.FirstAssignment &&
297
+ kind <= ts.SyntaxKind.LastAssignment
298
+ )
299
+ }
300
+
163
301
  /**
164
302
  * Extract parameter names from a function expression string.
165
303
  * Handles: arrow functions, single-param arrows, function expressions.
package/src/relocate.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  } from './adapters/interface.ts'
23
23
  import { tsNodeToParsedExpr, type ParsedExpr } from './expression-parser.ts'
24
24
  import { prepareLoweringMatchers, type LoweringMatcher } from './lowering-registry.ts'
25
+ import { identifierPattern } from './identifier-pattern.ts'
25
26
 
26
27
  export interface RelocateEnv {
27
28
  /**
@@ -713,7 +714,7 @@ function scanRefsByName(
713
714
  ): Map<string, ts.Identifier[]> {
714
715
  const result = new Map<string, ts.Identifier[]>()
715
716
  for (const name of bindings.keys()) {
716
- const re = new RegExp(`\\b${name}\\b`)
717
+ const re = identifierPattern(name)
717
718
  if (re.test(text)) result.set(name, [])
718
719
  }
719
720
  return result