Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
145 changes: 81 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,98 @@ 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>
),
};
});
Loading