Skip to content

Commit 36d60c5

Browse files
seefeldbclaude
andauthored
Fix: Prevent map transformation when inside derive and exclude untransformed map parameters (#1960)
* Fix: Skip map transformation when map will be inside derive When a map is nested in an expression with opaque refs (e.g., `list.length > 0 && list.map(...)`), the OpaqueRefJSXTransformer wraps the entire expression in `derive(list, list => ...)`, which unwraps the array to a plain array. If ClosureTransformer had already transformed the map to `mapWithPattern`, this causes runtime errors since plain arrays don't have `mapWithPattern`. This fix adds `shouldTransformMap()` which uses dataflow analysis to detect when a map will be inside a derive and skips the transformation, keeping it as plain `.map()` which works correctly on unwrapped arrays. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix: Exclude untransformed map callback parameters from outer derive capture When a plain .map() call is inside an expression that gets wrapped in derive(), the map callback parameters should not be treated as opaque parameters for the purposes of the outer derive. This is because: 1. The map is NOT being transformed to mapWithPattern (stays as plain .map) 2. The callback runs on unwrapped array elements inside the derive 3. The callback parameters are local to that scope, not outer scope variables This fix modifies getOpaqueCallKindForParameter() to check if a callback was actually transformed (using context.isMapCallback) before treating its parameters as opaque. Untransformed callbacks have regular parameters that should not be captured in outer derives. Example that's now fixed: ```tsx {items.map((item) => <div>{item.name && <span>{item.name}</span>}</div>)} ``` Before: derive({ items, item_name: item.name }, ...) ❌ After: derive({ items }, ...) ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b587a4a commit 36d60c5

File tree

7 files changed

+134
-79
lines changed

7 files changed

+134
-79
lines changed

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

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ts from "typescript";
22
import { TransformationContext, Transformer } from "../core/mod.ts";
33
import { isOpaqueRefType } from "../transformers/opaque-ref/opaque-ref.ts";
4-
import { visitEachChildWithJsx } from "../ast/mod.ts";
4+
import { createDataFlowAnalyzer, visitEachChildWithJsx } from "../ast/mod.ts";
55

66
export class ClosureTransformer extends Transformer {
77
override filter(context: TransformationContext): boolean {
@@ -715,6 +715,72 @@ function expressionsMatch(a: ts.Expression, b: ts.Expression): boolean {
715715
return false;
716716
}
717717

718+
/**
719+
* Check if a map call should be transformed to mapWithPattern.
720+
* Returns false if the map will end up inside a derive (where the array is unwrapped).
721+
*
722+
* This happens when the map is nested inside a larger expression with opaque refs,
723+
* e.g., `list.length > 0 && list.map(...)` becomes `derive(list, list => ...)`
724+
*/
725+
function shouldTransformMap(
726+
mapCall: ts.CallExpression,
727+
context: TransformationContext,
728+
): boolean {
729+
// Find the closest containing JSX expression
730+
let node: ts.Node = mapCall;
731+
let closestJsxExpression: ts.JsxExpression | undefined;
732+
733+
while (node.parent) {
734+
if (ts.isJsxExpression(node.parent)) {
735+
closestJsxExpression = node.parent;
736+
break;
737+
}
738+
node = node.parent;
739+
}
740+
741+
// If we didn't find a JSX expression, default to transforming
742+
// (this handles maps in regular statements like `const x = items.map(...)`)
743+
if (!closestJsxExpression || !closestJsxExpression.expression) {
744+
return true;
745+
}
746+
747+
const analyze = createDataFlowAnalyzer(context.checker);
748+
749+
//Case 1: Map is nested in a larger expression within the same JSX expression
750+
// Example: {list.length > 0 && list.map(...)}
751+
// Only check THIS expression for derive wrapping
752+
if (closestJsxExpression.expression !== mapCall) {
753+
const analysis = analyze(closestJsxExpression.expression);
754+
// Check if this will be wrapped in a derive (not just transformed in some other way)
755+
// Array-map calls have skip-call-rewrite hint, so they won't be wrapped in derive
756+
const willBeWrappedInDerive = analysis.requiresRewrite &&
757+
!(analysis.rewriteHint?.kind === "skip-call-rewrite" &&
758+
analysis.rewriteHint.reason === "array-map");
759+
return !willBeWrappedInDerive;
760+
}
761+
762+
// Case 2: Map IS the direct content of the JSX expression
763+
// Example: <div>{list.map(...)}</div>
764+
// Check if an ANCESTOR JSX expression will wrap this in a derive
765+
node = closestJsxExpression.parent;
766+
while (node) {
767+
if (ts.isJsxExpression(node) && node.expression) {
768+
const analysis = analyze(node.expression);
769+
const willBeWrappedInDerive = analysis.requiresRewrite &&
770+
!(analysis.rewriteHint?.kind === "skip-call-rewrite" &&
771+
analysis.rewriteHint.reason === "array-map");
772+
if (willBeWrappedInDerive) {
773+
// An ancestor JSX expression will wrap this in a derive
774+
return false;
775+
}
776+
}
777+
node = node.parent;
778+
}
779+
780+
// No ancestor will wrap in derive, transform normally
781+
return true;
782+
}
783+
718784
/**
719785
* Create a visitor function that transforms OpaqueRef map calls.
720786
* This visitor can be reused for both top-level and nested transformations.
@@ -734,7 +800,11 @@ function createMapTransformVisitor(
734800
callback &&
735801
(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))
736802
) {
737-
return transformMapCallback(node, callback, context, visit);
803+
// Check if this map will end up inside a derive (where array is unwrapped)
804+
// If so, skip transformation and keep it as plain .map
805+
if (shouldTransformMap(node, context)) {
806+
return transformMapCallback(node, callback, context, visit);
807+
}
738808
}
739809
}
740810

packages/ts-transformers/src/transformers/opaque-ref/helpers.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function originatesFromIgnoredParameter(
1515
scopeId: number,
1616
analysis: DataFlowAnalysis,
1717
checker: ts.TypeChecker,
18+
context?: TransformationContext,
1819
): boolean {
1920
const scope = analysis.graph.scopes.find((candidate) =>
2021
candidate.id === scopeId
@@ -28,7 +29,7 @@ function originatesFromIgnoredParameter(
2829
if (parameter.symbol === symbol || parameter.name === symbolName) {
2930
if (
3031
parameter.declaration &&
31-
getOpaqueCallKindForParameter(parameter.declaration, checker)
32+
getOpaqueCallKindForParameter(parameter.declaration, checker, context)
3233
) {
3334
return false;
3435
}
@@ -70,6 +71,7 @@ function originatesFromIgnoredParameter(
7071
function getOpaqueCallKindForParameter(
7172
declaration: ts.ParameterDeclaration,
7273
checker: ts.TypeChecker,
74+
context?: TransformationContext,
7375
): "builder" | "array-map" | undefined {
7476
let functionNode: ts.Node | undefined = declaration.parent;
7577
while (functionNode && !ts.isFunctionLike(functionNode)) {
@@ -84,8 +86,18 @@ function getOpaqueCallKindForParameter(
8486
if (!candidate) return undefined;
8587

8688
const callKind = detectCallKind(candidate, checker);
87-
if (callKind?.kind === "builder" || callKind?.kind === "array-map") {
88-
return callKind.kind;
89+
if (callKind?.kind === "builder") {
90+
return "builder";
91+
}
92+
if (callKind?.kind === "array-map") {
93+
// For array-map calls, only treat parameters as opaque if the callback
94+
// was actually transformed (marked in mapCallbackRegistry)
95+
// Untransformed maps (plain .map inside derives) should have regular parameters
96+
if (context && !context.isMapCallback(functionNode)) {
97+
// Callback was not transformed, parameters are not opaque
98+
return undefined;
99+
}
100+
return "array-map";
89101
}
90102
return undefined;
91103
}
@@ -156,6 +168,7 @@ export function filterRelevantDataFlows(
156168
dataFlow.scopeId,
157169
analysis,
158170
context.checker,
171+
context,
159172
)
160173
) {
161174
return false;
@@ -195,6 +208,7 @@ export function filterRelevantDataFlows(
195208
dataFlow.scopeId,
196209
analysis,
197210
context.checker,
211+
context,
198212
)
199213
) {
200214
return false;
@@ -216,6 +230,7 @@ export function filterRelevantDataFlows(
216230
dataFlow.scopeId,
217231
analysis,
218232
context.checker,
233+
context,
219234
)
220235
) {
221236
return false;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as __ctHelpers from "commontools";
2+
import { cell, recipe, UI } from "commontools";
3+
export default recipe("MapArrayLengthConditional", (_state) => {
4+
const list = cell(["apple", "banana", "cherry"]);
5+
return {
6+
[UI]: (<div>
7+
{__ctHelpers.derive(list, list => list.length > 0 && (<div>
8+
{list.map((name) => (<span>{name}</span>))}
9+
</div>))}
10+
</div>),
11+
};
12+
});
13+
// @ts-ignore: Internals
14+
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
15+
// @ts-ignore: Internals
16+
h.fragment = __ctHelpers.h.fragment;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/// <cts-enable />
2+
import { cell, recipe, UI } from "commontools";
3+
4+
export default recipe("MapArrayLengthConditional", (_state) => {
5+
const list = cell(["apple", "banana", "cherry"]);
6+
7+
return {
8+
[UI]: (
9+
<div>
10+
{list.length > 0 && (
11+
<div>
12+
{list.map((name) => (
13+
<span>{name}</span>
14+
))}
15+
</div>
16+
)}
17+
</div>
18+
),
19+
};
20+
});

packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.expected.tsx

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,9 @@ export default recipe("MapNestedConditional", (_state) => {
66
return {
77
[UI]: (<div>
88
{__ctHelpers.derive({ showList, items }, ({ showList: showList, items: items }) => showList && (<div>
9-
{items.mapWithPattern(__ctHelpers.recipe({
10-
$schema: "https://json-schema.org/draft/2020-12/schema",
11-
type: "object",
12-
properties: {
13-
element: {
14-
$ref: "#/$defs/__object"
15-
},
16-
params: {
17-
type: "object",
18-
properties: {}
19-
}
20-
},
21-
required: ["element", "params"],
22-
$defs: {
23-
__object: {
24-
type: "object",
25-
properties: {
26-
name: {
27-
type: "string"
28-
}
29-
},
30-
required: ["name"]
31-
}
32-
}
33-
} as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (<div>
34-
{__ctHelpers.derive(element.name, _v1 => _v1 && <span>{_v1}</span>)}
35-
</div>)), {})}
9+
{items.map((item) => (<div>
10+
{__ctHelpers.derive(item.name, _v1 => _v1 && <span>{_v1}</span>)}
11+
</div>))}
3612
</div>))}
3713
</div>),
3814
};

packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,7 @@ export default recipe("MapSingleCapture", (_state) => {
88
return {
99
[UI]: (<div>
1010
{__ctHelpers.derive(people, people => people.length > 0 && (<ul>
11-
{people.mapWithPattern(__ctHelpers.recipe({
12-
$schema: "https://json-schema.org/draft/2020-12/schema",
13-
type: "object",
14-
properties: {
15-
element: {
16-
$ref: "#/$defs/__object"
17-
},
18-
params: {
19-
type: "object",
20-
properties: {}
21-
}
22-
},
23-
required: ["element", "params"],
24-
$defs: {
25-
__object: {
26-
type: "object",
27-
properties: {
28-
id: {
29-
type: "string"
30-
},
31-
name: {
32-
type: "string"
33-
}
34-
},
35-
required: ["id", "name"]
36-
}
37-
}
38-
} as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (<li key={element.id}>{element.name}</li>)), {})}
11+
{people.map((person) => (<li key={person.id}>{person.name}</li>))}
3912
</ul>))}
4013
</div>),
4114
};

packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,26 +101,12 @@ export default recipe("Charms Launcher", () => {
101101
[UI]: (<div>
102102
<h3>Stored Charms:</h3>
103103
{ifElse(__ctHelpers.derive(typedCellRef, typedCellRef => !typedCellRef?.length), <div>No charms created yet</div>, <ul>
104-
{typedCellRef.mapWithPattern(__ctHelpers.recipe({
105-
$schema: "https://json-schema.org/draft/2020-12/schema",
106-
type: "object",
107-
properties: {
108-
element: true,
109-
index: {
110-
type: "number"
111-
},
112-
params: {
113-
type: "object",
114-
properties: {}
115-
}
116-
},
117-
required: ["element", "params"]
118-
} as const satisfies __ctHelpers.JSONSchema, ({ element, index, params: {} }) => (<li>
119-
<ct-button onClick={goToCharm({ charm: element })}>
104+
{typedCellRef.map((charm: any, index: number) => (<li>
105+
<ct-button onClick={goToCharm({ charm })}>
120106
Go to Charm {__ctHelpers.derive(index, index => index + 1)}
121107
</ct-button>
122-
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(element, element => element[NAME] || "Unnamed")}</span>
123-
</li>)), {})}
108+
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(charm, charm => charm[NAME] || "Unnamed")}</span>
109+
</li>))}
124110
</ul>)}
125111

126112
<ct-button onClick={createSimpleRecipe({ cellRef })}>
@@ -134,4 +120,3 @@ export default recipe("Charms Launcher", () => {
134120
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
135121
// @ts-ignore: Internals
136122
h.fragment = __ctHelpers.h.fragment;
137-

0 commit comments

Comments
 (0)