diff --git a/packages/ts-transformers/src/closures/transformer.ts b/packages/ts-transformers/src/closures/transformer.ts index 71e36bdf5..bdc108fd7 100644 --- a/packages/ts-transformers/src/closures/transformer.ts +++ b/packages/ts-transformers/src/closures/transformer.ts @@ -897,17 +897,114 @@ function transformDestructuredProperties( ): ts.ConciseBody { const elemName = elemParam?.name; - // Collect destructured property names if the param is an object destructuring pattern - const destructuredProps = new Set(); + let transformedBody: ts.ConciseBody = body; + + const prependStatements = ( + statements: readonly ts.Statement[], + currentBody: ts.ConciseBody, + ): ts.ConciseBody => { + if (statements.length === 0) return currentBody; + + if (ts.isBlock(currentBody)) { + return factory.updateBlock( + currentBody, + factory.createNodeArray([ + ...statements, + ...currentBody.statements, + ]), + ); + } + + return factory.createBlock( + [ + ...statements, + factory.createReturnStatement(currentBody as ts.Expression), + ], + true, + ); + }; + + const destructuredProps = new Map ts.Expression>(); + const computedInitializers: ts.VariableStatement[] = []; + const usedTempNames = new Set(); + + const registerTempName = (base: string): string => { + let candidate = `__ct_${base || "prop"}_key`; + let counter = 1; + while (usedTempNames.has(candidate)) { + candidate = `__ct_${base || "prop"}_key_${counter++}`; + } + usedTempNames.add(candidate); + return candidate; + }; + if (elemName && ts.isObjectBindingPattern(elemName)) { for (const element of elemName.elements) { if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) { - destructuredProps.add(element.name.text); + const alias = element.name.text; + const propertyName = element.propertyName; + usedTempNames.add(alias); + + destructuredProps.set(alias, () => { + const target = factory.createIdentifier("element"); + + if (!propertyName) { + return factory.createPropertyAccessExpression( + target, + factory.createIdentifier(alias), + ); + } + + if (ts.isIdentifier(propertyName)) { + return factory.createPropertyAccessExpression( + target, + factory.createIdentifier(propertyName.text), + ); + } + + if ( + ts.isStringLiteral(propertyName) || + ts.isNumericLiteral(propertyName) + ) { + return factory.createElementAccessExpression(target, propertyName); + } + + if (ts.isComputedPropertyName(propertyName)) { + const tempName = registerTempName(alias); + const tempIdentifier = factory.createIdentifier(tempName); + + computedInitializers.push( + factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + tempIdentifier, + undefined, + undefined, + propertyName.expression, + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + + return factory.createElementAccessExpression( + target, + tempIdentifier, + ); + } + + return factory.createPropertyAccessExpression( + target, + factory.createIdentifier(alias), + ); + }); } } } - // Collect array destructured identifiers: [date, pizza] -> {date: 0, pizza: 1} const arrayDestructuredVars = new Map(); if (elemName && ts.isArrayBindingPattern(elemName)) { let index = 0; @@ -919,35 +1016,28 @@ function transformDestructuredProperties( } } - // If param was object-destructured, replace property references with element.prop if (destructuredProps.size > 0) { const visitor: ts.Visitor = (node) => { if (ts.isIdentifier(node) && destructuredProps.has(node.text)) { - // Check if this identifier is not part of a property access already - // (e.g., don't transform the 'x' in 'something.x') if ( !node.parent || !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) ) { - return factory.createPropertyAccessExpression( - factory.createIdentifier("element"), - factory.createIdentifier(node.text), - ); + const accessFactory = destructuredProps.get(node.text)!; + return accessFactory(); } } return visitEachChildWithJsx(node, visitor, undefined); }; - return ts.visitNode(body, visitor) as ts.ConciseBody; + transformedBody = ts.visitNode(transformedBody, visitor) as ts.ConciseBody; } - // If param was array-destructured, replace variable references with element[index] if (arrayDestructuredVars.size > 0) { const visitor: ts.Visitor = (node) => { if (ts.isIdentifier(node)) { const index = arrayDestructuredVars.get(node.text); if (index !== undefined) { - // Check if this identifier is not part of a property access already if ( !node.parent || !(ts.isPropertyAccessExpression(node.parent) && @@ -962,10 +1052,14 @@ function transformDestructuredProperties( } return visitEachChildWithJsx(node, visitor, undefined); }; - return ts.visitNode(body, visitor) as ts.ConciseBody; + transformedBody = ts.visitNode(transformedBody, visitor) as ts.ConciseBody; } - return body; + if (computedInitializers.length > 0) { + transformedBody = prependStatements(computedInitializers, transformedBody); + } + + return transformedBody; } /** @@ -1155,12 +1249,32 @@ function transformMapCallback( const captureExpressions = collectCaptures(callback, checker); // Build map of capture name -> expression - const captures = new Map(); + const captureEntries: Array<{ name: string; expr: ts.Expression }> = []; + const usedNames = new Set(["element", "index", "array", "params"]); + for (const expr of captureExpressions) { - const name = getCaptureName(expr); - if (name && !captures.has(name)) { - captures.set(name, expr); + const baseName = getCaptureName(expr); + if (!baseName) continue; + + // Skip if an existing entry captures an equivalent expression + const existing = captureEntries.find((entry) => + expressionsMatch(expr, entry.expr) + ); + if (existing) continue; + + let candidate = baseName; + let counter = 2; + while (usedNames.has(candidate)) { + candidate = `${baseName}_${counter++}`; } + + usedNames.add(candidate); + captureEntries.push({ name: candidate, expr }); + } + + const captures = new Map(); + for (const entry of captureEntries) { + captures.set(entry.name, entry.expr); } // Build set of captured variable names diff --git a/packages/ts-transformers/test/fixtures/closures/map-computed-alias-side-effect.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-side-effect.expected.tsx new file mode 100644 index 000000000..8170239e3 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-side-effect.expected.tsx @@ -0,0 +1,55 @@ +import * as __ctHelpers from "commontools"; +import { recipe, UI } from "commontools"; +let keyCounter = 0; +function nextKey() { + return `value-${keyCounter++}`; +} +interface State { + items: Array>; +} +export default recipe({ + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: {}, + additionalProperties: { + type: "number" + } + } + } + }, + required: ["items"] +} as const satisfies __ctHelpers.JSONSchema, (state) => { + return { + [UI]: (
+ {state.items.mapWithPattern(__ctHelpers.recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + element: { + type: "object", + properties: {}, + additionalProperties: { + type: "number" + } + }, + params: { + type: "object", + properties: {} + } + }, + required: ["element", "params"] + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => { + const __ct_amount_key = nextKey(); + return ({__ctHelpers.derive({ element, __ct_amount_key }, ({ element: element, __ct_amount_key: __ct_amount_key }) => element[__ct_amount_key])}); + }), {})} +
), + }; +}); +// @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/closures/map-computed-alias-side-effect.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-side-effect.input.tsx new file mode 100644 index 000000000..93169e621 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-side-effect.input.tsx @@ -0,0 +1,23 @@ +/// +import { recipe, UI } from "commontools"; + +let keyCounter = 0; +function nextKey() { + return `value-${keyCounter++}`; +} + +interface State { + items: Array>; +} + +export default recipe("ComputedAliasSideEffect", (state) => { + return { + [UI]: ( +
+ {state.items.map(({ [nextKey()]: amount }) => ( + {amount} + ))} +
+ ), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/map-destructured-alias.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-alias.expected.tsx new file mode 100644 index 000000000..1bdc9609d --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-alias.expected.tsx @@ -0,0 +1,64 @@ +import * as __ctHelpers from "commontools"; +import { recipe, UI } from "commontools"; +interface State { + items: Array<{ + price: number; + }>; + discount: number; +} +export default recipe({ + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: { + price: { + type: "number" + } + }, + required: ["price"] + } + }, + discount: { + type: "number" + } + }, + required: ["items", "discount"] +} as const satisfies __ctHelpers.JSONSchema, (state) => { + return { + [UI]: (
+ {state.items.mapWithPattern(__ctHelpers.recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + element: { + type: "object", + properties: { + price: { + type: "number" + } + }, + required: ["price"] + }, + params: { + type: "object", + properties: { + discount: { + type: "number", + asOpaque: true + } + }, + required: ["discount"] + } + }, + required: ["element", "params"] + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { discount } }) => ({__ctHelpers.derive({ element_price: element.price, discount }, ({ element_price: _v1, discount: discount }) => _v1 * discount)})), { discount: state.discount })} +
), + }; +}); +// @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/closures/map-destructured-alias.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-alias.input.tsx new file mode 100644 index 000000000..7661c4285 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-alias.input.tsx @@ -0,0 +1,19 @@ +/// +import { recipe, UI } from "commontools"; + +interface State { + items: Array<{ price: number }>; + discount: number; +} + +export default recipe("MapDestructuredAlias", (state) => { + return { + [UI]: ( +
+ {state.items.map(({ price: cost }) => ( + {cost * state.discount} + ))} +
+ ), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/map-destructured-computed-alias.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-computed-alias.expected.tsx new file mode 100644 index 000000000..16927be2d --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-computed-alias.expected.tsx @@ -0,0 +1,77 @@ +import * as __ctHelpers from "commontools"; +import { recipe, UI } from "commontools"; +const dynamicKey = "value" as const; +interface Item { + value: number; + other: number; +} +interface State { + items: Item[]; +} +export default recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + items: { + type: "array", + items: { + $ref: "#/$defs/Item" + } + } + }, + required: ["items"], + $defs: { + Item: { + type: "object", + properties: { + value: { + type: "number" + }, + other: { + type: "number" + } + }, + required: ["value", "other"] + } + } +} as const satisfies __ctHelpers.JSONSchema, (state) => { + return { + [UI]: (
+ {state.items.mapWithPattern(__ctHelpers.recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + element: { + $ref: "#/$defs/Item" + }, + params: { + type: "object", + properties: {} + } + }, + required: ["element", "params"], + $defs: { + Item: { + type: "object", + properties: { + value: { + type: "number" + }, + other: { + type: "number" + } + }, + required: ["value", "other"] + } + } + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => { + const __ct_val_key = dynamicKey; + return ({__ctHelpers.derive({ element, __ct_val_key }, ({ element: element, __ct_val_key: __ct_val_key }) => element[__ct_val_key])}); + }), {})} +
), + }; +}); +// @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/closures/map-destructured-computed-alias.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-computed-alias.input.tsx new file mode 100644 index 000000000..631fe4e41 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-computed-alias.input.tsx @@ -0,0 +1,25 @@ +/// +import { recipe, UI } from "commontools"; + +const dynamicKey = "value" as const; + +interface Item { + value: number; + other: number; +} + +interface State { + items: Item[]; +} + +export default recipe("MapDestructuredComputedAlias", (state) => { + return { + [UI]: ( +
+ {state.items.map(({ [dynamicKey]: val }) => ( + {val} + ))} +
+ ), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/map-destructured-numeric-alias.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-numeric-alias.expected.tsx new file mode 100644 index 000000000..61bd72420 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-numeric-alias.expected.tsx @@ -0,0 +1,54 @@ +import * as __ctHelpers from "commontools"; +import { recipe, UI } from "commontools"; +interface State { + entries: Array<{ + 0: number; + }>; +} +export default recipe({ + type: "object", + properties: { + entries: { + type: "array", + items: { + type: "object", + properties: { + 0: { + type: "number" + } + }, + required: ["0"] + } + } + }, + required: ["entries"] +} as const satisfies __ctHelpers.JSONSchema, (state) => { + return { + [UI]: (
+ {state.entries.mapWithPattern(__ctHelpers.recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + element: { + type: "object", + properties: { + 0: { + type: "number" + } + }, + required: ["0"] + }, + params: { + type: "object", + properties: {} + } + }, + required: ["element", "params"] + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => ({element[0]})), {})} +
), + }; +}); +// @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/closures/map-destructured-numeric-alias.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-numeric-alias.input.tsx new file mode 100644 index 000000000..b9722c92f --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-numeric-alias.input.tsx @@ -0,0 +1,18 @@ +/// +import { recipe, UI } from "commontools"; + +interface State { + entries: Array<{ 0: number }>; +} + +export default recipe("MapDestructuredNumericAlias", (state) => { + return { + [UI]: ( +
+ {state.entries.map(({ 0: first }) => ( + {first} + ))} +
+ ), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/map-destructured-string-alias.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-string-alias.expected.tsx new file mode 100644 index 000000000..34f6ae688 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-string-alias.expected.tsx @@ -0,0 +1,54 @@ +import * as __ctHelpers from "commontools"; +import { recipe, UI } from "commontools"; +interface State { + items: Array<{ + couponCode: string; + }>; +} +export default recipe({ + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: { + couponCode: { + type: "string" + } + }, + required: ["couponCode"] + } + } + }, + required: ["items"] +} as const satisfies __ctHelpers.JSONSchema, (state) => { + return { + [UI]: (
+ {state.items.mapWithPattern(__ctHelpers.recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + element: { + type: "object", + properties: { + couponCode: { + type: "string" + } + }, + required: ["couponCode"] + }, + params: { + type: "object", + properties: {} + } + }, + required: ["element", "params"] + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => ({element.couponCode})), {})} +
), + }; +}); +// @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/closures/map-destructured-string-alias.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-string-alias.input.tsx new file mode 100644 index 000000000..9150f861f --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-string-alias.input.tsx @@ -0,0 +1,18 @@ +/// +import { recipe, UI } from "commontools"; + +interface State { + items: Array<{ couponCode: string }>; +} + +export default recipe("MapDestructuredStringAlias", (state) => { + return { + [UI]: ( +
+ {state.items.map(({ couponCode: code }) => ( + {code} + ))} +
+ ), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/map-multiple-similar-captures.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-multiple-similar-captures.expected.tsx new file mode 100644 index 000000000..8fda24c4f --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-multiple-similar-captures.expected.tsx @@ -0,0 +1,90 @@ +import * as __ctHelpers from "commontools"; +import { recipe, UI } from "commontools"; +interface State { + items: Array<{ + price: number; + }>; + checkout: { + discount: number; + }; + upsell: { + discount: number; + }; +} +export default recipe({ + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: { + price: { + type: "number" + } + }, + required: ["price"] + } + }, + checkout: { + type: "object", + properties: { + discount: { + type: "number" + } + }, + required: ["discount"] + }, + upsell: { + type: "object", + properties: { + discount: { + type: "number" + } + }, + required: ["discount"] + } + }, + required: ["items", "checkout", "upsell"] +} as const satisfies __ctHelpers.JSONSchema, (state) => { + return { + [UI]: (
+ {state.items.mapWithPattern(__ctHelpers.recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + element: { + type: "object", + properties: { + price: { + type: "number" + } + }, + required: ["price"] + }, + params: { + type: "object", + properties: { + discount: { + type: "number", + asOpaque: true + }, + discount_2: { + type: "number", + asOpaque: true + } + }, + required: ["discount", "discount_2"] + } + }, + required: ["element", "params"] + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { discount, discount_2 } }) => ( + {__ctHelpers.derive({ element_price: element.price, discount, discount_2 }, ({ element_price: _v1, discount: discount, discount_2: discount_2 }) => _v1 * discount * discount_2)} + )), { discount: state.checkout.discount, discount_2: state.upsell.discount })} +
), + }; +}); +// @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/closures/map-multiple-similar-captures.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-multiple-similar-captures.input.tsx new file mode 100644 index 000000000..1ba648613 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-multiple-similar-captures.input.tsx @@ -0,0 +1,22 @@ +/// +import { recipe, UI } from "commontools"; + +interface State { + items: Array<{ price: number }>; + checkout: { discount: number }; + upsell: { discount: number }; +} + +export default recipe("MultipleSimilarCaptures", (state) => { + return { + [UI]: ( +
+ {state.items.map((item) => ( + + {item.price * state.checkout.discount * state.upsell.discount} + + ))} +
+ ), + }; +});