@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
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Single door for building "does this expression text reference identifier
3
+ * X?" regexes (#2592).
4
+ *
5
+ * The naive `new RegExp(`\\b${name}\\b`)` idiom used throughout the compiler
6
+ * breaks for `$`-containing identifiers (legal in JS: `$item`, `item$`,
7
+ * `a$b`) in two independent ways:
8
+ *
9
+ * 1. Unescaped: an interpolated `$` is a regex metacharacter (end-of-
10
+ * input/line anchor), so `\b$item\b` / `\ba$b\b` can (almost) never
11
+ * match mid-string — false negative.
12
+ * 2. Even escaped, `\b` requires a `\w`/non-`\w` transition and `$` is
13
+ * not `\w` (`[A-Za-z0-9_]`) — so `\b\$item\b` still fails to match the
14
+ * leading boundary in `($item)` (both `(` and `$` are non-word, so no
15
+ * transition occurs there).
16
+ *
17
+ * `identifierPattern` / `identifierCallPattern` fix both: the identifier
18
+ * text is escaped before interpolation, and the boundary is asserted with
19
+ * lookaround against `\p{ID_Continue}` (Unicode "can continue an
20
+ * identifier") unioned with `$`, so `$` is correctly treated as
21
+ * identifier-like on both sides of the match.
22
+ *
23
+ * Scope: these remain the same *bounded lexical heuristic* the compiler has
24
+ * always used for expression-text scanning (not a general JS/TS parse —
25
+ * see CLAUDE.md's structural-parsing rule, which does not apply to this
26
+ * class of check). This module only fixes the `$` boundary bug; it does not
27
+ * change what the heuristic considers a "reference" (string literals,
28
+ * comments, and member-access tails are still opaque to it — callers that
29
+ * need that precision use `tokenContainsIdent` / `node.freeIdentifiers`
30
+ * instead, per #1267).
31
+ */
32
+
33
+ // Duplicate regex flags throw at construction ('gu' + 'u' -> SyntaxError),
34
+ // so `u` is added only when the caller didn't already pass it.
35
+ function withUnicodeFlag(flags: string): string {
36
+ return flags.includes('u') ? flags : `${flags}u`
37
+ }
38
+
39
+ /** Escape regex metacharacters in a literal identifier before interpolation. */
40
+ export function escapeIdentifierForRegex(name: string): string {
41
+ return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
42
+ }
43
+
44
+ /**
45
+ * Lookaround fragments asserting "not preceded/followed by an identifier
46
+ * continuation character (including `$`)". `$` is a legal identifier char
47
+ * in JS but is not `\p{ID_Continue}`, so it's unioned in explicitly.
48
+ *
49
+ * Exported for the few call sites that must splice extra assertions
50
+ * between the identifier and the trailing boundary (e.g. "not followed by
51
+ * a call" as well as "not followed by an identifier char") — compose with
52
+ * these fragments rather than reintroducing a bare `\b`.
53
+ */
54
+ export const ID_BOUNDARY_BEFORE = '(?<![\\p{ID_Continue}$])'
55
+ export const ID_BOUNDARY_AFTER = '(?![\\p{ID_Continue}$])'
56
+
57
+ /**
58
+ * Regex matching a standalone reference to identifier `name` — the `$`-safe
59
+ * replacement for `new RegExp(`\\b${name}\\b`)`. Always carries the `u`
60
+ * (unicode) flag, required for `\p{ID_Continue}`; pass additional flags
61
+ * (e.g. `'g'` for `String.replace`/`matchAll` substitution sites) via
62
+ * `flags`.
63
+ */
64
+ export function identifierPattern(name: string, flags = ''): RegExp {
65
+ const esc = escapeIdentifierForRegex(name)
66
+ return new RegExp(`${ID_BOUNDARY_BEFORE}${esc}${ID_BOUNDARY_AFTER}`, withUnicodeFlag(flags))
67
+ }
68
+
69
+ /**
70
+ * Regex matching identifier `name` used in call position (`name(...)`,
71
+ * allowing whitespace before the paren) — the `$`-safe replacement for
72
+ * `new RegExp(`\\b${name}\\s*\\(`)`. No trailing boundary assertion is
73
+ * needed: `\s`/`(` are already not `\p{ID_Continue}`/`$`, so they can't be
74
+ * mistaken for a continuation of `name`.
75
+ */
76
+ export function identifierCallPattern(name: string, flags = ''): RegExp {
77
+ const esc = escapeIdentifierForRegex(name)
78
+ return new RegExp(`${ID_BOUNDARY_BEFORE}${esc}\\s*\\(`, withUnicodeFlag(flags))
79
+ }
package/src/index.ts CHANGED
@@ -89,7 +89,7 @@ export type {
89
89
  } from './adapters/interface.ts'
90
90
  export { JsxAdapter } from './adapters/jsx-adapter.ts'
91
91
  export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
92
- export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
92
+ export { rewriteImportsForTemplate, rewriteDynamicImportsInSource } from './adapters/template-imports.ts'
93
93
  export { emitParsedExpr, groupBinaryOperand, isStringTypedOperand, isStringConcatBinary } from './adapters/parsed-expr-emitter.ts'
94
94
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
95
95
  export { collectLoopBoundNames } from './adapters/loop-bound-names.ts'
@@ -5,12 +5,14 @@
5
5
  import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp, pickAttrMetaFromIR } from '../types.ts'
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
- import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings } from './reactivity.ts'
8
+ import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings, buildLoopRowScope, anyNameIn } from './reactivity.ts'
9
9
  import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, renderFlatMapClientBody, renderFlatMapProjectionClientBody, flatMapCallbackHasKeyedLeaf, type SkeletonSlotPaths } from './html-template.ts'
10
10
  import { templateRootIsSvg } from './control-flow/stringify/template-parse.ts'
11
11
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
12
+ import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
12
13
  import { walkIR, stopAt } from './walker.ts'
13
14
  import { buildLoopChainExpr } from '../loop-chain.ts'
15
+ import { identifierPattern } from '../identifier-pattern.ts'
14
16
 
15
17
  /** Expressions that render nothing (0 DOM nodes) — `&&` / `?:` empty branches. */
16
18
  const EMPTY_RENDER_EXPRS = new Set(['null', 'undefined', 'false', "''", '""', '``'])
@@ -311,7 +313,7 @@ export function collectInnerLoops(
311
313
  const template = n.children.map(c => irToPlaceholderTemplate(c, undefined, emitDepth, loopParamsForTemplate)).join('')
312
314
  // Check if array expression references the outer loop param
313
315
  const refsOuter = outerLoopParam
314
- ? new RegExp(`\\b${outerLoopParam}\\b`).test(n.array)
316
+ ? identifierPattern(outerLoopParam).test(n.array)
315
317
  : false
316
318
  // Per-item bindings for inner loop body, collected uniformly when
317
319
  // ctx is available: reactiveTexts / reactiveAttrs / refs are each
@@ -332,8 +334,8 @@ export function collectInnerLoops(
332
334
  const innerPreambleNames = preambleNamesOf(n)
333
335
  if (ctx) {
334
336
  for (const child of n.children) {
335
- bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings))
336
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, false, innerPreambleNames))
337
+ bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings, false, innerPreambleNames, n.index))
338
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, false, innerPreambleNames, n.index))
337
339
  bindings.refs.push(...collectLoopChildRefs(child))
338
340
  }
339
341
  }
@@ -375,6 +377,8 @@ export function collectInnerLoops(
375
377
  siblingOffsets,
376
378
  n.param,
377
379
  n.paramBindings,
380
+ innerPreambleNames,
381
+ n.index,
378
382
  ))
379
383
  }
380
384
  }
@@ -671,7 +675,7 @@ export function collectElements(
671
675
  const childHandlers: string[] = []
672
676
  const bindings = projectionInner
673
677
  ? emptyLoopChildBindings()
674
- : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings, preambleNamesOf(l))
678
+ : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings, preambleNamesOf(l), l.index)
675
679
  if (!projectionInner) {
676
680
  for (const child of l.children) {
677
681
  childHandlers.push(...collectEventHandlersFromIR(child))
@@ -1146,7 +1150,7 @@ function collectBranchLoops(
1146
1150
  // which caused reactive reads inside simple loop bodies to silently
1147
1151
  // no-op for existing items.
1148
1152
  const branchBindings = ctx && !projectionInner
1149
- ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings, preambleNamesOf(n))
1153
+ ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings, preambleNamesOf(n), n.index)
1150
1154
  : emptyLoopChildBindings()
1151
1155
 
1152
1156
  loops.push({
@@ -1346,6 +1350,12 @@ export function collectLoopChildBindings(
1346
1350
  * `collectLoopChildReactiveAttrs`. Omitted for a loop with no preamble.
1347
1351
  */
1348
1352
  preambleNames?: ReadonlySet<string>,
1353
+ /**
1354
+ * The loop's index param name (`.map((item, i) => ...)`'s `i`), when
1355
+ * present — folded into the shadow guard (Copilot review on #2595) so
1356
+ * an index-shadowing const doesn't get const-folded either.
1357
+ */
1358
+ loopIndex?: string | null,
1349
1359
  ): LoopChildBindings {
1350
1360
  const bindings = emptyLoopChildBindings()
1351
1361
  for (const child of children) {
@@ -1355,10 +1365,10 @@ export function collectLoopChildBindings(
1355
1365
  // `collectLoopChildConditionals`, which gives each its own insert() +
1356
1366
  // arm-scoped attrs/texts (`LoopChildBranchSummary.reactiveAttrs` /
1357
1367
  // `.reactiveTexts`) — descending into them here too would double-bind.
1358
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings, true, preambleNames))
1359
- bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, loopParam, loopParamBindings, true))
1368
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings, true, preambleNames, loopIndex))
1369
+ bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, loopParam, loopParamBindings, true, preambleNames, loopIndex))
1360
1370
  bindings.refs.push(...collectLoopChildRefs(child))
1361
- bindings.conditionals.push(...collectLoopChildConditionals(child, ctx, siblingOffsets, loopParam, loopParamBindings))
1371
+ bindings.conditionals.push(...collectLoopChildConditionals(child, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex))
1362
1372
  }
1363
1373
  return bindings
1364
1374
  }
@@ -1369,8 +1379,24 @@ export function collectLoopChildConditionals(
1369
1379
  siblingOffsets: Map<IRLoop, IRNode[]>,
1370
1380
  loopParam?: string,
1371
1381
  loopParamBindings?: readonly import('../types.ts').LoopParamBinding[],
1382
+ /**
1383
+ * The enclosing loop's `.map()` callback preamble locals (#2447), when
1384
+ * known — folded into the `expandConstantForReactivity` shadow guard
1385
+ * (#2482 Stage 1b) so a preamble local shadowing a component/module
1386
+ * const doesn't get const-folded into the condition, which would
1387
+ * corrupt `classifyReactivity`'s verdict below (a substituted literal
1388
+ * reads as "not reactive," silently freezing the branch at its initial
1389
+ * value instead of wiring an `insert()`).
1390
+ */
1391
+ preambleNames?: ReadonlySet<string>,
1392
+ /**
1393
+ * The loop's index param name, when present — see
1394
+ * `collectLoopChildBindings`'s doc comment (Copilot review on #2595).
1395
+ */
1396
+ loopIndex?: string | null,
1372
1397
  ): LoopChildConditional[] {
1373
1398
  const conditionals: LoopChildConditional[] = []
1399
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
1374
1400
 
1375
1401
  // Widen the source-level "references loop param" check so destructured
1376
1402
  // callbacks fire too — the pattern text `[, cfg]` never word-matches on
@@ -1402,10 +1428,23 @@ export function collectLoopChildConditionals(
1402
1428
  // Pre-gate using AST `reactive` flag on the source condition before
1403
1429
  // paying for constant expansion — matches the legacy short-circuit.
1404
1430
  if (!n.reactive && !refsLoopParamInSource) return
1405
- const expanded = expandConstantForReactivity(n.condition, ctx, sourceFreeIds)
1431
+ const expanded = expandConstantForReactivity(n.condition, ctx, sourceFreeIds, scope)
1432
+ // A `.map()` callback preamble local (#2596, twin of
1433
+ // `collectLoopChildReactiveAttrs`'s `readsPreamble` #2447). Phase 1
1434
+ // already proved this condition reactive when it's set (`n.reactive`
1435
+ // came from `markPreambleConditionalReactivity`, gated on the local's
1436
+ // OWN initializer reading a signal) — `classifyReactivity` below
1437
+ // cannot independently confirm that: `expandConstantForReactivity`
1438
+ // leaves a preamble-bound identifier like `label` unexpanded on
1439
+ // purpose (the #2482 Stage 1b shadow guard, `scope.isBound`), so the
1440
+ // raw token never string-matches a signal/memo/prop pattern.
1441
+ const readsPreamble =
1442
+ preambleNames !== undefined &&
1443
+ preambleNames.size > 0 &&
1444
+ anyNameIn(expanded.freeIds ?? extractFreeIdentifiersFromText(expanded.expr), preambleNames)
1406
1445
  // Loop-param conditionals are reactive via per-item signal accessors;
1407
1446
  // classifyReactivity sees both paths (signal/memo/prop + loop-param).
1408
- if (classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') return
1447
+ if (!readsPreamble && classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') return
1409
1448
 
1410
1449
  const loopParamsForCond = loopParam
1411
1450
  ? [{ param: loopParam, bindings: loopParamBindings }]
@@ -1421,9 +1460,10 @@ export function collectLoopChildConditionals(
1421
1460
  condition: expanded.expr,
1422
1461
  whenTrueHtml,
1423
1462
  whenFalseHtml,
1424
- whenTrue: summarizeLoopChildBranch(n.whenTrue, ctx, siblingOffsets, loopParam, loopParamBindings),
1425
- whenFalse: summarizeLoopChildBranch(n.whenFalse, ctx, siblingOffsets, loopParam, loopParamBindings),
1463
+ whenTrue: summarizeLoopChildBranch(n.whenTrue, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex),
1464
+ whenFalse: summarizeLoopChildBranch(n.whenFalse, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex),
1426
1465
  ...(expanded.freeIds !== undefined && { conditionFreeIdentifiers: expanded.freeIds }),
1466
+ ...(readsPreamble && { readsPreamble: true }),
1427
1467
  })
1428
1468
  },
1429
1469
  })
@@ -1444,19 +1484,23 @@ function summarizeLoopChildBranch(
1444
1484
  siblingOffsets: Map<IRLoop, IRNode[]>,
1445
1485
  loopParam?: string,
1446
1486
  loopParamBindings?: readonly import('../types.ts').LoopParamBinding[],
1487
+ /** Enclosing loop's preamble locals (#2447) — see `collectLoopChildConditionals`. */
1488
+ preambleNames?: ReadonlySet<string>,
1489
+ /** Enclosing loop's index param name — see `collectLoopChildConditionals` (Copilot review on #2595). */
1490
+ loopIndex?: string | null,
1447
1491
  ): LoopChildBranchSummary {
1448
1492
  const inner = collectInnerLoops([node], siblingOffsets, loopParam, ctx, branchInnerLoopOptions)
1449
1493
  return {
1450
1494
  childComponents: collectConditionalBranchChildComponents(node),
1451
1495
  innerLoops: inner.length > 0 ? inner : undefined,
1452
- conditionals: collectLoopChildConditionals(node, ctx, siblingOffsets, loopParam, loopParamBindings),
1496
+ conditionals: collectLoopChildConditionals(node, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex),
1453
1497
  events: collectConditionalBranchEvents(node),
1454
1498
  // Loop-param-aware — reuses the flat loop-item collectors scoped to just
1455
1499
  // this branch's subtree. Both already stop descending into any further
1456
1500
  // nested reactive conditional (own insert()/arm), so calling them here
1457
1501
  // on the branch root yields exactly this branch's direct bindings
1458
1502
  // without re-collecting what a nested arm already owns (#2347).
1459
- reactiveAttrs: collectLoopChildReactiveAttrs(node, ctx, loopParam, loopParamBindings, true),
1503
+ reactiveAttrs: collectLoopChildReactiveAttrs(node, ctx, loopParam, loopParamBindings, true, preambleNames, loopIndex),
1460
1504
  // Skip ONLY when the branch's entire content is a single bare
1461
1505
  // `expression` (no wrapping element) that MAY yield a live DOM node —
1462
1506
  // i.e. it contains a call anywhere (`node.hasFunctionCalls`, computed
@@ -1506,6 +1550,6 @@ function summarizeLoopChildBranch(
1506
1550
  // two can't disagree on shape).
1507
1551
  reactiveTexts: node.type === 'expression' && node.hasFunctionCalls
1508
1552
  ? []
1509
- : collectLoopChildReactiveTexts(node, ctx, loopParam, loopParamBindings, true),
1553
+ : collectLoopChildReactiveTexts(node, ctx, loopParam, loopParamBindings, true, preambleNames, loopIndex),
1510
1554
  }
1511
1555
  }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { TopLevelLoop, BranchLoop, LoopChildEvent } from '../../types.ts'
13
13
  import { buildChainedArrayExpr, varSlotId, substituteLoopBindings } from '../../utils.ts'
14
+ import { identifierPattern } from '../../../identifier-pattern.ts'
14
15
  import { renderPreamble, irToHtmlTemplate } from '../../html-template.ts'
15
16
  import type {
16
17
  EventDelegationPlan,
@@ -138,7 +139,7 @@ function buildKeyedOrIndexLookup(args: {
138
139
  // we substitute bindings with `item.<path>` directly (#951).
139
140
  const keyWithItem = hasBindings
140
141
  ? substituteLoopBindings(args.key, args.paramBindings!, 'item')
141
- : args.key.replace(new RegExp(`\\b${args.param}\\b`, 'g'), 'item')
142
+ : args.key.replace(identifierPattern(args.param, 'g'), 'item')
142
143
  return {
143
144
  kind: 'keyed',
144
145
  arrayExpr: args.array,
@@ -112,6 +112,7 @@ export function buildReactiveEffectsPlan(
112
112
  whenFalseTemplateHtml: addCondAttrToTemplate(wrap(cond.whenFalseHtml), cond.slotId),
113
113
  whenTrueArm: buildOuterArm(cond.whenTrue, wrap, loopParam, loopParamBindings, profileComponentName),
114
114
  whenFalseArm: buildOuterArm(cond.whenFalse, wrap, loopParam, loopParamBindings, profileComponentName),
115
+ ...(cond.readsPreamble && { readsPreamble: true }),
115
116
  })
116
117
  }
117
118
  }
@@ -62,6 +62,13 @@ export interface NestedConditionalPlan {
62
62
  whenFalseTemplateHtml: string
63
63
  whenTrueArm: LoopChildArmPlan
64
64
  whenFalseArm: LoopChildArmPlan
65
+ /**
66
+ * `wrappedCondition` reads a `.map()` callback preamble local (#2596,
67
+ * twin of `ReactiveAttrEffect.readsPreamble`) — the stringifier must run
68
+ * the row's preamble ahead of evaluating the condition getter passed to
69
+ * `insert()`; the local isn't otherwise in scope there.
70
+ */
71
+ readsPreamble?: boolean
65
72
  }
66
73
 
67
74
  export interface ReactiveEffectsPlan {
@@ -34,6 +34,7 @@
34
34
 
35
35
  import { toDomEventName, varSlotId, substituteLoopBindings, buildLoopChildIndexSubtraction, DATA_KEY, keyAttrName } from '../../utils.ts'
36
36
  import { extractFreeIdentifiersFromText } from '../../csr-substitute.ts'
37
+ import { identifierPattern } from '../../../identifier-pattern.ts'
37
38
  import type {
38
39
  EventDelegationPlan,
39
40
  KeyedItemLookup,
@@ -215,7 +216,7 @@ function emitKeyedLookup(
215
216
  const rawKey = nested.key ?? ''
216
217
  const innerKeyExpr = nested.paramBindings && nested.paramBindings.length > 0
217
218
  ? substituteLoopBindings(rawKey, nested.paramBindings, 'item')
218
- : rawKey.replace(new RegExp(`\\b${nested.param}\\b`, 'g'), 'item')
219
+ : rawKey.replace(identifierPattern(nested.param, 'g'), 'item')
219
220
  const outerRef = hasBindings ? '__bfLoopItem' : param
220
221
  ls.push(` const ${nested.param} = ${outerRef} && ${nested.array}.find(item => String(${innerKeyExpr}) === innerKey${nested.depth})`)
221
222
  }
@@ -140,7 +140,7 @@ export function stringifyReactiveEffects(
140
140
  // from the attrs/texts/regions merge above (its own per-branch disposable
141
141
  // effects), so it's untouched by row-granularity consolidation.
142
142
  for (const cond of conditionals) {
143
- emitOuterConditional(lines, indent, elVar, cond, pc)
143
+ emitOuterConditional(lines, indent, elVar, cond, pc, mapPreambleWrapped)
144
144
  }
145
145
  }
146
146
 
@@ -342,12 +342,22 @@ function emitOuterConditional(
342
342
  elVar: string,
343
343
  cond: NestedConditionalPlan,
344
344
  pc: string | undefined,
345
+ mapPreambleWrapped: string | undefined,
345
346
  ): void {
346
347
  const armIndent = `${indent} `
347
348
 
348
349
  // Body-form arrows so live `Node` returns from Child-position
349
350
  // interpolations route through `__bfSlot` and survive the splice (#1213).
350
- lines.push(`${indent}insert(${elVar}, '${cond.slotId}', () => ${cond.wrappedCondition}, {`)
351
+ // A condition reading a preamble local (#2596) needs the preamble re-run
352
+ // INSIDE the getter — `insert()` re-invokes this closure on every
353
+ // dependency change to decide the branch, and the local isn't otherwise in
354
+ // scope here (it's a plain per-row `const`, not a signal `insert()` can see
355
+ // through on its own). Same treatment as `readsPreamble` attrs
356
+ // (`emitAttrUpdate`'s callers) get ahead of their own write.
357
+ const conditionGetter = cond.readsPreamble && mapPreambleWrapped
358
+ ? `() => { ${mapPreambleWrapped}; return (${cond.wrappedCondition}) }`
359
+ : `() => ${cond.wrappedCondition}`
360
+ lines.push(`${indent}insert(${elVar}, '${cond.slotId}', ${conditionGetter}, {`)
351
361
  lines.push(`${indent} template: () => { const __slots = []; return { html: \`${cond.whenTrueTemplateHtml}\`, slots: __slots } },`)
352
362
  lines.push(`${indent} bindEvents: (__branchScope, { isFirstRun: __bfFirstRun = false } = {}) => {`)
353
363
  stringifyLoopChildArm(lines, cond.whenTrueArm, armIndent, pc)
@@ -28,6 +28,7 @@ import ts from 'typescript'
28
28
  import { PROPS_PARAM, inferDefaultValue } from './utils.ts'
29
29
  import { extractFreeIdentifiersFromNode } from '../analyzer.ts'
30
30
  import type { MemoInfo, SignalInfo } from '../types.ts'
31
+ import type { BindingScope } from '../scope/binding-scope.ts'
31
32
 
32
33
  /**
33
34
  * CSR-substituted const value: the const's initializer with every
@@ -94,10 +95,21 @@ export interface CsrEnv {
94
95
  * so chained inline expansions (const A inlines to a form that mentions
95
96
  * const B's already-rewritten value) stay accurate without analytical
96
97
  * bookkeeping.
98
+ *
99
+ * `enclosingScope` (#2482 Stage 1b): an optional `BindingScope` for loop
100
+ * bindings ENCLOSING this expression — names bound outside the
101
+ * expression's own AST, which the internal `boundStack` (built purely
102
+ * from arrow/function scopes found while walking `value` itself) can
103
+ * never see on its own. Every current call site already pre-filters
104
+ * `env.substitutions` for loop-shadowed names before calling in (see
105
+ * `html-template.ts`'s `loop` case), so this is defense-in-depth, not a
106
+ * currently-observable behavior change: the two mechanisms should agree
107
+ * on what's shadowed regardless of which one runs first.
97
108
  */
98
109
  export function csrSubstitute(
99
110
  value: string,
100
111
  env: CsrEnv,
112
+ enclosingScope?: BindingScope,
101
113
  ): { rewritten: string; freeIdentifiers: ReadonlySet<string> } {
102
114
  if (!value || value.trim().length === 0) {
103
115
  return { rewritten: value, freeIdentifiers: new Set() }
@@ -111,7 +123,7 @@ export function csrSubstitute(
111
123
  let current = value
112
124
  let lastFreeIdentifiers: ReadonlySet<string> = new Set()
113
125
  for (let i = 0; i < maxIter; i++) {
114
- const step = csrSubstituteOnce(current, env)
126
+ const step = csrSubstituteOnce(current, env, enclosingScope)
115
127
  lastFreeIdentifiers = step.freeIdentifiers
116
128
  if (step.rewritten === current) break
117
129
  current = step.rewritten
@@ -122,6 +134,7 @@ export function csrSubstitute(
122
134
  function csrSubstituteOnce(
123
135
  value: string,
124
136
  env: CsrEnv,
137
+ enclosingScope?: BindingScope,
125
138
  ): { rewritten: string; freeIdentifiers: ReadonlySet<string> } {
126
139
  if (!value || value.trim().length === 0) {
127
140
  return { rewritten: value, freeIdentifiers: new Set() }
@@ -151,12 +164,16 @@ function csrSubstituteOnce(
151
164
 
152
165
  // Track the bound names in nested arrow/function scopes so we don't
153
166
  // substitute identifiers shadowed by a parameter or local binding.
167
+ // `enclosingScope` seeds the OUTER frames — loop bindings introduced
168
+ // outside `value`'s own AST (item / index / destructure / preamble) —
169
+ // so a name bound there is treated identically to one bound by an
170
+ // arrow/function found while walking `value` itself.
154
171
  const boundStack: Array<Set<string>> = []
155
172
  const isBound = (name: string): boolean => {
156
173
  for (let i = boundStack.length - 1; i >= 0; i--) {
157
174
  if (boundStack[i].has(name)) return true
158
175
  }
159
- return false
176
+ return enclosingScope?.isBound(name) ?? false
160
177
  }
161
178
 
162
179
  const recordSubstitution = (start: number, end: number, sub: CsrSubstitution): void => {
@@ -13,6 +13,7 @@ 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
15
  import { derivesScopeFromSlot } from '../adapters/child-scope.ts'
16
+ import { BindingScope } from '../scope/binding-scope.ts'
16
17
 
17
18
  /**
18
19
  * Protect string literals from regex-based replacements.
@@ -728,6 +729,11 @@ function escapeLeafTextExpressions(ir: IRNode): IRNode {
728
729
  }
729
730
  }
730
731
 
732
+ // `loopParams` here is the accessor-rewrite spec list `wrapExprWithLoopParams`
733
+ // consumes, not a `BindingScope`-trackable name set — see that function's
734
+ // docstring (`ir-to-client-js/utils.ts`) for why this stays outside #2482's
735
+ // migration (it's the client-JS-emitter twin of the Go adapter's
736
+ // `loopBindingStack`).
731
737
  export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, inHoistedChildren = false): string {
732
738
  const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, inHoistedChildren)
733
739
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
@@ -1572,19 +1578,26 @@ export interface TemplateOptions {
1572
1578
  csrEnv?: CsrEnv
1573
1579
  loopDepth?: number
1574
1580
  /**
1575
- * Names bound by the ENCLOSING loops' callback params (item / index /
1576
- * destructured binding names), accumulated by the `loop` case as the
1577
- * recursion descends (#2222). Inside a loop body, `csrSubstitute`
1578
- * receives each expression in isolation no enclosing arrow text — so
1579
- * its own bound-name tracking can't see the loop binding; the `loop`
1580
- * case instead filters these names out of the child recursion's
1581
- * `csrEnv`, so an inlinable const / signal / memo whose name is
1582
- * shadowed by a loop param never substitutes at the shadowed
1583
- * occurrence. Scope-accurate per nesting level (the CURRENT level's
1584
- * own `transformExpr` e.g. the loop's array-source expression
1585
- * keeps the unfiltered env).
1581
+ * `BindingScope` for the ENCLOSING loops' callback bindings (item /
1582
+ * index / destructured names / preamble locals), threaded through the
1583
+ * recursion one `enterLoopRow` per nesting level (#2482 Stage 1b
1584
+ * migrated from the ad-hoc flat loop-bound-names `ReadonlySet<string>`
1585
+ * this field replaces). Inside a loop body, `csrSubstitute` receives each
1586
+ * expression in isolation no enclosing arrow text so its own
1587
+ * bound-name tracking can't see the loop binding; the `loop` case
1588
+ * instead filters these names out of the child recursion's `csrEnv`
1589
+ * (via `scope.boundNames()`, the SHADOW-GUARD query see
1590
+ * `BindingScope.valueBoundNames`'s doc comment for why this must stay
1591
+ * the all-sources query, not the value-only one), so an inlinable
1592
+ * const / signal / memo whose name is shadowed by a loop-row binding —
1593
+ * including a `.map()` callback preamble local (#2447) — never
1594
+ * substitutes at the shadowed occurrence. Scope-accurate per nesting
1595
+ * level (the CURRENT level's own `transformExpr` — e.g. the loop's
1596
+ * array-source expression — keeps the unfiltered env). `undefined` at
1597
+ * the top of the recursion means "no enclosing loop" (treated as
1598
+ * `BindingScope.EMPTY`).
1586
1599
  */
1587
- loopBoundNames?: ReadonlySet<string>
1600
+ scope?: BindingScope
1588
1601
  /** Emit `bf-s` placeholder on scoped elements inside a jsx-children prop (#1320). */
1589
1602
  inHoistedChildren?: boolean
1590
1603
  /**
@@ -2226,7 +2239,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2226
2239
  // the IR — emit does no string transformation of its own (#1277).
2227
2240
  const source = templateExpr ?? expr
2228
2241
  if (!source) return source
2229
- const { rewritten, freeIdentifiers } = csrSubstitute(source, env)
2242
+ const { rewritten, freeIdentifiers } = csrSubstitute(source, env, opts.scope)
2230
2243
 
2231
2244
  // The CSR template runs at module scope, so any post-substitution
2232
2245
  // free identifier that lands in `unsafeLocalNames` (init-body-only
@@ -2458,23 +2471,21 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2458
2471
  }
2459
2472
 
2460
2473
  case 'loop': {
2461
- // Accumulate this loop's callback-bound names and filter them out
2462
- // of the child recursion's substitution env (#2222): an inlinable
2463
- // const / signal / memo whose name the callback shadows must never
2464
- // substitute inside the body. Destructured callbacks contribute
2465
- // their individual binding names; a raw pattern-text `param` (the
2466
- // BF025 fallback shapes, detected by the same leading-`[`/`{` check
2467
- // `destructureLoopParam` uses NOT an ASCII-identifier regex, which
2468
- // would drop Unicode param names like `π` and re-enable the shadow
2469
- // bug for that body) contributes nothing conservatively
2470
- // unfiltered rather than mis-filtered.
2471
- const boundHere = new Set(opts.loopBoundNames ?? [])
2472
- if (node.paramBindings && node.paramBindings.length > 0) {
2473
- for (const b of node.paramBindings) boundHere.add(b.name)
2474
- } else if (!node.param.startsWith('[') && !node.param.startsWith('{')) {
2475
- boundHere.add(node.param)
2476
- }
2477
- if (node.index) boundHere.add(node.index)
2474
+ // Enter this loop's row frame and filter its bound names out of the
2475
+ // child recursion's substitution env (#2222, #2482 Stage 1b): an
2476
+ // inlinable const / signal / memo whose name the callback shadows
2477
+ // item, index, a destructured binding, OR a `.map()` callback
2478
+ // preamble local (#2447) must never substitute inside the body.
2479
+ // `enterLoopRow` mirrors `jsx-to-ir.ts`'s legacy mutable loop-param
2480
+ // set semantics exactly (see its doc comment): a raw pattern-text `param` (the
2481
+ // BF025 fallback shapes) with no `paramBindings` still gets added,
2482
+ // but as non-identifier pattern text it can never collide with a
2483
+ // real const/signal/memo name, so it's a harmless no-op for this
2484
+ // filter. `boundNames()` (not `valueBoundNames()`) is the correct
2485
+ // query here this is a SHADOW GUARD (every binding source must
2486
+ // hide an outer same-named const), not a reactivity classifier.
2487
+ const childScope = (opts.scope ?? BindingScope.EMPTY).enterLoopRow(node)
2488
+ const boundHere = childScope.boundNames()
2478
2489
  const childEnv: CsrEnv = {
2479
2490
  ...env,
2480
2491
  substitutions: new Map([...env.substitutions].filter(([name]) => !boundHere.has(name))),
@@ -2483,7 +2494,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2483
2494
  ...opts,
2484
2495
  loopDepth: loopDepth + 1,
2485
2496
  inHoistedChildren: false,
2486
- loopBoundNames: boundHere,
2497
+ scope: childScope,
2487
2498
  csrEnv: childEnv,
2488
2499
  })
2489
2500
  let childTemplate = node.children.map(recurseInLoopBody).join('')
@@ -5,6 +5,7 @@
5
5
  import type { ComponentIR, IRNode } from '../types.ts'
6
6
  import { isClientBuiltinName } from '../builtins.ts'
7
7
  import { collectValueReferencedNames } from '../value-references.ts'
8
+ import { identifierCallPattern } from '../identifier-pattern.ts'
8
9
 
9
10
  // All exports from @barefootjs/client/runtime that may be used in generated code
10
11
  export const RUNTIME_IMPORT_CANDIDATES = [
@@ -57,7 +58,7 @@ export function detectUsedImports(code: string): Set<string> {
57
58
  const used = new Set<string>()
58
59
  for (const name of RUNTIME_IMPORT_CANDIDATES) {
59
60
  // Match function calls: name(
60
- if (new RegExp(`\\b${name}\\s*\\(`).test(code)) {
61
+ if (identifierCallPattern(name).test(code)) {
61
62
  used.add(name)
62
63
  }
63
64
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { ParamInfo, SignalInfo } from '../types.ts'
6
6
  import type { ClientJsContext } from './types.ts'
7
+ import type { BindingScope } from '../scope/binding-scope.ts'
7
8
 
8
9
  /**
9
10
  * Expand dynamic prop value by resolving local constants.
@@ -11,9 +12,22 @@ import type { ClientJsContext } from './types.ts'
11
12
  * Per spec/compiler.md, no prop reference transformation is needed:
12
13
  * - Destructured props are captured once at hydration, used as-is
13
14
  * - Props object already uses props.xxx syntax
15
+ *
16
+ * `scope` (#2482 Stage 1b): the enclosing loop row's `BindingScope`, when
17
+ * `value` is being expanded from inside a `.map()` row. `ClientJsContext`
18
+ * itself carries no loop-scope field — it's a per-component object, built
19
+ * once and shared across the whole tree — so without this guard a loop
20
+ * row binding (item / index / destructured / preamble local) that shares
21
+ * a name with a component/module-level const resolves to the OUTER
22
+ * const's value instead of staying an unresolved reference to the row's
23
+ * own binding. `isBound` (not `valueBoundNames`) is the correct query —
24
+ * this is a SHADOW GUARD (every binding source hides an outer same-named
25
+ * const), not a reactivity classifier — see `BindingScope.valueBoundNames`'s
26
+ * doc comment.
14
27
  */
15
- export function expandDynamicPropValue(value: string, ctx: ClientJsContext): string {
28
+ export function expandDynamicPropValue(value: string, ctx: ClientJsContext, scope?: BindingScope): string {
16
29
  const trimmedValue = value.trim()
30
+ if (scope?.isBound(trimmedValue)) return value
17
31
 
18
32
  const constant = ctx.localConstants.find((c) => c.name === trimmedValue)
19
33
  if (constant && constant.value) {
@@ -35,16 +49,29 @@ export function expandDynamicPropValue(value: string, ctx: ClientJsContext): str
35
49
  * Otherwise it is `originalFreeIds` passed by the caller (typically derived
36
50
  * from the IR node's `origin.freeRefs`) — `undefined` when the caller has
37
51
  * no precomputed set.
52
+ *
53
+ * `scope` (#2482 Stage 1b): see `expandDynamicPropValue` — same guard, same
54
+ * reasoning. Without it, a loop row's own binding (bare identifier, e.g. a
55
+ * `.map()` callback preamble local or the item param itself) that shares a
56
+ * name with a component/module-level const gets const-folded into the
57
+ * OUTER value here, which then corrupts BOTH the emitted expression AND
58
+ * downstream reactivity classification (`classifyReactivity` sees a
59
+ * literal instead of a live reference and concludes "not reactive" — the
60
+ * observable failure mode is a reactive attribute/conditional that
61
+ * silently freezes at its initial value instead of updating).
38
62
  */
39
63
  export function expandConstantForReactivity(
40
64
  expr: string,
41
65
  ctx: ClientJsContext,
42
66
  originalFreeIds?: ReadonlySet<string>,
67
+ scope?: BindingScope,
43
68
  ): { expr: string; freeIds: ReadonlySet<string> | undefined } {
44
69
  // Stateful components use props.xxx directly — reactivity is already detected.
45
70
  if (ctx.propsObjectName) return { expr, freeIds: originalFreeIds }
46
71
 
47
72
  const trimmedValue = expr.trim()
73
+ if (scope?.isBound(trimmedValue)) return { expr, freeIds: originalFreeIds }
74
+
48
75
  const constant = ctx.localConstants.find((c) => c.name === trimmedValue)
49
76
  if (constant && constant.value) {
50
77
  return { expr: constant.value, freeIds: constant.freeIdentifiers }