@barefootjs/jsx 0.30.0 → 0.30.3

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.
package/dist/index.js CHANGED
@@ -4542,20 +4542,115 @@ function isSimplePropExpression(expr, propNames) {
4542
4542
  }
4543
4543
 
4544
4544
  // src/prop-rewrite.ts
4545
+ function collectBindingNames(name, out) {
4546
+ if (ts5.isIdentifier(name)) {
4547
+ out.add(name.text);
4548
+ return;
4549
+ }
4550
+ for (const el of name.elements) {
4551
+ if (ts5.isBindingElement(el))
4552
+ collectBindingNames(el.name, out);
4553
+ }
4554
+ }
4555
+ function scopeFrameOf(n) {
4556
+ if (ts5.isFunctionLike(n)) {
4557
+ const frame = new Set;
4558
+ for (const p of n.parameters)
4559
+ collectBindingNames(p.name, frame);
4560
+ if ((ts5.isFunctionExpression(n) || ts5.isFunctionDeclaration(n)) && n.name)
4561
+ frame.add(n.name.text);
4562
+ return frame.size > 0 ? frame : null;
4563
+ }
4564
+ if (ts5.isBlock(n)) {
4565
+ const frame = new Set;
4566
+ for (const st of n.statements) {
4567
+ if (ts5.isVariableStatement(st)) {
4568
+ for (const d of st.declarationList.declarations)
4569
+ collectBindingNames(d.name, frame);
4570
+ } else if (ts5.isFunctionDeclaration(st) && st.name) {
4571
+ frame.add(st.name.text);
4572
+ }
4573
+ }
4574
+ return frame.size > 0 ? frame : null;
4575
+ }
4576
+ if (ts5.isCatchClause(n) && n.variableDeclaration) {
4577
+ const frame = new Set;
4578
+ collectBindingNames(n.variableDeclaration.name, frame);
4579
+ return frame.size > 0 ? frame : null;
4580
+ }
4581
+ return null;
4582
+ }
4583
+ function walkWithScope(root, visit) {
4584
+ const scopeStack = [];
4585
+ const isShadowed = (name) => scopeStack.some((frame) => frame.has(name));
4586
+ function rec(n, parent) {
4587
+ const frame = scopeFrameOf(n);
4588
+ if (frame)
4589
+ scopeStack.push(frame);
4590
+ if (ts5.isIdentifier(n))
4591
+ visit(n, parent, isShadowed(n.text));
4592
+ ts5.forEachChild(n, (child) => rec(child, n));
4593
+ if (frame)
4594
+ scopeStack.pop();
4595
+ }
4596
+ rec(root);
4597
+ }
4598
+ function isNonValuePosition(n, parent) {
4599
+ if (!parent)
4600
+ return false;
4601
+ if (ts5.isPropertyAssignment(parent) && parent.name === n)
4602
+ return true;
4603
+ if (ts5.isPropertyAccessExpression(parent) && parent.name === n)
4604
+ return true;
4605
+ if (ts5.isQualifiedName(parent) && parent.right === n)
4606
+ return true;
4607
+ if ((ts5.isParameter(parent) || ts5.isVariableDeclaration(parent) || ts5.isBindingElement(parent)) && parent.name === n)
4608
+ return true;
4609
+ if (ts5.isTypeReferenceNode(parent))
4610
+ return true;
4611
+ return false;
4612
+ }
4545
4613
  function collectAstPropRefs(node, propNames, out) {
4546
- function visit(n, parent) {
4547
- if (ts5.isIdentifier(n) && propNames.has(n.text)) {
4548
- if (parent && ts5.isPropertyAssignment(parent) && parent.name === n)
4549
- return;
4550
- if (parent && ts5.isShorthandPropertyAssignment(parent) && parent.name === n)
4551
- return;
4552
- if (parent && ts5.isPropertyAccessExpression(parent) && parent.name === n)
4553
- return;
4554
- out.add(n.text);
4614
+ walkWithScope(node, (n, parent, shadowed) => {
4615
+ if (shadowed || !propNames.has(n.text))
4616
+ return;
4617
+ if (parent && ts5.isShorthandPropertyAssignment(parent) && parent.name === n)
4618
+ return;
4619
+ if (isNonValuePosition(n, parent))
4620
+ return;
4621
+ out.add(n.text);
4622
+ });
4623
+ }
4624
+ function applyScopedPropRefRewrite(text, propRefs) {
4625
+ const prefix = "(";
4626
+ const sf = ts5.createSourceFile("__bf_prop_rewrite.ts", `${prefix}${text}
4627
+ )`, ts5.ScriptTarget.Latest, true);
4628
+ const parseDiagnostics = sf.parseDiagnostics;
4629
+ if (parseDiagnostics && parseDiagnostics.length > 0)
4630
+ return null;
4631
+ const edits = [];
4632
+ walkWithScope(sf, (n, parent, shadowed) => {
4633
+ if (shadowed || !propRefs.has(n.text))
4634
+ return;
4635
+ if (isNonValuePosition(n, parent))
4636
+ return;
4637
+ const start = n.getStart(sf) - prefix.length;
4638
+ const end = n.getEnd() - prefix.length;
4639
+ if (start < 0 || end > text.length)
4640
+ return;
4641
+ if (parent && ts5.isShorthandPropertyAssignment(parent) && parent.name === n) {
4642
+ edits.push({ start, end, replacement: `${n.text}: ${PROPS_PARAM}.${n.text}` });
4643
+ return;
4555
4644
  }
4556
- ts5.forEachChild(n, (child) => visit(child, n));
4645
+ edits.push({ start, end, replacement: `${PROPS_PARAM}.${n.text}` });
4646
+ });
4647
+ if (edits.length === 0)
4648
+ return text;
4649
+ let result = text;
4650
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
4651
+ result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end);
4557
4652
  }
4558
- visit(node);
4653
+ return result;
4559
4654
  }
4560
4655
  function applyRegexPropRefRewrite(text, propRefs) {
4561
4656
  const { protect, restore } = createTemplateAwareStringProtector();
@@ -4585,7 +4680,7 @@ function rewriteBarePropRefs(text, node, propNames, extraPropRefs) {
4585
4680
  }
4586
4681
  if (foundPropRefs.size === 0)
4587
4682
  return;
4588
- return applyRegexPropRefRewrite(text, foundPropRefs);
4683
+ return applyScopedPropRefRewrite(text, foundPropRefs) ?? applyRegexPropRefRewrite(text, foundPropRefs);
4589
4684
  }
4590
4685
 
4591
4686
  // src/instrumentation.ts
@@ -12663,20 +12758,20 @@ function attrValueText(value) {
12663
12758
  }
12664
12759
  return out.join(" ");
12665
12760
  }
12666
- function collectBindingNames(name, out) {
12761
+ function collectBindingNames2(name, out) {
12667
12762
  if (ts11.isIdentifier(name)) {
12668
12763
  out.add(name.text);
12669
12764
  return;
12670
12765
  }
12671
12766
  for (const el of name.elements) {
12672
12767
  if (ts11.isBindingElement(el))
12673
- collectBindingNames(el.name, out);
12768
+ collectBindingNames2(el.name, out);
12674
12769
  }
12675
12770
  }
12676
12771
  function collectPreambleDeclaredNames(stmt, out) {
12677
12772
  if (ts11.isVariableStatement(stmt)) {
12678
12773
  for (const decl of stmt.declarationList.declarations) {
12679
- collectBindingNames(decl.name, out);
12774
+ collectBindingNames2(decl.name, out);
12680
12775
  }
12681
12776
  } else if (ts11.isFunctionDeclaration(stmt) && stmt.name) {
12682
12777
  out.add(stmt.name.text);
@@ -13069,6 +13164,8 @@ function parseTemplateLiteral(expr, ctx) {
13069
13164
  }
13070
13165
  function tryResolveTemplateSpanFromConst(expr, ctx) {
13071
13166
  if (ts11.isIdentifier(expr)) {
13167
+ if (ctx.loopParams.has(expr.text))
13168
+ return null;
13072
13169
  const constInfo = findLocalConst(expr.text, ctx.analyzer);
13073
13170
  if (!constInfo)
13074
13171
  return null;
@@ -13083,6 +13180,8 @@ function tryResolveTemplateSpanFromConst(expr, ctx) {
13083
13180
  if (ts11.isElementAccessExpression(expr)) {
13084
13181
  if (!ts11.isIdentifier(expr.expression))
13085
13182
  return null;
13183
+ if (ctx.loopParams.has(expr.expression.text))
13184
+ return null;
13086
13185
  const constInfo = findLocalConst(expr.expression.text, ctx.analyzer);
13087
13186
  if (!constInfo)
13088
13187
  return null;
@@ -18799,7 +18898,7 @@ function analyzeLazyPreamble(preamble, indexParam, primableNames) {
18799
18898
  if (!isConst)
18800
18899
  return NO2("map-callback preamble declares a mutable binding (let/var)");
18801
18900
  for (const decl of stmt.declarationList.declarations) {
18802
- collectBindingNames2(decl.name, declaredNames);
18901
+ collectBindingNames3(decl.name, declaredNames);
18803
18902
  if (!decl.initializer) {
18804
18903
  return NO2("map-callback preamble has a declaration with no initializer");
18805
18904
  }
@@ -18823,7 +18922,7 @@ function analyzeLazyPreamble(preamble, indexParam, primableNames) {
18823
18922
  freeNames.delete(declared);
18824
18923
  return { lazySafe: true, facts: { declaredNames, freeNames } };
18825
18924
  }
18826
- function collectBindingNames2(name, out) {
18925
+ function collectBindingNames3(name, out) {
18827
18926
  if (ts15.isIdentifier(name)) {
18828
18927
  out.add(name.text);
18829
18928
  return;
@@ -18831,7 +18930,7 @@ function collectBindingNames2(name, out) {
18831
18930
  for (const element of name.elements) {
18832
18931
  if (ts15.isOmittedExpression(element))
18833
18932
  continue;
18834
- collectBindingNames2(element.name, out);
18933
+ collectBindingNames3(element.name, out);
18835
18934
  }
18836
18935
  }
18837
18936
  function findImpureNode(root, primableNames) {
@@ -22410,19 +22509,19 @@ function isJsxLike(expr) {
22410
22509
  function collectArrowParamNames(arrow) {
22411
22510
  const names = new Set;
22412
22511
  for (const p of arrow.parameters)
22413
- collectBindingNames3(p.name, names);
22512
+ collectBindingNames4(p.name, names);
22414
22513
  return names;
22415
22514
  }
22416
- function collectBindingNames3(name, out) {
22515
+ function collectBindingNames4(name, out) {
22417
22516
  const push = Array.isArray(out) ? (n) => out.push(n) : (n) => out.add(n);
22418
22517
  if (ts18.isIdentifier(name)) {
22419
22518
  push(name.text);
22420
22519
  } else if (ts18.isObjectBindingPattern(name)) {
22421
- name.elements.forEach((el) => collectBindingNames3(el.name, out));
22520
+ name.elements.forEach((el) => collectBindingNames4(el.name, out));
22422
22521
  } else if (ts18.isArrayBindingPattern(name)) {
22423
22522
  name.elements.forEach((el) => {
22424
22523
  if (!ts18.isOmittedExpression(el))
22425
- collectBindingNames3(el.name, out);
22524
+ collectBindingNames4(el.name, out);
22426
22525
  });
22427
22526
  }
22428
22527
  }
@@ -22431,12 +22530,12 @@ function collectFreeIdentifiers(arrow) {
22431
22530
  const bound = [];
22432
22531
  for (const p of arrow.parameters) {
22433
22532
  const names = [];
22434
- collectBindingNames3(p.name, names);
22533
+ collectBindingNames4(p.name, names);
22435
22534
  bound.push(...names);
22436
22535
  }
22437
22536
  function pushBindings(name) {
22438
22537
  const names = [];
22439
- collectBindingNames3(name, names);
22538
+ collectBindingNames4(name, names);
22440
22539
  bound.push(...names);
22441
22540
  return names;
22442
22541
  }
@@ -22563,7 +22662,7 @@ function collectModuleScopeNames(sourceFile) {
22563
22662
  names.add(stmt.name.text);
22564
22663
  else if (ts18.isVariableStatement(stmt)) {
22565
22664
  for (const decl of stmt.declarationList.declarations)
22566
- collectBindingNames3(decl.name, names);
22665
+ collectBindingNames4(decl.name, names);
22567
22666
  } else if (ts18.isImportDeclaration(stmt) && stmt.importClause) {
22568
22667
  const ic = stmt.importClause;
22569
22668
  if (ic.name)
@@ -3,16 +3,29 @@
3
3
  *
4
4
  * Walks the TypeScript AST to identify destructured prop names used as
5
5
  * value references (not as object keys, property access targets, or
6
- * shorthand properties), then applies targeted regex replacement.
6
+ * shorthand properties), then splices `_p.` onto exactly those
7
+ * references in the emitted text via a second scope-aware walk over
8
+ * the text's own AST.
9
+ *
10
+ * Scope model: a name bound INSIDE the expression — a nested arrow /
11
+ * function parameter, a block-scoped declaration, a catch variable —
12
+ * refers to that binding, not the prop, for the whole binding's scope.
13
+ * Both the discovery walk and the rewrite walk carry the same binding
14
+ * stack, so `items.map((title) => title.a)` never turns into the
15
+ * syntactically invalid `.map((_p.title) => _p.title.a)` when `title`
16
+ * is also a prop. (Names bound by loop callbacks that ENCLOSE the
17
+ * expression are the caller's job — see the `ctx.loopParams` filter in
18
+ * `jsx-to-ir.ts`'s `rewriteBarePropRefs` wrapper, #2222.)
7
19
  */
8
20
  import ts from 'typescript';
9
21
  /**
10
22
  * Walk an AST node for destructured-prop value references and add
11
23
  * each found name to `out`. Same skip rules as the rewrite path —
12
- * object-literal keys, shorthand properties, and property-access
13
- * names are excluded so only true value references get picked up.
14
- * Exported for callers that need the raw discovery set (e.g. the
15
- * branch-local prop-dep cache from #1425).
24
+ * object-literal keys, shorthand properties, property-access names,
25
+ * and names shadowed by a binding inside `node` are excluded so only
26
+ * true value references get picked up. Exported for callers that need
27
+ * the raw discovery set (e.g. the branch-local prop-dep cache from
28
+ * #1425).
16
29
  */
17
30
  export declare function collectAstPropRefs(node: ts.Node, propNames: Set<string>, out: Set<string>): void;
18
31
  /**
@@ -20,6 +33,10 @@ export declare function collectAstPropRefs(node: ts.Node, propNames: Set<string>
20
33
  * type-stripped expression text. Idempotent under `_p.X` (negative
21
34
  * lookbehind on `_p\\.`) and skips object-literal keys via the
22
35
  * post-match `{,` + `:` shape check.
36
+ *
37
+ * Legacy fallback for text that doesn't parse as a standalone
38
+ * expression — it cannot see scopes, so `applyScopedPropRefRewrite`
39
+ * is always tried first.
23
40
  */
24
41
  export declare function applyRegexPropRefRewrite(text: string, propRefs: Iterable<string>): string;
25
42
  /**
@@ -32,8 +49,8 @@ export declare function applyRegexPropRefRewrite(text: string, propRefs: Iterabl
32
49
  * @param extraPropRefs - Optional prop names known to appear in
33
50
  * `text` via substitution sources the AST walk can't see (e.g.
34
51
  * `text` was produced by inlining a branch-local whose initializer
35
- * references the prop). The post-match `_p\\.` lookbehind keeps
36
- * the regex idempotent, so passing an over-broad set is safe.
52
+ * references the prop). The rewrite only touches genuine value
53
+ * references, so passing an over-broad set is safe.
37
54
  */
38
55
  export declare function rewriteBarePropRefs(text: string, node: ts.Node, propNames: Set<string>, extraPropRefs?: ReadonlySet<string>): string | undefined;
39
56
  //# sourceMappingURL=prop-rewrite.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"prop-rewrite.d.ts","sourceRoot":"","sources":["../src/prop-rewrite.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,MAAM,YAAY,CAAA;AAI3B;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,EAAE,CAAC,IAAI,EACb,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,EACtB,GAAG,EAAE,GAAG,CAAC,MAAM,CAAC,GACf,IAAI,CAcN;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,GACzB,MAAM,CAkBR;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,EAAE,CAAC,IAAI,EACb,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,EACtB,aAAa,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAClC,MAAM,GAAG,SAAS,CAWpB"}
1
+ {"version":3,"file":"prop-rewrite.d.ts","sourceRoot":"","sources":["../src/prop-rewrite.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,MAAM,YAAY,CAAA;AAoF3B;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,EAAE,CAAC,IAAI,EACb,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,EACtB,GAAG,EAAE,GAAG,CAAC,MAAM,CAAC,GACf,IAAI,CAON;AA0CD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,GACzB,MAAM,CAkBR;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,EAAE,CAAC,IAAI,EACb,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,EACtB,aAAa,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAClC,MAAM,GAAG,SAAS,CAWpB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/jsx",
3
- "version": "0.30.0",
3
+ "version": "0.30.3",
4
4
  "description": "JSX compiler for BarefootJS - transforms JSX to server HTML + client JS",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -53,7 +53,7 @@
53
53
  "directory": "packages/jsx"
54
54
  },
55
55
  "dependencies": {
56
- "@barefootjs/shared": "0.30.0"
56
+ "@barefootjs/shared": "0.30.3"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@barefootjs/client": ">=0.2.0",
@@ -94,3 +94,74 @@ export function Fire() {
94
94
  expect(clientJs).toMatch(/setFlag\s*\]\s*=\s*createSignal\(0\)/)
95
95
  })
96
96
  })
97
+
98
+ /**
99
+ * Scope-aware prop rewrite (#2482 audit): `rewriteBarePropRefs` must
100
+ * not touch a name where a binding INSIDE the expression shadows the
101
+ * prop — a nested callback's parameter above all. The pre-fix global
102
+ * word-boundary regex rewrote the parameter declaration itself,
103
+ * emitting `.map((_p.title) => _p.title.a)`: a parse error that killed
104
+ * the whole client bundle.
105
+ */
106
+ describe('scope-aware prop rewrite (nested callback param sharing a prop name)', () => {
107
+ test('memo computation keeps the callback param bare', () => {
108
+ const clientJs = clientJsOf(`
109
+ "use client"
110
+ import { createSignal, createMemo } from '@barefootjs/client'
111
+
112
+ export function Titles({ title }: { title: string }) {
113
+ const [items, setItems] = createSignal([{ a: 'x' }])
114
+ const joined = createMemo(() => items().map((title) => title.a).join(','))
115
+ return <div><span>{joined()}</span><button onClick={() => setItems([])}>x</button></div>
116
+ }
117
+ `)
118
+ expect(clientJs).not.toMatch(/\(\s*_p\.title\s*\)\s*=>/)
119
+ expect(clientJs).not.toMatch(/_p\.title\.a/)
120
+ })
121
+
122
+ test('signal initializer keeps the callback param bare', () => {
123
+ const clientJs = clientJsOf(`
124
+ "use client"
125
+ import { createSignal } from '@barefootjs/client'
126
+
127
+ export function Titles({ title }: { title: string }) {
128
+ const [joined, setJoined] = createSignal([{ a: 'x' }].map((title) => title.a).join(','))
129
+ return <div><span>{joined()}</span><button onClick={() => setJoined('')}>x</button></div>
130
+ }
131
+ `)
132
+ expect(clientJs).not.toMatch(/\(\s*_p\.title\s*\)\s*=>/)
133
+ })
134
+
135
+ test('mixed usage rewrites exactly the genuine outer reference', () => {
136
+ const clientJs = clientJsOf(`
137
+ "use client"
138
+ import { createSignal, createMemo } from '@barefootjs/client'
139
+
140
+ export function Titles({ title }: { title: string }) {
141
+ const [items, setItems] = createSignal([{ a: 'x' }])
142
+ const label = createMemo(() => title + ':' + items().map((title) => title.a).join(','))
143
+ return <div><span>{label()}</span><button onClick={() => setItems([])}>x</button></div>
144
+ }
145
+ `)
146
+ const template = templateLambdaOf(clientJs, 'Titles')
147
+ // outer ref rewritten…
148
+ expect(template).toContain('_p.title +')
149
+ // …inner param and its references untouched
150
+ expect(template).toMatch(/\(\s*title\s*\)\s*=>\s*title\.a/)
151
+ expect(template).not.toMatch(/\(\s*_p\.title\s*\)\s*=>/)
152
+ })
153
+
154
+ test('a loop-shadowed record const is not folded into a template span', () => {
155
+ const clientJs = clientJsOf(`
156
+ const tone = { a: 'outer-a', b: 'outer-b' }
157
+
158
+ export function Tones({ items, k }: { items: { id: number; a: string; b: string }[]; k: string }) {
159
+ return <ul>{items.map((tone) => <li key={tone.id} data-t={\`t \${tone[k]}\`}>{tone.a}</li>)}</ul>
160
+ }
161
+ `)
162
+ const template = templateLambdaOf(clientJs, 'Tones')
163
+ // The span must read the row binding, not the outer record literal.
164
+ expect(template).toContain('tone[_p.k]')
165
+ expect(template).not.toContain('outer-a')
166
+ })
167
+ })
package/src/jsx-to-ir.ts CHANGED
@@ -6074,6 +6074,11 @@ function tryResolveTemplateSpanFromConst(
6074
6074
  ): IRTemplatePart[] | null {
6075
6075
  // ${IDENT}
6076
6076
  if (ts.isIdentifier(expr)) {
6077
+ // #2222-family: inside a loop callback the name may be the loop's
6078
+ // item/index binding shadowing a same-named const — resolving the
6079
+ // const would bake the outer value into every row. Fall back to
6080
+ // the bare-expression path, which sees the loop binding.
6081
+ if (ctx.loopParams.has(expr.text)) return null
6077
6082
  const constInfo = findLocalConst(expr.text, ctx.analyzer)
6078
6083
  if (!constInfo) return null
6079
6084
  const ast = parseConstInitializer(constInfo)
@@ -6087,6 +6092,10 @@ function tryResolveTemplateSpanFromConst(
6087
6092
  // ${IDENT[KEY]}
6088
6093
  if (ts.isElementAccessExpression(expr)) {
6089
6094
  if (!ts.isIdentifier(expr.expression)) return null
6095
+ // Same loop-shadowing guard as the ${IDENT} arm: `tone[k]` inside
6096
+ // `items.map((tone) => …)` must read the row's `tone`, not a
6097
+ // same-named module/component record const.
6098
+ if (ctx.loopParams.has(expr.expression.text)) return null
6090
6099
  const constInfo = findLocalConst(expr.expression.text, ctx.analyzer)
6091
6100
  if (!constInfo) return null
6092
6101
  const ast = parseConstInitializer(constInfo)
@@ -3,39 +3,165 @@
3
3
  *
4
4
  * Walks the TypeScript AST to identify destructured prop names used as
5
5
  * value references (not as object keys, property access targets, or
6
- * shorthand properties), then applies targeted regex replacement.
6
+ * shorthand properties), then splices `_p.` onto exactly those
7
+ * references in the emitted text via a second scope-aware walk over
8
+ * the text's own AST.
9
+ *
10
+ * Scope model: a name bound INSIDE the expression — a nested arrow /
11
+ * function parameter, a block-scoped declaration, a catch variable —
12
+ * refers to that binding, not the prop, for the whole binding's scope.
13
+ * Both the discovery walk and the rewrite walk carry the same binding
14
+ * stack, so `items.map((title) => title.a)` never turns into the
15
+ * syntactically invalid `.map((_p.title) => _p.title.a)` when `title`
16
+ * is also a prop. (Names bound by loop callbacks that ENCLOSE the
17
+ * expression are the caller's job — see the `ctx.loopParams` filter in
18
+ * `jsx-to-ir.ts`'s `rewriteBarePropRefs` wrapper, #2222.)
7
19
  */
8
20
 
9
21
  import ts from 'typescript'
10
22
  import { PROPS_PARAM } from './ir-to-client-js/utils.ts'
11
23
  import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template.ts'
12
24
 
25
+ /** Collect every name introduced by a binding name (identifier or pattern). */
26
+ function collectBindingNames(name: ts.BindingName, out: Set<string>): void {
27
+ if (ts.isIdentifier(name)) {
28
+ out.add(name.text)
29
+ return
30
+ }
31
+ for (const el of name.elements) {
32
+ if (ts.isBindingElement(el)) collectBindingNames(el.name, out)
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Names bound by `n` for its subtree, or null when `n` introduces no
38
+ * scope. Function-likes bind their parameters (and a function
39
+ * expression its own name); blocks bind their statement-level
40
+ * variable/function declarations; catch clauses bind their variable.
41
+ */
42
+ function scopeFrameOf(n: ts.Node): Set<string> | null {
43
+ if (ts.isFunctionLike(n)) {
44
+ const frame = new Set<string>()
45
+ for (const p of n.parameters) collectBindingNames(p.name, frame)
46
+ if ((ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) && n.name) frame.add(n.name.text)
47
+ return frame.size > 0 ? frame : null
48
+ }
49
+ if (ts.isBlock(n)) {
50
+ const frame = new Set<string>()
51
+ for (const st of n.statements) {
52
+ if (ts.isVariableStatement(st)) {
53
+ for (const d of st.declarationList.declarations) collectBindingNames(d.name, frame)
54
+ } else if (ts.isFunctionDeclaration(st) && st.name) {
55
+ frame.add(st.name.text)
56
+ }
57
+ }
58
+ return frame.size > 0 ? frame : null
59
+ }
60
+ if (ts.isCatchClause(n) && n.variableDeclaration) {
61
+ const frame = new Set<string>()
62
+ collectBindingNames(n.variableDeclaration.name, frame)
63
+ return frame.size > 0 ? frame : null
64
+ }
65
+ return null
66
+ }
67
+
68
+ /**
69
+ * Depth-first walk that maintains the binding stack described above and
70
+ * reports every identifier along with whether a binding within `root`
71
+ * currently shadows it.
72
+ */
73
+ function walkWithScope(
74
+ root: ts.Node,
75
+ visit: (ident: ts.Identifier, parent: ts.Node | undefined, shadowed: boolean) => void,
76
+ ): void {
77
+ const scopeStack: Set<string>[] = []
78
+ const isShadowed = (name: string) => scopeStack.some(frame => frame.has(name))
79
+ function rec(n: ts.Node, parent?: ts.Node) {
80
+ const frame = scopeFrameOf(n)
81
+ if (frame) scopeStack.push(frame)
82
+ if (ts.isIdentifier(n)) visit(n, parent, isShadowed(n.text))
83
+ ts.forEachChild(n, child => rec(child, n))
84
+ if (frame) scopeStack.pop()
85
+ }
86
+ rec(root)
87
+ }
88
+
89
+ /**
90
+ * True when `n` sits in a non-value position where a prop rewrite must
91
+ * never apply: an object-literal key, a member-access name, a binding
92
+ * position (parameter / variable / binding-element name), or a type
93
+ * reference.
94
+ */
95
+ function isNonValuePosition(n: ts.Identifier, parent: ts.Node | undefined): boolean {
96
+ if (!parent) return false
97
+ if (ts.isPropertyAssignment(parent) && parent.name === n) return true
98
+ if (ts.isPropertyAccessExpression(parent) && parent.name === n) return true
99
+ if (ts.isQualifiedName(parent) && parent.right === n) return true
100
+ if ((ts.isParameter(parent) || ts.isVariableDeclaration(parent) || ts.isBindingElement(parent)) && parent.name === n) return true
101
+ if (ts.isTypeReferenceNode(parent)) return true
102
+ return false
103
+ }
104
+
13
105
  /**
14
106
  * Walk an AST node for destructured-prop value references and add
15
107
  * each found name to `out`. Same skip rules as the rewrite path —
16
- * object-literal keys, shorthand properties, and property-access
17
- * names are excluded so only true value references get picked up.
18
- * Exported for callers that need the raw discovery set (e.g. the
19
- * branch-local prop-dep cache from #1425).
108
+ * object-literal keys, shorthand properties, property-access names,
109
+ * and names shadowed by a binding inside `node` are excluded so only
110
+ * true value references get picked up. Exported for callers that need
111
+ * the raw discovery set (e.g. the branch-local prop-dep cache from
112
+ * #1425).
20
113
  */
21
114
  export function collectAstPropRefs(
22
115
  node: ts.Node,
23
116
  propNames: Set<string>,
24
117
  out: Set<string>,
25
118
  ): void {
26
- function visit(n: ts.Node, parent?: ts.Node) {
27
- if (ts.isIdentifier(n) && propNames.has(n.text)) {
28
- // Skip: object literal key { org: ... }
29
- if (parent && ts.isPropertyAssignment(parent) && parent.name === n) return
30
- // Skip: shorthand property { org }
31
- if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n) return
32
- // Skip: property access name foo.org
33
- if (parent && ts.isPropertyAccessExpression(parent) && parent.name === n) return
34
- out.add(n.text)
119
+ walkWithScope(node, (n, parent, shadowed) => {
120
+ if (shadowed || !propNames.has(n.text)) return
121
+ if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n) return
122
+ if (isNonValuePosition(n, parent)) return
123
+ out.add(n.text)
124
+ })
125
+ }
126
+
127
+ /**
128
+ * Scope-aware rewrite: parse `text` as an expression, walk it with the
129
+ * binding stack, and splice `${PROPS_PARAM}.` onto exactly the
130
+ * identifier references that are (a) in `propRefs` and (b) not
131
+ * shadowed by a binding inside the text. Shorthand properties expand
132
+ * (`{ org }` → `{ org: _p.org }`) so the result stays syntactically
133
+ * valid.
134
+ *
135
+ * Returns null when `text` does not parse cleanly as an expression —
136
+ * the caller falls back to the legacy regex rewrite.
137
+ */
138
+ function applyScopedPropRefRewrite(text: string, propRefs: Set<string>): string | null {
139
+ // Wrap in parens so object literals and arrows parse as expressions.
140
+ const prefix = '('
141
+ const sf = ts.createSourceFile('__bf_prop_rewrite.ts', `${prefix}${text}\n)`, ts.ScriptTarget.Latest, true)
142
+ const parseDiagnostics = (sf as unknown as { parseDiagnostics?: unknown[] }).parseDiagnostics
143
+ if (parseDiagnostics && parseDiagnostics.length > 0) return null
144
+
145
+ const edits: Array<{ start: number; end: number; replacement: string }> = []
146
+ walkWithScope(sf, (n, parent, shadowed) => {
147
+ if (shadowed || !propRefs.has(n.text)) return
148
+ if (isNonValuePosition(n, parent)) return
149
+ const start = n.getStart(sf) - prefix.length
150
+ const end = n.getEnd() - prefix.length
151
+ if (start < 0 || end > text.length) return
152
+ if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n) {
153
+ edits.push({ start, end, replacement: `${n.text}: ${PROPS_PARAM}.${n.text}` })
154
+ return
35
155
  }
36
- ts.forEachChild(n, child => visit(child, n))
156
+ edits.push({ start, end, replacement: `${PROPS_PARAM}.${n.text}` })
157
+ })
158
+
159
+ if (edits.length === 0) return text
160
+ let result = text
161
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
162
+ result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end)
37
163
  }
38
- visit(node)
164
+ return result
39
165
  }
40
166
 
41
167
  /**
@@ -43,6 +169,10 @@ export function collectAstPropRefs(
43
169
  * type-stripped expression text. Idempotent under `_p.X` (negative
44
170
  * lookbehind on `_p\\.`) and skips object-literal keys via the
45
171
  * post-match `{,` + `:` shape check.
172
+ *
173
+ * Legacy fallback for text that doesn't parse as a standalone
174
+ * expression — it cannot see scopes, so `applyScopedPropRefRewrite`
175
+ * is always tried first.
46
176
  */
47
177
  export function applyRegexPropRefRewrite(
48
178
  text: string,
@@ -77,8 +207,8 @@ export function applyRegexPropRefRewrite(
77
207
  * @param extraPropRefs - Optional prop names known to appear in
78
208
  * `text` via substitution sources the AST walk can't see (e.g.
79
209
  * `text` was produced by inlining a branch-local whose initializer
80
- * references the prop). The post-match `_p\\.` lookbehind keeps
81
- * the regex idempotent, so passing an over-broad set is safe.
210
+ * references the prop). The rewrite only touches genuine value
211
+ * references, so passing an over-broad set is safe.
82
212
  */
83
213
  export function rewriteBarePropRefs(
84
214
  text: string,
@@ -95,5 +225,5 @@ export function rewriteBarePropRefs(
95
225
  }
96
226
  }
97
227
  if (foundPropRefs.size === 0) return undefined
98
- return applyRegexPropRefRewrite(text, foundPropRefs)
228
+ return applyScopedPropRefRewrite(text, foundPropRefs) ?? applyRegexPropRefRewrite(text, foundPropRefs)
99
229
  }