diff --git a/packages/schema-generator/promote-all-named-options.txt b/packages/schema-generator/promote-all-named-options.txt deleted file mode 100644 index f5a3c9b30..000000000 --- a/packages/schema-generator/promote-all-named-options.txt +++ /dev/null @@ -1,98 +0,0 @@ -Title: Options for making hoisted named-type $ref schemas resolvable - -Context - We changed the generator to an "all-named" policy: Every named type (except - wrappers/containers like Array, ReadonlyArray, Cell, Stream, Default, Date) - is hoisted into `definitions`, and non-root uses emit `$ref` to that entry. - -Problem observed in CT builder (chatbot-outliner) - The builder sometimes operates on schema fragments (e.g., after merges or - link steps). Those fragments can now contain `$ref` without a local - `definitions`, leading to unresolved `$ref` at runtime. - -Goal - Keep the all-named behavior (for readability/reuse), while ensuring - `$ref`-bearing fragments remain resolvable in the CT runner/builder. - -Option A — Embed subset `definitions` into non-root fragments (short-term) - Idea: - - When the generator returns a non-root schema that contains `$ref`, attach - the minimal subset of `definitions` required by that fragment (including - transitive dependencies). - - This makes fragments self-contained and resolvable, regardless of how the - builder slices schemas later. - Changes required: - - Generator: After formatting a non-root schema, detect referenced names, - compute a transitive closure over `definitions`, and attach that subset - under `definitions` on the fragment. - - Tests/fixtures: Update expectations for places where nested fragments are - asserted to include a small `definitions` block. - Pros: - - Unblocks current use-cases without touching the CT builder. - - Keeps the all-named policy intact. - Cons: - - Slight duplication/bloat in nested fragments. - - Requires fixture updates and careful subset computation. - -Option B — Inline in fragments, hoist only at root (hybrid) - Idea: - - Keep all-named hoisting at the root level, but when returning schemas for - nested positions (likely to be lifted out by the builder), inline named - types (no `$ref`). - Changes required: - - Generator: Introduce a policy toggle or heuristics to inline named types - when formatting non-root schemas that are likely to be used in isolation. - - Tests/fixtures: Update to expect inlined shapes in fragments. - Pros: - - Fragments remain self-contained; fewer nested `definitions`. - Cons: - - Hybrid policy is more complex and less predictable for readers. - - Loses some reuse within fragments. - -Option C — Patch CT runner to resolve `$ref` from parent/root (long-term) - Idea: - - In the CT builder/runner, when operating on a fragment that contains a - `$ref` without local `definitions`, resolve against the nearest parent - schema that carries `definitions` (e.g., the component's input/output - root). Alternatively, thread a shared `definitions` map through the - joinSchema/resolveRef code paths. - Changes required: - - packages/runner: ContextualFlowControl.resolveSchemaRefOrThrow and - related joinSchema helpers need to accept an optional "definitions - provider" or resolve upward to find a root `definitions` map. - - Tests: Add builder-level tests where fragments contain `$ref` and validate - successful resolution from the parent context. - Pros: - - Clean architecture; avoids duplicating `definitions` into fragments. - - Keeps all-named behavior consistent and DRY. - Cons: - - Requires a multi-package change (runner + tests); coordination needed. - -Recommended sequence - 1) Implement Option A now to unblock current work: - - Add a helper: collectReferencedDefinitions(fragment, fullDefs) → subset - map of needed definitions (including transitive deps). - - Attach subset `definitions` for non-root results that contain `$ref`. - - Update fixtures to account for nested `definitions` where applicable. - - 2) Plan Option C as a follow-up: - - Add a "definitions context" resolver in the CT runner so fragments with - `$ref` can be resolved against the root schema. This preserves compact - fragments and centralizes definition storage. - - Once runner support lands, Option A's nested `definitions` can be - simplified or removed (guards remain for extreme edge cases). - -Implementation notes for Option A - - Definition subset: - • Start with the set of `$ref` names found in the fragment (scan keys and - nested values). For each referenced name N, include `definitions[N]` and - recursively scan that definition for further `$ref`s. - • Use a visited set to avoid cycles. - - Attachment point: - • Only attach `definitions` on non-root returns from the generator (root is - handled by buildFinalSchema which already includes full `definitions`). - • Keep the subset minimal; do not attach the entire `definitions` map. - - Tests: - • Where unit tests assert nested fragments, expect a small `definitions` - block, and assert that required definitions exist and shape is correct. - diff --git a/packages/ts-transformers/docs/hierarchical-params-spec.md b/packages/ts-transformers/docs/hierarchical-params-spec.md index d80950b2e..8e715adb4 100644 --- a/packages/ts-transformers/docs/hierarchical-params-spec.md +++ b/packages/ts-transformers/docs/hierarchical-params-spec.md @@ -1,460 +1,205 @@ -# Hierarchical Params Transformation Spec +# Hierarchical Params Refactor – Implementation Summary -## Overview +## Executive Summary -This spec describes a proposed refactoring of our map closure transformation to -use hierarchical parameter structures that preserve the original code's variable -names and structure, eliminating the need for body rewriting. +- We rewrote the map-closure transformer so generated code keeps the original + callback bodies and variable names, while maintaining compatibility with the + existing `mapWithPattern` runtime contract. +- The change substantially improves readability, removes several brittle AST + rewriting passes, and closes correctness gaps (optional chaining, computed + keys, identifier collisions). -## Current Approach vs Proposed Approach +## Why We Needed This -### Current Transformation +The earlier transformer renamed callback parameters (`item` → `element`), +flattened captured state into a single object, and rewrote the callback body to +match those new names. That approach worked functionally but left us with: -**Input:** +- Generated code that no longer resembled the source, making reviews and + debugging harder. +- Symbol-resolution issues in TypeScript because we injected synthetic + identifiers. +- Edge-case bugs around optional chaining, computed property names, and alias + collisions (`element`, `_v1`, etc.). -```typescript -state.items.map((item) => {item.price * (1 - state.discount)}); -``` - -**Current Output:** - -```typescript -state.items.mapWithPattern( - recipe({ - type: "object", - properties: { - element: { ... }, // item type - params: { - type: "object", - properties: { - discount: { type: "number", asOpaque: true } - } - } - } - }, - ({ element, params: { discount } }) => ( - {element.price * (1 - discount)} - )), - { discount: state.discount } -) -``` +Our goal for this refactor was to eliminate the body rewrite altogether and let +developers read the transformed file as if it were hand-written. -**Problems:** +## What Changed -1. Renamed `item` → `element` (loses semantic meaning) -2. Flattened `state.discount` → `discount` (loses structure) -3. Body requires rewriting to match new names -4. Destructured params create binding elements, not parameters -5. TypeChecker symbol resolution breaks for synthetic nodes +### 1. Capture hierarchy mirrors the source -### Proposed Transformation +- We now build a **capture tree** (`groupCapturesByRoot`) that records each + captured expression by its root (e.g. `state.pricing.discount`). +- The generated schema and runtime params object follow that same shape. + Example: `{ state: { pricing: { discount: … } } }` instead of + `{ discount: … }`. -**Input:** - -```typescript -state.items.map((item) => {item.price * (1 - state.discount)}); -``` +#### Before/after: single state capture -**Proposed Output:** - -```typescript -state.items.mapWithPattern( - recipe({ - type: "object", - properties: { - item: { ... }, // KEEP original name! - state: { // PRESERVE hierarchy! - type: "object", - properties: { - discount: { type: "number", asOpaque: true } - } - } - } - }, - ({ item, state }) => ( - // BODY UNCHANGED! - {item.price * (1 - state.discount)} - )), - { state: { discount: state.discount } } // Hierarchical params object -) ``` +// Source +state.items.map((item) => item.price * state.discount); -**Benefits:** - -1. Body is **completely unchanged** (except for nested map transforms) -2. Original variable names preserved (`item`, `state`) -3. Hierarchical structure maintained (`state.discount`) -4. Direct parameters (not destructured binding elements) -5. Symbol resolution works correctly -6. Easier to debug/understand generated code - -## Implementation Requirements - -### 1. Capture Analysis (No Change) - -The existing `collectCaptures()` function already identifies captured -expressions: +// Old output (simplified) +({ element, params: { discount } }) => element.price * discount +{ discount: state.discount } -- `state.discount` → captured expression -- `state.taxRate` → captured expression - -### 2. Hierarchical Grouping (NEW) - -**New Function:** `groupCapturesByRoot()` - -Takes captured expressions and groups them by root identifier: - -```typescript -Input captures: [state.discount, state.taxRate, other.foo] - -Output structure: -{ - "state": { - properties: ["discount", "taxRate"], - expressions: [state.discount, state.taxRate] - }, - "other": { - properties: ["foo"], - expressions: [other.foo] - } -} +// New output +({ element: item, params: { state } }) => item.price * state.discount +{ state: { discount: state.discount } } ``` -### 3. Schema Generation (MODIFIED) - -**Current:** Generates flat params object +### 2. Destructuring aliases recover original names -```typescript -params: { - type: "object", - properties: { - discount: { type: "number", asOpaque: true }, - taxRate: { type: "number", asOpaque: true } - } -} -``` +- Runtime still delivers `{ element, index, array, params }` to the recipe + callback. +- We destructure to the developer’s names + (`({ element: item, params: { state } })`) so the callback body is untouched. +- New helpers normalise identifiers across transformers, ensuring shared + behaviour when we need fresh names. -**Proposed:** Generates hierarchical params object +#### Before/after: optional chaining & nested structure -```typescript -state: { - type: "object", - properties: { - discount: { type: "number", asOpaque: true }, - taxRate: { type: "number", asOpaque: true } - } -} ``` +// Source +orders.map((order) => order.customer?.address ?? state.fallback); -The schema properties are now named after the **captured root identifiers** -rather than using a generic "params" wrapper. - -### 4. Parameter Naming (MODIFIED) +// Old output rewrote the chain and flattened params +({ element, params: { fallback } }) => element.customer.address ?? fallback -**Current:** Fixed names `element`, `index`, `params` (then destructure params) - -**Proposed:** Use original names from source - -- Element parameter: Use original callback parameter name (e.g., `item`) -- Index parameter: Use original index parameter name if present -- Captured roots: Use their original names (e.g., `state`, `other`) - -```typescript -// Current -({ element, params: { discount, taxRate } }) => ... - -// Proposed -({ item, state }) => ... +// New output preserves the body and structure +({ element: order, params: { state } }) => order.customer?.address ?? state.fallback ``` -### 5. Params Object Creation (MODIFIED) +### 3. Body rewriting is minimal and safer -**Current:** Flat object with extracted property names +- We no longer rename identifiers or replace capture references. +- The only edits we still make are to rebuild destructured element bindings and + to cache computed property names once per callback (so expressions like + `{ [nextKey()]: value }` run exactly once). +- Optional chaining is preserved because params are built from the original AST + rather than reconstructed manually. -```typescript -{ discount: state.discount, taxRate: state.taxRate } -``` +#### Before/after: outer `element` variable collision -**Proposed:** Hierarchical object matching schema structure - -```typescript -{ - state: { - discount: state.discount, - taxRate: state.taxRate - } -} ``` +const element = highlight; +items.map(() => {element}); -### 6. Body Transformation (SIMPLIFIED) - -**Current transformations:** - -1. Rename element parameter (`item` → `element`) -2. Transform destructured properties (`{price}` → `element.price`) -3. Replace captures (`state.discount` → `discount`) - -**Proposed transformations:** +// Old output shadowed the outer variable +({ element }) => {element} -1. ~~Rename element parameter~~ ❌ REMOVED -2. Transform destructured properties (`{price}` → `item.price`) ✅ KEEP (but use - original name) -3. ~~Replace captures~~ ❌ REMOVED - captures already have correct names! - -The key insight: **If params object structure matches original variable -structure, no replacement needed!** - -## Edge Cases - -### Multiple Root Captures - -**Input:** - -```typescript -items.map((item) => item.price * state.discount + config.taxRate); -``` - -**Output:** - -```typescript -items.mapWithPattern( - recipe({ - type: "object", - properties: { - item: { ... }, - state: { - type: "object", - properties: { - discount: { type: "number", asOpaque: true } - } - }, - config: { - type: "object", - properties: { - taxRate: { type: "number", asOpaque: true } - } - } - } - }, - ({ item, state, config }) => ( - item.price * state.discount + config.taxRate - )), - { - state: { discount: state.discount }, - config: { taxRate: config.taxRate } - } -) +// New output aliases the runtime value and keeps the capture intact +({ element: __ct_element, params: { element } }) => {element} ``` -### Deep Property Access +### 4. Supporting utilities were aligned -**Input:** +- `capture-tree.ts` now rebuilds access expressions using the original operators + (optional chaining vs. plain dot access). +- Identifier normalisation is shared across closures, derives, and opaque-ref + transforms. +- The `derive` transformer was updated alongside closures so its synthesized + callbacks use the same hierarchical capture helpers and keep destructuring + intact; a focused regression test guards the collision case we fixed there. +- Regression fixtures were updated manually to document the new structure and to + add coverage for numeric aliases, computed keys, and collision scenarios. -```typescript -items.map((item) => item.price * state.pricing.discount); -``` +#### Before/after: computed property caching -**Output:** - -```typescript -items.mapWithPattern( - recipe({ - properties: { - item: { ... }, - state: { - type: "object", - properties: { - pricing: { - type: "object", - properties: { - discount: { type: "number", asOpaque: true } - } - } - } - } - } - }, - ({ item, state }) => ( - item.price * state.pricing.discount - )), - { state: { pricing: { discount: state.pricing.discount } } } -) ``` +// Source +items.map(({ [nextKey()]: value }) => value); -Wait, this gets complex! Let me reconsider... +// Old output re-evaluated nextKey() for every read +const __ct_amount_key = nextKey(); +({ element }) => element[__ct_amount_key] -Actually, the current approach captures `state.discount` as a single expression. -Under the hierarchical approach, we'd want to pass just the values we need, not -intermediate objects. - -**Two options:** - -**Option A:** Pass full nested structure - -```typescript -{ - state: { - pricing: { - discount: state.pricing.discount; - } - } -} +// New output caches once and threads the key through derive +const __ct_val_key = nextKey(); +({ element }) => derive({ element, __ct_val_key }, ({ element, __ct_val_key: key }) => element[key]) ``` -Problem: Need to construct nested objects +#### Before/after: derive callback collision fix -**Option B:** Keep flat capture names but organize in hierarchy - -```typescript -{ - state: { - pricing_discount: state.pricing.discount; - } -} ``` +// Source +const fallback = _v1(); +derive(items, () => _v1()); -Problem: Name doesn't match original (`state.pricing_discount` vs -`state.pricing.discount`) - -**Option C:** Pass only what's captured, use path-based names +// Old output reused _v1 inside the lambda, shadowing the capture +({ _v1 }) => _v1() -```typescript -// Schema -state_pricing_discount: { type: "number", asOpaque: true } - -// Params -{ state_pricing_discount: state.pricing.discount } - -// But callback still references... -state.pricing.discount // How does this resolve? +// New output generates a stable alias +({ _v1_1 }) => _v1_1() ``` -This reveals a fundamental challenge: **how do we make `state.pricing.discount` -work when `state` is a parameter?** - -### Solution: Nested Object Construction - -We need to actually build nested objects in the params. For -`state.pricing.discount`: - -```typescript -{ - state: { - pricing: { - discount: state.pricing.discount; - } - } -} -``` - -The schema would define the full nested structure, and TypeScript would -correctly type `state.pricing.discount` as accessible. - -## Implementation Complexity Analysis - -### What Changes - -**File:** `src/closures/transformer.ts` - -**Function:** `groupCapturesByRoot()` - NEW - -- Parse property access chains to extract root + path -- Group by root identifier -- Build hierarchical structure map - -**Function:** `buildParamsProperties()` - MAJOR CHANGE - -- Instead of flat list of properties -- Build nested object type structure -- Recursively create property signatures for each level - -**Function:** `buildCallbackParamTypeNode()` - MODERATE CHANGE - -- Don't use fixed "element", "params" names -- Use original parameter names from callback -- For each captured root, add as top-level property - -**Function:** `createRecipeCallWithParams()` - MAJOR CHANGE - -- Build hierarchical params object instead of flat -- Parameters: use original names, not `element/params` -- No destructuring of params - -**Function:** `replaceCaptures()` - DELETE - -- No longer needed! Captures already have correct names - -**Function:** `transformElementReferences()` - MINIMAL CHANGE - -- Still needed for destructured params -- But use original name, not "element" - -### Complexity Rating - -- **Capture grouping logic:** MEDIUM (new algorithm for hierarchy) -- **Schema generation:** HIGH (recursive nested object types) -- **Params object construction:** HIGH (recursive nested objects) -- **Body transformation:** LOW (actually simpler - remove code!) -- **Testing:** HIGH (many edge cases to validate) - -### Est - -imated Implementation Time - -- Grouping algorithm: 2-3 hours -- Schema generation refactor: 4-6 hours -- Params construction: 3-4 hours -- Body transformation cleanup: 1-2 hours -- Testing & debugging: 6-8 hours -- **Total: 16-23 hours** (~2-3 days of focused work) - -## Benefits vs Costs +## Impact & Benefits -### Benefits +- **Readability:** Generated output now mirrors the source; reviewers can reason + about behaviour without mentally translating renamed variables. +- **Correctness:** Fixes long-standing edge cases (optional chaining, computed + aliases, captured `element` collisions) and prevents repeated evaluation of + computed keys. +- **Maintainability:** We removed entire classes of substitution logic, making + the transformer easier to extend (e.g. upcoming handler-closure support can + reuse the same helpers). +- **Confidence:** Full `deno task test` passes; targeted regression fixtures + fail if we revert the business-logic pieces (demonstrated via stash/unstash + checks during development). -1. **Correctness:** Fixes synthetic node symbol resolution bug -2. **Maintainability:** Simpler mental model, less transformation -3. **Debuggability:** Generated code looks more like source -4. **Robustness:** Fewer edge cases in body rewriting +## Trade-offs & Remaining Questions -### Costs +- **Fixture churn:** Updating fixtures by hand was tedious but gives us a + high-confidence baseline that future regressions will surface clearly. +- **Runtime contract:** We intentionally kept the existing + `{ element, index, array, params }` shape to minimise risk. If we ever adjust + the runtime API we can simplify further, but that is out of scope for now. +- **Future alignment:** Other transformers (e.g. handler closures, `derive`) can + adopt the same capture-tree utilities so we continue converging on a single + naming story. -1. **Implementation time:** ~2-3 days -2. **Risk:** Complex refactor of core transformation -3. **Testing burden:** Need to update all existing test fixtures -4. **Schema complexity:** Nested object types are harder to reason about +## Recommended Next Steps -## Recommendation +1. **Share this summary with stakeholders** so everyone understands the new + shape and the reasons behind it. +2. **Apply the shared helpers** to upcoming work (handler closures, additional + built-ins) to avoid drifting naming rules. +3. **Monitor for follow-on cleanups:** once handler closures land, reassess + whether we can simplify the runtime contract or remove more legacy code + paths. -**IMPLEMENT IT** +With these changes in place the closure transformer is significantly more +predictable, and we have a solid foundation for the remaining roadmap items. -The benefits outweigh the costs, especially: +## Appendix -- Fixes current bug with synthetic nodes immediately -- Makes system more maintainable long-term -- Aligns with how developers think about closures -- Reduces transformation complexity overall +### Code & Fixture References -The hierarchical approach is more aligned with JavaScript semantics and will be -easier to maintain as the system grows. +- `packages/ts-transformers/src/closures/transformer.ts`: hierarchical capture + tree and map callback rewriting logic. +- `packages/ts-transformers/src/utils/capture-tree.ts`: rebuilds access + expressions (including optional chaining) based on the original AST. +- `packages/ts-transformers/src/utils/identifiers.ts`: shared identifier + normalisation helpers. +- `packages/ts-transformers/src/transformers/builtins/derive.ts`: derives share + the same capture/alias strategy as map closures. -## Migration Path +Representative fixtures documenting the new behaviour: -1. Implement hierarchical grouping logic -2. Update schema generation for nested structures -3. Update params object construction -4. Simplify body transformation (remove unnecessary steps) -5. Update all test fixtures with new expected output -6. Test thoroughly with edge cases -7. Deploy and monitor +- Map closures: + `packages/ts-transformers/test/fixtures/closures/map-outer-element.*`, + `map-computed-alias-side-effect.*`, `map-destructured-numeric-alias.*`. +- Derive transformer: + `packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.*` and + unit test `test/derive/create-derive-call.test.ts`. +- Optional chaining: + `packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-captures.*`. -## Open Questions +### Test Commands -1. **Deep nesting:** What's the max nesting depth we support? (Suggest: - unlimited, but warn if >5) -2. **Name collisions:** What if callback has parameter named same as captured - root? (Suggest: rename capture root with suffix) -3. **Array index captures:** `items[idx]` - do we capture `items` as object with - keys? (Suggest: out of scope for v1, keep flat) -4. **Mixed captures:** `state.foo` and `state` both captured - how to handle? - (Suggest: if root captured, don't nest children) +- `deno task test` (from `packages/ts-transformers/`) – runs unit and fixture + suites covering closures, derive, and opaque-ref transforms. +- `deno lint` / `deno fmt` – keep transformer and fixture files consistent (lint + excludes expected fixtures by default). diff --git a/packages/ts-transformers/docs/opaque-refs-rewrite-notes.txt b/packages/ts-transformers/docs/opaque-refs-rewrite-notes.txt deleted file mode 100644 index ff31ba300..000000000 --- a/packages/ts-transformers/docs/opaque-refs-rewrite-notes.txt +++ /dev/null @@ -1,83 +0,0 @@ -// the programmer writes: -lift(fn: (a: I) => O) -// the compiler will infer the types I and O if you don't specify them - -// eventually we want to generate: -lift(inputSchema, outputSchema, ...fn) -// by the way we want the schema transformer to do this for lift and not just recipe - -// this is not true yet but this is where we're going in the runtime: -recipe(arg) = lift(Opaque, O)(arg) - -// right now, we basically go from the recipe declaration to the version of -// recipe that uses the inputSchema/outputSchema, via the opaque ref transformer -recipe(name, (Opaque => Opaque)) // Opaque = literal | OpaqueRef ---> -recipe(, toSchema, ...?) // 'name' should just go into 'description' of inputSchema - -// let's see if we can make the current pipeline work for 'lift' the way it works for 'recipe' (and 'handler') -// this should be fast and if it's not then i'll re-prioritize - -// so yeah that would be: -lift(fn: (a: I) => O) -// becomes --> -lift, toSchema, ...) -// where IT/OT are input transformed and output transformed, as per the following approach: -// data analysis on inputs; if there are no reads, x becomes Opaque -// allowed: x.y, IF we aren't reading the value of x.y but merely passing it along to a lift/handler/etc -// allowed: .map, eventually .filter when it works, because they're exposed on Cell, SOMETIMES; - they have return value Opaque<...> so we need to see if we don't read the returned values - -// In JSX, tags transform expressions to make things Opaque -// example: `x+1` -// example: `<... class = { enabled ? ... : ... }>` -// enabled &&
...
-// ...these should be transformed into a combination of derive/lift and ifElse -// for now, we'll expect to output: -// lift(toSchema,toSchema,x=>x+1)(x) -// <... class = {ifElse(enabled, ..., ...)}> // NOTE: we need to add toSchema support for ifElse -// ifElse(enabled,
...
, null) - -// Optional extension: -// example: <... onClick={ (ev) => { ... lst ... inc ... }} > // <-- handler closes over 'lst' and 'inc' -// currently, you have to define a handler-type function outside the JSX and pass it 'lst' and 'inc' explicitly -// what if our transformer turned that into a handler((ev, {lst, inc}) => ... original function ..., {lst, inc}) -// Fly in the ointment: what if we want to modify lst by pushing something into it? Then we need to make it a Cell; -// this will need to happen after the OpaqueRef/Cell implementation merge, otherwise this will compile but not do -// the right thing at runtime. Eventually you need your outer recipe to declare that 'lst' is Mutable; then the -// transformer will make it Opaque everywhere except the actual handler that needs it to be Mutable. - -// QUESTIONS -* We might treat x?.y as not reading x either because the runtime will - just treat undefined values specially there anyway and allow x?.y --> undefined - -// NOTES -* derive(x, fn) <=> lift(fn)(x) - -// CLOSURES -A main goal of this project is to enable closures support. - -The main case where we use closures right now is in `map`. The function that `map` takes - right now is a classic map function. Internally, it transforms the three map parameters - into a single object containing each one as a property. In any event, it's common to - want to close over values from the context in writing a map. - -OpaqueRef.map(fn: (elem: T, index, list: T[]) => OpaqueRef) -// Internally, this creates a Node whose parameter is: recipe(({elem, index, list}) => fn(elem, index, list)) -// Basically it wraps the given mapping function in a recipe and then calls the built-in map on that recipe, ie: -// built-in-map({list, op: recipe(...)}) - -// How do we close over values from the context when we pass in our fn to the OpaqueRef map? - -// One long-term path would be to do all the heavy lifting in the AST transformer, so that the original -// map call would get transformed into a built-in map where we are passing in the closed-over value - -// In the future we could write our own `curry` function. -Opaque.map(lift(({closed_over_1, closed_over_2}, elem, index, list) => ...original_function...).\ - curry({closed_over_1, closed_over_2})) - -// A thing we can do in the shorter term, lacking a rigorous `curry` function, would be: -OpaqueRef.map({list, op:recipe(({elem, index, list, params: {closed_over_1, closed_over_2}}) => ...original_function...), - params: {closed_over_1, closed_over_2}}) -// Probably best to overload the OpaqueRef map function to accept that signature as well as the -// version that takes just a function diff --git a/packages/ts-transformers/src/closures/transformer.ts b/packages/ts-transformers/src/closures/transformer.ts index ece18f5c4..5056ca574 100644 --- a/packages/ts-transformers/src/closures/transformer.ts +++ b/packages/ts-transformers/src/closures/transformer.ts @@ -2,6 +2,16 @@ import ts from "typescript"; import { TransformationContext, Transformer } from "../core/mod.ts"; import { isOpaqueRefType } from "../transformers/opaque-ref/opaque-ref.ts"; import { createDataFlowAnalyzer, visitEachChildWithJsx } from "../ast/mod.ts"; +import { + buildHierarchicalParamsValue, + groupCapturesByRoot, +} from "../utils/capture-tree.ts"; +import type { CaptureTreeNode } from "../utils/capture-tree.ts"; +import { + getUniqueIdentifier, + isSafeIdentifierText, + maybeReuseIdentifier, +} from "../utils/identifiers.ts"; export class ClosureTransformer extends Transformer { override filter(context: TransformationContext): boolean { @@ -396,23 +406,144 @@ function isOpaqueRefArrayMapCall( /** * Extract the root identifier name from an expression. - * For property access like state.discount, returns "discount". - * For plain identifiers, returns the identifier name. + * For property access like state.discount, returns "state". */ -function getCaptureName(expr: ts.Expression): string | undefined { - if (ts.isPropertyAccessExpression(expr)) { - // For state.discount, capture as "discount" - return expr.name.text; - } else if (ts.isIdentifier(expr)) { - return expr.text; +type CaptureTreeMap = Map; + +function createSafePropertyName( + name: string, + factory: ts.NodeFactory, +): ts.PropertyName { + return isSafeIdentifierText(name) + ? factory.createIdentifier(name) + : factory.createStringLiteral(name); +} + +function normalizeBindingName( + name: ts.BindingName, + factory: ts.NodeFactory, + used: Set, +): ts.BindingName { + if (ts.isIdentifier(name)) { + return maybeReuseIdentifier(name, used); } - return undefined; + + if (ts.isObjectBindingPattern(name)) { + const elements = name.elements.map((element) => + factory.createBindingElement( + element.dotDotDotToken, + element.propertyName, + normalizeBindingName(element.name, factory, used), + element.initializer as ts.Expression | undefined, + ) + ); + return factory.createObjectBindingPattern(elements); + } + + if (ts.isArrayBindingPattern(name)) { + const elements = name.elements.map((element) => { + if (ts.isOmittedExpression(element)) { + return element; + } + if (ts.isBindingElement(element)) { + return factory.createBindingElement( + element.dotDotDotToken, + element.propertyName, + normalizeBindingName(element.name, factory, used), + element.initializer as ts.Expression | undefined, + ); + } + return element; + }); + return factory.createArrayBindingPattern(elements); + } + + return name; +} + +function typeNodeForExpression( + expr: ts.Expression, + context: TransformationContext, +): ts.TypeNode { + const { factory, checker } = context; + const exprType = checker.getTypeAtLocation(expr); + const node = checker.typeToTypeNode( + exprType, + context.sourceFile, + ts.NodeBuilderFlags.NoTruncation | + ts.NodeBuilderFlags.UseStructuralFallback, + ) ?? factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); + + const typeRegistry = context.options.typeRegistry; + if (typeRegistry) { + typeRegistry.set(node, exprType); + } + + return node; +} + +function buildCaptureTypeProperties( + node: CaptureTreeNode, + context: TransformationContext, +): ts.TypeElement[] { + const { factory } = context; + const properties: ts.TypeElement[] = []; + + for (const [propName, childNode] of node.properties) { + let typeNode: ts.TypeNode; + if (childNode.properties.size > 0 && !childNode.expression) { + const nested = buildCaptureTypeProperties(childNode, context); + typeNode = factory.createTypeLiteralNode(nested); + } else if (childNode.expression) { + typeNode = typeNodeForExpression(childNode.expression, context); + } else { + typeNode = factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); + } + + properties.push( + factory.createPropertySignature( + undefined, + createSafePropertyName(propName, factory), + undefined, + typeNode, + ), + ); + } + + return properties; +} + +function buildParamsTypeElements( + captureTree: Map, + context: TransformationContext, +): ts.TypeElement[] { + const { factory } = context; + const properties: ts.TypeElement[] = []; + + for (const [rootName, rootNode] of captureTree) { + let typeNode: ts.TypeNode; + if (rootNode.properties.size > 0 && !rootNode.expression) { + const nested = buildCaptureTypeProperties(rootNode, context); + typeNode = factory.createTypeLiteralNode(nested); + } else if (rootNode.expression) { + typeNode = typeNodeForExpression(rootNode.expression, context); + } else { + typeNode = factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); + } + + properties.push( + factory.createPropertySignature( + undefined, + createSafePropertyName(rootName, factory), + undefined, + typeNode, + ), + ); + } + + return properties; } -/** - * Determine the element type for a map callback parameter. - * Prefers explicit type annotation, falls back to inference from array type. - */ function determineElementType( mapCall: ts.CallExpression, elemParam: ts.ParameterDeclaration | undefined, @@ -421,13 +552,10 @@ function determineElementType( const { checker } = context; const typeRegistry = context.options.typeRegistry; - // Check if we have an explicit type annotation that's not 'any' if (elemParam?.type) { const annotationType = checker.getTypeFromTypeNode(elemParam.type); if (!(annotationType.flags & ts.TypeFlags.Any)) { - // Use the explicit annotation const result = { typeNode: elemParam.type, type: annotationType }; - // Register with typeRegistry if (typeRegistry && annotationType) { typeRegistry.set(elemParam.type, annotationType); } @@ -435,60 +563,13 @@ function determineElementType( } } - // No annotation or annotation is 'any', infer from array const inferred = inferElementType(mapCall, context); - // Register with typeRegistry if (typeRegistry && inferred.type) { typeRegistry.set(inferred.typeNode, inferred.type); } return inferred; } -/** - * Build params object type properties for captured variables. - */ -function buildParamsProperties( - capturedVarNames: Set, - captures: Map, - context: TransformationContext, -): ts.TypeElement[] { - const { factory, checker } = context; - const typeRegistry = context.options.typeRegistry; - const paramsProperties: ts.TypeElement[] = []; - - for (const varName of capturedVarNames) { - const expr = captures.get(varName); - if (!expr) continue; - - // Get the Type of the captured expression - const exprType = checker.getTypeAtLocation(expr); - - // Convert Type to TypeNode - const typeNode = checker.typeToTypeNode( - exprType, - context.sourceFile, - ts.NodeBuilderFlags.NoTruncation | - ts.NodeBuilderFlags.UseStructuralFallback, - ) ?? factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); - - // Register this property's TypeNode with its Type - if (typeRegistry) { - typeRegistry.set(typeNode, exprType); - } - - paramsProperties.push( - factory.createPropertySignature( - undefined, - factory.createIdentifier(varName), - undefined, - typeNode, - ), - ); - } - - return paramsProperties; -} - /** * Build a TypeNode for the callback parameter and register property TypeNodes in typeRegistry. * Returns a TypeLiteral representing { element: T, index?: number, array?: T[], params: {...} } @@ -498,8 +579,7 @@ function buildCallbackParamTypeNode( elemParam: ts.ParameterDeclaration | undefined, indexParam: ts.ParameterDeclaration | undefined, arrayParam: ts.ParameterDeclaration | undefined, - capturedVarNames: Set, - captures: Map, + captureTree: Map, context: TransformationContext, ): ts.TypeNode { const { factory } = context; @@ -547,10 +627,9 @@ function buildCallbackParamTypeNode( ); } - // 5. Build params object type with captured variables - const paramsProperties = buildParamsProperties( - capturedVarNames, - captures, + // 5. Build params object type with hierarchical captures + const paramsProperties = buildParamsTypeElements( + captureTree, context, ); @@ -700,21 +779,6 @@ function inferElementType( }; } -/** - * Recursively compare expressions for structural equality. - */ -function expressionsMatch(a: ts.Expression, b: ts.Expression): boolean { - if (ts.isPropertyAccessExpression(a) && ts.isPropertyAccessExpression(b)) { - // Property names must match - if (a.name.text !== b.name.text) return false; - // Recursively compare the object expressions - return expressionsMatch(a.expression, b.expression); - } else if (ts.isIdentifier(a) && ts.isIdentifier(b)) { - return a.text === b.text; - } - return false; -} - /** * Check if a map call should be transformed to mapWithPattern. * Returns false if the map will end up inside a derive (where the array is unwrapped). @@ -815,302 +879,6 @@ function createMapTransformVisitor( return visit; } -/** - * Generic parameter reference transformer that handles: - * - Renaming identifiers from oldName to newName - * - Converting shorthand property assignments to preserve property names - * (e.g., { charm } becomes { charm: element }) - */ -function transformParameterReferences( - body: ts.ConciseBody, - param: ts.ParameterDeclaration | undefined, - newName: string, - factory: ts.NodeFactory, -): ts.ConciseBody { - const paramName = param?.name; - - if (paramName && ts.isIdentifier(paramName) && paramName.text !== newName) { - const oldName = paramName.text; - const visitor: ts.Visitor = (node) => { - // Handle shorthand property assignments: { oldName } -> { oldName: newName } - if ( - ts.isShorthandPropertyAssignment(node) && - node.name.text === oldName - ) { - return factory.createPropertyAssignment( - node.name, // Keep original property name - factory.createIdentifier(newName), // Use new variable name - ); - } - - if (ts.isIdentifier(node) && node.text === oldName) { - return factory.createIdentifier(newName); - } - return visitEachChildWithJsx(node, visitor, undefined); - }; - return ts.visitNode(body, visitor) as ts.ConciseBody; - } - - return body; -} - -/** - * Transform references to original element parameter to use "element" instead. - */ -function transformElementReferences( - body: ts.ConciseBody, - elemParam: ts.ParameterDeclaration | undefined, - factory: ts.NodeFactory, -): ts.ConciseBody { - return transformParameterReferences(body, elemParam, "element", factory); -} - -/** - * Transform references to original index parameter to use "index" instead. - */ -function transformIndexReferences( - body: ts.ConciseBody, - indexParam: ts.ParameterDeclaration | undefined, - factory: ts.NodeFactory, -): ts.ConciseBody { - return transformParameterReferences(body, indexParam, "index", factory); -} - -/** - * Transform references to original array parameter to use "array" instead. - */ -function transformArrayReferences( - body: ts.ConciseBody, - arrayParam: ts.ParameterDeclaration | undefined, - factory: ts.NodeFactory, -): ts.ConciseBody { - return transformParameterReferences(body, arrayParam, "array", factory); -} - -/** - * Transform destructured property references to use element.prop or element[index]. - */ -function transformDestructuredProperties( - body: ts.ConciseBody, - elemParam: ts.ParameterDeclaration | undefined, - factory: ts.NodeFactory, -): ts.ConciseBody { - const elemName = elemParam?.name; - - 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)) { - // Find where directive prologues end (e.g., "use strict") - // Directives must be string literal expression statements at the start - let directiveEnd = 0; - for (const stmt of currentBody.statements) { - if ( - ts.isExpressionStatement(stmt) && - ts.isStringLiteral(stmt.expression) - ) { - directiveEnd++; - } else { - break; // First non-directive = end of prologue - } - } - - return factory.updateBlock( - currentBody, - factory.createNodeArray([ - ...currentBody.statements.slice(0, directiveEnd), // Keep directives first - ...statements, // Then our computed initializers - ...currentBody.statements.slice(directiveEnd), // Then rest of body - ]), - ); - } - - 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)) { - const alias = element.name.text; - const propertyName = element.propertyName; - usedTempNames.add(alias); - - // For computed properties, create the temp variable upfront (not in the factory) - // so multiple uses of the same property share one temp variable - let computedTempIdentifier: ts.Identifier | undefined; - if (propertyName && ts.isComputedPropertyName(propertyName)) { - const tempName = registerTempName(alias); - computedTempIdentifier = factory.createIdentifier(tempName); - - computedInitializers.push( - factory.createVariableStatement( - undefined, - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - computedTempIdentifier, - undefined, - undefined, - propertyName.expression, - ), - ], - ts.NodeFlags.Const, - ), - ), - ); - } - - 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) && computedTempIdentifier - ) { - return factory.createElementAccessExpression( - target, - computedTempIdentifier, - ); - } - - return factory.createPropertyAccessExpression( - target, - factory.createIdentifier(alias), - ); - }); - } - } - } - - const arrayDestructuredVars = new Map(); - if (elemName && ts.isArrayBindingPattern(elemName)) { - let index = 0; - for (const element of elemName.elements) { - if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) { - arrayDestructuredVars.set(element.name.text, index); - } - index++; - } - } - - if (destructuredProps.size > 0) { - const visitor: ts.Visitor = (node) => { - if (ts.isIdentifier(node) && destructuredProps.has(node.text)) { - if ( - !node.parent || - !(ts.isPropertyAccessExpression(node.parent) && - node.parent.name === node) - ) { - const accessFactory = destructuredProps.get(node.text)!; - return accessFactory(); - } - } - return visitEachChildWithJsx(node, visitor, undefined); - }; - transformedBody = ts.visitNode(transformedBody, visitor) as ts.ConciseBody; - } - - if (arrayDestructuredVars.size > 0) { - const visitor: ts.Visitor = (node) => { - if (ts.isIdentifier(node)) { - const index = arrayDestructuredVars.get(node.text); - if (index !== undefined) { - if ( - !node.parent || - !(ts.isPropertyAccessExpression(node.parent) && - node.parent.name === node) - ) { - return factory.createElementAccessExpression( - factory.createIdentifier("element"), - factory.createNumericLiteral(index), - ); - } - } - } - return visitEachChildWithJsx(node, visitor, undefined); - }; - transformedBody = ts.visitNode(transformedBody, visitor) as ts.ConciseBody; - } - - if (computedInitializers.length > 0) { - transformedBody = prependStatements(computedInitializers, transformedBody); - } - - return transformedBody; -} - -/** - * Replace captured expressions with their parameter names. - */ -function replaceCaptures( - body: ts.ConciseBody, - captures: Map, - factory: ts.NodeFactory, -): ts.ConciseBody { - let transformedBody = body; - - for (const [varName, capturedExpr] of captures) { - const visitor: ts.Visitor = (node) => { - // Check if this node matches the captured expression - if (ts.isExpression(node) && expressionsMatch(node, capturedExpr)) { - return factory.createIdentifier(varName); - } - return visitEachChildWithJsx(node, visitor, undefined); - }; - transformedBody = ts.visitNode( - transformedBody, - visitor, - ) as ts.ConciseBody; - } - - return transformedBody; -} - /** * Create the final recipe call with params object. */ @@ -1121,57 +889,80 @@ function createRecipeCallWithParams( elemParam: ts.ParameterDeclaration | undefined, indexParam: ts.ParameterDeclaration | undefined, arrayParam: ts.ParameterDeclaration | undefined, - capturedVarNames: Set, - captures: Map, + captureTree: Map, context: TransformationContext, ): ts.CallExpression { const { factory } = context; - // Create the destructured parameter - const properties: ts.BindingElement[] = [ + const bindingElements: ts.BindingElement[] = []; + const usedBindingNames = new Set(); + + const createBindingIdentifier = (name: string): ts.Identifier => { + if (isSafeIdentifierText(name) && !usedBindingNames.has(name)) { + usedBindingNames.add(name); + return factory.createIdentifier(name); + } + const fallback = name.length > 0 ? name : "ref"; + const unique = getUniqueIdentifier(fallback, usedBindingNames, { + fallback: "ref", + }); + return factory.createIdentifier(unique); + }; + + const elementBindingName = elemParam + ? normalizeBindingName(elemParam.name, factory, usedBindingNames) + : createBindingIdentifier( + captureTree.has("element") ? "__ct_element" : "element", + ); + bindingElements.push( factory.createBindingElement( - undefined, undefined, factory.createIdentifier("element"), + elementBindingName, undefined, ), - ]; + ); if (indexParam) { - properties.push( + bindingElements.push( factory.createBindingElement( - undefined, undefined, factory.createIdentifier("index"), + normalizeBindingName(indexParam.name, factory, usedBindingNames), undefined, ), ); } if (arrayParam) { - properties.push( + bindingElements.push( factory.createBindingElement( - undefined, undefined, factory.createIdentifier("array"), + normalizeBindingName(arrayParam.name, factory, usedBindingNames), undefined, ), ); } - // Add params destructuring - const paramsPattern = factory.createObjectBindingPattern( - Array.from(capturedVarNames).map((name) => + const paramsBindings: ts.BindingElement[] = []; + for (const [rootName] of captureTree) { + const propertyName = isSafeIdentifierText(rootName) + ? undefined + : createSafePropertyName(rootName, factory); + paramsBindings.push( factory.createBindingElement( undefined, + propertyName, + createBindingIdentifier(rootName), undefined, - factory.createIdentifier(name), - undefined, - ) - ), - ); + ), + ); + } + + const paramsPattern = factory.createObjectBindingPattern(paramsBindings); - properties.push( + bindingElements.push( factory.createBindingElement( undefined, factory.createIdentifier("params"), @@ -1183,13 +974,12 @@ function createRecipeCallWithParams( const destructuredParam = factory.createParameterDeclaration( undefined, undefined, - factory.createObjectBindingPattern(properties), + factory.createObjectBindingPattern(bindingElements), undefined, undefined, undefined, ); - // Create the new callback const newCallback = factory.createArrowFunction( callback.modifiers, callback.typeParameters, @@ -1201,52 +991,49 @@ 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() const callbackParamTypeNode = buildCallbackParamTypeNode( mapCall, elemParam, indexParam, arrayParam, - capturedVarNames, - captures, + captureTree, context, ); - // Wrap in recipe() using type argument const recipeExpr = context.ctHelpers.getHelperExpr("recipe"); const recipeCall = factory.createCallExpression( recipeExpr, - [callbackParamTypeNode], // Type argument + [callbackParamTypeNode], [newCallback], ); - // Create the params object const paramProperties: ts.PropertyAssignment[] = []; - for (const [varName, expr] of captures) { + for (const [rootName, rootNode] of captureTree) { paramProperties.push( factory.createPropertyAssignment( - factory.createIdentifier(varName), - expr, + createSafePropertyName(rootName, factory), + buildHierarchicalParamsValue(rootNode, rootName, factory), ), ); } - const paramsObject = factory.createObjectLiteralExpression(paramProperties); - // Create mapWithPattern property access + const paramsObject = factory.createObjectLiteralExpression( + paramProperties, + paramProperties.length > 0, + ); + if (!ts.isPropertyAccessExpression(mapCall.expression)) { throw new Error( "Expected mapCall.expression to be a PropertyAccessExpression", ); } const mapWithPatternAccess = factory.createPropertyAccessExpression( - mapCall.expression.expression, // state.items + mapCall.expression.expression, factory.createIdentifier("mapWithPattern"), ); - // Return the transformed mapWithPattern call return factory.createCallExpression( mapWithPatternAccess, mapCall.typeArguments, @@ -1265,42 +1052,11 @@ function transformMapCallback( context: TransformationContext, visitor: ts.Visitor, ): ts.CallExpression { - const { factory, checker } = context; + const { checker } = context; // Collect captured variables from the callback const captureExpressions = collectCaptures(callback, checker); - - // Build map of capture name -> expression - const captureEntries: Array<{ name: string; expr: ts.Expression }> = []; - const usedNames = new Set(["element", "index", "array", "params"]); - - for (const expr of captureExpressions) { - 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 - const capturedVarNames = new Set(captures.keys()); + const captureTree = groupCapturesByRoot(captureExpressions); // Get callback parameters const originalParams = callback.parameters; @@ -1311,39 +1067,10 @@ function transformMapCallback( // IMPORTANT: First, recursively transform any nested map callbacks BEFORE we change // parameter names. This ensures nested callbacks can properly detect captures from // parent callback scope. Reuse the same visitor for consistency. - let transformedBody = ts.visitNode(callback.body, visitor) as ts.ConciseBody; - - // Transform the callback body in stages: - // 1. Replace element parameter name - transformedBody = transformElementReferences( - transformedBody, - elemParam, - factory, - ); - - // 2. Replace index parameter name - transformedBody = transformIndexReferences( - transformedBody, - indexParam, - factory, - ); - - // 3. Replace array parameter name - transformedBody = transformArrayReferences( - transformedBody, - arrayParam, - factory, - ); - - // 4. Transform destructured properties - transformedBody = transformDestructuredProperties( - transformedBody, - elemParam, - factory, - ); - - // 5. Replace captured expressions with their parameter names - transformedBody = replaceCaptures(transformedBody, captures, factory); + const transformedBody = ts.visitNode( + callback.body, + visitor, + ) as ts.ConciseBody; // Create the final recipe call with params return createRecipeCallWithParams( @@ -1353,8 +1080,7 @@ function transformMapCallback( elemParam, indexParam, arrayParam, - capturedVarNames, - captures, + captureTree, context, ); } diff --git a/packages/ts-transformers/src/transformers/builtins/derive.ts b/packages/ts-transformers/src/transformers/builtins/derive.ts index 3f120ca24..4934d1a8f 100644 --- a/packages/ts-transformers/src/transformers/builtins/derive.ts +++ b/packages/ts-transformers/src/transformers/builtins/derive.ts @@ -1,6 +1,15 @@ import ts from "typescript"; import { CTHelpers } from "../../core/ct-helpers.ts"; import { getExpressionText } from "../../ast/mod.ts"; +import { + buildHierarchicalParamsValue, + groupCapturesByRoot, + parseCaptureExpression, +} from "../../utils/capture-tree.ts"; +import { + getUniqueIdentifier, + isSafeIdentifierText, +} from "../../utils/identifiers.ts"; function replaceOpaqueRefsWithParams( expression: ts.Expression, @@ -19,11 +28,7 @@ function replaceOpaqueRefsWithParams( return visit(expression) as ts.Expression; } -function getSimpleName(ref: ts.Expression): string | undefined { - return ts.isIdentifier(ref) ? ref.text : undefined; -} - -interface DeriveEntry { +interface FallbackEntry { readonly ref: ts.Expression; readonly paramName: string; readonly propertyName: string; @@ -35,75 +40,129 @@ export interface DeriveCallOptions { readonly ctHelpers: CTHelpers; } -function createPropertyName( - ref: ts.Expression, - index: number, -): string { - if (ts.isIdentifier(ref)) { - return ref.text; - } - if (ts.isPropertyAccessExpression(ref)) { - // Use getExpressionText to handle both regular and synthetic nodes - return getExpressionText(ref).replace(/\./g, "_"); - } - return `ref${index + 1}`; -} - function planDeriveEntries( refs: readonly ts.Expression[], ): { - readonly entries: readonly DeriveEntry[]; + readonly captureTree: ReturnType; + readonly fallbackEntries: readonly FallbackEntry[]; readonly refToParamName: Map; } { - const entries: DeriveEntry[] = []; - const refToParamName = new Map(); - const seen = new Map(); + const structured: ts.Expression[] = []; + const fallback: ts.Expression[] = []; refs.forEach((ref) => { - // Use getExpressionText to handle both regular and synthetic nodes - const key = getExpressionText(ref); - let entry = seen.get(key); - if (!entry) { - const paramName = getSimpleName(ref) ?? `_v${entries.length + 1}`; - entry = { - ref, - paramName, - propertyName: createPropertyName(ref, entries.length), - }; - seen.set(key, entry); - entries.push(entry); + if (parseCaptureExpression(ref)) { + structured.push(ref); + } else { + fallback.push(ref); } - refToParamName.set(ref, entry.paramName); }); - return { entries, refToParamName }; + const captureTree = groupCapturesByRoot(structured); + const fallbackEntries: FallbackEntry[] = []; + const refToParamName = new Map(); + + const usedPropertyNames = new Set(); + const usedParamNames = new Set(); + + fallback.forEach((ref, index) => { + const baseName = getExpressionText(ref).replace(/\./g, "_"); + const propertyName = getUniqueIdentifier(baseName, usedPropertyNames, { + fallback: `ref${index + 1}`, + trimLeadingUnderscores: true, + }); + + const paramName = ts.isIdentifier(ref) + ? getUniqueIdentifier(ref.text, usedParamNames) + : getUniqueIdentifier(`_v${index + 1}`, usedParamNames, { + fallback: `_v${index + 1}`, + }); + + fallbackEntries.push({ ref, propertyName, paramName }); + refToParamName.set(ref, paramName); + }); + + return { captureTree, fallbackEntries, refToParamName }; } -function createParameterForEntries( +function createPropertyName( factory: ts.NodeFactory, - entries: readonly DeriveEntry[], + name: string, +): ts.PropertyName { + return isSafeIdentifierText(name) + ? factory.createIdentifier(name) + : factory.createStringLiteral(name); +} + +function createParameterForPlan( + factory: ts.NodeFactory, + captureTree: ReturnType, + fallbackEntries: readonly FallbackEntry[], + refToParamName: Map, ): ts.ParameterDeclaration { - if (entries.length === 1) { - const entry = entries[0]!; + const bindings: ts.BindingElement[] = []; + const usedNames = new Set(); + + const register = (candidate: string): ts.Identifier => { + if (isSafeIdentifierText(candidate) && !usedNames.has(candidate)) { + usedNames.add(candidate); + return factory.createIdentifier(candidate); + } + const unique = getUniqueIdentifier(candidate, usedNames, { + fallback: candidate.length > 0 ? candidate : "ref", + }); + return factory.createIdentifier(unique); + }; + + for (const [rootName] of captureTree) { + const bindingIdentifier = register(rootName); + const propertyName = isSafeIdentifierText(rootName) + ? undefined + : createPropertyName(factory, rootName); + bindings.push( + factory.createBindingElement( + undefined, + propertyName, + bindingIdentifier, + undefined, + ), + ); + } + + for (const entry of fallbackEntries) { + const bindingIdentifier = register(entry.paramName); + const currentName = refToParamName.get(entry.ref); + if (currentName !== bindingIdentifier.text) { + refToParamName.set(entry.ref, bindingIdentifier.text); + } + bindings.push( + factory.createBindingElement( + undefined, + factory.createIdentifier(entry.propertyName), + bindingIdentifier, + undefined, + ), + ); + } + + const shouldInlineSoleBinding = bindings.length === 1 && + captureTree.size === 0 && + fallbackEntries.length === 1 && + !bindings[0]!.propertyName && + !bindings[0]!.dotDotDotToken && + !bindings[0]!.initializer; + + if (shouldInlineSoleBinding) { return factory.createParameterDeclaration( undefined, undefined, - factory.createIdentifier(entry.paramName), + bindings[0]!.name, undefined, undefined, undefined, ); } - const bindings = entries.map((entry) => - factory.createBindingElement( - undefined, - factory.createIdentifier(entry.propertyName), - factory.createIdentifier(entry.paramName), - undefined, - ) - ); - return factory.createParameterDeclaration( undefined, undefined, @@ -116,23 +175,48 @@ function createParameterForEntries( function createDeriveArgs( factory: ts.NodeFactory, - entries: readonly DeriveEntry[], + captureTree: ReturnType, + fallbackEntries: readonly FallbackEntry[], ): readonly ts.Expression[] { - if (entries.length === 1) { - return [entries[0]!.ref]; + const properties: ts.ObjectLiteralElementLike[] = []; + + for (const [rootName, node] of captureTree) { + properties.push( + factory.createPropertyAssignment( + createPropertyName(factory, rootName), + buildHierarchicalParamsValue(node, rootName, factory), + ), + ); } - const properties = entries.map((entry) => { - if (ts.isIdentifier(entry.ref)) { - return factory.createShorthandPropertyAssignment(entry.ref, undefined); + for (const entry of fallbackEntries) { + if (ts.isIdentifier(entry.ref) && entry.propertyName === entry.ref.text) { + properties.push( + factory.createShorthandPropertyAssignment(entry.ref, undefined), + ); + } else { + properties.push( + factory.createPropertyAssignment( + factory.createIdentifier(entry.propertyName), + entry.ref, + ), + ); } - return factory.createPropertyAssignment( - factory.createIdentifier(entry.propertyName), - entry.ref, - ); - }); + } - return [factory.createObjectLiteralExpression(properties, false)]; + if (properties.length === 1 && fallbackEntries.length === 0) { + const first = captureTree.values().next(); + if (!first.done) { + const node = first.value; + if (node.expression && node.properties.size === 0) { + return [node.expression]; + } + } + } + + return [ + factory.createObjectLiteralExpression(properties, properties.length > 1), + ]; } export function createDeriveCall( @@ -143,8 +227,19 @@ export function createDeriveCall( if (refs.length === 0) return undefined; const { factory, tsContext, ctHelpers } = options; - const { entries, refToParamName } = planDeriveEntries(refs); - if (entries.length === 0) return undefined; + const { captureTree, fallbackEntries, refToParamName } = planDeriveEntries( + refs, + ); + if (captureTree.size === 0 && fallbackEntries.length === 0) { + return undefined; + } + + const parameter = createParameterForPlan( + factory, + captureTree, + fallbackEntries, + refToParamName, + ); const lambdaBody = replaceOpaqueRefsWithParams( expression, @@ -156,7 +251,7 @@ export function createDeriveCall( const arrowFunction = factory.createArrowFunction( undefined, undefined, - [createParameterForEntries(factory, entries)], + [parameter], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), lambdaBody, @@ -164,7 +259,7 @@ export function createDeriveCall( const deriveExpr = ctHelpers.getHelperExpr("derive"); const deriveArgs = [ - ...createDeriveArgs(factory, entries), + ...createDeriveArgs(factory, captureTree, fallbackEntries), arrowFunction, ]; diff --git a/packages/ts-transformers/src/transformers/opaque-ref/bindings.ts b/packages/ts-transformers/src/transformers/opaque-ref/bindings.ts index 34582cf66..81d4adfe0 100644 --- a/packages/ts-transformers/src/transformers/opaque-ref/bindings.ts +++ b/packages/ts-transformers/src/transformers/opaque-ref/bindings.ts @@ -1,6 +1,7 @@ import ts from "typescript"; import { getExpressionText, type NormalizedDataFlow } from "../../ast/mod.ts"; +import { getUniqueIdentifier } from "../../utils/identifiers.ts"; export interface BindingPlanEntry { readonly dataFlow: NormalizedDataFlow; @@ -27,36 +28,6 @@ function deriveBaseName( return `ref${index + 1}`; } -interface UniqueNameOptions { - readonly trimLeadingUnderscores?: boolean; -} - -function createUniqueIdentifier( - candidate: string, - fallback: string, - used: Set, - options: UniqueNameOptions = {}, -): string { - let base = candidate.replace(/[^A-Za-z0-9_]/g, "_"); - if (options.trimLeadingUnderscores) { - base = base.replace(/^_+/, ""); - } - if (base.length === 0) { - base = fallback; - } - if (!/^[A-Za-z_]/.test(base.charAt(0))) { - base = fallback; - } - - let name = base; - let suffix = 1; - while (used.has(name)) { - name = `${base}_${suffix++}`; - } - used.add(name); - return name; -} - export function createBindingPlan( dataFlows: readonly NormalizedDataFlow[], ): BindingPlan { @@ -67,24 +38,17 @@ export function createBindingPlan( dataFlows.forEach((dataFlow, index) => { const base = deriveBaseName(dataFlow.expression, index); const fallback = `ref${index + 1}`; - const propertyName = createUniqueIdentifier( - base, + const propertyName = getUniqueIdentifier(base, usedPropertyNames, { fallback, - usedPropertyNames, - { trimLeadingUnderscores: true }, - ); - - const paramName = ts.isIdentifier(dataFlow.expression) - ? createUniqueIdentifier( - dataFlow.expression.text, - `_v${index + 1}`, - usedParamNames, - ) - : createUniqueIdentifier( - `_v${index + 1}`, - `_v${index + 1}`, - usedParamNames, - ); + trimLeadingUnderscores: true, + }); + + const paramCandidate = ts.isIdentifier(dataFlow.expression) + ? dataFlow.expression.text + : `_v${index + 1}`; + const paramName = getUniqueIdentifier(paramCandidate, usedParamNames, { + fallback: `_v${index + 1}`, + }); entries.push({ dataFlow, propertyName, paramName }); }); diff --git a/packages/ts-transformers/src/utils/capture-tree.ts b/packages/ts-transformers/src/utils/capture-tree.ts new file mode 100644 index 000000000..94e4ba5a6 --- /dev/null +++ b/packages/ts-transformers/src/utils/capture-tree.ts @@ -0,0 +1,199 @@ +import ts from "typescript"; + +import { isSafeIdentifierText } from "./identifiers.ts"; + +export interface CapturePathInfo { + readonly root: string; + readonly path: readonly string[]; + readonly expression: ts.Expression; +} + +export interface CaptureTreeNode { + readonly properties: Map; + readonly path: readonly string[]; + expression?: ts.Expression; +} + +export function parseCaptureExpression( + expr: ts.Expression, +): CapturePathInfo | undefined { + if (ts.isIdentifier(expr)) { + return { root: expr.text, path: [], expression: expr }; + } + + if (ts.isPropertyAccessExpression(expr)) { + const segments: string[] = []; + let current: ts.Expression = expr; + + while (ts.isPropertyAccessExpression(current)) { + segments.unshift(current.name.text); + current = current.expression; + } + + if (ts.isIdentifier(current)) { + return { root: current.text, path: segments, expression: expr }; + } + } + + return undefined; +} + +export function createCaptureTreeNode( + path: readonly string[], +): CaptureTreeNode { + return { properties: new Map(), path }; +} + +function ensureChildNode( + parent: CaptureTreeNode, + key: string, +): CaptureTreeNode { + let child = parent.properties.get(key); + if (!child) { + child = createCaptureTreeNode([...parent.path, key]); + parent.properties.set(key, child); + } + return child; +} + +export function groupCapturesByRoot( + captureExpressions: Iterable, +): Map { + const rootMap = new Map(); + + for (const expr of captureExpressions) { + const pathInfo = parseCaptureExpression(expr); + if (!pathInfo) continue; + + const { root, path, expression } = pathInfo; + let rootNode = rootMap.get(root); + if (!rootNode) { + rootNode = createCaptureTreeNode([]); + rootMap.set(root, rootNode); + } + + let currentNode = rootNode; + + if (path.length === 0) { + currentNode.expression = expression; + currentNode.properties.clear(); + continue; + } + + if (currentNode.expression) { + continue; + } + + for (const segment of path) { + currentNode = ensureChildNode(currentNode, segment); + if (currentNode.expression) { + break; + } + } + + if (!currentNode.expression) { + currentNode.expression = expression; + currentNode.properties.clear(); + } + } + + return rootMap; +} + +export function createCaptureAccessExpression( + rootName: string, + path: readonly string[], + factory: ts.NodeFactory, + template?: ts.Expression, +): ts.Expression { + if (template) { + const rebuild = (expr: ts.Expression): ts.Expression | undefined => { + if (ts.isIdentifier(expr)) { + return factory.createIdentifier(rootName); + } + if (ts.isPropertyAccessExpression(expr)) { + const target = rebuild(expr.expression); + if (!target) return undefined; + if (ts.isPropertyAccessChain(expr)) { + return factory.createPropertyAccessChain( + target, + factory.createToken(ts.SyntaxKind.QuestionDotToken), + expr.name, + ); + } + return factory.createPropertyAccessExpression(target, expr.name); + } + if (ts.isElementAccessExpression(expr)) { + const target = rebuild(expr.expression); + if (!target) return undefined; + if (ts.isElementAccessChain(expr)) { + return factory.createElementAccessChain( + target, + factory.createToken(ts.SyntaxKind.QuestionDotToken), + expr.argumentExpression, + ); + } + return factory.createElementAccessExpression( + target, + expr.argumentExpression, + ); + } + return undefined; + }; + + const rebuilt = rebuild(template); + if (rebuilt) { + return rebuilt; + } + } + + let expr: ts.Expression = factory.createIdentifier(rootName); + for (const segment of path) { + expr = factory.createPropertyAccessExpression( + expr, + factory.createIdentifier(segment), + ); + } + return expr; +} + +export function buildHierarchicalParamsValue( + node: CaptureTreeNode, + rootName: string, + factory: ts.NodeFactory, +): ts.Expression { + if (node.expression && node.properties.size === 0) { + return createCaptureAccessExpression( + rootName, + node.path, + factory, + node.expression, + ); + } + + const assignments: ts.PropertyAssignment[] = []; + for (const [propName, childNode] of node.properties) { + assignments.push( + factory.createPropertyAssignment( + isSafeIdentifierText(propName) + ? factory.createIdentifier(propName) + : factory.createStringLiteral(propName), + buildHierarchicalParamsValue(childNode, rootName, factory), + ), + ); + } + + if (assignments.length === 0 && node.expression) { + return createCaptureAccessExpression( + rootName, + node.path, + factory, + node.expression, + ); + } + + return factory.createObjectLiteralExpression( + assignments, + assignments.length > 0, + ); +} diff --git a/packages/ts-transformers/src/utils/identifiers.ts b/packages/ts-transformers/src/utils/identifiers.ts new file mode 100644 index 000000000..91df9ee3b --- /dev/null +++ b/packages/ts-transformers/src/utils/identifiers.ts @@ -0,0 +1,152 @@ +import ts from "typescript"; + +const DEFAULT_FALLBACK = "_"; + +export interface SanitizeIdentifierOptions { + readonly fallback?: string; + readonly trimLeadingUnderscores?: boolean; +} + +export interface UniqueIdentifierOptions extends SanitizeIdentifierOptions { + readonly suffixSeparator?: string; +} + +export function isSafeIdentifierText(name: string): boolean { + if (name.length === 0) return false; + const first = name.codePointAt(0)!; + if (!ts.isIdentifierStart(first, ts.ScriptTarget.ESNext)) { + return false; + } + for (let i = 1; i < name.length; i++) { + const code = name.codePointAt(i)!; + if (!ts.isIdentifierPart(code, ts.ScriptTarget.ESNext)) { + return false; + } + } + const scanner = ts.createScanner( + ts.ScriptTarget.ESNext, + /*skipTrivia*/ false, + ts.LanguageVariant.Standard, + name, + ); + const token = scanner.scan(); + const isWholeToken = scanner.getTokenText().length === name.length; + if (!isWholeToken) return true; + return token < ts.SyntaxKind.FirstReservedWord || + token > ts.SyntaxKind.LastReservedWord; +} + +export function sanitizeIdentifierCandidate( + raw: string, + options: SanitizeIdentifierOptions = {}, +): string { + const fallbackValue = options.fallback ?? DEFAULT_FALLBACK; + + const normaliseFallback = (value: string): string => { + let text = value; + if (options.trimLeadingUnderscores) { + text = text.replace(/^_+/, ""); + } + text = text.replace(/[^A-Za-z0-9_$]/g, "_"); + + if (text.length === 0) { + return DEFAULT_FALLBACK; + } + + if (!ts.isIdentifierStart(text.charCodeAt(0), ts.ScriptTarget.ESNext)) { + text = `${DEFAULT_FALLBACK}${text}`; + } + + if (!isSafeIdentifierText(text)) { + return DEFAULT_FALLBACK; + } + + return text; + }; + + const fallback = normaliseFallback(fallbackValue); + + let candidate = raw; + if (options.trimLeadingUnderscores) { + candidate = candidate.replace(/^_+/, ""); + } + + candidate = candidate.replace(/[^A-Za-z0-9_$]/g, "_"); + + if (candidate.length === 0) { + candidate = fallback; + } + + const ensureIdentifierStart = (text: string): string => { + if (text.length === 0) return fallback; + if (ts.isIdentifierStart(text.charCodeAt(0), ts.ScriptTarget.ESNext)) { + return text; + } + return `${fallback}${text}`; + }; + + candidate = ensureIdentifierStart(candidate); + + if (!isSafeIdentifierText(candidate)) { + candidate = fallback; + } + + let safe = candidate; + while (!isSafeIdentifierText(safe)) { + safe = ensureIdentifierStart(`${safe}_`); + } + + return safe; +} + +export function getUniqueIdentifier( + candidate: string, + used: Set, + options: UniqueIdentifierOptions = {}, +): string { + const fallback = options.fallback ?? DEFAULT_FALLBACK; + const separator = options.suffixSeparator ?? "_"; + + const base = sanitizeIdentifierCandidate(candidate, options); + let name = base.length > 0 + ? base + : sanitizeIdentifierCandidate(fallback, options); + + if (used.has(name)) { + let index = 1; + while (true) { + const next = sanitizeIdentifierCandidate( + `${name}${separator}${index++}`, + { ...options, fallback }, + ); + if (!used.has(next)) { + name = next; + break; + } + } + } + + used.add(name); + return name; +} + +export function maybeReuseIdentifier( + identifier: ts.Identifier, + used: Set, +): ts.Identifier { + if (!used.has(identifier.text) && isSafeIdentifierText(identifier.text)) { + used.add(identifier.text); + return identifier; + } + const fresh = getUniqueIdentifier(identifier.text, used); + return ts.factory.createIdentifier(fresh); +} + +export function createSafeIdentifier( + name: string, + used: Set, + options?: UniqueIdentifierOptions, +): ts.Identifier { + const text = getUniqueIdentifier(name, used, options); + return ts.factory.createIdentifier(text); +} diff --git a/packages/ts-transformers/test/derive/create-derive-call.test.ts b/packages/ts-transformers/test/derive/create-derive-call.test.ts new file mode 100644 index 000000000..8f736dfab --- /dev/null +++ b/packages/ts-transformers/test/derive/create-derive-call.test.ts @@ -0,0 +1,59 @@ +import { assertStringIncludes } from "@std/assert"; +import ts from "typescript"; + +import { createDeriveCall } from "../../src/transformers/builtins/derive.ts"; +import { CTHelpers } from "../../src/core/ct-helpers.ts"; + +Deno.test("createDeriveCall keeps fallback refs synced when names collide", () => { + const source = ts.createSourceFile( + "test.tsx", + "", + ts.ScriptTarget.ES2022, + true, + ts.ScriptKind.TSX, + ); + + let printed: string | undefined; + + const transformer: ts.TransformerFactory = (context) => { + const { factory } = context; + + const ctHelpers = { + getHelperExpr(name: string) { + return factory.createPropertyAccessExpression( + factory.createIdentifier("__ctHelpers"), + name, + ); + }, + } as unknown as CTHelpers; + + const rootIdentifier = factory.createIdentifier("_v1"); + const fallbackExpr = factory.createParenthesizedExpression(rootIdentifier); + + const derive = createDeriveCall(fallbackExpr, [ + rootIdentifier, + fallbackExpr, + ], { + factory, + tsContext: context, + ctHelpers, + }); + + if (!derive) { + throw new Error("expected derive call"); + } + + const printer = ts.createPrinter(); + printed = printer.printNode(ts.EmitHint.Unspecified, derive, source); + + return (file) => file; + }; + + ts.transform(source, [transformer]); + + if (!printed) { + throw new Error("derive call not printed"); + } + + assertStringIncludes(printed, "=> _v1_1"); +}); diff --git a/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx index 1949649f9..ffd3bc9db 100644 --- a/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx @@ -21,7 +21,7 @@ export default recipe({ return { [NAME]: state.label, [UI]: (
- {__ctHelpers.ifElse(__ctHelpers.derive({ state, state_count: state.count }, ({ state: state, state_count: _v2 }) => state && _v2 > 0),

Positive

,

Non-positive

)} + {__ctHelpers.ifElse(__ctHelpers.derive(state, ({ state }) => state && state.count > 0),

Positive

,

Non-positive

)}
), }; }); diff --git a/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe.expected.tsx index a4fa65358..c4b78d7bb 100644 --- a/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe.expected.tsx @@ -47,7 +47,9 @@ export default recipe({ [UI]: (
-
    -
  • next number: {__ctHelpers.ifElse(state.value, __ctHelpers.derive(state.value, _v1 => _v1 + 1), "unknown")}
  • +
  • next number: {__ctHelpers.ifElse(state.value, __ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value + 1), "unknown")}
+
), diff --git a/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx index b812c86c9..97312715b 100644 --- a/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx @@ -32,7 +32,7 @@ export default recipe({ return { [UI]: (
{/* Regular JSX expression - should be wrapped in derive */} - Count: {__ctHelpers.derive(count, count => count + 1)} + Count: {__ctHelpers.derive(count, ({ count }) => count + 1)} {/* Event handler with OpaqueRef - should NOT be wrapped in derive */} diff --git a/packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.expected.tsx index d97975676..0d17a195f 100644 --- a/packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.expected.tsx @@ -56,8 +56,8 @@ export default recipe({ } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element, index, params: {} }) => (
- {index}: {element} + } as const satisfies __ctHelpers.JSONSchema, ({ element: value, index: index, params: {} }) => (
+ {index}: {value}
)), {})}
), diff --git a/packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.expected.tsx index 4a949ab50..c90445a18 100644 --- a/packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.expected.tsx @@ -16,7 +16,11 @@ export default recipe({ return { [NAME]: "test ternary with derive", [UI]: (
- {__ctHelpers.ifElse(__ctHelpers.derive(state.value, _v1 => _v1 + 1), __ctHelpers.derive(state.value, _v1 => _v1 + 2), "undefined")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value + 1), __ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value + 2), "undefined")}
), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/cell-map-with-captures.expected.tsx b/packages/ts-transformers/test/fixtures/closures/cell-map-with-captures.expected.tsx index 6cfef6e02..f82538fcf 100644 --- a/packages/ts-transformers/test/fixtures/closures/cell-map-with-captures.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/cell-map-with-captures.expected.tsx @@ -33,16 +33,31 @@ export default recipe({ params: { type: "object", properties: { - multiplier: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + multiplier: { + type: "number", + asOpaque: true + } + }, + required: ["multiplier"] } }, - required: ["multiplier"] + required: ["state"] } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { multiplier } }) => ({__ctHelpers.derive({ element, multiplier }, ({ element: element, multiplier: multiplier }) => element * multiplier)})), { multiplier: state.multiplier })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: value, params: { state } }) => ({__ctHelpers.derive({ + value: value, + state: { + multiplier: state.multiplier + } + }, ({ value, state }) => value * state.multiplier)})), { + state: { + multiplier: state.multiplier + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/filter-map-chain.expected.tsx b/packages/ts-transformers/test/fixtures/closures/filter-map-chain.expected.tsx index 0e762d8ed..ad62d4b40 100644 --- a/packages/ts-transformers/test/fixtures/closures/filter-map-chain.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/filter-map-chain.expected.tsx @@ -45,7 +45,10 @@ export default recipe({ return { [UI]: (
{/* Method chain: filter then map, both with captures */} - {__ctHelpers.derive(state.items, _v1 => _v1.filter((item) => item.active)).mapWithPattern(__ctHelpers.recipe({ + {__ctHelpers.derive({ state: { + items: state.items + } }, ({ state }) => state.items + .filter((item) => item.active)).mapWithPattern(__ctHelpers.recipe({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { @@ -56,12 +59,18 @@ export default recipe({ params: { type: "object", properties: { - taxRate: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + taxRate: { + type: "number", + asOpaque: true + } + }, + required: ["taxRate"] } }, - required: ["taxRate"] + required: ["state"] } }, required: ["element", "params"], @@ -82,9 +91,20 @@ export default recipe({ required: ["id", "price", "active"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { taxRate } }) => (
- Total: {__ctHelpers.derive({ element_price: element.price, taxRate }, ({ element_price: _v1, taxRate: taxRate }) => _v1 * (1 + taxRate))} -
)), { taxRate: state.taxRate })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => (
+ Total: {__ctHelpers.derive({ + item: { + price: item.price + }, + state: { + taxRate: state.taxRate + } + }, ({ item, state }) => item.price * (1 + state.taxRate))} +
)), { + state: { + taxRate: state.taxRate + } + })}
), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-array-destructure-shorthand.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-array-destructure-shorthand.expected.tsx index 27cbf3eb6..565ece9b4 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-array-destructure-shorthand.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-array-destructure-shorthand.expected.tsx @@ -61,7 +61,7 @@ export default recipe({ } } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (
{element[0]}
)), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element: [item], params: {} }) => (
{item}
)), {})} {/* Multiple array destructured params */} {items.mapWithPattern(__ctHelpers.recipe({ @@ -89,8 +89,8 @@ export default recipe({ } } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (
- {element[0]}: {element[1]} + } as const satisfies __ctHelpers.JSONSchema, ({ element: [item, count], params: {} }) => (
+ {item}: {count}
)), {})}
), }; diff --git a/packages/ts-transformers/test/fixtures/closures/map-array-destructured.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-array-destructured.expected.tsx index 643591960..fba479953 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-array-destructured.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-array-destructured.expected.tsx @@ -56,8 +56,8 @@ export default recipe({ } } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (
- {element[0]}: {element[1]} + } as const satisfies __ctHelpers.JSONSchema, ({ element: [date, pizza], params: {} }) => (
+ {date}: {pizza}
)), {})} {/* Map with array destructured parameter and capture */} @@ -71,12 +71,18 @@ export default recipe({ params: { type: "object", properties: { - scale: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + scale: { + type: "number", + asOpaque: true + } + }, + required: ["scale"] } }, - required: ["scale"] + required: ["state"] } }, required: ["element", "params"], @@ -88,9 +94,13 @@ export default recipe({ } } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { scale } }) => (
- {element[0]}: {element[1]} (scale: {scale}) -
)), { scale: state.scale })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: [date, pizza], params: { state } }) => (
+ {date}: {pizza} (scale: {state.scale}) +
)), { + state: { + scale: state.scale + } + })}
), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param.expected.tsx index 74bb1f1b2..2f5d2f754 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param.expected.tsx @@ -103,11 +103,13 @@ export default recipe({ required: ["text"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, index, params: { items } }) => (
  • + } as const satisfies __ctHelpers.JSONSchema, ({ element: _, index: index, params: { items } }) => (
  • Remove -
  • )), { items: items })} + )), { + items: items + })} ), }; }); 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 index 8170239e3..06263bd78 100644 --- 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 @@ -42,10 +42,7 @@ export default recipe({ } }, 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])}); - }), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element: { [nextKey()]: amount }, params: {} }) => ({amount})), {})} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-computed-alias-strict.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-strict.expected.tsx index 9bb72a6ad..f0c33260f 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-computed-alias-strict.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-strict.expected.tsx @@ -64,10 +64,9 @@ export default recipe({ required: ["value", "other"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => { + } as const satisfies __ctHelpers.JSONSchema, ({ element: { [dynamicKey]: val }, params: {} }) => { "use strict"; - const __ct_val_key = dynamicKey; - return element[__ct_val_key])}>{__ctHelpers.derive({ element, __ct_val_key }, ({ element: element, __ct_val_key: __ct_val_key }) => element[__ct_val_key] * 2)}; + return {__ctHelpers.derive(val, ({ val }) => val * 2)}; }), {})} ), }; diff --git a/packages/ts-transformers/test/fixtures/closures/map-conditional-expression.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-conditional-expression.expected.tsx index efca7e28b..cee8b8951 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-conditional-expression.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-conditional-expression.expected.tsx @@ -55,16 +55,22 @@ export default recipe({ params: { type: "object", properties: { - threshold: { - type: "number", - asOpaque: true - }, - discount: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + threshold: { + type: "number", + asOpaque: true + }, + discount: { + type: "number", + asOpaque: true + } + }, + required: ["threshold", "discount"] } }, - required: ["threshold", "discount"] + required: ["state"] } }, required: ["element", "params"], @@ -82,9 +88,28 @@ export default recipe({ required: ["id", "price"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { threshold, discount } }) => (
    - Price: ${__ctHelpers.ifElse(__ctHelpers.derive({ element_price: element.price, threshold }, ({ element_price: _v1, threshold: threshold }) => _v1 > threshold), __ctHelpers.derive({ element_price: element.price, discount }, ({ element_price: _v1, discount: discount }) => _v1 * (1 - discount)), element.price)} -
    )), { threshold: state.threshold, discount: state.discount })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => (
    + Price: ${__ctHelpers.ifElse(__ctHelpers.derive({ + item: { + price: item.price + }, + state: { + threshold: state.threshold + } + }, ({ item, state }) => item.price > state.threshold), __ctHelpers.derive({ + item: { + price: item.price + }, + state: { + discount: state.discount + } + }, ({ item, state }) => item.price * (1 - state.discount)), item.price)} +
    )), { + state: { + threshold: state.threshold, + discount: state.discount + } + })} ), }; }); 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 index 1bdc9609d..047dd49ad 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-destructured-alias.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-alias.expected.tsx @@ -45,16 +45,31 @@ export default recipe({ params: { type: "object", properties: { - discount: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + discount: { + type: "number", + asOpaque: true + } + }, + required: ["discount"] } }, - required: ["discount"] + required: ["state"] } }, 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 })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: { price: cost }, params: { state } }) => ({__ctHelpers.derive({ + cost: cost, + state: { + discount: state.discount + } + }, ({ cost, state }) => cost * state.discount)})), { + state: { + discount: 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 index 16927be2d..01c5e8ed3 100644 --- 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 @@ -64,10 +64,7 @@ export default recipe({ 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])}); - }), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element: { [dynamicKey]: val }, params: {} }) => ({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 index 61bd72420..2fa343831 100644 --- 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 @@ -44,7 +44,7 @@ export default recipe({ } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => ({element[0]})), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element: { 0: first }, params: {} }) => ({first})), {})} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-destructured-param.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-param.expected.tsx index 8735c9a20..4168112a8 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-destructured-param.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-param.expected.tsx @@ -51,12 +51,18 @@ export default recipe({ params: { type: "object", properties: { - scale: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + scale: { + type: "number", + asOpaque: true + } + }, + required: ["scale"] } }, - required: ["scale"] + required: ["state"] } }, required: ["element", "params"], @@ -74,9 +80,23 @@ export default recipe({ required: ["x", "y"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { scale } }) => (
    - Point: ({__ctHelpers.derive({ element_x: element.x, scale }, ({ element_x: _v1, scale: scale }) => _v1 * scale)}, {__ctHelpers.derive({ element_y: element.y, scale }, ({ element_y: _v1, scale: scale }) => _v1 * scale)}) -
    )), { scale: state.scale })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: { x, y }, params: { state } }) => (
    + Point: ({__ctHelpers.derive({ + x: x, + state: { + scale: state.scale + } + }, ({ x, state }) => x * state.scale)}, {__ctHelpers.derive({ + y: y, + state: { + scale: state.scale + } + }, ({ y, state }) => y * state.scale)}) +
    )), { + state: { + scale: state.scale + } + })} ), }; }); 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 index 34f6ae688..db4bb2151 100644 --- 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 @@ -44,7 +44,7 @@ export default recipe({ } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => ({element.couponCode})), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element: { couponCode: code }, params: {} }) => ({code})), {})} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-element-access-opaque.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-element-access-opaque.expected.tsx index 770672cc0..96963aa27 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-element-access-opaque.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-element-access-opaque.expected.tsx @@ -35,22 +35,37 @@ export default recipe({ params: { type: "object", properties: { - tagCounts: { + state: { type: "object", - properties: {}, - additionalProperties: { - type: "number" + properties: { + tagCounts: { + type: "object", + properties: {}, + additionalProperties: { + type: "number" + }, + asOpaque: true + } }, - asOpaque: true + required: ["tagCounts"] } }, - required: ["tagCounts"] + required: ["state"] } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { tagCounts } }) => ( - {element}: {__ctHelpers.derive({ tagCounts, element }, ({ tagCounts: tagCounts, element: element }) => tagCounts[element])} - )), { tagCounts: state.tagCounts })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: tag, params: { state } }) => ( + {tag}: {__ctHelpers.derive({ + state: { + tagCounts: state.tagCounts + }, + tag: tag + }, ({ state, tag }) => state.tagCounts[tag])} + )), { + state: { + tagCounts: state.tagCounts + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-handler-reference.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-handler-reference.expected.tsx index 9bc866f43..b755492ff 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-handler-reference.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-handler-reference.expected.tsx @@ -72,13 +72,19 @@ export default recipe({ params: { type: "object", properties: { - count: { - type: "number", - asCell: true, - asOpaque: true + state: { + type: "object", + properties: { + count: { + type: "number", + asCell: true, + asOpaque: true + } + }, + required: ["count"] } }, - required: ["count"] + required: ["state"] } }, required: ["element", "params"], @@ -96,9 +102,13 @@ export default recipe({ required: ["id", "name"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { count } }) => ( - {element.name} - )), { count: state.count })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => ( + {item.name} + )), { + state: { + count: state.count + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-import-reference.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-import-reference.expected.tsx index 1f6eb83f9..aa334c67a 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-import-reference.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-import-reference.expected.tsx @@ -70,8 +70,10 @@ export default recipe({ required: ["id", "price"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (
    - Item: {__ctHelpers.derive(element.price, _v1 => formatPrice(_v1 * (1 + TAX_RATE)))} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: {} }) => (
    + Item: {__ctHelpers.derive({ item: { + price: item.price + } }, ({ item }) => formatPrice(item.price * (1 + TAX_RATE)))}
    )), {})}
    ), }; diff --git a/packages/ts-transformers/test/fixtures/closures/map-index-param-used.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-index-param-used.expected.tsx index 02715a80f..f28765f72 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-index-param-used.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-index-param-used.expected.tsx @@ -54,12 +54,18 @@ export default recipe({ params: { type: "object", properties: { - offset: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + offset: { + type: "number", + asOpaque: true + } + }, + required: ["offset"] } }, - required: ["offset"] + required: ["state"] } }, required: ["element", "params"], @@ -77,9 +83,18 @@ export default recipe({ required: ["id", "name"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, index, params: { offset } }) => (
    - Item #{__ctHelpers.derive({ index, offset }, ({ index: index, offset: offset }) => index + offset)}: {element.name} -
    )), { offset: state.offset })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, index: index, params: { state } }) => (
    + Item #{__ctHelpers.derive({ + index: index, + state: { + offset: state.offset + } + }, ({ index, state }) => index + state.offset)}: {item.name} +
    )), { + state: { + offset: state.offset + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-index-shorthand.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-index-shorthand.expected.tsx index 42e855005..a253d99e3 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-index-shorthand.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-index-shorthand.expected.tsx @@ -67,8 +67,8 @@ export default recipe({ required: ["id", "name"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, index, params: {} }) => (
    - Item #{index}: {element.name} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, index: i, params: {} }) => (
    + Item #{i}: {item.name}
    )), {})} {/* Map with idx as index parameter */} @@ -102,8 +102,8 @@ export default recipe({ required: ["id", "name"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, index, params: {} }) => (
    - Position {index}: {element.name} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, index: idx, params: {} }) => (
    + Position {idx}: {item.name}
    )), {})}
    ), }; diff --git a/packages/ts-transformers/test/fixtures/closures/map-multiple-captures.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-multiple-captures.expected.tsx index 764c9fe94..8cb68713a 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-multiple-captures.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-multiple-captures.expected.tsx @@ -56,20 +56,26 @@ export default recipe({ params: { type: "object", properties: { - discount: { - type: "number", - asOpaque: true - }, - taxRate: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + discount: { + type: "number", + asOpaque: true + }, + taxRate: { + type: "number", + asOpaque: true + } + }, + required: ["discount", "taxRate"] }, multiplier: { type: "number", enum: [2] } }, - required: ["discount", "taxRate", "multiplier"] + required: ["state", "multiplier"] } }, required: ["element", "params"], @@ -87,9 +93,24 @@ export default recipe({ required: ["price", "quantity"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { discount, taxRate, multiplier } }) => ( - Total: {__ctHelpers.derive({ element_price: element.price, element_quantity: element.quantity, discount, taxRate, multiplier }, ({ element_price: _v1, element_quantity: _v2, discount: discount, taxRate: taxRate, multiplier: multiplier }) => _v1 * _v2 * discount * taxRate * multiplier + shippingCost)} - )), { discount: state.discount, taxRate: state.taxRate, multiplier: multiplier })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state, multiplier } }) => ( + Total: {__ctHelpers.derive({ + item: { + price: item.price, + quantity: item.quantity + }, + state: { + discount: state.discount, + taxRate: state.taxRate + } + }, ({ item, state }) => item.price * item.quantity * state.discount * state.taxRate * multiplier + shippingCost)} + )), { + state: { + discount: state.discount, + taxRate: state.taxRate + }, + multiplier: multiplier + })}
    ), }; }); 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 index 8fda24c4f..5cbd3c66e 100644 --- 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 @@ -65,22 +65,61 @@ export default recipe({ params: { type: "object", properties: { - discount: { - type: "number", - asOpaque: true - }, - discount_2: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + checkout: { + type: "object", + properties: { + discount: { + type: "number", + asOpaque: true + } + }, + required: ["discount"] + }, + upsell: { + type: "object", + properties: { + discount: { + type: "number", + asOpaque: true + } + }, + required: ["discount"] + } + }, + required: ["checkout", "upsell"] } }, - required: ["discount", "discount_2"] + required: ["state"] } }, 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 })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => ( + {__ctHelpers.derive({ + item: { + price: item.price + }, + state: { + checkout: { + discount: state.checkout.discount + }, + upsell: { + discount: state.upsell.discount + } + } + }, ({ item, state }) => item.price * state.checkout.discount * state.upsell.discount)} + )), { + state: { + checkout: { + discount: state.checkout.discount + }, + upsell: { + discount: state.upsell.discount + } + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-nested-callback.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-nested-callback.expected.tsx index 42bab42f8..ea145d7f7 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-nested-callback.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-nested-callback.expected.tsx @@ -74,12 +74,18 @@ export default recipe({ params: { type: "object", properties: { - prefix: { - type: "string", - asOpaque: true + state: { + type: "object", + properties: { + prefix: { + type: "string", + asOpaque: true + } + }, + required: ["prefix"] } }, - required: ["prefix"] + required: ["state"] } }, required: ["element", "params"], @@ -115,10 +121,10 @@ export default recipe({ required: ["id", "name"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { prefix } }) => (
    - {prefix}: {element.name} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => (
    + {state.prefix}: {item.name}
      - {element.tags.mapWithPattern(__ctHelpers.recipe({ + {item.tags.mapWithPattern(__ctHelpers.recipe({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { @@ -128,12 +134,18 @@ export default recipe({ params: { type: "object", properties: { - name: { - type: "string", - asOpaque: true + item: { + type: "object", + properties: { + name: { + type: "string", + asOpaque: true + } + }, + required: ["name"] } }, - required: ["name"] + required: ["item"] } }, required: ["element", "params"], @@ -151,9 +163,17 @@ export default recipe({ required: ["id", "name"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { name } }) => (
    • {name} - {element.name}
    • )), { name: element.name })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: tag, params: { item } }) => (
    • {item.name} - {tag.name}
    • )), { + item: { + name: item.name + } + })}
    -
    )), { prefix: state.prefix })} +
    )), { + state: { + prefix: state.prefix + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-nested-property.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-nested-property.expected.tsx index a323e71e0..777d3eff5 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-nested-property.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-nested-property.expected.tsx @@ -66,16 +66,28 @@ export default recipe({ params: { type: "object", properties: { - firstName: { - type: "string", - asOpaque: true - }, - lastName: { - type: "string", - asOpaque: true + state: { + type: "object", + properties: { + currentUser: { + type: "object", + properties: { + firstName: { + type: "string", + asOpaque: true + }, + lastName: { + type: "string", + asOpaque: true + } + }, + required: ["firstName", "lastName"] + } + }, + required: ["currentUser"] } }, - required: ["firstName", "lastName"] + required: ["state"] } }, required: ["element", "params"], @@ -93,9 +105,16 @@ export default recipe({ required: ["id", "name"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { firstName, lastName } }) => (
    - {element.name} - edited by {firstName} {lastName} -
    )), { firstName: state.currentUser.firstName, lastName: state.currentUser.lastName })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => (
    + {item.name} - edited by {state.currentUser.firstName} {state.currentUser.lastName} +
    )), { + state: { + currentUser: { + firstName: state.currentUser.firstName, + lastName: state.currentUser.lastName + } + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-no-captures.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-no-captures.expected.tsx index e66e8a6aa..ef50f5529 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-no-captures.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-no-captures.expected.tsx @@ -64,7 +64,7 @@ export default recipe({ required: ["id", "price"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (
    Item #{element.id}: ${element.price}
    )), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: {} }) => (
    Item #{item.id}: ${item.price}
    )), {})} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-outer-element.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-outer-element.expected.tsx new file mode 100644 index 000000000..250be498a --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-outer-element.expected.tsx @@ -0,0 +1,53 @@ +import * as __ctHelpers from "commontools"; +import { recipe, UI } from "commontools"; +interface State { + items: number[]; + highlight: string; +} +export default recipe({ + type: "object", + properties: { + items: { + type: "array", + items: { + type: "number" + } + }, + highlight: { + type: "string" + } + }, + required: ["items", "highlight"] +} as const satisfies __ctHelpers.JSONSchema, (state) => { + const element = state.highlight; + return { + [UI]: (
    + {state.items.mapWithPattern(__ctHelpers.recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + element: { + type: "number" + }, + params: { + type: "object", + properties: { + element: { + type: "string", + asOpaque: true + } + }, + required: ["element"] + } + }, + required: ["element", "params"] + } as const satisfies __ctHelpers.JSONSchema, ({ element: __ct_element, params: { element } }) => ({element})), { + element: element + })} +
    ), + }; +}); +// @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-outer-element.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-outer-element.input.tsx new file mode 100644 index 000000000..41fdbbc8f --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-outer-element.input.tsx @@ -0,0 +1,20 @@ +/// +import { recipe, UI } from "commontools"; + +interface State { + items: number[]; + highlight: string; +} + +export default recipe("MapOuterElement", (state) => { + const element = state.highlight; + return { + [UI]: ( +
    + {state.items.map(() => ( + {element} + ))} +
    + ), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/map-plain-array-no-transform.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-plain-array-no-transform.expected.tsx index 2ea5aea87..89315294f 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-plain-array-no-transform.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-plain-array-no-transform.expected.tsx @@ -16,7 +16,12 @@ export default recipe({ return { [UI]: (
    {/* Plain array should NOT be transformed, even with captures */} - {plainArray.map((n) => ({__ctHelpers.derive({ n, state_multiplier: state.multiplier }, ({ n: n, state_multiplier: _v2 }) => n * _v2)}))} + {plainArray.map((n) => ({__ctHelpers.derive({ + n: n, + state: { + multiplier: state.multiplier + } + }, ({ n, state }) => n * state.multiplier)}))}
    ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-single-capture.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-single-capture.expected.tsx index 1bdc9609d..c461ca0a6 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-single-capture.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-single-capture.expected.tsx @@ -45,16 +45,33 @@ export default recipe({ params: { type: "object", properties: { - discount: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + discount: { + type: "number", + asOpaque: true + } + }, + required: ["discount"] } }, - required: ["discount"] + required: ["state"] } }, 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 })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => ({__ctHelpers.derive({ + item: { + price: item.price + }, + state: { + discount: state.discount + } + }, ({ item, state }) => item.price * state.discount)})), { + state: { + discount: state.discount + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-template-literal.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-template-literal.expected.tsx index 5aaaf2dee..fe133b381 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-template-literal.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-template-literal.expected.tsx @@ -55,16 +55,22 @@ export default recipe({ params: { type: "object", properties: { - prefix: { - type: "string", - asOpaque: true - }, - suffix: { - type: "string", - asOpaque: true + state: { + type: "object", + properties: { + prefix: { + type: "string", + asOpaque: true + }, + suffix: { + type: "string", + asOpaque: true + } + }, + required: ["prefix", "suffix"] } }, - required: ["prefix", "suffix"] + required: ["state"] } }, required: ["element", "params"], @@ -82,7 +88,20 @@ export default recipe({ required: ["id", "name"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { prefix, suffix } }) => (
    {`${prefix} ${element.name} ${suffix}`}
    )), { prefix: state.prefix, suffix: state.suffix })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => (
    {__ctHelpers.derive({ + state: { + prefix: state.prefix, + suffix: state.suffix + }, + item: { + name: item.name + } + }, ({ state, item }) => `${state.prefix} ${item.name} ${state.suffix}`)}
    )), { + state: { + prefix: state.prefix, + suffix: state.suffix + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-type-assertion.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-type-assertion.expected.tsx index acf833400..f7bfc599d 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-type-assertion.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-type-assertion.expected.tsx @@ -33,12 +33,18 @@ export default recipe({ params: { type: "object", properties: { - prefix: { - type: "string", - asOpaque: true + state: { + type: "object", + properties: { + prefix: { + type: "string", + asOpaque: true + } + }, + required: ["prefix"] } }, - required: ["prefix"] + required: ["state"] } }, required: ["element", "params"], @@ -56,9 +62,13 @@ export default recipe({ required: ["id", "name"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { prefix } }) => (
    - {prefix}: {element.name} -
    )), { prefix: state.prefix })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => (
    + {state.prefix}: {item.name} +
    )), { + state: { + prefix: state.prefix + } + })} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-with-array-param.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-with-array-param.expected.tsx index dc07dc8a2..9bbcd3760 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-with-array-param.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-with-array-param.expected.tsx @@ -21,8 +21,8 @@ export default recipe("MapWithArrayParam", (_state) => { } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element, index, array, params: {} }) => (
    - Item {element} at index {index} of {array.length} total items + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, index: index, array: array, params: {} }) => (
    + Item {item} at index {index} of {array.length} total items
    )), {})}
    ), }; diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/complex-expressions.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/complex-expressions.expected.tsx index 9be423af9..b36cf2c17 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/complex-expressions.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/complex-expressions.expected.tsx @@ -23,8 +23,15 @@ export default recipe({ return { [UI]: (

    Price: {price}

    -

    Discount: {__ctHelpers.derive({ price, discount }, ({ price: price, discount: discount }) => price - discount)}

    -

    With tax: {__ctHelpers.derive({ price, discount, tax }, ({ price: price, discount: discount, tax: tax }) => (price - discount) * (1 + tax))}

    +

    Discount: {__ctHelpers.derive({ + price: price, + discount: discount + }, ({ price, discount }) => price - discount)}

    +

    With tax: {__ctHelpers.derive({ + price: price, + discount: discount, + tax: tax + }, ({ price, discount, tax }) => (price - discount) * (1 + tax))}

    ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-both-opaque.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-both-opaque.expected.tsx index dc4d256a6..115888b18 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-both-opaque.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-both-opaque.expected.tsx @@ -7,7 +7,10 @@ export default recipe("ElementAccessBothOpaque", (_state) => { [UI]: (

    Element Access with Both OpaqueRefs

    {/* Both items and index are OpaqueRefs */} -

    Selected item: {__ctHelpers.derive({ items, index }, ({ items: items, index: index }) => items[index])}

    +

    Selected item: {__ctHelpers.derive({ + items: items, + index: index + }, ({ items, index }) => items[index])}

    ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.expected.tsx index 5613a7390..c91f00599 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.expected.tsx @@ -111,72 +111,132 @@ export default recipe({ [UI]: (

    Nested Element Access

    {/* Double indexing into matrix */} -

    Matrix value: {__ctHelpers.derive({ state_matrix: state.matrix, state_row: state.row, state_col: state.col }, ({ state_matrix: _v1, state_row: _v2, state_col: _v3 }) => _v1[_v2][_v3])}

    +

    Matrix value: {__ctHelpers.derive({ state: { + matrix: state.matrix, + row: state.row, + col: state.col + } }, ({ state }) => state.matrix[state.row][state.col])}

    {/* Triple nested access */} -

    Deep nested: {__ctHelpers.derive({ state_nested_arrays: state.nested.arrays, state_nested_index: state.nested.index, state_row: state.row }, ({ state_nested_arrays: _v1, state_nested_index: _v2, state_row: _v3 }) => _v1[_v2][_v3])}

    +

    Deep nested: {__ctHelpers.derive({ state: { + nested: { + arrays: state.nested.arrays, + index: state.nested.index + }, + row: state.row + } }, ({ state }) => state.nested.arrays[state.nested.index][state.row])}

    Multiple References to Same Array

    {/* Same array accessed multiple times with different indices */}

    First and last: {state.items[0]} and{" "} - {__ctHelpers.derive({ state_items: state.items, state_items_length: state.items.length }, ({ state_items: _v1, state_items_length: _v2 }) => _v1[_v2 - 1])} + {__ctHelpers.derive({ state: { + items: state.items + } }, ({ state }) => state.items[state.items.length - 1])}

    {/* Array used in computation and access */} -

    Sum of ends: {__ctHelpers.derive({ state_arr: state.arr, state_arr_length: state.arr.length }, ({ state_arr: _v1, state_arr_length: _v2 }) => _v1[0] + _v1[_v2 - 1])}

    +

    Sum of ends: {__ctHelpers.derive({ state: { + arr: state.arr + } }, ({ state }) => state.arr[0] + state.arr[state.arr.length - 1])}

    Computed Indices

    {/* Index from multiple state values */} -

    Computed index: {__ctHelpers.derive({ state_arr: state.arr, state_a: state.a, state_b: state.b }, ({ state_arr: _v1, state_a: _v2, state_b: _v3 }) => _v1[_v2 + _v3])}

    +

    Computed index: {__ctHelpers.derive({ state: { + arr: state.arr, + a: state.a, + b: state.b + } }, ({ state }) => state.arr[state.a + state.b])}

    {/* Index from computation involving array */} -

    Modulo index: {__ctHelpers.derive({ state_items: state.items, state_row: state.row, state_items_length: state.items.length }, ({ state_items: _v1, state_row: _v2, state_items_length: _v3 }) => _v1[_v2 % _v3])}

    +

    Modulo index: {__ctHelpers.derive({ state: { + items: state.items, + row: state.row + } }, ({ state }) => state.items[state.row % state.items.length])}

    {/* Complex index expression */} -

    Complex: {__ctHelpers.derive({ state_arr: state.arr, state_a: state.a, state_arr_length: state.arr.length }, ({ state_arr: _v1, state_a: _v2, state_arr_length: _v3 }) => _v1[Math.min(_v2 * 2, _v3 - 1)])}

    +

    Complex: {__ctHelpers.derive({ state: { + arr: state.arr, + a: state.a + } }, ({ state }) => state.arr[Math.min(state.a * 2, state.arr.length - 1)])}

    Chained Element Access

    {/* Element access returning array, then accessing that */}

    User score:{" "} - {__ctHelpers.derive({ state_users: state.users, state_selectedUser: state.selectedUser, state_selectedScore: state.selectedScore }, ({ state_users: _v1, state_selectedUser: _v2, state_selectedScore: _v3 }) => _v1[_v2].scores[_v3])} + {__ctHelpers.derive({ state: { + users: state.users, + selectedUser: state.selectedUser, + selectedScore: state.selectedScore + } }, ({ state }) => state.users[state.selectedUser].scores[state.selectedScore])}

    {/* Using one array element as index for another */} -

    Indirect: {__ctHelpers.derive({ state_items: state.items, state_indices: state.indices }, ({ state_items: _v1, state_indices: _v2 }) => _v1[_v2[0]])}

    +

    Indirect: {__ctHelpers.derive({ state: { + items: state.items, + indices: state.indices + } }, ({ state }) => state.items[state.indices[0]])}

    {/* Array element used as index for same array */} -

    Self reference: {__ctHelpers.derive(state.arr, _v1 => _v1[_v1[0]])}

    +

    Self reference: {__ctHelpers.derive({ state: { + arr: state.arr + } }, ({ state }) => state.arr[state.arr[0]])}

    Mixed Property and Element Access

    {/* Property access followed by element access with computed index */} -

    Mixed: {__ctHelpers.derive({ state_nested_arrays: state.nested.arrays, state_nested_index: state.nested.index }, ({ state_nested_arrays: _v1, state_nested_index: _v2 }) => _v1[_v2].length)}

    +

    Mixed: {__ctHelpers.derive({ state: { + nested: { + arrays: state.nested.arrays, + index: state.nested.index + } + } }, ({ state }) => state.nested.arrays[state.nested.index].length)}

    {/* Element access followed by property access */} -

    User name length: {__ctHelpers.derive({ state_users: state.users, state_selectedUser: state.selectedUser }, ({ state_users: _v1, state_selectedUser: _v2 }) => _v1[_v2].name.length)}

    +

    User name length: {__ctHelpers.derive({ state: { + users: state.users, + selectedUser: state.selectedUser + } }, ({ state }) => state.users[state.selectedUser].name.length)}

    Element Access in Conditions

    {/* Element access in ternary */}

    Conditional:{" "} - {__ctHelpers.ifElse(__ctHelpers.derive({ state_arr: state.arr, state_a: state.a }, ({ state_arr: _v1, state_a: _v2 }) => _v1[_v2] > 10), __ctHelpers.derive({ state_items: state.items, state_b: state.b }, ({ state_items: _v1, state_b: _v2 }) => _v1[_v2]), state.items[0])} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + arr: state.arr, + a: state.a + } }, ({ state }) => state.arr[state.a] > 10), __ctHelpers.derive({ state: { + items: state.items, + b: state.b + } }, ({ state }) => state.items[state.b]), state.items[0])}

    {/* Element access in boolean expression */}

    - Has value: {ifElse(__ctHelpers.derive({ state_matrix: state.matrix, state_row: state.row, state_col: state.col }, ({ state_matrix: _v1, state_row: _v2, state_col: _v3 }) => _v1[_v2][_v3] > 0), "positive", "non-positive")} + Has value: {ifElse(__ctHelpers.derive({ state: { + matrix: state.matrix, + row: state.row, + col: state.col + } }, ({ state }) => state.matrix[state.row][state.col] > 0), "positive", "non-positive")}

    Element Access with Operators

    {/* Element access with arithmetic */} -

    Product: {__ctHelpers.derive({ state_arr: state.arr, state_a: state.a, state_b: state.b }, ({ state_arr: _v1, state_a: _v2, state_b: _v3 }) => _v1[_v2] * _v1[_v3])}

    +

    Product: {__ctHelpers.derive({ state: { + arr: state.arr, + a: state.a, + b: state.b + } }, ({ state }) => state.arr[state.a] * state.arr[state.b])}

    {/* Element access with string concatenation */} -

    Concat: {__ctHelpers.derive({ state_items: state.items, state_indices: state.indices }, ({ state_items: _v1, state_indices: _v2 }) => _v1[0] + " - " + _v1[_v2[0]])}

    +

    Concat: {__ctHelpers.derive({ state: { + items: state.items, + indices: state.indices + } }, ({ state }) => state.items[0] + " - " + state.items[state.indices[0]])}

    {/* Multiple element accesses in single expression */} -

    Sum: {__ctHelpers.derive(state.arr, _v1 => _v1[0] + _v1[1] + _v1[2])}

    +

    Sum: {__ctHelpers.derive({ state: { + arr: state.arr + } }, ({ state }) => state.arr[0] + state.arr[1] + state.arr[2])}

    ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.expected.tsx index 028ffab3c..131e83349 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.expected.tsx @@ -41,13 +41,22 @@ export default recipe({ [UI]: (

    Dynamic Element Access

    {/* Basic dynamic index */} -

    Item: {__ctHelpers.derive({ state_items: state.items, state_index: state.index }, ({ state_items: _v1, state_index: _v2 }) => _v1[_v2])}

    +

    Item: {__ctHelpers.derive({ state: { + items: state.items, + index: state.index + } }, ({ state }) => state.items[state.index])}

    {/* Computed index */} -

    Last: {__ctHelpers.derive({ state_items: state.items, state_items_length: state.items.length }, ({ state_items: _v1, state_items_length: _v2 }) => _v1[_v2 - 1])}

    +

    Last: {__ctHelpers.derive({ state: { + items: state.items + } }, ({ state }) => state.items[state.items.length - 1])}

    {/* Double indexing */} -

    Matrix: {__ctHelpers.derive({ state_matrix: state.matrix, state_row: state.row, state_col: state.col }, ({ state_matrix: _v1, state_row: _v2, state_col: _v3 }) => _v1[_v2][_v3])}

    +

    Matrix: {__ctHelpers.derive({ state: { + matrix: state.matrix, + row: state.row, + col: state.col + } }, ({ state }) => state.matrix[state.row][state.col])}

    ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx index 275a9e5e7..e8f7ac3f5 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx @@ -27,24 +27,54 @@ export default recipe({ return { [UI]: (

    Basic Arithmetic

    -

    Count + 1: {__ctHelpers.derive(state.count, _v1 => _v1 + 1)}

    -

    Count - 1: {__ctHelpers.derive(state.count, _v1 => _v1 - 1)}

    -

    Count * 2: {__ctHelpers.derive(state.count, _v1 => _v1 * 2)}

    -

    Price / 2: {__ctHelpers.derive(state.price, _v1 => _v1 / 2)}

    -

    Count % 3: {__ctHelpers.derive(state.count, _v1 => _v1 % 3)}

    +

    Count + 1: {__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count + 1)}

    +

    Count - 1: {__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count - 1)}

    +

    Count * 2: {__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count * 2)}

    +

    Price / 2: {__ctHelpers.derive({ state: { + price: state.price + } }, ({ state }) => state.price / 2)}

    +

    Count % 3: {__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count % 3)}

    Complex Expressions

    -

    Discounted Price: {__ctHelpers.derive({ state_price: state.price, state_discount: state.discount }, ({ state_price: _v1, state_discount: _v2 }) => _v1 - (_v1 * _v2))}

    -

    Total: {__ctHelpers.derive({ state_price: state.price, state_quantity: state.quantity }, ({ state_price: _v1, state_quantity: _v2 }) => _v1 * _v2)}

    -

    With Tax (8%): {__ctHelpers.derive({ state_price: state.price, state_quantity: state.quantity }, ({ state_price: _v1, state_quantity: _v2 }) => (_v1 * _v2) * 1.08)}

    +

    Discounted Price: {__ctHelpers.derive({ state: { + price: state.price, + discount: state.discount + } }, ({ state }) => state.price - (state.price * state.discount))}

    +

    Total: {__ctHelpers.derive({ state: { + price: state.price, + quantity: state.quantity + } }, ({ state }) => state.price * state.quantity)}

    +

    With Tax (8%): {__ctHelpers.derive({ state: { + price: state.price, + quantity: state.quantity + } }, ({ state }) => (state.price * state.quantity) * 1.08)}

    - Complex: {__ctHelpers.derive({ state_count: state.count, state_quantity: state.quantity, state_price: state.price, state_discount: state.discount }, ({ state_count: _v1, state_quantity: _v2, state_price: _v3, state_discount: _v4 }) => (_v1 + _v2) * _v3 - - (_v3 * _v4))} + Complex: {__ctHelpers.derive({ state: { + count: state.count, + quantity: state.quantity, + price: state.price, + discount: state.discount + } }, ({ state }) => (state.count + state.quantity) * state.price - + (state.price * state.discount))}

    Multiple Same Ref

    -

    Count³: {__ctHelpers.derive(state.count, _v1 => _v1 * _v1 * _v1)}

    -

    Price Range: ${__ctHelpers.derive(state.price, _v1 => _v1 - 10)} - ${__ctHelpers.derive(state.price, _v1 => _v1 + 10)}

    +

    Count³: {__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count * state.count * state.count)}

    +

    Price Range: ${__ctHelpers.derive({ state: { + price: state.price + } }, ({ state }) => state.price - 10)} - ${__ctHelpers.derive({ state: { + price: state.price + } }, ({ state }) => state.price + 10)}

    ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx index c6ca326e1..997d89778 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx @@ -60,7 +60,10 @@ export default recipe({

    Total items: {state.items.length}

    Filtered count:{" "} - {__ctHelpers.derive({ state_items: state.items, state_filter: state.filter }, ({ state_items: _v1, state_filter: _v2 }) => _v1.filter((i) => i.name.includes(_v2)).length)} + {__ctHelpers.derive({ state: { + items: state.items, + filter: state.filter + } }, ({ state }) => state.items.filter((i) => i.name.includes(state.filter)).length)}

    Array with Complex Expressions

    @@ -75,16 +78,22 @@ export default recipe({ params: { type: "object", properties: { - discount: { - type: "number", - asOpaque: true - }, - taxRate: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + discount: { + type: "number", + asOpaque: true + }, + taxRate: { + type: "number", + asOpaque: true + } + }, + required: ["discount", "taxRate"] } }, - required: ["discount", "taxRate"] + required: ["state"] } }, required: ["element", "params"], @@ -108,38 +117,74 @@ export default recipe({ required: ["id", "name", "price", "active"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { discount, taxRate } }) => (
  • - {element.name} - - Original: ${element.price} + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => (
  • + {item.name} + - Original: ${item.price} - - Discounted: ${__ctHelpers.derive({ element_price: element.price, discount }, ({ element_price: _v1, discount: discount }) => (_v1 * (1 - discount)).toFixed(2))} + - Discounted: ${__ctHelpers.derive({ + item: { + price: item.price + }, + state: { + discount: state.discount + } + }, ({ item, state }) => (item.price * (1 - state.discount)).toFixed(2))} - With tax: - ${__ctHelpers.derive({ element_price: element.price, discount, taxRate }, ({ element_price: _v1, discount: discount, taxRate: taxRate }) => (_v1 * (1 - discount) * (1 + taxRate)) + ${__ctHelpers.derive({ + item: { + price: item.price + }, + state: { + discount: state.discount, + taxRate: state.taxRate + } + }, ({ item, state }) => (item.price * (1 - state.discount) * (1 + state.taxRate)) .toFixed(2))} -
  • )), { discount: state.discount, taxRate: state.taxRate })} + )), { + state: { + discount: state.discount, + taxRate: state.taxRate + } + })}

    Array Methods

    Item count: {state.items.length}

    -

    Active items: {__ctHelpers.derive(state.items, _v1 => _v1.filter((i) => i.active).length)}

    +

    Active items: {__ctHelpers.derive({ state: { + items: state.items + } }, ({ state }) => state.items.filter((i) => i.active).length)}

    Simple Operations

    -

    Discount percent: {__ctHelpers.derive(state.discount, _v1 => _v1 * 100)}%

    -

    Tax percent: {__ctHelpers.derive(state.taxRate, _v1 => _v1 * 100)}%

    +

    Discount percent: {__ctHelpers.derive({ state: { + discount: state.discount + } }, ({ state }) => state.discount * 100)}%

    +

    Tax percent: {__ctHelpers.derive({ state: { + taxRate: state.taxRate + } }, ({ state }) => state.taxRate * 100)}%

    Array Predicates

    -

    All active: {__ctHelpers.ifElse(__ctHelpers.derive(state.items, _v1 => _v1.every((i) => i.active)), "Yes", "No")}

    -

    Any active: {__ctHelpers.ifElse(__ctHelpers.derive(state.items, _v1 => _v1.some((i) => i.active)), "Yes", "No")}

    +

    All active: {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + items: state.items + } }, ({ state }) => state.items.every((i) => i.active)), "Yes", "No")}

    +

    Any active: {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + items: state.items + } }, ({ state }) => state.items.some((i) => i.active)), "Yes", "No")}

    Has expensive (gt 100):{" "} - {__ctHelpers.ifElse(__ctHelpers.derive(state.items, _v1 => _v1.some((i) => i.price > 100)), "Yes", "No")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + items: state.items + } }, ({ state }) => state.items.some((i) => i.price > 100)), "Yes", "No")}

    Object Operations

    -
    _v1 > 0)} data-discount={state.discount}> +
    state.filter.length > 0)} data-discount={state.discount}> Object attributes
    ), diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx index dd5f7e585..05fe9f215 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx @@ -39,39 +39,65 @@ export default recipe({ {__ctHelpers.ifElse(state.hasPermission, "Authorized", "Denied")}

    Ternary with Comparisons

    - {__ctHelpers.ifElse(__ctHelpers.derive(state.count, _v1 => _v1 > 10), "High", "Low")} - {__ctHelpers.ifElse(__ctHelpers.derive(state.score, _v1 => _v1 >= 90), "A", __ctHelpers.derive(state.score, _v1 => _v1 >= 80 ? "B" : "C"))} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count > 10), "High", "Low")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + score: state.score + } }, ({ state }) => state.score >= 90), "A", __ctHelpers.derive({ state: { + score: state.score + } }, ({ state }) => state.score >= 80 ? "B" : "C"))} - {__ctHelpers.ifElse(__ctHelpers.derive(state.count, _v1 => _v1 === 0), "Empty", __ctHelpers.derive(state.count, _v1 => _v1 === 1 + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count === 0), "Empty", __ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count === 1 ? "Single" : "Multiple"))}

    Nested Ternary

    - {__ctHelpers.ifElse(state.isActive, __ctHelpers.derive(state.isPremium, _v1 => (_v1 ? "Premium Active" : "Regular Active")), "Inactive")} + {__ctHelpers.ifElse(state.isActive, __ctHelpers.derive({ state: { + isPremium: state.isPremium + } }, ({ state }) => (state.isPremium ? "Premium Active" : "Regular Active")), "Inactive")} - {__ctHelpers.ifElse(__ctHelpers.derive(state.userType, _v1 => _v1 === "admin"), "Admin", __ctHelpers.derive(state.userType, _v1 => _v1 === "user" + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + userType: state.userType + } }, ({ state }) => state.userType === "admin"), "Admin", __ctHelpers.derive({ state: { + userType: state.userType + } }, ({ state }) => state.userType === "user" ? "User" : "Guest"))}

    Complex Conditions

    - {__ctHelpers.ifElse(__ctHelpers.derive({ state_isActive: state.isActive, state_hasPermission: state.hasPermission }, ({ state_isActive: _v1, state_hasPermission: _v2 }) => _v1 && _v2), "Full Access", "Limited Access")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + isActive: state.isActive, + hasPermission: state.hasPermission + } }, ({ state }) => state.isActive && state.hasPermission), "Full Access", "Limited Access")} - {__ctHelpers.ifElse(__ctHelpers.derive(state.count, _v1 => _v1 > 0 && _v1 < 10), "In Range", "Out of Range")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count > 0 && state.count < 10), "In Range", "Out of Range")} - {__ctHelpers.ifElse(__ctHelpers.derive({ state_isPremium: state.isPremium, state_score: state.score }, ({ state_isPremium: _v1, state_score: _v2 }) => _v1 || _v2 > 100), "Premium Features", "Basic Features")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + isPremium: state.isPremium, + score: state.score + } }, ({ state }) => state.isPremium || state.score > 100), "Premium Features", "Basic Features")}

    IfElse Component

    {ifElse(state.isActive,
    User is active with {state.count} items
    ,
    User is inactive
    )} - {ifElse(__ctHelpers.derive(state.count, _v1 => _v1 > 5),
      + {ifElse(__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => state.count > 5),
      • Many items: {state.count}
      ,

      Few items: {state.count}

      )} ), diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx index d9b15d828..180253fb8 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx @@ -42,40 +42,91 @@ export default recipe({ return { [UI]: (

      Math Functions

      -

      Max: {__ctHelpers.derive({ state_a: state.a, state_b: state.b }, ({ state_a: _v1, state_b: _v2 }) => Math.max(_v1, _v2))}

      -

      Min: {__ctHelpers.derive(state.a, _v1 => Math.min(_v1, 10))}

      -

      Abs: {__ctHelpers.derive({ state_a: state.a, state_b: state.b }, ({ state_a: _v1, state_b: _v2 }) => Math.abs(_v1 - _v2))}

      -

      Round: {__ctHelpers.derive(state.price, _v1 => Math.round(_v1))}

      -

      Floor: {__ctHelpers.derive(state.price, _v1 => Math.floor(_v1))}

      -

      Ceiling: {__ctHelpers.derive(state.price, _v1 => Math.ceil(_v1))}

      -

      Square root: {__ctHelpers.derive(state.a, _v1 => Math.sqrt(_v1))}

      +

      Max: {__ctHelpers.derive({ state: { + a: state.a, + b: state.b + } }, ({ state }) => Math.max(state.a, state.b))}

      +

      Min: {__ctHelpers.derive({ state: { + a: state.a + } }, ({ state }) => Math.min(state.a, 10))}

      +

      Abs: {__ctHelpers.derive({ state: { + a: state.a, + b: state.b + } }, ({ state }) => Math.abs(state.a - state.b))}

      +

      Round: {__ctHelpers.derive({ state: { + price: state.price + } }, ({ state }) => Math.round(state.price))}

      +

      Floor: {__ctHelpers.derive({ state: { + price: state.price + } }, ({ state }) => Math.floor(state.price))}

      +

      Ceiling: {__ctHelpers.derive({ state: { + price: state.price + } }, ({ state }) => Math.ceil(state.price))}

      +

      Square root: {__ctHelpers.derive({ state: { + a: state.a + } }, ({ state }) => Math.sqrt(state.a))}

      String Methods as Function Calls

      -

      Uppercase: {__ctHelpers.derive(state.name, _v1 => _v1.toUpperCase())}

      -

      Lowercase: {__ctHelpers.derive(state.name, _v1 => _v1.toLowerCase())}

      -

      Substring: {__ctHelpers.derive(state.text, _v1 => _v1.substring(0, 5))}

      -

      Replace: {__ctHelpers.derive(state.text, _v1 => _v1.replace("old", "new"))}

      -

      Includes: {__ctHelpers.ifElse(__ctHelpers.derive(state.text, _v1 => _v1.includes("test")), "Yes", "No")}

      -

      Starts with: {__ctHelpers.ifElse(__ctHelpers.derive(state.name, _v1 => _v1.startsWith("A")), "Yes", "No")}

      +

      Uppercase: {__ctHelpers.derive({ state: { + name: state.name + } }, ({ state }) => state.name.toUpperCase())}

      +

      Lowercase: {__ctHelpers.derive({ state: { + name: state.name + } }, ({ state }) => state.name.toLowerCase())}

      +

      Substring: {__ctHelpers.derive({ state: { + text: state.text + } }, ({ state }) => state.text.substring(0, 5))}

      +

      Replace: {__ctHelpers.derive({ state: { + text: state.text + } }, ({ state }) => state.text.replace("old", "new"))}

      +

      Includes: {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + text: state.text + } }, ({ state }) => state.text.includes("test")), "Yes", "No")}

      +

      Starts with: {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + name: state.name + } }, ({ state }) => state.name.startsWith("A")), "Yes", "No")}

      Number Methods

      -

      To Fixed: {__ctHelpers.derive(state.price, _v1 => _v1.toFixed(2))}

      -

      To Precision: {__ctHelpers.derive(state.price, _v1 => _v1.toPrecision(4))}

      +

      To Fixed: {__ctHelpers.derive({ state: { + price: state.price + } }, ({ state }) => state.price.toFixed(2))}

      +

      To Precision: {__ctHelpers.derive({ state: { + price: state.price + } }, ({ state }) => state.price.toPrecision(4))}

      Parse Functions

      -

      Parse Int: {__ctHelpers.derive(state.float, _v1 => parseInt(_v1))}

      -

      Parse Float: {__ctHelpers.derive(state.float, _v1 => parseFloat(_v1))}

      +

      Parse Int: {__ctHelpers.derive({ state: { + float: state.float + } }, ({ state }) => parseInt(state.float))}

      +

      Parse Float: {__ctHelpers.derive({ state: { + float: state.float + } }, ({ state }) => parseFloat(state.float))}

      Array Method Calls

      -

      Sum: {__ctHelpers.derive(state.values, _v1 => _v1.reduce((a, b) => a + b, 0))}

      -

      Max value: {__ctHelpers.derive(state.values, _v1 => Math.max(..._v1))}

      -

      Joined: {__ctHelpers.derive(state.values, _v1 => _v1.join(", "))}

      +

      Sum: {__ctHelpers.derive({ state: { + values: state.values + } }, ({ state }) => state.values.reduce((a, b) => a + b, 0))}

      +

      Max value: {__ctHelpers.derive({ state: { + values: state.values + } }, ({ state }) => Math.max(...state.values))}

      +

      Joined: {__ctHelpers.derive({ state: { + values: state.values + } }, ({ state }) => state.values.join(", "))}

      Complex Function Calls

      -

      Multiple args: {__ctHelpers.derive(state.a, _v1 => Math.pow(_v1, 2))}

      -

      Nested calls: {__ctHelpers.derive(state.a, _v1 => Math.round(Math.sqrt(_v1)))}

      -

      Chained calls: {__ctHelpers.derive(state.name, _v1 => _v1.trim().toUpperCase())}

      -

      With expressions: {__ctHelpers.derive({ state_a: state.a, state_b: state.b }, ({ state_a: _v1, state_b: _v2 }) => Math.max(_v1 + 1, _v2 * 2))}

      +

      Multiple args: {__ctHelpers.derive({ state: { + a: state.a + } }, ({ state }) => Math.pow(state.a, 2))}

      +

      Nested calls: {__ctHelpers.derive({ state: { + a: state.a + } }, ({ state }) => Math.round(Math.sqrt(state.a)))}

      +

      Chained calls: {__ctHelpers.derive({ state: { + name: state.name + } }, ({ state }) => state.name.trim().toUpperCase())}

      +

      With expressions: {__ctHelpers.derive({ state: { + a: state.a, + b: state.b + } }, ({ state }) => Math.max(state.a + 1, state.b * 2))}

      ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx index 05a876e9c..1293d014a 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx @@ -150,24 +150,52 @@ export default recipe({

      Property Access with Operations

      -

      Age + 1: {__ctHelpers.derive(state.user.age, _v1 => _v1 + 1)}

      +

      Age + 1: {__ctHelpers.derive({ state: { + user: { + age: state.user.age + } + } }, ({ state }) => state.user.age + 1)}

      Name length: {state.user.name.length}

      -

      Uppercase name: {__ctHelpers.derive(state.user.name, _v1 => _v1.toUpperCase())}

      +

      Uppercase name: {__ctHelpers.derive({ state: { + user: { + name: state.user.name + } + } }, ({ state }) => state.user.name.toUpperCase())}

      Location includes city:{" "} - {__ctHelpers.ifElse(__ctHelpers.derive(state.user.profile.location, _v1 => _v1.includes("City")), "Yes", "No")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + user: { + profile: { + location: state.user.profile.location + } + } + } }, ({ state }) => state.user.profile.location.includes("City")), "Yes", "No")}

      Array Element Access

      -

      Item at index: {__ctHelpers.derive({ state_items: state.items, state_index: state.index }, ({ state_items: _v1, state_index: _v2 }) => _v1[_v2])}

      +

      Item at index: {__ctHelpers.derive({ state: { + items: state.items, + index: state.index + } }, ({ state }) => state.items[state.index])}

      First item: {state.items[0]}

      -

      Last item: {__ctHelpers.derive({ state_items: state.items, state_items_length: state.items.length }, ({ state_items: _v1, state_items_length: _v2 }) => _v1[_v2 - 1])}

      -

      Number at index: {__ctHelpers.derive({ state_numbers: state.numbers, state_index: state.index }, ({ state_numbers: _v1, state_index: _v2 }) => _v1[_v2])}

      +

      Last item: {__ctHelpers.derive({ state: { + items: state.items + } }, ({ state }) => state.items[state.items.length - 1])}

      +

      Number at index: {__ctHelpers.derive({ state: { + numbers: state.numbers, + index: state.index + } }, ({ state }) => state.numbers[state.index])}

      Config Access with Styles

      _v1 + "px"), + fontSize: __ctHelpers.derive({ state: { + config: { + theme: { + fontSize: state.config.theme.fontSize + } + } + } }, ({ state }) => state.config.theme.fontSize + "px"), }}> Styled text

      @@ -179,11 +207,31 @@ export default recipe({

      Complex Property Chains

      -

      {__ctHelpers.derive({ state_user_name: state.user.name, state_user_profile_location: state.user.profile.location }, ({ state_user_name: _v1, state_user_profile_location: _v2 }) => _v1 + " from " + _v2)}

      -

      Font size + 2: {__ctHelpers.derive(state.config.theme.fontSize, _v1 => _v1 + 2)}px

      +

      {__ctHelpers.derive({ state: { + user: { + name: state.user.name, + profile: { + location: state.user.profile.location + } + } + } }, ({ state }) => state.user.name + " from " + state.user.profile.location)}

      +

      Font size + 2: {__ctHelpers.derive({ state: { + config: { + theme: { + fontSize: state.config.theme.fontSize + } + } + } }, ({ state }) => state.config.theme.fontSize + 2)}px

      Has beta and dark mode:{" "} - {__ctHelpers.ifElse(__ctHelpers.derive({ state_config_features_beta: state.config.features.beta, state_config_features_darkMode: state.config.features.darkMode }, ({ state_config_features_beta: _v1, state_config_features_darkMode: _v2 }) => _v1 && _v2), "Yes", "No")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + config: { + features: { + beta: state.config.features.beta, + darkMode: state.config.features.darkMode + } + } + } }, ({ state }) => state.config.features.beta && state.config.features.darkMode), "Yes", "No")}

      ), }; diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx index 43b137db0..cff6a43e4 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx @@ -31,25 +31,57 @@ export default recipe({ return { [UI]: (

      String Concatenation

      -

      {__ctHelpers.derive({ state_title: state.title, state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_title: _v1, state_firstName: _v2, state_lastName: _v3 }) => _v1 + ": " + _v2 + " " + _v3)}

      -

      {__ctHelpers.derive({ state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_firstName: _v1, state_lastName: _v2 }) => _v1 + _v2)}

      -

      {__ctHelpers.derive(state.firstName, _v1 => "Hello, " + _v1 + "!")}

      +

      {__ctHelpers.derive({ state: { + title: state.title, + firstName: state.firstName, + lastName: state.lastName + } }, ({ state }) => state.title + ": " + state.firstName + " " + state.lastName)}

      +

      {__ctHelpers.derive({ state: { + firstName: state.firstName, + lastName: state.lastName + } }, ({ state }) => state.firstName + state.lastName)}

      +

      {__ctHelpers.derive({ state: { + firstName: state.firstName + } }, ({ state }) => "Hello, " + state.firstName + "!")}

      Template Literals

      -

      {__ctHelpers.derive(state.firstName, _v1 => `Welcome, ${_v1}!`)}

      -

      {__ctHelpers.derive({ state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_firstName: _v1, state_lastName: _v2 }) => `Full name: ${_v1} ${_v2}`)}

      -

      {__ctHelpers.derive({ state_title: state.title, state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_title: _v1, state_firstName: _v2, state_lastName: _v3 }) => `${_v1}: ${_v2} ${_v3}`)}

      +

      {__ctHelpers.derive({ state: { + firstName: state.firstName + } }, ({ state }) => `Welcome, ${state.firstName}!`)}

      +

      {__ctHelpers.derive({ state: { + firstName: state.firstName, + lastName: state.lastName + } }, ({ state }) => `Full name: ${state.firstName} ${state.lastName}`)}

      +

      {__ctHelpers.derive({ state: { + title: state.title, + firstName: state.firstName, + lastName: state.lastName + } }, ({ state }) => `${state.title}: ${state.firstName} ${state.lastName}`)}

      String Methods

      -

      Uppercase: {__ctHelpers.derive(state.firstName, _v1 => _v1.toUpperCase())}

      -

      Lowercase: {__ctHelpers.derive(state.title, _v1 => _v1.toLowerCase())}

      +

      Uppercase: {__ctHelpers.derive({ state: { + firstName: state.firstName + } }, ({ state }) => state.firstName.toUpperCase())}

      +

      Lowercase: {__ctHelpers.derive({ state: { + title: state.title + } }, ({ state }) => state.title.toLowerCase())}

      Length: {state.message.length}

      -

      Substring: {__ctHelpers.derive(state.message, _v1 => _v1.substring(0, 5))}

      +

      Substring: {__ctHelpers.derive({ state: { + message: state.message + } }, ({ state }) => state.message.substring(0, 5))}

      Mixed String and Number

      -

      {__ctHelpers.derive({ state_firstName: state.firstName, state_count: state.count }, ({ state_firstName: _v1, state_count: _v2 }) => _v1 + " has " + _v2 + " items")}

      -

      {__ctHelpers.derive({ state_firstName: state.firstName, state_count: state.count }, ({ state_firstName: _v1, state_count: _v2 }) => `${_v1} has ${_v2} items`)}

      -

      Count as string: {__ctHelpers.derive(state.count, _v1 => "Count: " + _v1)}

      +

      {__ctHelpers.derive({ state: { + firstName: state.firstName, + count: state.count + } }, ({ state }) => state.firstName + " has " + state.count + " items")}

      +

      {__ctHelpers.derive({ state: { + firstName: state.firstName, + count: state.count + } }, ({ state }) => `${state.firstName} has ${state.count} items`)}

      +

      Count as string: {__ctHelpers.derive({ state: { + count: state.count + } }, ({ state }) => "Count: " + state.count)}

      ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx index d527c2d25..e87b9d271 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx @@ -4,7 +4,7 @@ export default recipe("MapArrayLengthConditional", (_state) => { const list = cell(["apple", "banana", "cherry"]); return { [UI]: (
      - {__ctHelpers.derive(list, list => list.length > 0 && (
      + {__ctHelpers.derive(list, ({ list }) => list.length > 0 && (
      {list.map((name) => ({name}))}
      ))}
      ), 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 index 0ea3d4113..7fd00c176 100644 --- 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 @@ -5,9 +5,14 @@ export default recipe("MapNestedConditional", (_state) => { const showList = cell(true); return { [UI]: (
      - {__ctHelpers.derive({ showList, items }, ({ showList: showList, items: items }) => showList && (
      + {__ctHelpers.derive({ + showList: showList, + items: items + }, ({ showList, items }) => showList && (
      {items.map((item) => (
      - {__ctHelpers.derive(item.name, _v1 => _v1 && {_v1})} + {__ctHelpers.derive({ item: { + name: item.name + } }, ({ item }) => item.name && {item.name})}
      ))}
      ))}
      ), diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx index 261ad143c..986700838 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx @@ -7,7 +7,7 @@ export default recipe("MapSingleCapture", (_state) => { ]); return { [UI]: (
      - {__ctHelpers.derive(people, people => people.length > 0 && (
        + {__ctHelpers.derive(people, ({ people }) => people.length > 0 && (
          {people.map((person) => (
        • {person.name}
        • ))}
        ))}
      ), diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx index ed995d1bd..ecc93e204 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx @@ -107,30 +107,43 @@ export default recipe({ [UI]: (

      Chained String Methods

      {/* Simple chain */} -

      Trimmed lower: {__ctHelpers.derive(state.text, _v1 => _v1.trim().toLowerCase())}

      +

      Trimmed lower: {__ctHelpers.derive({ state: { + text: state.text + } }, ({ state }) => state.text.trim().toLowerCase())}

      {/* Chain with reactive argument */}

      Contains search:{" "} - {__ctHelpers.derive({ state_text: state.text, state_searchTerm: state.searchTerm }, ({ state_text: _v1, state_searchTerm: _v2 }) => _v1.toLowerCase().includes(_v2.toLowerCase()))} + {__ctHelpers.derive({ state: { + text: state.text, + searchTerm: state.searchTerm + } }, ({ state }) => state.text.toLowerCase().includes(state.searchTerm.toLowerCase()))}

      {/* Longer chain */}

      Processed:{" "} - {__ctHelpers.derive(state.text, _v1 => _v1.trim().toLowerCase().replace("old", "new").toUpperCase())} + {__ctHelpers.derive({ state: { + text: state.text + } }, ({ state }) => state.text.trim().toLowerCase().replace("old", "new").toUpperCase())}

      Array Method Chains

      {/* Filter then length */}

      Count above threshold:{" "} - {__ctHelpers.derive({ state_items: state.items, state_threshold: state.threshold }, ({ state_items: _v1, state_threshold: _v2 }) => _v1.filter((x) => x > _v2).length)} + {__ctHelpers.derive({ state: { + items: state.items, + threshold: state.threshold + } }, ({ state }) => state.items.filter((x) => x > state.threshold).length)}

      {/* Filter then map */}
        - {__ctHelpers.derive({ state_items: state.items, state_threshold: state.threshold }, ({ state_items: _v1, state_threshold: _v2 }) => _v1.filter((x) => x > _v2)).mapWithPattern(__ctHelpers.recipe({ + {__ctHelpers.derive({ state: { + items: state.items, + threshold: state.threshold + } }, ({ state }) => state.items.filter((x) => x > state.threshold)).mapWithPattern(__ctHelpers.recipe({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { @@ -141,39 +154,68 @@ export default recipe({ params: { type: "object", properties: { - factor: { - type: "number", - asOpaque: true + state: { + type: "object", + properties: { + factor: { + type: "number", + asOpaque: true + } + }, + required: ["factor"] } }, - required: ["factor"] + required: ["state"] } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: { factor } }) => (
      • Value: {__ctHelpers.derive({ element, factor }, ({ element: element, factor: factor }) => element * factor)}
      • )), { factor: state.factor })} + } as const satisfies __ctHelpers.JSONSchema, ({ element: x, params: { state } }) => (
      • Value: {__ctHelpers.derive({ + x: x, + state: { + factor: state.factor + } + }, ({ x, state }) => x * state.factor)}
      • )), { + state: { + factor: state.factor + } + })}
      {/* Multiple filters */}

      Double filter count:{" "} - {__ctHelpers.derive({ state_items: state.items, state_start: state.start, state_end: state.end }, ({ state_items: _v1, state_start: _v2, state_end: _v3 }) => _v1.filter((x) => x > _v2).filter((x) => x < _v3).length)} + {__ctHelpers.derive({ state: { + items: state.items, + start: state.start, + end: state.end + } }, ({ state }) => state.items.filter((x) => x > state.start).filter((x) => x < state.end).length)}

      Methods with Reactive Arguments

      {/* Slice with reactive indices */}

      - Sliced items: {__ctHelpers.derive({ state_items: state.items, state_start: state.start, state_end: state.end }, ({ state_items: _v1, state_start: _v2, state_end: _v3 }) => _v1.slice(_v2, _v3).join(", "))} + Sliced items: {__ctHelpers.derive({ state: { + items: state.items, + start: state.start, + end: state.end + } }, ({ state }) => state.items.slice(state.start, state.end).join(", "))}

      {/* String methods with reactive args */}

      Starts with:{" "} - {__ctHelpers.derive({ state_names: state.names, state_prefix: state.prefix }, ({ state_names: _v1, state_prefix: _v2 }) => _v1.filter((n) => n.startsWith(_v2)).join(", "))} + {__ctHelpers.derive({ state: { + names: state.names, + prefix: state.prefix + } }, ({ state }) => state.names.filter((n) => n.startsWith(state.prefix)).join(", "))}

      {/* Array find with reactive predicate */}

      - First match: {__ctHelpers.derive({ state_names: state.names, state_searchTerm: state.searchTerm }, ({ state_names: _v1, state_searchTerm: _v2 }) => _v1.find((n) => n.includes(_v2)))} + First match: {__ctHelpers.derive({ state: { + names: state.names, + searchTerm: state.searchTerm + } }, ({ state }) => state.names.find((n) => n.includes(state.searchTerm)))}

      Complex Method Combinations

      @@ -192,36 +234,52 @@ export default recipe({ } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (
    • {__ctHelpers.derive(element, element => element.trim().toLowerCase().replace(" ", "-"))}
    • )), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element: name, params: {} }) => (
    • {__ctHelpers.derive(name, ({ name }) => name.trim().toLowerCase().replace(" ", "-"))}
    • )), {})}
    {/* Reduce with reactive accumulator */}

    - Total with discount: {__ctHelpers.derive({ state_prices: state.prices, state_discount: state.discount }, ({ state_prices: _v1, state_discount: _v2 }) => _v1.reduce((sum, price) => sum + price * (1 - _v2), 0))} + Total with discount: {__ctHelpers.derive({ state: { + prices: state.prices, + discount: state.discount + } }, ({ state }) => state.prices.reduce((sum, price) => sum + price * (1 - state.discount), 0))}

    {/* Method result used in computation */}

    Average * factor:{" "} - {__ctHelpers.derive({ state_items: state.items, state_items_length: state.items.length, state_factor: state.factor }, ({ state_items: _v1, state_items_length: _v2, state_factor: _v3 }) => (_v1.reduce((a, b) => a + b, 0) / _v2) * _v3)} + {__ctHelpers.derive({ state: { + items: state.items, + factor: state.factor + } }, ({ state }) => (state.items.reduce((a, b) => a + b, 0) / state.items.length) * + state.factor)}

    Methods on Computed Values

    {/* Method on binary expression result */}

    - Formatted price: {__ctHelpers.derive({ state_prices: state.prices, state_discount: state.discount }, ({ state_prices: _v1, state_discount: _v2 }) => (_v1[0] * (1 - _v2)).toFixed(2))} + Formatted price: {__ctHelpers.derive({ state: { + prices: state.prices, + discount: state.discount + } }, ({ state }) => (state.prices[0] * (1 - state.discount)).toFixed(2))}

    {/* Method on conditional result */}

    Conditional trim:{" "} - {__ctHelpers.derive({ state_text: state.text, state_text_length: state.text.length, state_prefix: state.prefix }, ({ state_text: _v1, state_text_length: _v2, state_prefix: _v3 }) => (_v2 > 10 ? _v1 : _v3).trim())} + {__ctHelpers.derive({ state: { + text: state.text, + prefix: state.prefix + } }, ({ state }) => (state.text.length > 10 ? state.text : state.prefix).trim())}

    {/* Method chain on computed value */}

    Complex:{" "} - {__ctHelpers.derive({ state_text: state.text, state_prefix: state.prefix }, ({ state_text: _v1, state_prefix: _v2 }) => (_v1 + " " + _v2).trim().toLowerCase().split(" ") + {__ctHelpers.derive({ state: { + text: state.text, + prefix: state.prefix + } }, ({ state }) => (state.text + " " + state.prefix).trim().toLowerCase().split(" ") .join("-"))}

    @@ -229,7 +287,10 @@ export default recipe({ {/* Filter with multiple conditions */}

    Active adults:{" "} - {__ctHelpers.derive({ state_users: state.users, state_minAge: state.minAge }, ({ state_users: _v1, state_minAge: _v2 }) => _v1.filter((u) => u.age >= _v2 && u.active).length)} + {__ctHelpers.derive({ state: { + users: state.users, + minAge: state.minAge + } }, ({ state }) => state.users.filter((u) => u.age >= state.minAge && u.active).length)}

    {/* Map with conditional logic */} @@ -259,29 +320,47 @@ export default recipe({ } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => (
  • {__ctHelpers.ifElse(element.active, __ctHelpers.derive(element.name, _v1 => _v1.toUpperCase()), __ctHelpers.derive(element.name, _v1 => _v1.toLowerCase()))}
  • )), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element: u, params: {} }) => (
  • {__ctHelpers.ifElse(u.active, __ctHelpers.derive({ u: { + name: u.name + } }, ({ u }) => u.name.toUpperCase()), __ctHelpers.derive({ u: { + name: u.name + } }, ({ u }) => u.name.toLowerCase()))}
  • )), {})} {/* Some/every with reactive predicates */}

    Has adults:{" "} - {__ctHelpers.ifElse(__ctHelpers.derive({ state_users: state.users, state_minAge: state.minAge }, ({ state_users: _v1, state_minAge: _v2 }) => _v1.some((u) => u.age >= _v2)), "Yes", "No")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + users: state.users, + minAge: state.minAge + } }, ({ state }) => state.users.some((u) => u.age >= state.minAge)), "Yes", "No")}

    -

    All active: {__ctHelpers.ifElse(__ctHelpers.derive(state.users, _v1 => _v1.every((u) => u.active)), "Yes", "No")}

    +

    All active: {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + users: state.users + } }, ({ state }) => state.users.every((u) => u.active)), "Yes", "No")}

    Method Calls in Expressions

    {/* Method result in arithmetic */}

    - Length sum: {__ctHelpers.derive({ state_text: state.text, state_prefix: state.prefix }, ({ state_text: _v1, state_prefix: _v2 }) => _v1.trim().length + _v2.trim().length)} + Length sum: {__ctHelpers.derive({ state: { + text: state.text, + prefix: state.prefix + } }, ({ state }) => state.text.trim().length + state.prefix.trim().length)}

    {/* Method result in comparison */}

    - Is long: {__ctHelpers.ifElse(__ctHelpers.derive({ state_text: state.text, state_threshold: state.threshold }, ({ state_text: _v1, state_threshold: _v2 }) => _v1.trim().length > _v2), "Yes", "No")} + Is long: {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + text: state.text, + threshold: state.threshold + } }, ({ state }) => state.text.trim().length > state.threshold), "Yes", "No")}

    {/* Multiple method results combined */} -

    Joined: {__ctHelpers.derive({ state_words: state.words, state_separator: state.separator }, ({ state_words: _v1, state_separator: _v2 }) => _v1.join(_v2).toUpperCase())}

    +

    Joined: {__ctHelpers.derive({ state: { + words: state.words, + separator: state.separator + } }, ({ state }) => state.words.join(state.separator).toUpperCase())}

    ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx index a3d245604..a71d5cf49 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx @@ -100,12 +100,12 @@ export default recipe("Charms Launcher", () => { [NAME]: "Charms Launcher", [UI]: (

    Stored Charms:

    - {ifElse(__ctHelpers.derive(typedCellRef, typedCellRef => !typedCellRef?.length),
    No charms created yet
    ,
      + {ifElse(__ctHelpers.derive(typedCellRef, ({ typedCellRef }) => !typedCellRef?.length),
      No charms created yet
      ,
        {typedCellRef.map((charm: any, index: number) => (
      • - Go to Charm {__ctHelpers.derive(index, index => index + 1)} + Go to Charm {__ctHelpers.derive(index, ({ index }) => index + 1)} - Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(charm, charm => charm[NAME] || "Unnamed")} + Charm {__ctHelpers.derive(index, ({ index }) => index + 1)}: {__ctHelpers.derive(charm, ({ charm }) => charm[NAME] || "Unnamed")}
      • ))}
      )} diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx index 7932d069c..86433b132 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx @@ -6,9 +6,9 @@ export default recipe("OpaqueRefOperations", (_state) => { return { [UI]: (

      Count: {count}

      -

      Next: {__ctHelpers.derive(count, count => count + 1)}

      -

      Double: {__ctHelpers.derive(count, count => count * 2)}

      -

      Total: {__ctHelpers.derive(price, price => price * 1.1)}

      +

      Next: {__ctHelpers.derive(count, ({ count }) => count + 1)}

      +

      Double: {__ctHelpers.derive(count, ({ count }) => count * 2)}

      +

      Total: {__ctHelpers.derive(price, ({ price }) => price * 1.1)}

      ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-captures.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-captures.expected.tsx new file mode 100644 index 000000000..165cd0709 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-captures.expected.tsx @@ -0,0 +1,95 @@ +import * as __ctHelpers from "commontools"; +import { recipe, UI } from "commontools"; +interface Item { + maybe?: { + value: number; + }; +} +interface State { + maybe?: { + value: number; + }; + items: Item[]; +} +export default recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + maybe: { + type: "object", + properties: { + value: { + type: "number" + } + }, + required: ["value"] + }, + items: { + type: "array", + items: { + $ref: "#/$defs/Item" + } + } + }, + required: ["items"], + $defs: { + Item: { + type: "object", + properties: { + maybe: { + type: "object", + properties: { + value: { + type: "number" + } + }, + required: ["value"] + } + } + } + } +} as const satisfies __ctHelpers.JSONSchema, (state) => { + return { + [UI]: (
      + {state.maybe?.value} + {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: { + maybe: { + type: "object", + properties: { + value: { + type: "number" + } + }, + required: ["value"] + } + } + } + } + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: {} }) => ({__ctHelpers.derive({ item: { + maybe: { + value: item.maybe?.value + } + } }, ({ item }) => item.maybe?.value ?? 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/jsx-expressions/optional-chain-captures.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-captures.input.tsx new file mode 100644 index 000000000..96908699b --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-captures.input.tsx @@ -0,0 +1,24 @@ +/// +import { recipe, UI } from "commontools"; + +interface Item { + maybe?: { value: number }; +} + +interface State { + maybe?: { value: number }; + items: Item[]; +} + +export default recipe("OptionalChainCaptures", (state) => { + return { + [UI]: ( +
      + {state.maybe?.value} + {state.items.map((item) => ( + {item.maybe?.value ?? 0} + ))} +
      + ), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx index 1f1dcdedc..87c666ad2 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx @@ -5,7 +5,7 @@ export default recipe("Optional Chain Predicate", () => { return { [NAME]: "Optional chain predicate", [UI]: (
      - {__ctHelpers.derive(items, items => !items?.length && No items)} + {__ctHelpers.derive(items, ({ items }) => !items?.length && No items)}
      ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx index a2cbf9529..5af9047cf 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx @@ -5,7 +5,7 @@ export default recipe("Optional Element Access", () => { return { [NAME]: "Optional element access", [UI]: (
      - {__ctHelpers.derive(list, list => !list?.[0] && No first entry)} + {__ctHelpers.derive(list, ({ list }) => !list?.[0] && No first entry)}
      ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.expected.tsx index 39666115f..0e2387ebd 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.expected.tsx @@ -327,13 +327,30 @@ export default recipe({ {/* String concatenation with multiple property accesses */}

      Full profile:{" "} - {__ctHelpers.derive({ state_user_name: state.user.name, state_user_profile_location: state.user.profile.location, state_user_profile_bio: state.user.profile.bio }, ({ state_user_name: _v1, state_user_profile_location: _v2, state_user_profile_bio: _v3 }) => _v1 + " from " + _v2 + " - " + _v3)} + {__ctHelpers.derive({ state: { + user: { + name: state.user.name, + profile: { + location: state.user.profile.location, + bio: state.user.profile.bio + } + } + } }, ({ state }) => state.user.name + " from " + state.user.profile.location + " - " + + state.user.profile.bio)}

      {/* Arithmetic with multiple properties from same base */}

      - Age calculation: {__ctHelpers.derive(state.user.age, _v1 => _v1 * 12)} months, or{" "} - {__ctHelpers.derive(state.user.age, _v1 => _v1 * 365)} days + Age calculation: {__ctHelpers.derive({ state: { + user: { + age: state.user.age + } + } }, ({ state }) => state.user.age * 12)} months, or{" "} + {__ctHelpers.derive({ state: { + user: { + age: state.user.age + } + } }, ({ state }) => state.user.age * 365)} days

      Deeply Nested Property Chains

      @@ -398,25 +415,64 @@ export default recipe({

      Complex Expressions with Shared Bases

      {/* Conditional with multiple property accesses */}

      - Status: {__ctHelpers.ifElse(state.user.settings.notifications, __ctHelpers.derive({ state_user_name: state.user.name, state_user_settings_theme: state.user.settings.theme }, ({ state_user_name: _v1, state_user_settings_theme: _v2 }) => _v1 + " has notifications on with " + _v2 + " theme"), __ctHelpers.derive(state.user.name, _v1 => _v1 + " has notifications off"))} + Status: {__ctHelpers.ifElse(state.user.settings.notifications, __ctHelpers.derive({ state: { + user: { + name: state.user.name, + settings: { + theme: state.user.settings.theme + } + } + } }, ({ state }) => state.user.name + " has notifications on with " + + state.user.settings.theme + " theme"), __ctHelpers.derive({ state: { + user: { + name: state.user.name + } + } }, ({ state }) => state.user.name + " has notifications off"))}

      {/* Computed expression with shared base */}

      - Spacing calc: {__ctHelpers.derive({ state_config_theme_spacing_small: state.config.theme.spacing.small, state_config_theme_spacing_medium: state.config.theme.spacing.medium, state_config_theme_spacing_large: state.config.theme.spacing.large }, ({ state_config_theme_spacing_small: _v1, state_config_theme_spacing_medium: _v2, state_config_theme_spacing_large: _v3 }) => _v1 + _v2 + _v3)} total + Spacing calc: {__ctHelpers.derive({ state: { + config: { + theme: { + spacing: { + small: state.config.theme.spacing.small, + medium: state.config.theme.spacing.medium, + large: state.config.theme.spacing.large + } + } + } + } }, ({ state }) => state.config.theme.spacing.small + + state.config.theme.spacing.medium + + state.config.theme.spacing.large)} total

      {/* Boolean expressions with multiple properties */}

      Features:{" "} - {__ctHelpers.ifElse(__ctHelpers.derive({ state_config_features_darkMode: state.config.features.darkMode, state_config_features_animations: state.config.features.animations }, ({ state_config_features_darkMode: _v1, state_config_features_animations: _v2 }) => _v1 && _v2), "Full features", "Limited features")} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + config: { + features: { + darkMode: state.config.features.darkMode, + animations: state.config.features.animations + } + } + } }, ({ state }) => state.config.features.darkMode && state.config.features.animations), "Full features", "Limited features")}

      Method Calls on Shared Bases

      {/* Multiple method calls on properties from same base */}

      - Formatted: {__ctHelpers.derive(state.user.name, _v1 => _v1.toUpperCase())} -{" "} - {__ctHelpers.derive(state.user.email, _v1 => _v1.toLowerCase())} + Formatted: {__ctHelpers.derive({ state: { + user: { + name: state.user.name + } + } }, ({ state }) => state.user.name.toUpperCase())} -{" "} + {__ctHelpers.derive({ state: { + user: { + email: state.user.email + } + } }, ({ state }) => state.user.email.toLowerCase())}

      {/* Property access and method calls mixed */} diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx index dedec078f..72c61710c 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx @@ -58,13 +58,21 @@ export default recipe({ {/* These SHOULD be transformed (JSX expression context) */} Current: {state.value}
      - Next number: {__ctHelpers.derive(state.value, _v1 => _v1 + 1)} + Next number: {__ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value + 1)}
      - Previous: {__ctHelpers.derive(state.value, _v1 => _v1 - 1)} + Previous: {__ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value - 1)}
      - Doubled: {__ctHelpers.derive(state.value, _v1 => _v1 * 2)} + Doubled: {__ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value * 2)}
      - Status: {__ctHelpers.ifElse(__ctHelpers.derive(state.value, _v1 => _v1 > 10), "High", "Low")} + Status: {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value > 10), "High", "Low")}

      +
    ), diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx index 67a371c0a..54fdae1c1 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx @@ -12,8 +12,12 @@ export default recipe({ return { [UI]: (

    Current value: {cell.value}

    -

    Next value: {__ctHelpers.derive(cell.value, _v1 => _v1 + 1)}

    -

    Double: {__ctHelpers.derive(cell.value, _v1 => _v1 * 2)}

    +

    Next value: {__ctHelpers.derive({ cell: { + value: cell.value + } }, ({ cell }) => cell.value + 1)}

    +

    Double: {__ctHelpers.derive({ cell: { + value: cell.value + } }, ({ cell }) => cell.value * 2)}

    ), value: cell.value, }; diff --git a/packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.expected.ts b/packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.expected.ts index 4f468cbaa..8a738bc42 100644 --- a/packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.expected.ts +++ b/packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.expected.ts @@ -59,7 +59,7 @@ export default recipe({ required: ["title", "done"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => element.title), {}); + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: {} }) => item.title), {}); // This should also be transformed const filtered = items.mapWithPattern(__ctHelpers.recipe({ $schema: "https://json-schema.org/draft/2020-12/schema", @@ -91,9 +91,9 @@ export default recipe({ required: ["title", "done"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element, index, params: {} }) => ({ - title: element.title, - done: element.done, + } as const satisfies __ctHelpers.JSONSchema, ({ element: item, index: index, params: {} }) => ({ + title: item.title, + done: item.done, position: index, })), {}); return { mapped, filtered }; diff --git a/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts b/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts index 53510d5e5..3fabe3d33 100644 --- a/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts +++ b/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts @@ -52,26 +52,26 @@ describe("OpaqueRef map callbacks", () => { ); assertStringIncludes( output, - "({ element, index, params: { defaultName } }) =>", + "({ element: charm, index: index, params: { state } }) =>", ); assertStringIncludes( output, - "{ defaultName: state.defaultName }", + "state: {\n defaultName: state.defaultName\n }", ); // Index parameter still gets derive wrapping for the arithmetic operation assertStringIncludes( output, - "__ctHelpers.derive(index, index => index + 1)", + "__ctHelpers.derive(index, ({ index }) => index + 1)", ); // element[NAME] uses NAME from module scope (import), defaultName from params assertStringIncludes( output, - "element[NAME] || defaultName", + "({ charm, state }) => charm[NAME] || state.defaultName", ); - // ifElse still gets derive for the negation + // ifElse still gets derive for the negation and preserves callback body assertStringIncludes( output, - "ifElse(__ctHelpers.derive(state.charms.length, _v1 => !_v1)", + "({ state }) => !state.charms.length", ); }); }); diff --git a/packages/ts-transformers/test/utils/identifiers.test.ts b/packages/ts-transformers/test/utils/identifiers.test.ts new file mode 100644 index 000000000..94a57fb23 --- /dev/null +++ b/packages/ts-transformers/test/utils/identifiers.test.ts @@ -0,0 +1,8 @@ +import { assertEquals } from "@std/assert"; + +import { sanitizeIdentifierCandidate } from "../../src/utils/identifiers.ts"; + +Deno.test("sanitizeIdentifierCandidate normalises invalid fallback prefixes", () => { + const result = sanitizeIdentifierCandidate("", { fallback: "-ref" }); + assertEquals(result, "_ref"); +});