@barefootjs/jsx 0.31.2 → 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 (65) 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/analyzer.d.ts.map +1 -1
  5. package/dist/debug.d.ts.map +1 -1
  6. package/dist/identifier-pattern.d.ts +62 -0
  7. package/dist/identifier-pattern.d.ts.map +1 -0
  8. package/dist/index.d.ts +3 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +280 -119
  11. package/dist/ir-to-client-js/collect-elements.d.ts +23 -2
  12. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/csr-substitute.d.ts +12 -1
  16. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/html-template.d.ts +20 -12
  18. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/prop-handling.d.ts +25 -2
  21. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/reactivity.d.ts +30 -2
  23. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  26. package/dist/jsx-to-ir.d.ts.map +1 -1
  27. package/dist/module-exports.d.ts.map +1 -1
  28. package/dist/prop-rewrite.d.ts +1 -1
  29. package/dist/relocate.d.ts.map +1 -1
  30. package/dist/scope/binding-scope.d.ts +179 -0
  31. package/dist/scope/binding-scope.d.ts.map +1 -0
  32. package/dist/types.d.ts +8 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/__tests__/binding-scope-preamble-shadowing.test.ts +115 -0
  36. package/src/__tests__/binding-scope-ratchet.test.ts +194 -0
  37. package/src/__tests__/binding-scope.test.ts +200 -0
  38. package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +76 -0
  39. package/src/__tests__/csr-substitute-enclosing-scope.test.ts +77 -0
  40. package/src/__tests__/identifier-pattern.test.ts +170 -0
  41. package/src/__tests__/let-type-annotation.test.ts +208 -0
  42. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +107 -0
  43. package/src/__tests__/rewrite-dynamic-imports.test.ts +98 -0
  44. package/src/adapters/jsx-adapter.ts +34 -5
  45. package/src/adapters/template-imports.ts +93 -0
  46. package/src/analyzer.ts +20 -3
  47. package/src/debug.ts +4 -3
  48. package/src/identifier-pattern.ts +79 -0
  49. package/src/index.ts +5 -1
  50. package/src/ir-to-client-js/collect-elements.ts +44 -15
  51. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +2 -1
  52. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +2 -1
  53. package/src/ir-to-client-js/csr-substitute.ts +19 -2
  54. package/src/ir-to-client-js/html-template.ts +37 -31
  55. package/src/ir-to-client-js/imports.ts +2 -1
  56. package/src/ir-to-client-js/prop-handling.ts +28 -1
  57. package/src/ir-to-client-js/reactivity.ts +54 -4
  58. package/src/ir-to-client-js/rewrite-props-object.ts +2 -1
  59. package/src/ir-to-client-js/utils.ts +9 -8
  60. package/src/jsx-to-ir.ts +187 -73
  61. package/src/module-exports.ts +3 -2
  62. package/src/prop-rewrite.ts +1 -1
  63. package/src/relocate.ts +2 -1
  64. package/src/scope/binding-scope.ts +238 -0
  65. package/src/types.ts +8 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Unit coverage for `rewriteDynamicImportsInSource` (#2588) — the source-text
3
+ * counterpart to `rewriteImportsForTemplate`.
4
+ *
5
+ * The e2e half lives in `packages/vite/src/__tests__/dynamic-import-rewrite.test.ts`
6
+ * (real `vite build`, real emitted template). This half pins the cases that
7
+ * an e2e fixture can't isolate: which AST nodes count, and the false matches
8
+ * a regex-based implementation would produce. Those false-match cases are
9
+ * the entire reason this parses (CLAUDE.md: never parse JS/TS with regex) —
10
+ * without them, a regex rewrite would pass every other assertion here.
11
+ */
12
+ import { describe, test, expect } from 'bun:test'
13
+ import { rewriteDynamicImportsInSource } from '../adapters/template-imports.ts'
14
+
15
+ /** Stand-in for `buildRelativeImportRewriter`: shifts one directory deeper. */
16
+ const deeper = (spec: string): string => `../${spec}`
17
+
18
+ describe('rewriteDynamicImportsInSource', () => {
19
+ test('rewrites a dynamic import call', () => {
20
+ expect(rewriteDynamicImportsInSource(`const m = import('./heavy')`, deeper))
21
+ .toBe(`const m = import('.././heavy')`)
22
+ })
23
+
24
+ test('rewrites an import type node (`typeof import(...)`)', () => {
25
+ expect(rewriteDynamicImportsInSource(`let p: Promise<typeof import('../lib/x')> | null = null`, deeper))
26
+ .toBe(`let p: Promise<typeof import('../../lib/x')> | null = null`)
27
+ })
28
+
29
+ test('rewrites a qualified import type (`import(...).Foo`)', () => {
30
+ expect(rewriteDynamicImportsInSource(`let v: import('../lib/x').Foo`, deeper))
31
+ .toBe(`let v: import('../../lib/x').Foo`)
32
+ })
33
+
34
+ test('rewrites every occurrence, keeping earlier spans intact', () => {
35
+ const out = rewriteDynamicImportsInSource(
36
+ `const a = import('./one'); const b = import('./two'); const c = import('./three')`,
37
+ deeper,
38
+ )
39
+ expect(out).toBe(
40
+ `const a = import('.././one'); const b = import('.././two'); const c = import('.././three')`,
41
+ )
42
+ })
43
+
44
+ test('leaves bare specifiers alone', () => {
45
+ const src = `const m = import('hono/jsx')`
46
+ expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
47
+ })
48
+
49
+ test('leaves a non-literal specifier alone', () => {
50
+ const src = `const m = import(chunkPath)`
51
+ expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
52
+ })
53
+
54
+ test('leaves static import statements to rewriteImportsForTemplate', () => {
55
+ // The adapter rewrites those from the parsed `templateImports` list; if
56
+ // this touched them too they would be rewritten twice.
57
+ const src = `import { x } from './sibling'`
58
+ expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
59
+ })
60
+
61
+ test('does not touch `import(` inside a string literal', () => {
62
+ const src = `const code = "const m = import('./heavy')"`
63
+ expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
64
+ })
65
+
66
+ test('does not touch `import(` inside a template literal', () => {
67
+ const src = 'const code = `await import(\'./heavy\')`'
68
+ expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
69
+ })
70
+
71
+ test('does not touch `import(` inside a comment', () => {
72
+ const src = `// const m = import('./heavy')\nconst n = 1`
73
+ expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
74
+ })
75
+
76
+ test('rewrites inside a TSX component body without disturbing the JSX', () => {
77
+ const src = [
78
+ `export function Lazy() {`,
79
+ ` const onClick = async () => { await import('./heavy') }`,
80
+ ` return <button onClick={onClick} data-x="import('./nope')">go</button>`,
81
+ `}`,
82
+ ].join('\n')
83
+ const out = rewriteDynamicImportsInSource(src, deeper)
84
+ expect(out).toContain(`await import('.././heavy')`)
85
+ // The attribute string is data, not a module reference.
86
+ expect(out).toContain(`data-x="import('./nope')"`)
87
+ })
88
+
89
+ test('returns the input unchanged when the rewriter is a no-op', () => {
90
+ const src = `const m = import('./heavy')`
91
+ expect(rewriteDynamicImportsInSource(src, (s) => s)).toBe(src)
92
+ })
93
+
94
+ test('returns the input unchanged when there is nothing to rewrite', () => {
95
+ const src = `export const answer = 42`
96
+ expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
97
+ })
98
+ })
@@ -19,6 +19,7 @@ import type { CallbackBodyAcceptor } from './interface.ts'
19
19
  import { ENV_SIGNAL_CLIENT_FACTORY } from './env-signal.ts'
20
20
  import { formatParamWithType, findReachableNames } from '../module-exports.ts'
21
21
  import { extractFreeIdentifiersFromText } from '../ir-to-client-js/csr-substitute.ts'
22
+ import { identifierPattern } from '../identifier-pattern.ts'
22
23
 
23
24
  export interface JsxAdapterConfig {
24
25
  /** Use typed versions (typedInitialValue, etc.) for type-safe .tsx output */
@@ -165,7 +166,7 @@ export abstract class JsxAdapter extends BaseAdapter {
165
166
 
166
167
  // Create a no-op setter for SSR — omit entirely if not referenced anywhere
167
168
  if (signal.setter) {
168
- const setterUsed = new RegExp(`\\b${signal.setter}\\b`).test(setterRefText)
169
+ const setterUsed = identifierPattern(signal.setter).test(setterRefText)
169
170
  if (setterUsed) {
170
171
  lines.push(` const ${signal.setter} = (..._args: any[]) => {}`)
171
172
  }
@@ -197,7 +198,10 @@ export abstract class JsxAdapter extends BaseAdapter {
197
198
  // No initializer (e.g. `let emblaApi: EmblaCarouselType | undefined`)
198
199
  // — carry the declared type annotation through so `.tsx` output
199
200
  // doesn't fall back to implicit `any` (TS7034/TS7005, #2573).
200
- const typeAnnotation = preserveTypes && constant.type ? `: ${constant.type.raw}` : ''
201
+ const typeAnnotation =
202
+ preserveTypes && (constant.typeAnnotation ?? constant.type)
203
+ ? `: ${constant.typeAnnotation ?? constant.type?.raw}`
204
+ : ''
201
205
  lines.push(` ${keyword} ${constant.name}${typeAnnotation}`)
202
206
  continue
203
207
  }
@@ -213,7 +217,17 @@ export abstract class JsxAdapter extends BaseAdapter {
213
217
  const constValue = preserveTypes
214
218
  ? (constant.typedValue ?? constant.value)
215
219
  : constant.value
216
- lines.push(` ${keyword} ${constant.name} = ${constValue}`)
220
+ // Preserve an explicit `let` type annotation from source (#2589)
221
+ // without it, TS infers the initializer's (often narrower) type and
222
+ // later reassignments/reads fail under strict (TS7034/TS7005, and
223
+ // TS2339 via `never` narrowing). `const` is left alone: its type
224
+ // always infers correctly from the (immutable) initializer, so
225
+ // adding annotations there would only churn snapshots.
226
+ const letTypeAnnotation =
227
+ preserveTypes && keyword === 'let' && constant.typeAnnotation
228
+ ? `: ${constant.typeAnnotation}`
229
+ : ''
230
+ lines.push(` ${keyword} ${constant.name}${letTypeAnnotation} = ${constValue}`)
217
231
  }
218
232
 
219
233
  // Include local functions — skip unreachable ones (only used in event
@@ -412,14 +426,29 @@ export abstract class JsxAdapter extends BaseAdapter {
412
426
  const keyword = c.declarationKind ?? 'const'
413
427
  const exportKw = c.isExported ? 'export ' : ''
414
428
  if (!c.value) {
415
- entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name}` })
429
+ // No initializer (e.g. module-scope `let pending: number`) — carry
430
+ // the declared type annotation through, mirroring the function-scope
431
+ // fix above (#2573 / #2589).
432
+ const typeAnnotation =
433
+ preserveTypes && (c.typeAnnotation ?? c.type)
434
+ ? `: ${c.typeAnnotation ?? c.type?.raw}`
435
+ : ''
436
+ entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name}${typeAnnotation}` })
416
437
  continue
417
438
  }
418
439
  const trimmed = c.value.trim()
419
440
  if (/^new WeakMap\b/.test(trimmed)) continue
420
441
  if (c.isExported && /^createContext\b/.test(trimmed)) continue
421
442
  const value = preserveTypes ? (c.typedValue ?? c.value) : c.value
422
- entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name} = ${value}` })
443
+ // Preserve an explicit module-scope `let` type annotation from source
444
+ // (#2589) — see the function-scope sibling above for rationale. `const`
445
+ // is left alone: its type always infers correctly from the (immutable)
446
+ // initializer.
447
+ const letTypeAnnotation =
448
+ preserveTypes && keyword === 'let' && c.typeAnnotation
449
+ ? `: ${c.typeAnnotation}`
450
+ : ''
451
+ entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name}${letTypeAnnotation} = ${value}` })
423
452
  }
424
453
 
425
454
  for (const f of ir.metadata.localFunctions) {
@@ -16,6 +16,7 @@
16
16
  * Adapters are responsible for calling this themselves before emitting any
17
17
  * import block. The compiler hands them `metadata.imports` unchanged.
18
18
  */
19
+ import ts from 'typescript'
19
20
  import type { ImportInfo, ImportSpecifier } from '../types.ts'
20
21
 
21
22
  const CLIENT_PACKAGE_SOURCES = new Set([
@@ -77,3 +78,95 @@ export function rewriteImportsForTemplate(
77
78
  function specKey(s: ImportSpecifier): string {
78
79
  return `${s.isDefault ? 'd' : ''}${s.isNamespace ? 'n' : ''}:${s.name}:${s.alias ?? ''}`
79
80
  }
81
+
82
+ /**
83
+ * Re-anchor relative specifiers carried inside emitted SOURCE TEXT — the
84
+ * counterpart to `rewriteImportsForTemplate`, which only sees the parsed
85
+ * static import list (`metadata.templateImports`).
86
+ *
87
+ * Declaration bodies re-emitted verbatim into a template
88
+ * (`generateModuleScopeDeclarations`' consts/functions, a component body's
89
+ * local handlers) can carry their own module references that never appear
90
+ * in that list:
91
+ *
92
+ * - `import('./x')` — a dynamic import expression
93
+ * - `typeof import('./x')` — an import TYPE node
94
+ *
95
+ * Those specifiers are written relative to the SOURCE file, so they break
96
+ * once the template is emitted to a directory at a different depth — the
97
+ * same depth shift `rewriteImportsForTemplate` already fixes for static
98
+ * imports (#1453, #2588).
99
+ *
100
+ * Only literal relative paths beginning with `.` are rewritten; bare
101
+ * specifiers pass through, matching `remap`'s guard above. A non-literal
102
+ * argument (`import(someVar)`) is left alone — there is no specifier to
103
+ * re-anchor, and guessing would be worse than leaving the source as-is.
104
+ *
105
+ * Parsed with the TS AST and applied by span splicing rather than by
106
+ * matching text: a regex would false-match `import(` inside a string or a
107
+ * comment, which is exactly the class of bug the repo-wide "never parse JS
108
+ * with regex" rule exists to prevent. Splices are applied back-to-front so
109
+ * earlier spans keep their offsets.
110
+ */
111
+ export function rewriteDynamicImportsInSource(
112
+ sourceText: string,
113
+ rewriteRelative: (importPath: string) => string,
114
+ ): string {
115
+ // Cheap pre-check: skip the parse entirely for the overwhelmingly common
116
+ // case of text with no dynamic import at all. Substring presence is not
117
+ // used to LOCATE anything — the AST still does that — so a false positive
118
+ // here costs one wasted parse and a false negative is impossible.
119
+ if (!sourceText.includes('import')) return sourceText
120
+
121
+ const sf = ts.createSourceFile(
122
+ 'bf-template-fragment.tsx',
123
+ sourceText,
124
+ ts.ScriptTarget.Latest,
125
+ /* setParentNodes */ false,
126
+ ts.ScriptKind.TSX,
127
+ )
128
+
129
+ const edits: Array<{ start: number, end: number, text: string }> = []
130
+
131
+ const visit = (node: ts.Node): void => {
132
+ // `import('./x')` — the argument is the first (and only) call argument.
133
+ if (
134
+ ts.isCallExpression(node) &&
135
+ node.expression.kind === ts.SyntaxKind.ImportKeyword &&
136
+ node.arguments.length > 0 &&
137
+ ts.isStringLiteralLike(node.arguments[0])
138
+ ) {
139
+ collect(node.arguments[0] as ts.StringLiteralLike)
140
+ }
141
+ // `typeof import('./x')` / `import('./x').Foo` — a TYPE-position node
142
+ // whose argument is a literal type wrapping the string.
143
+ if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
144
+ const literal = node.argument.literal
145
+ if (ts.isStringLiteralLike(literal)) collect(literal)
146
+ }
147
+ ts.forEachChild(node, visit)
148
+ }
149
+
150
+ const collect = (literal: ts.StringLiteralLike): void => {
151
+ const specifier = literal.text
152
+ if (!specifier.startsWith('.')) return
153
+ const next = rewriteRelative(specifier)
154
+ if (next === specifier) return
155
+ edits.push({
156
+ start: literal.getStart(sf),
157
+ end: literal.getEnd(),
158
+ // Re-quote rather than reusing the original delimiters: a rewritten
159
+ // POSIX-relative path never contains a quote to escape.
160
+ text: `'${next}'`,
161
+ })
162
+ }
163
+
164
+ ts.forEachChild(sf, visit)
165
+ if (edits.length === 0) return sourceText
166
+
167
+ let out = sourceText
168
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
169
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
170
+ }
171
+ return out
172
+ }
package/src/analyzer.ts CHANGED
@@ -504,8 +504,18 @@ function visit(
504
504
  collectAmbientGlobals(node, ctx)
505
505
  }
506
506
 
507
- // Module-level constants (outside component)
508
- if (ts.isVariableStatement(node) && !ctx.componentNode) {
507
+ // Module-level constants (outside component). Ambient statements
508
+ // (`declare let X: T`) are type-only contracts with no runtime binding —
509
+ // collectAmbientGlobals above already tracks them for BF052, and
510
+ // re-emitting one as a runtime `let` would shadow the real global, so
511
+ // they must not reach collectConstant. (Previously excluded only by
512
+ // accident: this path required an initializer, which `declare`
513
+ // statements never have — the #2589 uninitialized-`let` fix removed
514
+ // that gate, so the exclusion is now explicit.)
515
+ const isDeclareStatement =
516
+ ts.isVariableStatement(node) &&
517
+ (node.modifiers?.some(m => m.kind === ts.SyntaxKind.DeclareKeyword) ?? false)
518
+ if (ts.isVariableStatement(node) && !ctx.componentNode && !isDeclareStatement) {
509
519
  const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
510
520
  const isLet = (node.declarationList.flags & ts.NodeFlags.Let) !== 0
511
521
  const isModuleClientDirective = hasLeadingClientDirectiveOnStatement(node, ctx.sourceFile)
@@ -525,9 +535,15 @@ function visit(
525
535
  }
526
536
  continue
527
537
  }
538
+ // An initializer is required for `const` (TS grammar enforces this),
539
+ // but an uninitialized module-scope `let` (e.g. `let pending: number`)
540
+ // is legal source and must still be collected — mirroring the
541
+ // component-scope path below (line ~687), which has never gated on
542
+ // `decl.initializer` — so its declared type carries into the emitted
543
+ // template instead of the declaration vanishing outright (#2589).
528
544
  if (
529
545
  ts.isIdentifier(decl.name) &&
530
- decl.initializer &&
546
+ (decl.initializer || isLet) &&
531
547
  !isArrowComponentFunction(decl)
532
548
  ) {
533
549
  collectConstant(decl, ctx, true, isLet ? 'let' : 'const', isExported)
@@ -3203,6 +3219,7 @@ function collectConstant(
3203
3219
  value,
3204
3220
  parsed,
3205
3221
  typedValue: typedValue !== value ? typedValue : undefined,
3222
+ typeAnnotation: node.type ? node.type.getText(ctx.sourceFile) : undefined,
3206
3223
  valueBranches,
3207
3224
  declarationKind,
3208
3225
  isExported,
package/src/debug.ts CHANGED
@@ -29,6 +29,7 @@ import { analyzeClientNeeds } from './ir-to-client-js/index.ts'
29
29
  import type { WrapReason } from './ir-to-client-js/reactivity.ts'
30
30
  import { decideWrapFromAstFlags } from './ir-to-client-js/reactivity.ts'
31
31
  import { tokenContainsIdent } from './ir-to-client-js/utils.ts'
32
+ import { identifierCallPattern } from './identifier-pattern.ts'
32
33
 
33
34
  // =============================================================================
34
35
  // Types
@@ -1990,12 +1991,12 @@ function attrValueToString(value: AttrValue): string | null {
1990
1991
  function extractReactiveDeps(expr: string, signalGetters: Set<string>, memoNames: Set<string>): string[] {
1991
1992
  const deps: string[] = []
1992
1993
  for (const getter of signalGetters) {
1993
- if (new RegExp(`\\b${getter}\\s*\\(`).test(expr)) {
1994
+ if (identifierCallPattern(getter).test(expr)) {
1994
1995
  deps.push(getter)
1995
1996
  }
1996
1997
  }
1997
1998
  for (const memo of memoNames) {
1998
- if (new RegExp(`\\b${memo}\\s*\\(`).test(expr)) {
1999
+ if (identifierCallPattern(memo).test(expr)) {
1999
2000
  deps.push(memo)
2000
2001
  }
2001
2002
  }
@@ -2012,7 +2013,7 @@ function extractSetterRefs(expr: string, signalGetters: Set<string>): string[] {
2012
2013
  }
2013
2014
  // Also detect signal getter reads in handler
2014
2015
  for (const getter of signalGetters) {
2015
- if (new RegExp(`\\b${getter}\\s*\\(`).test(expr)) {
2016
+ if (identifierCallPattern(getter).test(expr)) {
2016
2017
  refs.push(getter)
2017
2018
  }
2018
2019
  }
@@ -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'
@@ -214,6 +214,10 @@ export { buildLoopChainExpr } from './loop-chain.ts'
214
214
  export type { LoopChainInputs } from './loop-chain.ts'
215
215
  export { isLowerableLoopDestructure, isLowerableObjectRestDestructure } from './loop-destructure.ts'
216
216
 
217
+ // Binding scope (#2482) — shared loop-bound-name resolution service
218
+ export { BindingScope } from './scope/binding-scope.ts'
219
+ export type { ScopeBindingSource, ScopeBinding, ScopeFrame, LoopBindingSource } from './scope/binding-scope.ts'
220
+
217
221
  // Debug analysis
218
222
  export {
219
223
  buildComponentGraph,
@@ -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,