diff --git a/packages/ts-transformers/src/transformers/opaque-ref/helpers.ts b/packages/ts-transformers/src/transformers/opaque-ref/helpers.ts index 799ec6c17..195787785 100644 --- a/packages/ts-transformers/src/transformers/opaque-ref/helpers.ts +++ b/packages/ts-transformers/src/transformers/opaque-ref/helpers.ts @@ -41,6 +41,16 @@ function originatesFromIgnoredParameter( const inner = (expr: ts.Expression): boolean => { if (ts.isIdentifier(expr)) { const symbol = checker.getSymbolAtLocation(expr); + + // Don't filter identifiers without symbols here - they might be synthetic + // identifiers created by transformers (like map callback parameters), or + // they might be legitimate identifiers that lost their symbols. Let + // filterRelevantDataFlows handle this with more context about all the + // dataflows being analyzed together. + if (!symbol) { + return false; + } + return isIgnoredSymbol(symbol); } if ( @@ -85,6 +95,101 @@ export function filterRelevantDataFlows( analysis: DataFlowAnalysis, context: TransformationContext, ): NormalizedDataFlow[] { + // Check if we have identifiers without symbols (synthetic identifiers created by transformers) + const hasSyntheticRoot = (expr: ts.Expression): boolean => { + let current = expr; + while ( + ts.isPropertyAccessExpression(current) || + ts.isElementAccessExpression(current) + ) { + current = current.expression; + } + if (ts.isIdentifier(current)) { + const symbol = context.checker.getSymbolAtLocation(current); + // No symbol means it's likely a synthetic identifier + return !symbol; + } + return false; + }; + + 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'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; + }); + } + + // 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; + } + 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 + ) { + return dataFlows.filter((df) => !hasSyntheticRoot(df.expression)); + } + + // Otherwise, we're inside a map callback with captures - keep all dataflows + } + return dataFlows.filter((dataFlow) => { if ( originatesFromIgnoredParameter( diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.expected.tsx new file mode 100644 index 000000000..ce879dc26 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.expected.tsx @@ -0,0 +1,43 @@ +import * as __ctHelpers from "commontools"; +import { cell, recipe, UI } from "commontools"; +export default recipe("MapNestedConditional", (_state) => { + const items = cell([{ name: "apple" }, { name: "banana" }]); + const showList = cell(true); + return { + [UI]: (
+ {__ctHelpers.derive({ showList, items }, ({ showList: showList, items: items }) => showList && (
+ {items.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: { + name: { + type: "string" + } + }, + required: ["name"] + } + } + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (
+ {__ctHelpers.derive(element.name, _v1 => _v1 && {_v1})} +
)), {})} +
))} +
), + }; +}); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.input.tsx new file mode 100644 index 000000000..e9c7244cf --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.input.tsx @@ -0,0 +1,23 @@ +/// +import { cell, recipe, UI } from "commontools"; + +export default recipe("MapNestedConditional", (_state) => { + const items = cell([{ name: "apple" }, { name: "banana" }]); + const showList = cell(true); + + return { + [UI]: ( +
+ {showList && ( +
+ {items.map((item) => ( +
+ {item.name && {item.name}} +
+ ))} +
+ )} +
+ ), + }; +});