Skip to content

Commit b62e7c1

Browse files
authored
Fix map capture collisions (#1975)
* Fix map closure destructured alias handling and add regression fixtures * Fix map closure capture name collisions and add regression fixture * Avoid recomputing computed destructured aliases and add fixture
1 parent 3b543fb commit b62e7c1

13 files changed

+653
-20
lines changed

packages/ts-transformers/src/closures/transformer.ts

Lines changed: 134 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -897,17 +897,114 @@ function transformDestructuredProperties(
897897
): ts.ConciseBody {
898898
const elemName = elemParam?.name;
899899

900-
// Collect destructured property names if the param is an object destructuring pattern
901-
const destructuredProps = new Set<string>();
900+
let transformedBody: ts.ConciseBody = body;
901+
902+
const prependStatements = (
903+
statements: readonly ts.Statement[],
904+
currentBody: ts.ConciseBody,
905+
): ts.ConciseBody => {
906+
if (statements.length === 0) return currentBody;
907+
908+
if (ts.isBlock(currentBody)) {
909+
return factory.updateBlock(
910+
currentBody,
911+
factory.createNodeArray([
912+
...statements,
913+
...currentBody.statements,
914+
]),
915+
);
916+
}
917+
918+
return factory.createBlock(
919+
[
920+
...statements,
921+
factory.createReturnStatement(currentBody as ts.Expression),
922+
],
923+
true,
924+
);
925+
};
926+
927+
const destructuredProps = new Map<string, () => ts.Expression>();
928+
const computedInitializers: ts.VariableStatement[] = [];
929+
const usedTempNames = new Set<string>();
930+
931+
const registerTempName = (base: string): string => {
932+
let candidate = `__ct_${base || "prop"}_key`;
933+
let counter = 1;
934+
while (usedTempNames.has(candidate)) {
935+
candidate = `__ct_${base || "prop"}_key_${counter++}`;
936+
}
937+
usedTempNames.add(candidate);
938+
return candidate;
939+
};
940+
902941
if (elemName && ts.isObjectBindingPattern(elemName)) {
903942
for (const element of elemName.elements) {
904943
if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
905-
destructuredProps.add(element.name.text);
944+
const alias = element.name.text;
945+
const propertyName = element.propertyName;
946+
usedTempNames.add(alias);
947+
948+
destructuredProps.set(alias, () => {
949+
const target = factory.createIdentifier("element");
950+
951+
if (!propertyName) {
952+
return factory.createPropertyAccessExpression(
953+
target,
954+
factory.createIdentifier(alias),
955+
);
956+
}
957+
958+
if (ts.isIdentifier(propertyName)) {
959+
return factory.createPropertyAccessExpression(
960+
target,
961+
factory.createIdentifier(propertyName.text),
962+
);
963+
}
964+
965+
if (
966+
ts.isStringLiteral(propertyName) ||
967+
ts.isNumericLiteral(propertyName)
968+
) {
969+
return factory.createElementAccessExpression(target, propertyName);
970+
}
971+
972+
if (ts.isComputedPropertyName(propertyName)) {
973+
const tempName = registerTempName(alias);
974+
const tempIdentifier = factory.createIdentifier(tempName);
975+
976+
computedInitializers.push(
977+
factory.createVariableStatement(
978+
undefined,
979+
factory.createVariableDeclarationList(
980+
[
981+
factory.createVariableDeclaration(
982+
tempIdentifier,
983+
undefined,
984+
undefined,
985+
propertyName.expression,
986+
),
987+
],
988+
ts.NodeFlags.Const,
989+
),
990+
),
991+
);
992+
993+
return factory.createElementAccessExpression(
994+
target,
995+
tempIdentifier,
996+
);
997+
}
998+
999+
return factory.createPropertyAccessExpression(
1000+
target,
1001+
factory.createIdentifier(alias),
1002+
);
1003+
});
9061004
}
9071005
}
9081006
}
9091007

910-
// Collect array destructured identifiers: [date, pizza] -> {date: 0, pizza: 1}
9111008
const arrayDestructuredVars = new Map<string, number>();
9121009
if (elemName && ts.isArrayBindingPattern(elemName)) {
9131010
let index = 0;
@@ -919,35 +1016,28 @@ function transformDestructuredProperties(
9191016
}
9201017
}
9211018

922-
// If param was object-destructured, replace property references with element.prop
9231019
if (destructuredProps.size > 0) {
9241020
const visitor: ts.Visitor = (node) => {
9251021
if (ts.isIdentifier(node) && destructuredProps.has(node.text)) {
926-
// Check if this identifier is not part of a property access already
927-
// (e.g., don't transform the 'x' in 'something.x')
9281022
if (
9291023
!node.parent ||
9301024
!(ts.isPropertyAccessExpression(node.parent) &&
9311025
node.parent.name === node)
9321026
) {
933-
return factory.createPropertyAccessExpression(
934-
factory.createIdentifier("element"),
935-
factory.createIdentifier(node.text),
936-
);
1027+
const accessFactory = destructuredProps.get(node.text)!;
1028+
return accessFactory();
9371029
}
9381030
}
9391031
return visitEachChildWithJsx(node, visitor, undefined);
9401032
};
941-
return ts.visitNode(body, visitor) as ts.ConciseBody;
1033+
transformedBody = ts.visitNode(transformedBody, visitor) as ts.ConciseBody;
9421034
}
9431035

944-
// If param was array-destructured, replace variable references with element[index]
9451036
if (arrayDestructuredVars.size > 0) {
9461037
const visitor: ts.Visitor = (node) => {
9471038
if (ts.isIdentifier(node)) {
9481039
const index = arrayDestructuredVars.get(node.text);
9491040
if (index !== undefined) {
950-
// Check if this identifier is not part of a property access already
9511041
if (
9521042
!node.parent ||
9531043
!(ts.isPropertyAccessExpression(node.parent) &&
@@ -962,10 +1052,14 @@ function transformDestructuredProperties(
9621052
}
9631053
return visitEachChildWithJsx(node, visitor, undefined);
9641054
};
965-
return ts.visitNode(body, visitor) as ts.ConciseBody;
1055+
transformedBody = ts.visitNode(transformedBody, visitor) as ts.ConciseBody;
9661056
}
9671057

968-
return body;
1058+
if (computedInitializers.length > 0) {
1059+
transformedBody = prependStatements(computedInitializers, transformedBody);
1060+
}
1061+
1062+
return transformedBody;
9691063
}
9701064

9711065
/**
@@ -1155,12 +1249,32 @@ function transformMapCallback(
11551249
const captureExpressions = collectCaptures(callback, checker);
11561250

11571251
// Build map of capture name -> expression
1158-
const captures = new Map<string, ts.Expression>();
1252+
const captureEntries: Array<{ name: string; expr: ts.Expression }> = [];
1253+
const usedNames = new Set<string>(["element", "index", "array", "params"]);
1254+
11591255
for (const expr of captureExpressions) {
1160-
const name = getCaptureName(expr);
1161-
if (name && !captures.has(name)) {
1162-
captures.set(name, expr);
1256+
const baseName = getCaptureName(expr);
1257+
if (!baseName) continue;
1258+
1259+
// Skip if an existing entry captures an equivalent expression
1260+
const existing = captureEntries.find((entry) =>
1261+
expressionsMatch(expr, entry.expr)
1262+
);
1263+
if (existing) continue;
1264+
1265+
let candidate = baseName;
1266+
let counter = 2;
1267+
while (usedNames.has(candidate)) {
1268+
candidate = `${baseName}_${counter++}`;
11631269
}
1270+
1271+
usedNames.add(candidate);
1272+
captureEntries.push({ name: candidate, expr });
1273+
}
1274+
1275+
const captures = new Map<string, ts.Expression>();
1276+
for (const entry of captureEntries) {
1277+
captures.set(entry.name, entry.expr);
11641278
}
11651279

11661280
// Build set of captured variable names
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as __ctHelpers from "commontools";
2+
import { recipe, UI } from "commontools";
3+
let keyCounter = 0;
4+
function nextKey() {
5+
return `value-${keyCounter++}`;
6+
}
7+
interface State {
8+
items: Array<Record<string, number>>;
9+
}
10+
export default recipe({
11+
type: "object",
12+
properties: {
13+
items: {
14+
type: "array",
15+
items: {
16+
type: "object",
17+
properties: {},
18+
additionalProperties: {
19+
type: "number"
20+
}
21+
}
22+
}
23+
},
24+
required: ["items"]
25+
} as const satisfies __ctHelpers.JSONSchema, (state) => {
26+
return {
27+
[UI]: (<div>
28+
{state.items.mapWithPattern(__ctHelpers.recipe({
29+
$schema: "https://json-schema.org/draft/2020-12/schema",
30+
type: "object",
31+
properties: {
32+
element: {
33+
type: "object",
34+
properties: {},
35+
additionalProperties: {
36+
type: "number"
37+
}
38+
},
39+
params: {
40+
type: "object",
41+
properties: {}
42+
}
43+
},
44+
required: ["element", "params"]
45+
} as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => {
46+
const __ct_amount_key = nextKey();
47+
return (<span>{__ctHelpers.derive({ element, __ct_amount_key }, ({ element: element, __ct_amount_key: __ct_amount_key }) => element[__ct_amount_key])}</span>);
48+
}), {})}
49+
</div>),
50+
};
51+
});
52+
// @ts-ignore: Internals
53+
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
54+
// @ts-ignore: Internals
55+
h.fragment = __ctHelpers.h.fragment;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/// <cts-enable />
2+
import { recipe, UI } from "commontools";
3+
4+
let keyCounter = 0;
5+
function nextKey() {
6+
return `value-${keyCounter++}`;
7+
}
8+
9+
interface State {
10+
items: Array<Record<string, number>>;
11+
}
12+
13+
export default recipe<State>("ComputedAliasSideEffect", (state) => {
14+
return {
15+
[UI]: (
16+
<div>
17+
{state.items.map(({ [nextKey()]: amount }) => (
18+
<span>{amount}</span>
19+
))}
20+
</div>
21+
),
22+
};
23+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as __ctHelpers from "commontools";
2+
import { recipe, UI } from "commontools";
3+
interface State {
4+
items: Array<{
5+
price: number;
6+
}>;
7+
discount: number;
8+
}
9+
export default recipe({
10+
type: "object",
11+
properties: {
12+
items: {
13+
type: "array",
14+
items: {
15+
type: "object",
16+
properties: {
17+
price: {
18+
type: "number"
19+
}
20+
},
21+
required: ["price"]
22+
}
23+
},
24+
discount: {
25+
type: "number"
26+
}
27+
},
28+
required: ["items", "discount"]
29+
} as const satisfies __ctHelpers.JSONSchema, (state) => {
30+
return {
31+
[UI]: (<div>
32+
{state.items.mapWithPattern(__ctHelpers.recipe({
33+
$schema: "https://json-schema.org/draft/2020-12/schema",
34+
type: "object",
35+
properties: {
36+
element: {
37+
type: "object",
38+
properties: {
39+
price: {
40+
type: "number"
41+
}
42+
},
43+
required: ["price"]
44+
},
45+
params: {
46+
type: "object",
47+
properties: {
48+
discount: {
49+
type: "number",
50+
asOpaque: true
51+
}
52+
},
53+
required: ["discount"]
54+
}
55+
},
56+
required: ["element", "params"]
57+
} as const satisfies __ctHelpers.JSONSchema, ({ element, params: { discount } }) => (<span>{__ctHelpers.derive({ element_price: element.price, discount }, ({ element_price: _v1, discount: discount }) => _v1 * discount)}</span>)), { discount: state.discount })}
58+
</div>),
59+
};
60+
});
61+
// @ts-ignore: Internals
62+
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
63+
// @ts-ignore: Internals
64+
h.fragment = __ctHelpers.h.fragment;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/// <cts-enable />
2+
import { recipe, UI } from "commontools";
3+
4+
interface State {
5+
items: Array<{ price: number }>;
6+
discount: number;
7+
}
8+
9+
export default recipe<State>("MapDestructuredAlias", (state) => {
10+
return {
11+
[UI]: (
12+
<div>
13+
{state.items.map(({ price: cost }) => (
14+
<span>{cost * state.discount}</span>
15+
))}
16+
</div>
17+
),
18+
};
19+
});

0 commit comments

Comments
 (0)