Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/ts-transformers/src/closures/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,9 @@ function createRecipeCallWithParams(
transformedBody,
);

// Mark this as a map callback for later transformers (e.g., OpaqueRefJSXTransformer)
context.markAsMapCallback(newCallback);

// Build a TypeNode for the callback parameter to pass as a type argument to recipe<T>()
const callbackParamTypeNode = buildCallbackParamTypeNode(
mapCall,
Expand Down
17 changes: 17 additions & 0 deletions packages/ts-transformers/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,21 @@ export class TransformationContext {
column: location.character + 1,
});
}

/**
* Mark an arrow function as a map callback created by ClosureTransformer.
* This allows later transformers to identify synthetic map callback scopes.
*/
markAsMapCallback(node: ts.Node): void {
if (this.options.mapCallbackRegistry) {
this.options.mapCallbackRegistry.add(node);
}
}

/**
* Check if a node is a map callback created by ClosureTransformer.
*/
isMapCallback(node: ts.Node): boolean {
return this.options.mapCallbackRegistry?.has(node) ?? false;
}
}
1 change: 1 addition & 0 deletions packages/ts-transformers/src/core/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface TransformationOptions {
readonly debug?: boolean;
readonly logger?: (message: string) => void;
readonly typeRegistry?: TypeRegistry;
readonly mapCallbackRegistry?: WeakSet<ts.Node>;
}

export interface TransformationDiagnostic {
Expand Down
1 change: 1 addition & 0 deletions packages/ts-transformers/src/ct-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export class CommonToolsTransformerPipeline extends Pipeline {
constructor(options: TransformationOptions = {}) {
const ops = {
typeRegistry: new WeakMap(),
mapCallbackRegistry: new WeakSet(),
...options,
};
super([
Expand Down
147 changes: 83 additions & 64 deletions packages/ts-transformers/src/transformers/opaque-ref/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,81 +115,100 @@ export function filterRelevantDataFlows(
const syntheticDataFlows = dataFlows.filter((df) =>
hasSyntheticRoot(df.expression)
);
const nonSyntheticDataFlows = dataFlows.filter((df) =>
!hasSyntheticRoot(df.expression)
);

// If we have both synthetic and non-synthetic dataflows, we need to determine
// if we're inside or outside the map callback
if (syntheticDataFlows.length > 0 && nonSyntheticDataFlows.length > 0) {
// Check if any dataflow is in a scope with parameters from a map callback
// If so, we're inside a callback being transformed and should keep all dataflows
const isInMapCallbackScope = dataFlows.some((df) => {
const scope = analysis.graph.scopes.find((s) => s.id === df.scopeId);
if (!scope) return false;

// Check if any parameter in this scope comes from a builder or array-map
return scope.parameters.some((param) => {
if (!param.declaration) return false;
const callKind = getOpaqueCallKindForParameter(
param.declaration,
context.checker,
);
return callKind === "builder" || callKind === "array-map";
});
// If we have synthetic dataflows (e.g., element, index, array from map callbacks),
// these are identifiers without symbols that were created by ClosureTransformer.
// We need to determine if they're being used in the correct scope or if they leaked.
if (syntheticDataFlows.length > 0) {
// Check if the synthetic identifiers are standard map callback parameter names
const hasSyntheticMapParams = syntheticDataFlows.some((df) => {
let rootExpr: ts.Expression = df.expression;
while (
ts.isPropertyAccessExpression(rootExpr) ||
ts.isElementAccessExpression(rootExpr)
) {
rootExpr = rootExpr.expression;
}
if (ts.isIdentifier(rootExpr)) {
const name = rootExpr.text;
// Standard map callback parameter names created by ClosureTransformer
return name === "element" || name === "index" || name === "array";
}
return false;
});

// If we're inside a map callback scope, keep all dataflows
if (isInMapCallbackScope) {
// Keep all dataflows - we're inside a callback with both synthetic params and captures
return dataFlows.filter((dataFlow) => {
if (
originatesFromIgnoredParameter(
dataFlow.expression,
dataFlow.scopeId,
analysis,
context.checker,
)
) {
return false;
}
return true;
});
}
if (hasSyntheticMapParams) {
// We have synthetic map callback params. These could be:
// 1. Inside a map callback (keep them)
// 2. In outer scope where they leaked (filter them out)

// Heuristic: Check if the non-synthetic dataflows have symbols in the outer
// scope If they reference outer-scope variables (like cells), we're at the
// outer scope and should filter synthetic params.
const nonSyntheticHaveOuterScopeSymbols = nonSyntheticDataFlows.every(
(df) => {
let rootExpr: ts.Expression = df.expression;
while (
ts.isPropertyAccessExpression(rootExpr) ||
ts.isElementAccessExpression(rootExpr)
) {
rootExpr = rootExpr.expression;
}
if (ts.isIdentifier(rootExpr)) {
const symbol = context.checker.getSymbolAtLocation(rootExpr);
// If it has a symbol, it's likely from outer scope
return !!symbol;
const nonSyntheticDataFlows = dataFlows.filter((df) =>
!hasSyntheticRoot(df.expression)
);

// If we have ONLY synthetic dataflows, we're definitely inside a map callback
if (nonSyntheticDataFlows.length === 0) {
// Pure synthetic - we're inside a map callback, keep all
return dataFlows.filter((dataFlow) => {
if (
originatesFromIgnoredParameter(
dataFlow.expression,
dataFlow.scopeId,
analysis,
context.checker,
)
) {
return false;
}
return true;
});
}

// We have both synthetic and non-synthetic. This could be:
// 1. Inside a map callback with captures (keep all)
// 2. Outer scope with leaked synthetic params (filter synthetics)

// Try to find if any dataflow is from a scope with parameters that's a marked callback
const isInMarkedCallback = dataFlows.some((df) => {
const scope = analysis.graph.scopes.find((s) => s.id === df.scopeId);
if (!scope || scope.parameters.length === 0) return false;

const firstParam = scope.parameters[0];
if (!firstParam || !firstParam.declaration) return false;

let node: ts.Node | undefined = firstParam.declaration.parent;
while (node) {
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
return context.isMapCallback(node);
}
node = node.parent;
}
return false;
},
);
});

// If all non-synthetic dataflows have outer-scope symbols AND we have 2 or
// more of them, we're likely analyzing an outer-scope expression that
// contains nested map callbacks
if (
nonSyntheticHaveOuterScopeSymbols && nonSyntheticDataFlows.length >= 2
) {
if (isInMarkedCallback) {
// Inside a map callback - keep all except ignored params
return dataFlows.filter((dataFlow) => {
if (
originatesFromIgnoredParameter(
dataFlow.expression,
dataFlow.scopeId,
analysis,
context.checker,
)
) {
return false;
}
return true;
});
}

// Synthetic map params in outer scope - filter them out
return dataFlows.filter((df) => !hasSyntheticRoot(df.expression));
}

// Otherwise, we're inside a map callback with captures - keep all dataflows
}

// No synthetic dataflows, use standard filtering
return dataFlows.filter((dataFlow) => {
if (
originatesFromIgnoredParameter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ export default recipe({
// @ts-ignore: Internals
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
// @ts-ignore: Internals
h.fragment = __ctHelpers.h.fragment;
h.fragment = __ctHelpers.h.fragment;
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as __ctHelpers from "commontools";
import { cell, recipe, UI } from "commontools";
export default recipe("MapSingleCapture", (_state) => {
const people = cell([
{ id: "1", name: "Alice" },
{ id: "2", name: "Bob" },
]);
return {
[UI]: (<div>
{__ctHelpers.derive(people, people => people.length > 0 && (<ul>
{people.mapWithPattern(__ctHelpers.recipe({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {
element: {
$ref: "#/$defs/__object"
},
params: {
type: "object",
properties: {}
}
},
required: ["element", "params"],
$defs: {
__object: {
type: "object",
properties: {
id: {
type: "string"
},
name: {
type: "string"
}
},
required: ["id", "name"]
}
}
} as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (<li key={element.id}>{element.name}</li>)), {})}
</ul>))}
</div>),
};
});
// @ts-ignore: Internals
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
// @ts-ignore: Internals
h.fragment = __ctHelpers.h.fragment;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// <cts-enable />
import { cell, recipe, UI } from "commontools";

export default recipe("MapSingleCapture", (_state) => {
const people = cell([
{ id: "1", name: "Alice" },
{ id: "2", name: "Bob" },
]);

return {
[UI]: (
<div>
{people.length > 0 && (
<ul>
{people.map((person) => (
<li key={person.id}>{person.name}</li>
))}
</ul>
)}
</div>
),
};
});