@barefootjs/jsx 0.31.3 → 0.31.4

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 (52) hide show
  1. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  2. package/dist/adapters/template-imports.d.ts +27 -15
  3. package/dist/adapters/template-imports.d.ts.map +1 -1
  4. package/dist/debug.d.ts.map +1 -1
  5. package/dist/identifier-pattern.d.ts +62 -0
  6. package/dist/identifier-pattern.d.ts.map +1 -0
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +226 -163
  10. package/dist/ir-to-client-js/collect-elements.d.ts +23 -2
  11. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/csr-substitute.d.ts +12 -1
  15. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/html-template.d.ts +20 -12
  17. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/prop-handling.d.ts +25 -2
  20. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/reactivity.d.ts +30 -2
  22. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/module-exports.d.ts.map +1 -1
  27. package/dist/relocate.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/binding-scope-ratchet.test.ts +1 -1
  30. package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +76 -0
  31. package/src/__tests__/csr-substitute-enclosing-scope.test.ts +77 -0
  32. package/src/__tests__/identifier-pattern.test.ts +170 -0
  33. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +107 -0
  34. package/src/__tests__/rewrite-dynamic-imports.test.ts +98 -0
  35. package/src/adapters/jsx-adapter.ts +2 -1
  36. package/src/adapters/template-imports.ts +93 -0
  37. package/src/debug.ts +4 -3
  38. package/src/identifier-pattern.ts +79 -0
  39. package/src/index.ts +1 -1
  40. package/src/ir-to-client-js/collect-elements.ts +44 -15
  41. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +2 -1
  42. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +2 -1
  43. package/src/ir-to-client-js/csr-substitute.ts +19 -2
  44. package/src/ir-to-client-js/html-template.ts +37 -31
  45. package/src/ir-to-client-js/imports.ts +2 -1
  46. package/src/ir-to-client-js/prop-handling.ts +28 -1
  47. package/src/ir-to-client-js/reactivity.ts +54 -4
  48. package/src/ir-to-client-js/rewrite-props-object.ts +2 -1
  49. package/src/ir-to-client-js/utils.ts +9 -8
  50. package/src/jsx-to-ir.ts +18 -9
  51. package/src/module-exports.ts +3 -2
  52. package/src/relocate.ts +2 -1
@@ -5,12 +5,13 @@
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 } 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
12
  import { walkIR, stopAt } from './walker.ts'
13
13
  import { buildLoopChainExpr } from '../loop-chain.ts'
14
+ import { identifierPattern } from '../identifier-pattern.ts'
14
15
 
15
16
  /** Expressions that render nothing (0 DOM nodes) — `&&` / `?:` empty branches. */
16
17
  const EMPTY_RENDER_EXPRS = new Set(['null', 'undefined', 'false', "''", '""', '``'])
@@ -311,7 +312,7 @@ export function collectInnerLoops(
311
312
  const template = n.children.map(c => irToPlaceholderTemplate(c, undefined, emitDepth, loopParamsForTemplate)).join('')
312
313
  // Check if array expression references the outer loop param
313
314
  const refsOuter = outerLoopParam
314
- ? new RegExp(`\\b${outerLoopParam}\\b`).test(n.array)
315
+ ? identifierPattern(outerLoopParam).test(n.array)
315
316
  : false
316
317
  // Per-item bindings for inner loop body, collected uniformly when
317
318
  // ctx is available: reactiveTexts / reactiveAttrs / refs are each
@@ -332,8 +333,8 @@ export function collectInnerLoops(
332
333
  const innerPreambleNames = preambleNamesOf(n)
333
334
  if (ctx) {
334
335
  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))
336
+ bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings, false, innerPreambleNames, n.index))
337
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, false, innerPreambleNames, n.index))
337
338
  bindings.refs.push(...collectLoopChildRefs(child))
338
339
  }
339
340
  }
@@ -375,6 +376,8 @@ export function collectInnerLoops(
375
376
  siblingOffsets,
376
377
  n.param,
377
378
  n.paramBindings,
379
+ innerPreambleNames,
380
+ n.index,
378
381
  ))
379
382
  }
380
383
  }
@@ -671,7 +674,7 @@ export function collectElements(
671
674
  const childHandlers: string[] = []
672
675
  const bindings = projectionInner
673
676
  ? emptyLoopChildBindings()
674
- : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings, preambleNamesOf(l))
677
+ : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings, preambleNamesOf(l), l.index)
675
678
  if (!projectionInner) {
676
679
  for (const child of l.children) {
677
680
  childHandlers.push(...collectEventHandlersFromIR(child))
@@ -1146,7 +1149,7 @@ function collectBranchLoops(
1146
1149
  // which caused reactive reads inside simple loop bodies to silently
1147
1150
  // no-op for existing items.
1148
1151
  const branchBindings = ctx && !projectionInner
1149
- ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings, preambleNamesOf(n))
1152
+ ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings, preambleNamesOf(n), n.index)
1150
1153
  : emptyLoopChildBindings()
1151
1154
 
1152
1155
  loops.push({
@@ -1346,6 +1349,12 @@ export function collectLoopChildBindings(
1346
1349
  * `collectLoopChildReactiveAttrs`. Omitted for a loop with no preamble.
1347
1350
  */
1348
1351
  preambleNames?: ReadonlySet<string>,
1352
+ /**
1353
+ * The loop's index param name (`.map((item, i) => ...)`'s `i`), when
1354
+ * present — folded into the shadow guard (Copilot review on #2595) so
1355
+ * an index-shadowing const doesn't get const-folded either.
1356
+ */
1357
+ loopIndex?: string | null,
1349
1358
  ): LoopChildBindings {
1350
1359
  const bindings = emptyLoopChildBindings()
1351
1360
  for (const child of children) {
@@ -1355,10 +1364,10 @@ export function collectLoopChildBindings(
1355
1364
  // `collectLoopChildConditionals`, which gives each its own insert() +
1356
1365
  // arm-scoped attrs/texts (`LoopChildBranchSummary.reactiveAttrs` /
1357
1366
  // `.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))
1367
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings, true, preambleNames, loopIndex))
1368
+ bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, loopParam, loopParamBindings, true, preambleNames, loopIndex))
1360
1369
  bindings.refs.push(...collectLoopChildRefs(child))
1361
- bindings.conditionals.push(...collectLoopChildConditionals(child, ctx, siblingOffsets, loopParam, loopParamBindings))
1370
+ bindings.conditionals.push(...collectLoopChildConditionals(child, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex))
1362
1371
  }
1363
1372
  return bindings
1364
1373
  }
@@ -1369,8 +1378,24 @@ export function collectLoopChildConditionals(
1369
1378
  siblingOffsets: Map<IRLoop, IRNode[]>,
1370
1379
  loopParam?: string,
1371
1380
  loopParamBindings?: readonly import('../types.ts').LoopParamBinding[],
1381
+ /**
1382
+ * The enclosing loop's `.map()` callback preamble locals (#2447), when
1383
+ * known — folded into the `expandConstantForReactivity` shadow guard
1384
+ * (#2482 Stage 1b) so a preamble local shadowing a component/module
1385
+ * const doesn't get const-folded into the condition, which would
1386
+ * corrupt `classifyReactivity`'s verdict below (a substituted literal
1387
+ * reads as "not reactive," silently freezing the branch at its initial
1388
+ * value instead of wiring an `insert()`).
1389
+ */
1390
+ preambleNames?: ReadonlySet<string>,
1391
+ /**
1392
+ * The loop's index param name, when present — see
1393
+ * `collectLoopChildBindings`'s doc comment (Copilot review on #2595).
1394
+ */
1395
+ loopIndex?: string | null,
1372
1396
  ): LoopChildConditional[] {
1373
1397
  const conditionals: LoopChildConditional[] = []
1398
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
1374
1399
 
1375
1400
  // Widen the source-level "references loop param" check so destructured
1376
1401
  // callbacks fire too — the pattern text `[, cfg]` never word-matches on
@@ -1402,7 +1427,7 @@ export function collectLoopChildConditionals(
1402
1427
  // Pre-gate using AST `reactive` flag on the source condition before
1403
1428
  // paying for constant expansion — matches the legacy short-circuit.
1404
1429
  if (!n.reactive && !refsLoopParamInSource) return
1405
- const expanded = expandConstantForReactivity(n.condition, ctx, sourceFreeIds)
1430
+ const expanded = expandConstantForReactivity(n.condition, ctx, sourceFreeIds, scope)
1406
1431
  // Loop-param conditionals are reactive via per-item signal accessors;
1407
1432
  // classifyReactivity sees both paths (signal/memo/prop + loop-param).
1408
1433
  if (classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') return
@@ -1421,8 +1446,8 @@ export function collectLoopChildConditionals(
1421
1446
  condition: expanded.expr,
1422
1447
  whenTrueHtml,
1423
1448
  whenFalseHtml,
1424
- whenTrue: summarizeLoopChildBranch(n.whenTrue, ctx, siblingOffsets, loopParam, loopParamBindings),
1425
- whenFalse: summarizeLoopChildBranch(n.whenFalse, ctx, siblingOffsets, loopParam, loopParamBindings),
1449
+ whenTrue: summarizeLoopChildBranch(n.whenTrue, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex),
1450
+ whenFalse: summarizeLoopChildBranch(n.whenFalse, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex),
1426
1451
  ...(expanded.freeIds !== undefined && { conditionFreeIdentifiers: expanded.freeIds }),
1427
1452
  })
1428
1453
  },
@@ -1444,19 +1469,23 @@ function summarizeLoopChildBranch(
1444
1469
  siblingOffsets: Map<IRLoop, IRNode[]>,
1445
1470
  loopParam?: string,
1446
1471
  loopParamBindings?: readonly import('../types.ts').LoopParamBinding[],
1472
+ /** Enclosing loop's preamble locals (#2447) — see `collectLoopChildConditionals`. */
1473
+ preambleNames?: ReadonlySet<string>,
1474
+ /** Enclosing loop's index param name — see `collectLoopChildConditionals` (Copilot review on #2595). */
1475
+ loopIndex?: string | null,
1447
1476
  ): LoopChildBranchSummary {
1448
1477
  const inner = collectInnerLoops([node], siblingOffsets, loopParam, ctx, branchInnerLoopOptions)
1449
1478
  return {
1450
1479
  childComponents: collectConditionalBranchChildComponents(node),
1451
1480
  innerLoops: inner.length > 0 ? inner : undefined,
1452
- conditionals: collectLoopChildConditionals(node, ctx, siblingOffsets, loopParam, loopParamBindings),
1481
+ conditionals: collectLoopChildConditionals(node, ctx, siblingOffsets, loopParam, loopParamBindings, preambleNames, loopIndex),
1453
1482
  events: collectConditionalBranchEvents(node),
1454
1483
  // Loop-param-aware — reuses the flat loop-item collectors scoped to just
1455
1484
  // this branch's subtree. Both already stop descending into any further
1456
1485
  // nested reactive conditional (own insert()/arm), so calling them here
1457
1486
  // on the branch root yields exactly this branch's direct bindings
1458
1487
  // without re-collecting what a nested arm already owns (#2347).
1459
- reactiveAttrs: collectLoopChildReactiveAttrs(node, ctx, loopParam, loopParamBindings, true),
1488
+ reactiveAttrs: collectLoopChildReactiveAttrs(node, ctx, loopParam, loopParamBindings, true, preambleNames, loopIndex),
1460
1489
  // Skip ONLY when the branch's entire content is a single bare
1461
1490
  // `expression` (no wrapping element) that MAY yield a live DOM node —
1462
1491
  // i.e. it contains a call anywhere (`node.hasFunctionCalls`, computed
@@ -1506,6 +1535,6 @@ function summarizeLoopChildBranch(
1506
1535
  // two can't disagree on shape).
1507
1536
  reactiveTexts: node.type === 'expression' && node.hasFunctionCalls
1508
1537
  ? []
1509
- : collectLoopChildReactiveTexts(node, ctx, loopParam, loopParamBindings, true),
1538
+ : collectLoopChildReactiveTexts(node, ctx, loopParam, loopParamBindings, true, preambleNames, loopIndex),
1510
1539
  }
1511
1540
  }
@@ -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,
@@ -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
  }
@@ -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.
@@ -1572,19 +1573,26 @@ export interface TemplateOptions {
1572
1573
  csrEnv?: CsrEnv
1573
1574
  loopDepth?: number
1574
1575
  /**
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).
1576
+ * `BindingScope` for the ENCLOSING loops' callback bindings (item /
1577
+ * index / destructured names / preamble locals), threaded through the
1578
+ * recursion one `enterLoopRow` per nesting level (#2482 Stage 1b
1579
+ * migrated from the ad-hoc flat loop-bound-names `ReadonlySet<string>`
1580
+ * this field replaces). Inside a loop body, `csrSubstitute` receives each
1581
+ * expression in isolation no enclosing arrow text so its own
1582
+ * bound-name tracking can't see the loop binding; the `loop` case
1583
+ * instead filters these names out of the child recursion's `csrEnv`
1584
+ * (via `scope.boundNames()`, the SHADOW-GUARD query see
1585
+ * `BindingScope.valueBoundNames`'s doc comment for why this must stay
1586
+ * the all-sources query, not the value-only one), so an inlinable
1587
+ * const / signal / memo whose name is shadowed by a loop-row binding —
1588
+ * including a `.map()` callback preamble local (#2447) — never
1589
+ * substitutes at the shadowed occurrence. Scope-accurate per nesting
1590
+ * level (the CURRENT level's own `transformExpr` — e.g. the loop's
1591
+ * array-source expression — keeps the unfiltered env). `undefined` at
1592
+ * the top of the recursion means "no enclosing loop" (treated as
1593
+ * `BindingScope.EMPTY`).
1586
1594
  */
1587
- loopBoundNames?: ReadonlySet<string>
1595
+ scope?: BindingScope
1588
1596
  /** Emit `bf-s` placeholder on scoped elements inside a jsx-children prop (#1320). */
1589
1597
  inHoistedChildren?: boolean
1590
1598
  /**
@@ -2226,7 +2234,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2226
2234
  // the IR — emit does no string transformation of its own (#1277).
2227
2235
  const source = templateExpr ?? expr
2228
2236
  if (!source) return source
2229
- const { rewritten, freeIdentifiers } = csrSubstitute(source, env)
2237
+ const { rewritten, freeIdentifiers } = csrSubstitute(source, env, opts.scope)
2230
2238
 
2231
2239
  // The CSR template runs at module scope, so any post-substitution
2232
2240
  // free identifier that lands in `unsafeLocalNames` (init-body-only
@@ -2458,23 +2466,21 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2458
2466
  }
2459
2467
 
2460
2468
  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)
2469
+ // Enter this loop's row frame and filter its bound names out of the
2470
+ // child recursion's substitution env (#2222, #2482 Stage 1b): an
2471
+ // inlinable const / signal / memo whose name the callback shadows
2472
+ // item, index, a destructured binding, OR a `.map()` callback
2473
+ // preamble local (#2447) must never substitute inside the body.
2474
+ // `enterLoopRow` mirrors `jsx-to-ir.ts`'s legacy mutable loop-param
2475
+ // set semantics exactly (see its doc comment): a raw pattern-text `param` (the
2476
+ // BF025 fallback shapes) with no `paramBindings` still gets added,
2477
+ // but as non-identifier pattern text it can never collide with a
2478
+ // real const/signal/memo name, so it's a harmless no-op for this
2479
+ // filter. `boundNames()` (not `valueBoundNames()`) is the correct
2480
+ // query here this is a SHADOW GUARD (every binding source must
2481
+ // hide an outer same-named const), not a reactivity classifier.
2482
+ const childScope = (opts.scope ?? BindingScope.EMPTY).enterLoopRow(node)
2483
+ const boundHere = childScope.boundNames()
2478
2484
  const childEnv: CsrEnv = {
2479
2485
  ...env,
2480
2486
  substitutions: new Map([...env.substitutions].filter(([name]) => !boundHere.has(name))),
@@ -2483,7 +2489,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2483
2489
  ...opts,
2484
2490
  loopDepth: loopDepth + 1,
2485
2491
  inHoistedChildren: false,
2486
- loopBoundNames: boundHere,
2492
+ scope: childScope,
2487
2493
  csrEnv: childEnv,
2488
2494
  })
2489
2495
  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 }
@@ -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,6 +667,8 @@ 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
674
  * spreading — this runs per attribute (Copilot review). */
@@ -636,8 +684,10 @@ export function collectLoopChildReactiveAttrs(
636
684
  loopParamBindings?: readonly LoopParamBinding[],
637
685
  stopAtReactiveConditionals = false,
638
686
  preambleNames?: ReadonlySet<string>,
687
+ loopIndex?: string | null,
639
688
  ): LoopChildReactiveAttr[] {
640
689
  const attrs: LoopChildReactiveAttr[] = []
690
+ const scope = buildLoopRowScope(loopParam, loopParamBindings, preambleNames, loopIndex)
641
691
  traverseElements(node, (el) => {
642
692
  if (el.slotId) {
643
693
  for (const attr of el.attrs) {
@@ -653,7 +703,7 @@ export function collectLoopChildReactiveAttrs(
653
703
  if (attr.name === 'key') continue
654
704
  const valueStr = attrValueToString(attr.value)
655
705
  if (!valueStr) continue
656
- const expanded = expandConstantForReactivity(valueStr, ctx, attr.freeIdentifiers)
706
+ const expanded = expandConstantForReactivity(valueStr, ctx, attr.freeIdentifiers, scope)
657
707
  // `/* @client */` always defers via per-item createEffect
658
708
  // regardless of the reactivity classifier — matches the
659
709
  // 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',
@@ -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
  )