@barefootjs/jsx 0.30.0 → 0.30.2
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 +124 -25
- package/dist/prop-rewrite.d.ts +24 -7
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/csr-template-scope-soundness.test.ts +71 -0
- package/src/jsx-to-ir.ts +9 -0
- package/src/prop-rewrite.ts +149 -19
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
|
-
|
|
4547
|
-
if (
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
22512
|
+
collectBindingNames4(p.name, names);
|
|
22414
22513
|
return names;
|
|
22415
22514
|
}
|
|
22416
|
-
function
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
22533
|
+
collectBindingNames4(p.name, names);
|
|
22435
22534
|
bound.push(...names);
|
|
22436
22535
|
}
|
|
22437
22536
|
function pushBindings(name) {
|
|
22438
22537
|
const names = [];
|
|
22439
|
-
|
|
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
|
-
|
|
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)
|
package/dist/prop-rewrite.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
13
|
-
* names
|
|
14
|
-
*
|
|
15
|
-
* branch-local prop-dep cache from
|
|
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
|
|
36
|
-
*
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.30.2",
|
|
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.
|
|
56
|
+
"@barefootjs/shared": "0.30.2"
|
|
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)
|
package/src/prop-rewrite.ts
CHANGED
|
@@ -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
|
|
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,
|
|
17
|
-
* names
|
|
18
|
-
*
|
|
19
|
-
* branch-local prop-dep cache from
|
|
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
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
*
|
|
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
|
}
|