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]: (