@kudzujs/core 0.8.19 → 0.8.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -216,6 +216,13 @@ This queue orders the next investigations by general migration value. Start only
216
216
  - Existing tagged collection expressions and selectors are canonical DerivedIR for rendered lists and derived effect dependencies; transformed source embeds the registered records rather than parallel analyzer values.
217
217
  - AST, `Map`, and `Set` callback descriptors remain private only until source-local lowering completes. Handler codegen now performs no TypeScript traversal or state/capture/reducer/import discovery, while command-only routes retain zero handler ESM.
218
218
 
219
+ ### Completed In 0.8.20
220
+
221
+ - Every rendered keyed collection now finalizes into JSON-safe KeyedBlockIR with deterministic slots, explicit parent/child links, collection and selector ownership, key/index policy, source provenance, and complete component-specialization membership.
222
+ - Keyed row states and refs retain their declaration source and specialization owner. Command, native, effect, list-expression, list-conditional, and calculated-collection records link back to their owning keyed block.
223
+ - Previous transformer-wide keyed value, condition, event, nested-list, effect, and rendered-list AST side tables were removed. AST remains private only inside immediate source-local validation and lowering.
224
+ - `core.mjs` remains authoritative for final list IDs, key paths, route descriptors, complete HTML, DOM identity, and exact state/effect/ref release; accepted syntax and browser output are unchanged.
225
+
219
226
  ## Cross-Cutting Performance Gates
220
227
 
221
228
  Every migration feature must preserve:
package/PERFORMANCE.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Performance Records
2
2
 
3
+ ## 0.8.20 Explicit Keyed Ownership
4
+
5
+ Measured UTC 2026-08-09 on Apple M3, 8 logical CPUs, 8 GiB RAM, macOS 26.5.2 / Darwin 25.5.0, Node 25.6.1, and npm 11.18.0. Baseline `0.8.19` commit `c516173` and the current `0.8.20` compiler candidate used the same local volume and identical installed dependencies.
6
+
7
+ The candidate implementation patch over `c516173` had SHA-256 `137f68090f6024374077f46cb61767f48dff27a193d33c9e92409b8dd7bb7d21`, produced by:
8
+
9
+ ```bash
10
+ git diff --binary c516173 -- framework/build.mjs framework/compiler/descriptor-session.mjs framework/compiler/ir/module-ir.mjs framework/core.d.ts | shasum -a 256
11
+ ```
12
+
13
+ Both targets received one warm-up followed by 21 clean `keyed-row-hooks` production builds in round-robin alternating order. Cleanup remained outside timing. The distributions overlap; the candidate median was 0.52% lower and does not establish a material change.
14
+
15
+ | Target | Build median | List runtime raw / gzip |
16
+ |---|---:|---:|
17
+ | 0.8.19 baseline | 266.8 ms | 21,831 B / 6,930 B |
18
+ | 0.8.20 candidate | 265.4 ms | 21,831 B / 6,930 B |
19
+
20
+ ```text
21
+ 0.8.19: [272.5,269.7,262.6,267.3,262.9,265.2,268.0,264.7,262.0,268.3,267.1,262.5,263.7,266.8,263.2,267.5,263.3,268.8,266.7,266.9,271.4]
22
+ 0.8.20: [262.5,267.4,266.2,261.7,265.1,262.9,269.9,264.1,275.0,268.2,263.4,263.7,265.4,268.7,263.8,266.1,265.4,265.3,267.2,270.1,263.2]
23
+ ```
24
+
25
+ The recorded cleanup, warm-up, timing, median, and runtime-size measurement is reproducible with:
26
+
27
+ ```bash
28
+ BASELINE_ROOT="/var/folders/bt/3r_ntp5x65j81brs6_p93rl00000gn/T/opencode/kudzu-0820-baseline" CANDIDATE_ROOT="/Users/songchibong/Documents/GitHub/kudzu" node --input-type=module -e 'import { spawnSync } from "node:child_process"; import { rmSync,readFileSync } from "node:fs"; import { gzipSync } from "node:zlib"; import { performance } from "node:perf_hooks"; import { resolve } from "node:path"; const roots={baseline:process.env.BASELINE_ROOT,candidate:process.env.CANDIDATE_ROOT}; const runs={baseline:[],candidate:[]}; const fixture="keyed-row-hooks"; const build=name=>{const root=roots[name],cwd=resolve(root,"test/fixtures",fixture); rmSync(resolve(cwd,"dist"),{recursive:true,force:true}); rmSync(resolve(cwd,".kudzu"),{recursive:true,force:true}); const start=performance.now(); const result=spawnSync(process.execPath,[resolve(root,"bin/kudzu.mjs"),"build"],{cwd,encoding:"utf8"}); if(result.status) throw new Error(result.stderr||result.stdout); return Number((performance.now()-start).toFixed(1));}; build("baseline"); build("candidate"); for(let index=0;index<21;index++) for(const name of index%2?["candidate","baseline"]:["baseline","candidate"]) runs[name].push(build(name)); const median=values=>[...values].sort((a,b)=>a-b)[Math.floor(values.length/2)]; const sizes={}; for(const [name,root] of Object.entries(roots)){const file=readFileSync(resolve(root,"test/fixtures",fixture,"dist/assets/kudzu-list.js")); sizes[name]=[file.length,gzipSync(file).length];} console.log(JSON.stringify({runs,medians:{baseline:median(runs.baseline),candidate:median(runs.candidate)},sizes}));'
29
+ ```
30
+
31
+ Before release-content updates, the complete site `dist` was byte-identical. Seven representative keyed fixtures retained identical file lists and SHA-256 content; `.kudzu` comparison replaced only each worktree's absolute root in existing source-location strings. The complete compared lists were:
32
+
33
+ ```text
34
+ lists: dist/assets/handlers/pages/index.js, dist/assets/kudzu-list.js, dist/assets/kudzu-native.js, dist/assets/kudzu-serialization.js, dist/assets/kudzu-style.js, dist/assets/kudzu.js, dist/assets/native/index.js, dist/index.html, .kudzu/kudzu-plan.json, .kudzu/pages/index.mjs
35
+ nested-lists: dist/assets/effects/index.js, dist/assets/handlers/pages/index.js, dist/assets/kudzu-effect.js, dist/assets/kudzu-list.js, dist/assets/kudzu-native.js, dist/assets/kudzu-serialization.js, dist/assets/kudzu.js, dist/assets/native/index.js, dist/index.html, .kudzu/kudzu-plan.json, .kudzu/pages/index.mjs
36
+ keyed-row-hooks: dist/assets/effects/index.js, dist/assets/handlers/pages/index.js, dist/assets/kudzu-binding.js, dist/assets/kudzu-effect.js, dist/assets/kudzu-list.js, dist/assets/kudzu-native.js, dist/assets/kudzu-serialization.js, dist/assets/kudzu-style.js, dist/assets/kudzu.js, dist/assets/native/index.js, dist/index.html, .kudzu/HookRow.mjs, .kudzu/kudzu-plan.json, .kudzu/pages/index.mjs
37
+ svg-structures: dist/assets/handlers/pages/index.js, dist/assets/kudzu-binding.js, dist/assets/kudzu-list.js, dist/assets/kudzu-native.js, dist/assets/kudzu-serialization.js, dist/assets/kudzu-style.js, dist/assets/kudzu.js, dist/assets/native/index.js, dist/index.html, .kudzu/kudzu-plan.json, .kudzu/pages/index.mjs
38
+ calculated-collections: dist/assets/handlers/pages/index.js, dist/assets/handlers/pages/ordinary.js, dist/assets/kudzu-binding.js, dist/assets/kudzu-list.js, dist/assets/kudzu-native.js, dist/assets/kudzu-serialization.js, dist/assets/kudzu-style.js, dist/assets/kudzu.js, dist/assets/native/index.js, dist/assets/native/ordinary/index.js, dist/index.html, dist/ordinary/index.html, dist/static/index.html, .kudzu/calculate.mjs, .kudzu/kudzu-plan.json, .kudzu/pages/index.mjs, .kudzu/pages/ordinary.mjs, .kudzu/pages/static.mjs
39
+ rendered-collections: dist/assets/handlers/pages/index.js, dist/assets/kudzu-list.js, dist/assets/kudzu-native.js, dist/assets/kudzu-serialization.js, dist/assets/kudzu.js, dist/assets/native/index.js, dist/index.html, .kudzu/kudzu-plan.json, .kudzu/pages/index.mjs, .kudzu/selectVisible.mjs
40
+ keyed-effects: dist/assets/effects/index.js, dist/assets/effects/item-only/index.js, dist/assets/effects/state-only/index.js, dist/assets/handlers/pages/index.js, dist/assets/handlers/pages/item-only.js, dist/assets/handlers/pages/state-only.js, dist/assets/kudzu-binding.js, dist/assets/kudzu-effect.js, dist/assets/kudzu-list.js, dist/assets/kudzu-native.js, dist/assets/kudzu-serialization.js, dist/assets/kudzu-style.js, dist/assets/kudzu.js, dist/assets/native/index.js, dist/index.html, dist/item-only/index.html, dist/state-only/index.html, .kudzu/EffectRow.mjs, .kudzu/kudzu-plan.json, .kudzu/pages/index.mjs, .kudzu/pages/item-only.mjs, .kudzu/pages/state-only.mjs
41
+ ```
42
+
43
+ This measurement covers compiler clean-build startup and generated list-runtime size, not browser reconciliation latency. Browser behavior remains covered by the existing insert/update/reorder/remove/nested/SVG/state/effect/ref integration checks.
44
+
3
45
  ## 0.8.19 Handler, Binding, And Derived IR
4
46
 
5
47
  Measured UTC 2026-08-08 on Apple M3, 8 logical CPUs, 8 GiB RAM, macOS 26.5.2 / Darwin 25.5.0, Node 25.6.1, and npm 11.18.0. Baseline `0.8.18` tag `3598be0` and the `0.8.19` compiler-only candidate used detached worktrees on the same temporary volume with identical installed dependencies.
package/README.md CHANGED
@@ -14,7 +14,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
14
14
 
15
15
  > Experimental `0.8.x`: the compiler API and supported TSX surface may change.
16
16
 
17
- **Latest release: 0.8.19 - Handler, binding, and derived IR.** Native callbacks, reactive bindings, list evaluators, imports, and pure derived expressions now cross one JSON-safe ModuleIR boundary before source-only artifact codegen. Read the [release notes](./RELEASES.md#0819---handler-binding-and-derived-ir), open the [release page](https://kudzujs.cloud/releases/0.8.19), or follow the [next architecture packet](./docs/next-architecture/README.md).
17
+ **Latest release: 0.8.20 - Explicit keyed ownership.** Keyed collections now retain deterministic JSON-safe parent/child, selector, component-specialization, row state/ref, handler, and binding ownership while final DOM allocation remains unchanged. Read the [release notes](./RELEASES.md#0820---explicit-keyed-ownership), open the [release page](https://kudzujs.cloud/releases/0.8.20), or follow the [next architecture packet](./docs/next-architecture/README.md).
18
18
 
19
19
  - [Documentation](https://kudzujs.cloud/docs)
20
20
  - [Installation guide](https://kudzujs.cloud/docs#install)
package/RELEASES.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.20 - Explicit keyed ownership
4
+
5
+ Kudzu 0.8.20 completes the next Goal A source-analysis boundary: keyed collection sites now finalize into deterministic JSON-safe ownership records before the existing build-time renderer allocates DOM identity and lifecycle state.
6
+
7
+ ### Changed in 0.8.20
8
+
9
+ - KeyedBlockIR records parent/child slots, collection signal or calculated binding, key/index policy, owner field, selector reference and states, source provenance, and static status.
10
+ - Recursive same-file and imported row expansion records every component-specialization slot, including stateless intermediate components.
11
+ - Row states and refs retain their specialization owner and declaration source.
12
+ - Command, native, effect, list-expression, list-conditional, and calculated-collection HandlerIR/BindingIR records link to their owning keyed block.
13
+ - Transformer-wide keyed value, condition, event, nested-list, effect, rendered-list, and alias AST side tables were removed; temporary AST stays inside immediate source-local validation and lowering.
14
+ - The existing route list descriptor now has an explicit TypeScript shape without changing its serialized data.
15
+
16
+ ### Goal A boundary
17
+
18
+ - `core.mjs` remains authoritative for final list IDs, row key paths, route descriptors, complete HTML, SVG context, state/ref/effect allocation, and exact release.
19
+ - Effect setup, cleanup, dependency, Worker, and lifetime ownership remains on the existing path until EffectIR in `0.8.21`.
20
+ - No accepted syntax, public API, browser capability, VDOM, hydration, component rerender, or retained browser component tree was added.
21
+
22
+ ### Validation
23
+
24
+ - The complete suite passes 168/168 tests, including deterministic KeyedBlockIR JSON round-trip and calculated, nested, recursive-component, selector, state, ref, handler, binding, effect, and SVG ownership.
25
+ - Before release-content updates, the complete site `dist` and seven representative keyed fixture output trees matched `v0.8.19`; detached-worktree roots were normalized only in existing `.kudzu` source strings.
26
+ - The keyed list runtime remains byte-identical at 21,831 B raw / 6,930 B gzip. A same-volume 21-run interleaved comparison measured a 0.52% lower candidate median; raw arrays and provenance are recorded in `PERFORMANCE.md`.
27
+ - `create-kudzu` remains 0.1.101 because its unchanged template already accepts `@kudzujs/core@^0.8.15`.
28
+
29
+ ### Upgrade
30
+
31
+ ```bash
32
+ npm install @kudzujs/core@^0.8.20
33
+ ```
34
+
3
35
  ## 0.8.19 - Handler, binding, and derived IR
4
36
 
5
37
  Kudzu 0.8.19 completes the next Goal A source-analysis boundary: native callbacks, reactive bindings, list evaluators, imports, and pure derived expressions now finalize into JSON-safe ModuleIR before mechanical artifact codegen.
@@ -6,7 +6,7 @@ The top-level [`GOAL_A.md`](../../GOAL_A.md) and [`GOAL_B.md`](../../GOAL_B.md)
6
6
 
7
7
  | Goal | Decision | Start condition |
8
8
  |---|---|---|
9
- | A: compiler foundation | `0.8.19` handler/binding/derived IR complete | Continue with `0.8.20` KeyedBlockIR ownership after the release boundary. |
9
+ | A: compiler foundation | `0.8.20` KeyedBlockIR ownership complete | Continue with `0.8.21` EffectIR ownership after the release boundary. |
10
10
  | B: optimization benchmarks | Deferred | Goal A complete and its output baseline recorded |
11
11
  | C: state/resource model | Research only | Reduced fixtures expose a limitation |
12
12
  | D: routing compatibility | Current behavior preserved | Revisit only with migration evidence and invariant review |
@@ -1,6 +1,6 @@
1
1
  # Current Compiler Architecture
2
2
 
3
- This maps the completed `0.8.19` handler, binding, and derived IR boundary. File and function names are the stable references; line numbers are intentionally omitted because Goal A moves code.
3
+ This maps the completed `0.8.20` keyed ownership boundary. File and function names are the stable references; line numbers are intentionally omitted because Goal A moves code.
4
4
 
5
5
  ## Responsibility Map
6
6
 
@@ -13,9 +13,9 @@ This maps the completed `0.8.19` handler, binding, and derived IR boundary. File
13
13
  | Focused normalization passes | [`framework/compiler/`](../../framework/compiler/) | React, Router, browser signals, animation-frame refs, custom-hook timers, Zustand, and render control each validate and lower a narrow source shape. |
14
14
  | Shared AST/scope helpers | [`framework/compiler/ast-helpers.mjs`](../../framework/compiler/ast-helpers.mjs) | Binding, scope, reference, effect-return, and source-location analysis. |
15
15
  | Pure collection language | [`framework/compiler/collection-analysis.mjs`](../../framework/compiler/collection-analysis.mjs) | Analyzes collection roots/selectors and serializes the allowed pure expression language used by lists and derived dependencies. |
16
- | Main semantic analysis | `framework/build.mjs`, `createKudzuTransformer()` | Produces transformed source plus explicit component, handler, binding, and derived results while still owning effect and keyed-list ownership side tables. |
16
+ | Main semantic analysis | `framework/build.mjs`, `createKudzuTransformer()` | Produces transformed source plus explicit component, handler, binding, derived, and keyed ownership results while still owning effect-lifetime side tables. |
17
17
  | Component ownership analysis | [`framework/compiler/analysis/component-analysis.mjs`](../../framework/compiler/analysis/component-analysis.mjs) | Retains ordered JSON-safe owner and specialization records for state, setters, props, refs, IDs, direct signal links, and source provenance; AST identity remains private to its source-local session. |
18
- | Per-source descriptor registration | [`framework/compiler/descriptor-session.mjs`](../../framework/compiler/descriptor-session.mjs), `createSemanticArtifact()`, `createDescriptorSession()` | Keeps AST descriptors private during analysis, then finalizes deterministic JSON-safe HandlerIR, BindingIR, DerivedIR, imports, and client roots into ModuleIR. |
18
+ | Per-source descriptor registration | [`framework/compiler/descriptor-session.mjs`](../../framework/compiler/descriptor-session.mjs), `createSemanticArtifact()`, `createDescriptorSession()` | Keeps AST descriptors private during analysis, then finalizes deterministic JSON-safe HandlerIR, BindingIR, DerivedIR, KeyedBlockIR, imports, and client roots into ModuleIR. |
19
19
  | Command IR and codegen | [`framework/compiler/optimize/command-specialization.mjs`](../../framework/compiler/optimize/command-specialization.mjs), [`framework/compiler/ir/module-ir.mjs`](../../framework/compiler/ir/module-ir.mjs), [`framework/compiler/codegen/command-codegen.mjs`](../../framework/compiler/codegen/command-codegen.mjs) | Supported command handlers specialize to JSON-safe ModuleIR, then emit the existing `__kBehavior` AST without changing route plans. |
20
20
  | Build module generation | `framework/build.mjs`, `compile()` | Runs TypeScript with the Kudzu transformer, writes build-executable modules to `.kudzu`, rejects surviving React/Router runtime references, and generates handler source when descriptors exist. |
21
21
  | Handler/evaluator lowering | [`framework/compiler/handler-lowering.mjs`](../../framework/compiler/handler-lowering.mjs) | Completes source-local callback/binding/list AST rewriting and diagnostics before the JSON-safe IR boundary. |
@@ -58,7 +58,7 @@ The browser consumes static HTML first. State seeds and descriptors in that HTML
58
58
  ## Current Coupling To Remove
59
59
 
60
60
  - `createKudzuTransformer()` combines discovery, validation, specialization, descriptor registration, and transformed-source emission.
61
- - Effect/keyed ownership relationships and transient component rewrite indexes remain in `WeakMap`/`WeakSet` tables keyed by AST identity; handler, binding, derived, and component ownership now have explicit JSON-safe source results.
61
+ - Effect ownership relationships and transient component rewrite indexes remain source-local AST indexes; handler, binding, derived, keyed, and component ownership now have explicit JSON-safe source results.
62
62
  - `build()` destructures a broad capability manifest into many booleans and performs artifact-specific source surgery.
63
63
  - Runtime specialization relies on exact source-string and regular-expression replacements in `framework/build.mjs`.
64
64
  - Route facts, rendered plans, artifact requirements, and emitted-file decisions are represented at adjacent but not fully explicit boundaries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Status
4
4
 
5
- The `0.8.19` HandlerIR, BindingIR, and DerivedIR seam is complete. Goal A continues with the planned `0.8.20` KeyedBlockIR ownership result while preserving accepted React-shaped syntax, diagnostics, complete HTML, emitted capability behavior, and current public APIs.
5
+ The `0.8.20` KeyedBlockIR ownership result is complete. Goal A continues with the planned `0.8.21` EffectIR seam while preserving accepted React-shaped syntax, diagnostics, complete HTML, emitted capability behavior, and current public APIs.
6
6
 
7
7
  ## Target Boundaries
8
8
 
@@ -77,7 +77,7 @@ Analysis produces plain data:
77
77
  }
78
78
  ```
79
79
 
80
- The `0.8.19` source result adds module-export HandlerIR, BindingIR, ordered imports/client roots, and canonical tagged DerivedIR beside command and component ownership. Source-local AST lowering completes before this boundary; artifact codegen consumes finalized records without semantic traversal. Initial values, final state IDs, effect ownership, and keyed blocks join their explicit results in later planned patches; `core.mjs` remains authoritative for route allocation today.
80
+ The `0.8.20` source result adds deterministic KeyedBlockIR parent/child ownership, collection and selector references, complete specialization membership, row state/ref provenance, and keyed HandlerIR/BindingIR links beside the existing command, component, handler, binding, and derived results. Source-local keyed AST validation is consumed during lowering and does not cross this boundary. Initial values, final state IDs, and effect lifetime ownership join their explicit results in later planned patches; `core.mjs` remains authoritative for route allocation today.
81
81
 
82
82
  Source codegen lowers that data through the existing build ABI:
83
83
 
@@ -1,6 +1,6 @@
1
1
  # Planned Version Sequence
2
2
 
3
- This is an execution sequence, not release history. `0.8.16` through `0.8.19` are completed scopes represented by package/release records; later rows remain planned until their package version and release record exist.
3
+ This is an execution sequence, not release history. `0.8.16` through `0.8.20` are completed scopes represented by package/release records; later rows remain planned until their package version and release record exist.
4
4
 
5
5
  Keep each patch behavior-preserving and independently reviewable. If a boundary proves inseparable, revise this plan before combining releases; do not silently broaden a patch.
6
6
 
@@ -36,12 +36,12 @@ Reduced Zustand migration stores lower to one ordinary layout-lifetime state slo
36
36
  - `compiler/animation-frame-pass.mjs`: effect-owned animation-frame ref validation and plain mutable effect-scope lowering.
37
37
  - `compiler/browser-signal-passes.mjs`: static media-query external-store and navigator capability-condition normalization.
38
38
  - `compiler/collection-analysis.mjs`: pure collection expression IR, selector pipeline, alias, and imported-transform analysis.
39
- - `compiler/descriptor-session.mjs`: private source-local descriptor registration and deterministic JSON-safe HandlerIR, BindingIR, DerivedIR, import, and client-root finalization.
39
+ - `compiler/descriptor-session.mjs`: private source-local descriptor registration and deterministic JSON-safe HandlerIR, BindingIR, DerivedIR, KeyedBlockIR, import, and client-root finalization.
40
40
  - `compiler/normalization-pipeline.mjs`: ordered source normalization with parent-pointer repair after every compiler pass.
41
41
  - `compiler/react-migration-pass.mjs`: React import, memo, callback, `forwardRef`, and `useId` migration validation and lowering.
42
42
  - `compiler/custom-hook-timer-pass.mjs`: private custom-hook timeout-ref validation and compiler-owned timer-state lowering.
43
43
  - `compiler/effect-codegen.mjs`: route-specific ordinary, owned, and navigable effect entry generation.
44
- - `compiler/optimize/command-specialization.mjs`, `compiler/ir/module-ir.mjs`, and `compiler/codegen/command-codegen.mjs`: JSON-safe command analysis, sparse per-source ModuleIR, and source-analysis-free existing behavior-call generation.
44
+ - `compiler/optimize/command-specialization.mjs`, `compiler/ir/module-ir.mjs`, and `compiler/codegen/command-codegen.mjs`: JSON-safe command and keyed ownership registration, sparse per-source ModuleIR, and source-analysis-free existing behavior-call generation.
45
45
  - `compiler/handler-lowering.mjs`: source-local native/effect callback, reducer, Context, Zustand, reactive-binding, and list-evaluator AST lowering before IR finalization.
46
46
  - `compiler/handler-codegen.mjs`: AST-free ordered import rendering and finalized handler/binding export-source assembly.
47
47
  - `compiler/render-control-pass.mjs`: render-function early-return and exhaustive adjacent-assignment normalization.
@@ -64,7 +64,7 @@ Reduced Zustand migration stores lower to one ordinary layout-lifetime state slo
64
64
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
65
65
  - `*.d.ts`: public TypeScript and JSX declarations.
66
66
 
67
- Compiler ownership follows four explicit stages. `build.mjs` owns project discovery, route planning, and artifact emission. `compiler/normalization-pipeline.mjs` owns pass order and repairs parent pointers after every source transform; migration passes keep per-file state local and return additional metadata explicitly. The main transformer analyzes normalized source while `compiler/descriptor-session.mjs` owns one per-source semantic artifact containing handler, effect, binding, list, and client-import descriptors. `compiler/collection-analysis.mjs` owns the shared pure collection language used by React migration, reactive expressions, effects, and keyed lists. After build-time rendering, `compiler/route-capability-planner.mjs` projects serializable route plans into one capability manifest consumed by runtime specialization and artifact emission. Codegen modules turn descriptors into route-specific ESM without participating in source analysis. The versioned continuation plan lives in `docs/next-architecture`; it adds no runtime or accepted syntax by itself.
67
+ Compiler ownership follows four explicit stages. `build.mjs` owns project discovery, route planning, and artifact emission. `compiler/normalization-pipeline.mjs` owns pass order and repairs parent pointers after every source transform; migration passes keep per-file state local and return additional metadata explicitly. The main transformer analyzes normalized source while `compiler/descriptor-session.mjs` owns one per-source semantic artifact containing handler, effect callback, binding, keyed block, and client-import descriptors. Keyed blocks link deterministic parent/child sites, component specializations, row states/refs, and generated handlers/bindings without replacing `core.mjs` final DOM ownership. `compiler/collection-analysis.mjs` owns the shared pure collection language used by React migration, reactive expressions, effects, and keyed lists. After build-time rendering, `compiler/route-capability-planner.mjs` projects serializable route plans into one capability manifest consumed by runtime specialization and artifact emission. Codegen modules turn descriptors into route-specific ESM without participating in source analysis. The versioned continuation plan lives in `docs/next-architecture`; it adds no runtime or accepted syntax by itself.
68
68
 
69
69
  New syntax support belongs in an existing pass or a focused new pass only when a reduced migration fixture proves it. Passes must preserve source-located diagnostics, avoid module-global analysis state, and expose metadata through return values rather than AST-identity side channels. Build orchestration stays in `build.mjs`; feature-specific graph validation or code generation moves under `compiler/` when it has a stable input/output boundary.
70
70
 
@@ -952,13 +952,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
952
952
  const customHooks = new Map()
953
953
  const jsxLocalDeclarations = new Map()
954
954
  const jsxLocalsByFunction = new Map()
955
- const listLocalDeclarations = new WeakSet()
956
- const listLocalUses = new WeakMap()
957
- const listValues = new WeakMap()
958
- const listEventItems = new WeakMap()
959
- const listConditions = new WeakMap()
960
- const nestedLists = new WeakMap()
961
- const listEffectEntries = new WeakMap()
955
+ const listLocalDeclarations = []
956
+ const listLocalUses = []
962
957
  const componentEffectEntries = new WeakMap()
963
958
  const analysisSource = node => {
964
959
  const original = ts.getOriginalNode(node)
@@ -1340,8 +1335,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1340
1335
  const position = sourceFile.getLineAndCharacterOfPosition(declaration.node.getStart(sourceFile))
1341
1336
  if (uses.length > 1) throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Keyed list local "${name}" must be rendered exactly once`)
1342
1337
  if (references !== uses.length) throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} Keyed list local "${name}" may only be used as a JSX child`)
1343
- listLocalDeclarations.add(declaration.node)
1344
- if (uses.length) listLocalUses.set(uses[0], parts)
1338
+ listLocalDeclarations.push(declaration.node)
1339
+ if (uses.length) listLocalUses.push({ node: uses[0], parts })
1345
1340
  }
1346
1341
  }
1347
1342
  }
@@ -1484,6 +1479,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1484
1479
  ids: result.ordinaryIds.map(({ name, source }) => ({ name, ...(analysisSource(source) ? { source: analysisSource(source) } : {}) }))
1485
1480
  })
1486
1481
  for (const state of [...result.rowStates, ...result.ordinaryStates]) state.analysisOwner = `specialization:${result.analysis.slot}`
1482
+ for (const ref of [...result.rowRefs, ...result.ordinaryRefs]) ref.analysisOwner = `specialization:${result.analysis.slot}`
1487
1483
  return result
1488
1484
  }
1489
1485
  const registerRowHooks = (call, specialization) => {
@@ -1847,20 +1843,20 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1847
1843
  if (expanded === value || !referencedStateNames(expanded, setters).size) fail(value, "Calculated collection fields must directly depend on local state")
1848
1844
  return expanded
1849
1845
  }
1850
- const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, setters, jsxLocalDeclarations.get(owner), fail, new Set(), importedCollections, factory, context, importedCollectionTransforms, calculatedCollection, staticCollection)
1846
+ const parts = listLocalUses.find(entry => entry.node === node)?.parts ?? keyedListParts(node.expression, setters, jsxLocalDeclarations.get(owner), fail, new Set(), importedCollections, factory, context, importedCollectionTransforms, calculatedCollection, staticCollection)
1851
1847
  if (parts) {
1852
- for (const declaration of parts.aliasDeclarations ?? []) listLocalDeclarations.add(declaration)
1848
+ for (const declaration of parts.aliasDeclarations ?? []) if (!listLocalDeclarations.includes(declaration)) listLocalDeclarations.push(declaration)
1853
1849
  rawRenderedLists.push({ node, parts })
1854
1850
  }
1855
1851
  }
1856
1852
  ts.forEachChild(node, collectRenderedLists)
1857
1853
  }
1858
1854
  collectRenderedLists(sourceFile)
1859
- const collectionAliasUses = new WeakSet(rawRenderedLists.flatMap(({ parts }) => parts.aliasUses ?? []))
1855
+ const collectionAliasUses = rawRenderedLists.flatMap(({ parts }) => parts.aliasUses ?? [])
1860
1856
  const collectionAliasDeclarations = new Set(rawRenderedLists.flatMap(({ parts }) => parts.aliasDeclarations ?? []))
1861
1857
  for (const declaration of collectionAliasDeclarations) {
1862
1858
  const owner = nearestFunction(declaration)
1863
- const unsupported = identifierReferences(owner.body, declaration.name.text).find(reference => !collectionAliasUses.has(reference))
1859
+ const unsupported = identifierReferences(owner.body, declaration.name.text).find(reference => !collectionAliasUses.includes(reference))
1864
1860
  if (unsupported) fail(unsupported, `Rendered collection alias "${declaration.name.text}" may only be used as a rendered collection source`)
1865
1861
  }
1866
1862
  const rejectUnsupportedRenderControl = node => {
@@ -1912,7 +1908,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1912
1908
  let count = 0
1913
1909
  const visit = (node, currentAggregate = aggregate) => {
1914
1910
  if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) {
1915
- const nestedAggregate = { calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [] }
1911
+ const nestedAggregate = { calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [], specializations: [] }
1916
1912
  for (const argument of node.arguments) visit(argument, nestedAggregate)
1917
1913
  if (nestedAggregate.hookDeclarations.length || nestedAggregate.effects.length) nestedRowSpecializations.set(`${node.pos}:${node.end}`, nestedAggregate)
1918
1914
  return
@@ -1939,6 +1935,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1939
1935
  if (imported) synthesizeTree(specialization.root = mergeSpecializedImports(specialization.root, component.getSourceFile(), node, specialization.effects))
1940
1936
  expandedRowSpecializations.set(specialization.root, specialization)
1941
1937
  if (currentAggregate) {
1938
+ currentAggregate.specializations ??= []
1939
+ currentAggregate.specializations.push(specialization.analysis.slot, ...(specialization.specializations ?? []))
1942
1940
  currentAggregate.effects.push(...specialization.effects)
1943
1941
  currentAggregate.hookDeclarations.push(...specialization.hookDeclarations)
1944
1942
  currentAggregate.rowStates.push(...specialization.rowStates)
@@ -1957,8 +1955,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1957
1955
  expanded.parent = root.parent
1958
1956
  return expanded
1959
1957
  }
1960
- const renderedLists = new WeakMap()
1961
- const prepareListCallback = (callback, root, specialization, item) => {
1958
+ const preparedRenderedLists = []
1959
+ const prepareListCallback = (callback, root, specialization, item, effectEntries) => {
1962
1960
  const statements = [...specialization.hookDeclarations]
1963
1961
  if (specialization.effects.length) {
1964
1962
  usesListEffects = true
@@ -1966,7 +1964,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1966
1964
  const call = factory.updateCallExpression(entry.call, factory.createIdentifier("__kListUseEffect"), entry.call.typeArguments, entry.call.arguments)
1967
1965
  synthesizeTree(call)
1968
1966
  const effectSource = entry.source.getSourceFile()
1969
- listEffectEntries.set(call, { item, source: entry.source, sourceFile: effectSource, imports: clientImportBindings(effectSource, effectSource.fileName, sourceFiles) })
1967
+ effectEntries.push({ node: call, item, source: entry.source, sourceFile: effectSource, imports: clientImportBindings(effectSource, effectSource.fileName, sourceFiles) })
1970
1968
  return factory.createExpressionStatement(call)
1971
1969
  }))
1972
1970
  }
@@ -1997,15 +1995,25 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1997
1995
  ts.setParentRecursive(callback, false)
1998
1996
  callback.parent = originalParts.callback.parent
1999
1997
  }
2000
- callback = prepareListCallback(callback, root, specialization, originalParts.item)
2001
- const parts = { ...originalParts, root, callback, analysisStateOwners: new Map([...stateOwnersForNode(originalParts.root), ...[...specialization.rowStates, ...specialization.ordinaryStates].map(state => [state.state, state.analysisOwner])]) }
1998
+ const effectEntries = []
1999
+ callback = prepareListCallback(callback, root, specialization, originalParts.item, effectEntries)
2000
+ const parts = {
2001
+ ...originalParts,
2002
+ root,
2003
+ callback,
2004
+ effectEntries,
2005
+ specializations: [specialization.analysis?.slot, ...(specialization.specializations ?? [])].filter(slot => slot !== undefined),
2006
+ rowStates: [...specialization.rowStates, ...specialization.ordinaryStates],
2007
+ rowRefs: specialization.rowRefs,
2008
+ analysisStateOwners: new Map([...stateOwnersForNode(originalParts.root), ...[...specialization.rowStates, ...specialization.ordinaryStates].map(state => [state.state, state.analysisOwner])])
2009
+ }
2002
2010
  for (const calculation of specialization.calculations) {
2003
2011
  ts.setParentRecursive(calculation, false)
2004
2012
  calculation.parent = callback
2005
2013
  validateListExpression(calculation, parts.item, originalParts.root, fail)
2006
2014
  }
2007
- validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, settersForNode(originalParts.root, settersByFunction), specialization.rowStates, nestedLists, componentSpecializations, expandedRowSpecializations, nestedRowSpecializations, factory, prepareListCallback)
2008
- renderedLists.set(node, parts)
2015
+ const analysis = validateKeyedList(parts, sourceFile, settersForNode(originalParts.root, settersByFunction), specialization.rowStates, componentSpecializations, expandedRowSpecializations, nestedRowSpecializations, factory, prepareListCallback)
2016
+ preparedRenderedLists.push({ node, parts, analysis })
2009
2017
  }
2010
2018
 
2011
2019
  const compileRenderExpression = (expression, anchor) => {
@@ -2027,6 +2035,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2027
2035
  }
2028
2036
 
2029
2037
  let activeStateOwners
2038
+ let activeKeyedBlock
2030
2039
  const visitWithStateOwners = (node, stateOwners) => {
2031
2040
  const previous = activeStateOwners
2032
2041
  activeStateOwners = new Map([...(previous ?? []), ...stateOwners])
@@ -2034,6 +2043,58 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2034
2043
  activeStateOwners = previous
2035
2044
  return result
2036
2045
  }
2046
+ const keyedEntry = (entries, node) => entries.find(entry => entry.node === node)
2047
+ const compileKeyedBlock = (node, { parts: listParts, analysis }) => {
2048
+ usesBehavior = true
2049
+ usesList = true
2050
+ const blockSlot = moduleIR.keyedBlocks.length
2051
+ let listSource = listParts.state
2052
+ let collection = { kind: "signal", name: listParts.state?.text }
2053
+ if (listParts.calculation) {
2054
+ usesBinding = true
2055
+ listSource = descriptors.compileReactiveBinding(listParts.calculation, { setters: settersForNode(node, settersByFunction), importBindings, keyedBlock: blockSlot })
2056
+ const exportName = ts.isCallExpression(listSource) && ts.isStringLiteral(listSource.arguments[2]) ? listSource.arguments[2].text : undefined
2057
+ collection = { kind: "binding", ...(exportName ? { exportName } : {}) }
2058
+ }
2059
+ const derived = listParts.selector?.length ? descriptors.registerDerived("selector", listParts.selector, listParts.selectorStates, node) : undefined
2060
+ const parent = activeKeyedBlock?.block
2061
+ const rowStates = (listParts.rowStates ?? []).map(state => ({ name: state.state, setter: state.setter, owner: state.analysisOwner, ...(analysisSource(state.source) ? { source: analysisSource(state.source) } : {}) }))
2062
+ const rowRefs = (listParts.rowRefs ?? []).map(ref => ({ name: ref.name, owner: ref.analysisOwner, ...(analysisSource(ref.source) ? { source: analysisSource(ref.source) } : {}) }))
2063
+ const specializations = [...new Set([...(listParts.specializations ?? []), ...rowStates.map(state => state.owner), ...rowRefs.map(ref => ref.owner)].filter(value => value !== undefined).map(value => typeof value === "string" ? Number(value.slice(value.lastIndexOf(":") + 1)) : value))]
2064
+ const block = descriptors.registerKeyedBlock({
2065
+ ...(analysisSource(node) ? { source: analysisSource(node) } : {}),
2066
+ ...(parent ? { parent: parent.slot } : {}),
2067
+ children: [],
2068
+ collection,
2069
+ key: listParts.keyField,
2070
+ ...(listParts.ownerField ? { ownerField: listParts.ownerField } : {}),
2071
+ item: listParts.item,
2072
+ ...(listParts.index ? { index: listParts.index } : {}),
2073
+ indexed: listParts.indexed,
2074
+ static: Boolean(listParts.static),
2075
+ ...(derived ? { selector: derived.slot } : {}),
2076
+ selectorStates: [...(listParts.selectorStates ?? [])],
2077
+ specializations,
2078
+ rowStates,
2079
+ rowRefs
2080
+ })
2081
+ if (parent) parent.children.push(block.slot)
2082
+ const previous = activeKeyedBlock
2083
+ activeKeyedBlock = { analysis, block, parts: listParts }
2084
+ const callback = visitWithStateOwners(listParts.callback, listParts.analysisStateOwners ?? new Map())
2085
+ activeKeyedBlock = previous
2086
+ const arguments_ = [
2087
+ listSource,
2088
+ block.key === null ? factory.createNull() : factory.createStringLiteral(block.key),
2089
+ callback,
2090
+ factory.createStringLiteral(block.ownerField ?? ""),
2091
+ jsonExpression(derived?.selector ?? listParts.selector ?? [], factory),
2092
+ block.indexed ? factory.createTrue() : factory.createFalse()
2093
+ ]
2094
+ if (block.selectorStates.length || block.static) arguments_.push(factory.createArrayLiteralExpression(block.selectorStates.map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)]))))
2095
+ if (block.static) arguments_.push(factory.createTrue())
2096
+ return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, arguments_))
2097
+ }
2037
2098
  const visitor = node => {
2038
2099
  if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && customHookPrivateFields.has(node)) {
2039
2100
  const privateFields = customHookPrivateFields.get(node)
@@ -2077,7 +2138,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2077
2138
  return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
2078
2139
  }
2079
2140
 
2080
- const listEffect = ts.isCallExpression(node) ? listEffectEntries.get(node) : undefined
2141
+ const listEffect = ts.isCallExpression(node) ? keyedEntry(activeKeyedBlock?.analysis.effects ?? [], node) : undefined
2081
2142
  const componentEffect = ts.isCallExpression(node) ? componentEffectEntries.get(node) : undefined
2082
2143
  const specializedEffect = listEffect ?? componentEffect
2083
2144
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (hasUseEffectImport && node.expression.text === "useEffect" || specializedEffect)) {
@@ -2195,6 +2256,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2195
2256
  reducers: reducersForNode(node, reducersByFunction),
2196
2257
  importBindings: specializedEffect?.imports ?? importBindings,
2197
2258
  listItem: dependencyItem,
2259
+ keyedBlock: activeKeyedBlock?.block.slot,
2198
2260
  deferValues: true,
2199
2261
  snapshotNested: returns.cleanup,
2200
2262
  liveStates: customHookTimerStates
@@ -2228,7 +2290,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2228
2290
  return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
2229
2291
  }
2230
2292
 
2231
- if (ts.isVariableDeclaration(node) && listLocalDeclarations.has(node)) {
2293
+ if (ts.isVariableDeclaration(node) && listLocalDeclarations.includes(node)) {
2232
2294
  return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createIdentifier("undefined"))
2233
2295
  }
2234
2296
 
@@ -2253,47 +2315,31 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2253
2315
  if (compiled !== node.expression) return factory.updateReturnStatement(node, compiled)
2254
2316
  }
2255
2317
 
2256
- if (ts.isJsxExpression(node) && node.expression && listConditions.has(node.expression)) {
2257
- const entry = listConditions.get(node.expression)
2318
+ const listCondition = ts.isJsxExpression(node) && node.expression ? keyedEntry(activeKeyedBlock?.analysis.conditions ?? [], node.expression) : undefined
2319
+ if (listCondition) {
2320
+ const entry = listCondition.value
2258
2321
  return factory.updateJsxExpression(node, descriptors.compileListConditional({
2259
2322
  ...entry,
2323
+ keyedBlock: activeKeyedBlock.block.slot,
2260
2324
  truthy: ts.visitNode(entry.truthy, visitor),
2261
2325
  falsy: ts.visitNode(entry.falsy, visitor)
2262
2326
  }))
2263
2327
  }
2264
2328
 
2265
- if (ts.isJsxExpression(node) && node.expression && listValues.has(node.expression)) {
2266
- return factory.updateJsxExpression(node, descriptors.compileListValue(node.expression, listValues.get(node.expression)))
2329
+ const listValue = ts.isJsxExpression(node) && node.expression ? keyedEntry(activeKeyedBlock?.analysis.values ?? [], node.expression) : undefined
2330
+ if (listValue) {
2331
+ return factory.updateJsxExpression(node, descriptors.compileListValue(node.expression, { ...listValue.value, keyedBlock: activeKeyedBlock.block.slot }))
2267
2332
  }
2268
2333
 
2269
- if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && listValues.has(node.initializer.expression)) {
2270
- return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, descriptors.compileListValue(node.initializer.expression, listValues.get(node.initializer.expression))))
2334
+ const attributeListValue = ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression ? keyedEntry(activeKeyedBlock?.analysis.values ?? [], node.initializer.expression) : undefined
2335
+ if (attributeListValue) {
2336
+ return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, descriptors.compileListValue(node.initializer.expression, { ...attributeListValue.value, keyedBlock: activeKeyedBlock.block.slot })))
2271
2337
  }
2272
2338
 
2273
2339
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
2274
- const nestedParts = nestedLists.get(unwrapExpression(node.expression))
2275
- const listParts = renderedLists.get(node) ?? nestedParts
2276
- if (listParts) {
2277
- usesBehavior = true
2278
- usesList = true
2279
- let listSource = listParts.state
2280
- if (listParts.calculation) {
2281
- usesBinding = true
2282
- listSource = descriptors.compileReactiveBinding(listParts.calculation, { setters: settersForNode(node, settersByFunction), importBindings })
2283
- }
2284
- const selector = listParts.selector?.length ? descriptors.registerDerived("selector", listParts.selector, listParts.selectorStates, node).selector : listParts.selector ?? []
2285
- const arguments_ = [
2286
- listSource,
2287
- listParts.keyField === null ? factory.createNull() : factory.createStringLiteral(listParts.keyField),
2288
- visitWithStateOwners(listParts.callback, listParts.analysisStateOwners ?? new Map()),
2289
- factory.createStringLiteral(listParts.ownerField ?? ""),
2290
- jsonExpression(selector, factory),
2291
- listParts.indexed ? factory.createTrue() : factory.createFalse()
2292
- ]
2293
- if (listParts.selectorStates?.size || listParts.static) arguments_.push(factory.createArrayLiteralExpression([...(listParts.selectorStates ?? [])].map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)]))))
2294
- if (listParts.static) arguments_.push(factory.createTrue())
2295
- return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, arguments_))
2296
- }
2340
+ const renderedList = keyedEntry(preparedRenderedLists, node)
2341
+ const nestedList = keyedEntry(activeKeyedBlock?.analysis.nested ?? [], unwrapExpression(node.expression))
2342
+ if (renderedList || nestedList) return compileKeyedBlock(node, renderedList ?? nestedList)
2297
2343
  const conditional = conditionalParts(node.expression)
2298
2344
  if (conditional) {
2299
2345
  const compiled = compileRenderExpression(node.expression, node)
@@ -2332,7 +2378,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2332
2378
  setters,
2333
2379
  reducers: reducersForNode(node, reducersByFunction),
2334
2380
  functions: functionsForNode(node),
2335
- listItem: listEventItems.get(node),
2381
+ listItem: activeKeyedBlock ? { item: activeKeyedBlock.parts.item, index: activeKeyedBlock.parts.index } : undefined,
2382
+ keyedBlock: activeKeyedBlock?.block.slot,
2336
2383
  importBindings: new Map([...importBindings, ...packageBindings])
2337
2384
  })
2338
2385
  if (event) {
@@ -2567,10 +2614,11 @@ function insideJsxEventHandler(node, root) {
2567
2614
  return false
2568
2615
  }
2569
2616
 
2570
- function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, setters, rowStates, nestedLists, componentSpecializations, expandedRowSpecializations, nestedRowSpecializations, factory, prepareListCallback) {
2617
+ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpecializations, expandedRowSpecializations, nestedRowSpecializations, factory, prepareListCallback) {
2571
2618
  const fail = (node, message) => {
2572
2619
  throw sourceNodeError(node, sourceFile, message)
2573
2620
  }
2621
+ const analysis = { values: [], conditions: [], nested: [], effects: parts.effectEntries ?? [] }
2574
2622
  const root = parts.root
2575
2623
  const item = parts.item
2576
2624
  const nestedDiagnostic = "Nested keyed list collections must be a direct property of the parent item"
@@ -2585,7 +2633,6 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2585
2633
  if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, nestedDiagnostic)
2586
2634
  if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, item)) fail(node, "Keyed list item spreads are not supported")
2587
2635
  if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.text)) {
2588
- listEventItems.set(node, { item, index: parts.index })
2589
2636
  return
2590
2637
  }
2591
2638
  if (ts.isJsxExpression(node) && node.expression) {
@@ -2610,16 +2657,28 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2610
2657
  ts.setParentRecursive(callback, false)
2611
2658
  callback.parent = nested.callback.parent
2612
2659
  }
2613
- callback = prepareListCallback(callback, root, specialization ?? { hookDeclarations: [], effects: [] }, nested.item)
2660
+ const effectEntries = []
2661
+ callback = prepareListCallback(callback, root, specialization ?? { hookDeclarations: [], effects: [] }, nested.item, effectEntries)
2614
2662
  const specializedStates = [...(specialization?.rowStates ?? []), ...(specialization?.ordinaryStates ?? [])]
2615
- const nestedParts = { ...nested, root, callback, state: parts.state, nested: true, analysisStateOwners: new Map([...(parts.analysisStateOwners ?? []), ...specializedStates.map(state => [state.state, state.analysisOwner])]) }
2663
+ const nestedParts = {
2664
+ ...nested,
2665
+ root,
2666
+ callback,
2667
+ state: parts.state,
2668
+ nested: true,
2669
+ effectEntries,
2670
+ specializations: [specialization?.analysis?.slot, ...(specialization?.specializations ?? [])].filter(slot => slot !== undefined),
2671
+ rowStates: specializedStates,
2672
+ rowRefs: specialization?.rowRefs ?? [],
2673
+ analysisStateOwners: new Map([...(parts.analysisStateOwners ?? []), ...specializedStates.map(state => [state.state, state.analysisOwner])])
2674
+ }
2616
2675
  for (const calculation of specialization?.calculations ?? []) {
2617
2676
  ts.setParentRecursive(calculation, false)
2618
2677
  calculation.parent = callback
2619
2678
  validateListExpression(calculation, nested.item, nested.root, fail)
2620
2679
  }
2621
- nestedLists.set(expression, nestedParts)
2622
- validateKeyedList(nestedParts, sourceFile, listValues, listEventItems, listConditions, setters, specialization?.rowStates ?? [], nestedLists, componentSpecializations, expandedRowSpecializations, nestedRowSpecializations, factory, prepareListCallback)
2680
+ const nestedAnalysis = validateKeyedList(nestedParts, sourceFile, setters, specialization?.rowStates ?? [], componentSpecializations, expandedRowSpecializations, nestedRowSpecializations, factory, prepareListCallback)
2681
+ analysis.nested.push({ node: expression, parts: nestedParts, analysis: nestedAnalysis })
2623
2682
  return
2624
2683
  }
2625
2684
  const condition = conditionalParts(expression)
@@ -2631,7 +2690,7 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2631
2690
  }
2632
2691
  if (!referencesIdentifier(condition.condition, item) && !(parts.index && referencesIdentifier(condition.condition, parts.index))) fail(node, "Keyed list item conditions must read the item or index")
2633
2692
  validateListExpression(condition.condition, item, node, fail, parts.index)
2634
- listConditions.set(node.expression, { ...condition, item, index: parts.index })
2693
+ analysis.conditions.push({ node: node.expression, value: { ...condition, item, index: parts.index } })
2635
2694
  visit(condition.truthy)
2636
2695
  visit(condition.falsy)
2637
2696
  return
@@ -2642,7 +2701,7 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2642
2701
  if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.text.toLowerCase())) fail(node, `Keyed list item ${node.parent.name.text} is not supported`)
2643
2702
  if (isRootKey) return
2644
2703
  if (field) {
2645
- listValues.set(node.expression, { field })
2704
+ analysis.values.push({ node: node.expression, value: { field } })
2646
2705
  return
2647
2706
  }
2648
2707
  if (referencesIdentifier(expression, item) || parts.index && referencesIdentifier(expression, parts.index)) {
@@ -2651,13 +2710,14 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2651
2710
  if (parts.nested && states.size) fail(node, "Nested keyed list item expressions cannot read parent state")
2652
2711
  validateListExpression(expression, item, node, fail, parts.index, states)
2653
2712
  if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.text.toLowerCase())) fail(node, `Keyed list item ${node.parent.name.text} is not supported`)
2654
- listValues.set(node.expression, { item, index: parts.index, states })
2713
+ analysis.values.push({ node: node.expression, value: { item, index: parts.index, states } })
2655
2714
  return
2656
2715
  }
2657
2716
  }
2658
2717
  ts.forEachChild(node, visit)
2659
2718
  }
2660
2719
  visit(root)
2720
+ return analysis
2661
2721
  }
2662
2722
 
2663
2723
  function directConstObjectLiteral(expression, call) {
@@ -2,7 +2,7 @@ import ts from "typescript"
2
2
  import { createComponentAnalysis } from "./analysis/component-analysis.mjs"
3
3
  import { bindingNames, isFunctionLike, isReferenceIdentifier, isShadowedByParameter, isShadowedIdentifier, unwrapExpression } from "./ast-helpers.mjs"
4
4
  import { generateCommandBehavior } from "./codegen/command-codegen.mjs"
5
- import { createModuleIR, registerBinding, registerCommandHandler, registerDerived, registerModuleHandler } from "./ir/module-ir.mjs"
5
+ import { createModuleIR, registerBinding, registerCommandHandler, registerDerived, registerKeyedBlock, registerModuleHandler } from "./ir/module-ir.mjs"
6
6
 
7
7
  export function createSemanticArtifact(file) {
8
8
  return { componentAnalysis: createComponentAnalysis(file), moduleIR: createModuleIR(file) }
@@ -16,9 +16,9 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
16
16
  const listExpressions = []
17
17
  const clientModules = new Set()
18
18
 
19
- function compileListExpression(read, expression, item, index, states = new Set()) {
19
+ function compileListExpression(read, expression, item, index, states = new Set(), keyedBlock) {
20
20
  const exportName = `listExpression${listExpressions.length}`
21
- listExpressions.push({ exportName, expression, item, index, states, role: "list-expression" })
21
+ listExpressions.push({ exportName, expression, item, index, states, role: "list-expression", keyedBlock })
22
22
  const arguments_ = [read, factory.createStringLiteral(handlerUrl), factory.createStringLiteral(exportName)]
23
23
  if (states.size) arguments_.push(factory.createArrayLiteralExpression([...states].map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)]))))
24
24
  return factory.createCallExpression(factory.createIdentifier("__kListExpression"), undefined, arguments_)
@@ -26,7 +26,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
26
26
 
27
27
  function compileListConditional(entry) {
28
28
  const exportName = `listExpression${listExpressions.length}`
29
- listExpressions.push({ exportName, expression: entry.condition, item: entry.item, index: entry.index, role: "list-conditional" })
29
+ listExpressions.push({ exportName, expression: entry.condition, item: entry.item, index: entry.index, role: "list-conditional", keyedBlock: entry.keyedBlock })
30
30
  const read = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), entry.condition)
31
31
  const thunk = branch => factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), branch)
32
32
  return factory.createCallExpression(factory.createIdentifier("__kListConditional"), undefined, [
@@ -44,16 +44,16 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
44
44
  const read = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), initial)
45
45
  return entry.field
46
46
  ? factory.createCallExpression(factory.createIdentifier("__kListField"), undefined, [read, factory.createStringLiteral(entry.field)])
47
- : compileListExpression(read, expression, entry.item, entry.index, entry.states)
47
+ : compileListExpression(read, expression, entry.item, entry.index, entry.states, entry.keyedBlock)
48
48
  }
49
49
 
50
- function compileReactiveBinding(expression, { setters, importBindings = new Map() }) {
50
+ function compileReactiveBinding(expression, { setters, importBindings = new Map(), keyedBlock }) {
51
51
  const parts = conditionalParts(expression)
52
52
  const state = parts && directStateIdentifier(parts.condition, setters)
53
53
  if (state && isPrimitiveLiteral(parts.truthy) && isPrimitiveLiteral(parts.falsy)) {
54
54
  return factory.createCallExpression(factory.createIdentifier("__kSelect"), undefined, [state, parts.truthy, parts.falsy])
55
55
  }
56
- return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, importBindings))
56
+ return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, importBindings, keyedBlock))
57
57
  }
58
58
 
59
59
  function compileConditional(kind, expression, truthy, falsy, setters) {
@@ -64,14 +64,14 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
64
64
  return factory.createCallExpression(factory.createIdentifier("__kConditional"), undefined, [factory.createStringLiteral(kind), initial, thunk(truthy), thunk(falsy), ...descriptor])
65
65
  }
66
66
 
67
- function compileReactiveExpression(expression, setters, importBindings = new Map()) {
67
+ function compileReactiveExpression(expression, setters, importBindings = new Map(), keyedBlock) {
68
68
  const usedStates = referencedStateNames(expression, setters)
69
69
  const importedNames = referencedImportedBindings(expression, importBindings)
70
70
  const imports = [...importedNames].map(name => importBindings.get(name))
71
71
  registerClientImports(imports)
72
72
  const captures = new Set([...captureNames(expression, expression, setters)].filter(name => !importedNames.has(name)))
73
73
  const exportName = `binding${reactiveBindings.length}`
74
- reactiveBindings.push({ exportName, expression, captures, states: usedStates, imports, role: "binding" })
74
+ reactiveBindings.push({ exportName, expression, captures, states: usedStates, imports, role: "binding", keyedBlock })
75
75
  const states = [...usedStates].map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)]))
76
76
  const scope = [...captures].map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)]))
77
77
  const stateNames = new Set(usedStates)
@@ -91,13 +91,13 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
91
91
  ]
92
92
  }
93
93
 
94
- function compileEvent(expression, { owner = "module", stateOwners = new Map(), setters, reducers, functions, listItem, importBindings }) {
94
+ function compileEvent(expression, { owner = "module", stateOwners = new Map(), setters, reducers, functions, listItem, keyedBlock, importBindings }) {
95
95
  if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
96
96
  if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
97
- const optimized = referencedReducerDispatches(expression.body, reducers, expression).size ? undefined : compileOptimizedEvent(expression, setters, stateOwners, owner)
97
+ const optimized = referencedReducerDispatches(expression.body, reducers, expression).size ? undefined : compileOptimizedEvent(expression, setters, stateOwners, owner, keyedBlock)
98
98
  if (optimized) return optimized
99
99
  rejectWorkerConstructions(expression)
100
- const descriptor = compileNativeCallback(expression, { setters, reducers, entries: nativeHandlers, importBindings, prefix: "handler", role: "native", listItem })
100
+ const descriptor = compileNativeCallback(expression, { setters, reducers, entries: nativeHandlers, importBindings, prefix: "handler", role: "native", listItem, keyedBlock })
101
101
  return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
102
102
  factory.createStringLiteral(handlerUrl), factory.createStringLiteral(descriptor.exportName), descriptor.states, descriptor.scope
103
103
  ])
@@ -107,7 +107,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
107
107
  return compileNativeCallback(expression, { ...options, entries: effectHandlers, prefix: "effect", role: "effect" })
108
108
  }
109
109
 
110
- function compileNativeCallback(expression, { setters, reducers, entries, importBindings, prefix, role, listItem, deferValues = false, snapshotNested = false, liveStates = new Set() }) {
110
+ function compileNativeCallback(expression, { setters, reducers, entries, importBindings, prefix, role, listItem, keyedBlock, deferValues = false, snapshotNested = false, liveStates = new Set() }) {
111
111
  const allCaptures = nativeCaptureNames(expression, setters)
112
112
  const usedReducers = referencedReducerDispatches(expression.body, reducers, expression)
113
113
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
@@ -120,7 +120,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
120
120
  if (reducer.contextAction) for (const state of referencedStateNames(reducer.contextAction.body, reducer.states, reducer.contextAction)) usedStates.add(state)
121
121
  }
122
122
  const exportName = `${prefix}${entries.length}`
123
- entries.push({ exportName, expression, captures, deferValues, imports, listItem, liveStates, role, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), reducers: new Map([...reducers].filter(([name]) => usedReducers.has(name))), snapshotNested, usedStates })
123
+ entries.push({ exportName, expression, captures, deferValues, imports, listItem, keyedBlock, liveStates, role, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), reducers: new Map([...reducers].filter(([name]) => usedReducers.has(name))), snapshotNested, usedStates })
124
124
  const value = name => deferValues
125
125
  ? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createIdentifier(name))
126
126
  : factory.createIdentifier(name)
@@ -136,13 +136,14 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
136
136
  }
137
137
  }
138
138
 
139
- function compileOptimizedEvent(expression, setters, stateOwners, owner) {
139
+ function compileOptimizedEvent(expression, setters, stateOwners, owner, keyedBlock) {
140
140
  const statements = ts.isBlock(expression.body) ? expression.body.statements : [factory.createExpressionStatement(expression.body)]
141
141
  const commands = statements.map(statement => ts.isExpressionStatement(statement) ? compileEventCommand(statement.expression, setters) : undefined)
142
142
  if (!commands.length || commands.some(command => !command)) return undefined
143
143
  const original = ts.getOriginalNode(expression)
144
144
  const source = original.pos >= 0 && original.end >= 0 ? { file: sourceName(original.getSourceFile()), start: original.getStart(), end: original.end } : undefined
145
145
  const handler = registerCommandHandler(moduleIR, commands.map(command => ({ ...command, owner: stateOwners.get(command.state) ?? owner })), source, owner)
146
+ if (keyedBlock !== undefined) handler.keyedBlock = keyedBlock
146
147
  return generateCommandBehavior(moduleIR, handler, factory)
147
148
  }
148
149
 
@@ -155,6 +156,10 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
155
156
  return registerDerived(moduleIR, { kind, [kind]: normalized, states: [...states], ...(source(node) ? { source: source(node) } : {}) })
156
157
  }
157
158
 
159
+ function registerKeyedBlockResult(descriptor) {
160
+ return registerKeyedBlock(moduleIR, descriptor)
161
+ }
162
+
158
163
  function finalize() {
159
164
  const callbacks = [...nativeHandlers, ...effectHandlers]
160
165
  for (const entry of callbacks) {
@@ -162,6 +167,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
162
167
  const setters = Map.groupBy(entry.setters, ([, state]) => state)
163
168
  registerModuleHandler(moduleIR, {
164
169
  role: entry.role,
170
+ ...(entry.keyedBlock !== undefined ? { keyedBlock: entry.keyedBlock } : {}),
165
171
  exportName: entry.exportName,
166
172
  async: Boolean(entry.expression.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)),
167
173
  generator: Boolean(entry.expression.asteriskToken),
@@ -184,6 +190,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
184
190
  }
185
191
  for (const entry of reactiveBindings) registerBinding(moduleIR, {
186
192
  role: entry.role,
193
+ ...(entry.keyedBlock !== undefined ? { keyedBlock: entry.keyedBlock } : {}),
187
194
  exportName: entry.exportName,
188
195
  parameters: ["__k"],
189
196
  states: [...entry.states],
@@ -194,6 +201,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
194
201
  })
195
202
  for (const entry of listExpressions) registerBinding(moduleIR, {
196
203
  role: entry.role,
204
+ ...(entry.keyedBlock !== undefined ? { keyedBlock: entry.keyedBlock } : {}),
197
205
  exportName: entry.exportName,
198
206
  parameters: [entry.item, entry.index ?? "__kIndex", "__k"],
199
207
  states: [...(entry.states ?? [])],
@@ -215,7 +223,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
215
223
 
216
224
  const importRecord = entry => ({ target: entry.target, kind: entry.kind, local: entry.local, ...(entry.imported ? { imported: entry.imported } : {}), package: Boolean(entry.package) })
217
225
 
218
- return { compileConditional, compileEffectCallback, compileEvent, compileListConditional, compileListValue, compileReactiveBinding, finalize, registerDerived: registerDerivedResult }
226
+ return { compileConditional, compileEffectCallback, compileEvent, compileListConditional, compileListValue, compileReactiveBinding, finalize, registerDerived: registerDerivedResult, registerKeyedBlock: registerKeyedBlockResult }
219
227
  }
220
228
 
221
229
  function directStateIdentifier(expression, setters) {
@@ -1,5 +1,5 @@
1
1
  export function createModuleIR(file) {
2
- return { version: 1, file, signals: [], handlers: [], bindings: [], derived: [], imports: [], clientModules: [] }
2
+ return { version: 1, file, signals: [], handlers: [], bindings: [], derived: [], keyedBlocks: [], imports: [], clientModules: [] }
3
3
  }
4
4
 
5
5
  export function registerCommandHandler(moduleIR, commands, source, scope = "module") {
@@ -39,3 +39,9 @@ export function registerDerived(moduleIR, descriptor) {
39
39
  moduleIR.derived.push(derived)
40
40
  return derived
41
41
  }
42
+
43
+ export function registerKeyedBlock(moduleIR, descriptor) {
44
+ const block = { slot: moduleIR.keyedBlocks.length, ...descriptor }
45
+ moduleIR.keyedBlocks.push(block)
46
+ return block
47
+ }
@@ -80,6 +80,57 @@ export type MetadataContext<Props = Record<string, unknown>> = {
80
80
  props: Props
81
81
  }
82
82
 
83
+ export type ListSourceDescriptor =
84
+ | { state: string; truthy?: unknown; falsy?: unknown }
85
+ | {
86
+ module: string
87
+ handler: string
88
+ states: Record<string, string>
89
+ scope: Record<string, unknown>
90
+ scopeStates: Record<string, string>
91
+ scopeBindings: Record<string, unknown>
92
+ }
93
+
94
+ export type ChildListDescriptor = {
95
+ id: string
96
+ field: string
97
+ key: string | null
98
+ selector?: unknown[]
99
+ }
100
+
101
+ export type ListDescriptor = {
102
+ id: string
103
+ state: string
104
+ key: string | null
105
+ keys: Array<string | number>
106
+ svg?: true
107
+ static?: true
108
+ source?: ListSourceDescriptor
109
+ ownerField?: string
110
+ selector?: unknown[]
111
+ selectorStates?: Record<string, string>
112
+ indexed?: true
113
+ reducer?: true
114
+ children?: ChildListDescriptor[]
115
+ mount?: true
116
+ nested?: true
117
+ effects?: true
118
+ conditions?: true
119
+ conditionHandlers?: true
120
+ textRanges?: true
121
+ attributes?: true
122
+ events?: true
123
+ expressions?: true
124
+ expressionAttributes?: true
125
+ expressionStates?: string[]
126
+ rowStates?: Array<{ id: string; initialValue: unknown }>
127
+ rowConditions?: string[]
128
+ rowRefs?: string[]
129
+ fastRelease?: true
130
+ seed?: Record<string, string | number | boolean | null>
131
+ valueSeed?: Record<string, string | number | boolean | null>
132
+ }
133
+
83
134
  export function renderPage<Props = Record<string, never>>(
84
135
  component: (props: Props) => unknown | Promise<unknown>,
85
136
  metadata?: PageMetadata,
@@ -117,6 +168,6 @@ export function renderPage<Props = Record<string, never>>(
117
168
  scopeBindings?: Record<string, unknown>
118
169
  }>
119
170
  conditions: Array<Record<string, unknown>>
120
- lists: Array<Record<string, unknown>>
171
+ lists: ListDescriptor[]
121
172
  }
122
173
  }>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.19",
3
+ "version": "0.8.20",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",