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");
+});