@kudzujs/core 0.14.3 → 0.16.1

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.
@@ -51,7 +51,7 @@ Syntax compatibility does not mean reproducing React wholesale. Kudzu accepts th
51
51
  - stronger JavaScript failure resilience;
52
52
  - no remaining material large-route build-scaling weakness.
53
53
 
54
- The active 0.9 plan defines the exact comparison and release gate. Benchmark-only feature omission, unmatched accessibility, weighted scores that hide losses, and unrecorded environment differences do not count as proof.
54
+ The completed 0.9 plan defines the cross-framework comparison and release gate. The active application packet is `0.16.2` in the capability release plan below. Benchmark-only feature omission, unmatched accessibility, weighted scores that hide losses, and unrecorded environment differences do not count as proof.
55
55
 
56
56
  ### 0.10.0 Through 0.21.x: Application Capability Release Train
57
57
 
@@ -128,6 +128,7 @@ The following are available building blocks, not future vertical roadmaps:
128
128
  - `useState`, independent repeated non-keyed child state with conditional mount ownership, reduced relative-imported `useReducer`, direct dispatch specialization, and reduced Zustand-shaped shared state lowered through package-neutral shared-state/action IR and proven by migration fixtures.
129
129
  - Mount and dependency effects with cleanup, route/layout lifetimes, conditional/keyed ownership, stale-write isolation, and relative TypeScript Workers.
130
130
  - Direct browser-only package references in inline effect setup/cleanup callbacks bundle into route-owned effect ESM without entering build-time component execution or static sibling output.
131
+ - A CodeMirror-class editor may retain one package instance across one mount effect and later dependency update effects through a null-initialized ref with one direct setup assignment and null-reset cleanup. The ref lowers to existing owner-scoped object state; package DOM, bidirectional updates, errors, accessibility, conditional disposal, and fresh remount require no widget runtime or React island.
131
132
  - Keyed local-state and imported static collections, pure selectors, nested direct-property lists, recursive and directly exported row specialization, static/keyed component reuse, analyzable specialized prop spreads, serializable literal defaults, direct intrinsic rest forwarding, forwarded JSX children, latest-item handlers, row state/effects/refs, and stable keyed identity.
132
133
  - Direct object-state props with one-segment binding/effect/list consumers when the child directly maps an array field; ComponentAnalysis links every consumer to the parent SignalIR without authored field state or a browser component.
133
134
  - Complete-document native fallback and optional shared-layout navigation groups with parameter initialization, finite prefetch caching, focus, history, cleanup, and independent capability output.
package/PERFORMANCE.md CHANGED
@@ -2,6 +2,104 @@
2
2
 
3
3
  Reproducibility classes: `npm run benchmark`, `npm run benchmark:keyed`, `npm run benchmark:native`, `npm run benchmark:module-cache`, `npm run benchmark:project-navigation`, `npm run benchmark:project-state`, and `npm run benchmark:source-scale` are maintained in this repository; `npm run benchmark:commerce` is a maintained paired runner over the public external storefront; older excluded-workspace sections are historical provenance only and are not current framework rankings.
4
4
 
5
+ ## 0.16.1 Retained Editor Ownership
6
+
7
+ Measured 2026-08-28 on Linux x64 with Node 24.14.0 and Chrome. The new
8
+ CodeMirror fixture emits 7 JavaScript files totaling 214,968 raw / 71,607
9
+ aggregate gzip bytes, while its static sibling emits zero JavaScript. These
10
+ bytes belong to the real editor package and existing selected effect/runtime
11
+ capabilities; no retained-ref runtime file or shared widget runtime was added.
12
+
13
+ Required Chrome proves initial content, retained package DOM across application
14
+ updates, editor-to-state updates, accessible error recovery, conditional
15
+ disposal/remount, and document disposal. The maintained Worker benchmark remains
16
+ byte-identical to 0.15.1 at 907 raw / 477 gzip B for the Worker graph and 14,456
17
+ raw / 6,159 gzip B for the window graph. The generated 50-route, 500-module
18
+ source-scale fixture retains compiler digest
19
+ `7c366079a984b3d62fb19a26305326d3278f029d2193d39340ddd3a4be5adc6e` and
20
+ output digest `e107d78a7f55bc8a1af0ea6e53efeffa19b3d44d21c892484d103fa346e7ba7b`.
21
+ Timing from this Linux host is recorded only as a local check and is not compared
22
+ with the prior macOS release sweep.
23
+
24
+ ## 0.15.1 Maintained Benchmark Sweep
25
+
26
+ Remeasured 2026-08-27 on macOS arm64 with Node 24.14.0, Apple M4 hardware,
27
+ 16 GiB RAM, and Chrome 151.0.7922.174. Candidate comparisons use clean
28
+ `v0.14.3` except project-state scale, whose maintained contract compares the
29
+ published `0.10.0` baseline. Every paired compiler/native/commerce output digest
30
+ or manifest is identical unless the row below records route-specific `0.15.1`
31
+ output.
32
+
33
+ | Maintained runner | Current `0.15.1` result |
34
+ |---|---|
35
+ | `npm run benchmark` | Worker-effects clean-build median 220.1 ms; Worker graph 907 raw / 477 gzip B; window graph 14,456 / 6,159 B. |
36
+ | `npm run benchmark:keyed` | 2,000-row build 233.2 ms; append 2.2 ms, filter 3.9 ms, restore 15.6 ms, reverse 5.3 ms medians; 30,048 raw / 11,748 gzip B JavaScript. |
37
+ | `RUNS=7 npm run benchmark:native` | `v0.14.3` / `0.15.1` dispatch medians 3.5 / 3.4 ms for 5,000 clicks; identical 14,354 raw / 6,470 gzip B JavaScript and zero changed files. |
38
+ | `RUNS=7 WARMUPS=1 npm run benchmark:module-cache` | `v0.14.3` / `0.15.1` medians 248.338 / 249.692 ms and 296.9 / 296.8 MiB peak RSS; identical 454,007-byte result and digest. |
39
+ | `npm run benchmark:source-scale` | 50 routes, 500 modules, 50,550 lines: `v0.14.3` / `0.15.1` compile medians 499.3 / 498.9 ms and clean builds 851.5 / 853.8 ms; identical compiler and output digests. |
40
+ | `RUNS=7 npm run benchmark:commerce` | 1,011-page storefront: `v0.14.3` / `0.15.1` clean-build medians 1,854.6 / 1,822.1 ms; all 1,053 files and 10,140,618 output bytes match. |
41
+ | `npm run benchmark:project-navigation` | Seven-profile table update median 0.4 ms and navigation median 2.3 ms; 17 session JavaScript files total 77,779 raw / 27,050 gzip B. |
42
+ | `npm run benchmark:project-state` | One-, eight-, and 32-state commit medians 0.2, 0.3, and 0.4 ms; deploy digest `3f83712254d95f1cf59c5034f363ea906406efa1bd9539f3bb8b9a0d54a3fdc5`. |
43
+ | `npm run benchmark:project-list-decision` | Direct load 138.2 ms with 90,023 nodes; pagination load/range 39.6/5.8 ms with 2,867 nodes; window load/range 40.7/5.8 ms with 2,868 nodes. |
44
+
45
+ The paired timing ranges overlap at module-cache and source-scale granularity,
46
+ so no compiler regression or improvement is claimed. Native output is byte
47
+ identical. Pagination remains selected over the timing-tied authored window: it
48
+ uses one listener instead of two, has lower median heap (1,485,168 versus
49
+ 1,586,528 B), and preserves native page, focus, keyboard, and variable-row-height
50
+ behavior without fixed-height range policy.
51
+
52
+ ## 0.15.1 Public Cross-Framework Commerce Fixture
53
+
54
+ Measured 2026-08-27 from public fixture commit
55
+ `f2d5be1a516c539e30f7125f6870d42b1dd02ecd` with 1,000 products and Kudzu
56
+ linked to this `0.15.1` checkout. The matched variants use Astro 7.1.3 with
57
+ React 19.2.8, React Router 8.3.0, TanStack Start 1.168.32, and Next.js 16.2.11.
58
+ All five builds completed before the same asset and browser harnesses ran.
59
+
60
+ | Variant | Initial JavaScript across six routes, gzip | Total output | First reliable click after first paint | Degraded capabilities |
61
+ |---|---:|---:|---:|---:|
62
+ | Kudzu 0.15.1 | 4.2-9.9 KiB | 9.42 MiB | 300 ms | 15 / 18 |
63
+ | Astro 7.1.3 + React | 60.6-61.1 KiB | 13.95 MiB | 1,500 ms | 12 / 18 |
64
+ | React Router 8.3.0 | 103.8-104.6 KiB | 7.04 MiB | 2,000 ms | 8 / 18 |
65
+ | TanStack Start 1.168.32 | 103.5-104.1 KiB | 9.87 MiB | 2,000 ms | 9 / 18 |
66
+ | Next.js 16.2.11 | 144.2-146.2 KiB | 35.76 MiB | 3,000 ms | 8 / 18 |
67
+
68
+ The initial-JavaScript column records what Chrome actually received for home,
69
+ search, collection, product, policy, and checkout routes. Browser results are
70
+ medians from seven fresh sessions under 4x CPU slowdown and Chrome Slow 4G.
71
+ Reliable click is the first tested delay with zero lost add-to-cart attempts in
72
+ seven isolated sessions. The resilience score covers six matched capabilities
73
+ under blocked JavaScript, a two-second script delay, and one missing script.
74
+ Pre-rendered content and native links remain usable without JavaScript in every
75
+ variant; the score does not treat read-only fallback as full interactivity.
76
+
77
+ This is the current executable public commerce comparison, not a replacement
78
+ for the broader historical 0.9 C1/C2/C5 contracts. That ignored local workspace
79
+ is unavailable in current checkouts, so its React, Vue, Svelte, and Astro result
80
+ remains provenance rather than a current ranking. The public commerce fixture
81
+ does not contain Vue or Svelte variants.
82
+
83
+ ## 0.15.1 Key-Scoped Native Popover Intake
84
+
85
+ Measured 2026-08-27 on macOS arm64 with Node 24.14.0 and Chrome
86
+ 151.0.7922.174. The maintained project fixture uses native Popover with one
87
+ key-scoped `useId()` relationship per project row. Required Chrome verifies
88
+ Escape, light dismiss, trigger focus restoration, reorder identity, inserted
89
+ and remounted IDs, and enhanced-navigation cleanup.
90
+
91
+ The application emits 44 files totaling 171,202 raw / 53,629 aggregate gzip
92
+ bytes with deploy SHA-256
93
+ `46ba45b4a423b9627c6ede5f8dd3a667f593cb5401454ae42ecc66207b07b323`.
94
+ The maintained two-route session contains 17 JavaScript files totaling 77,779
95
+ raw / 27,050 aggregate gzip bytes. `/help` remains 0 B JavaScript.
96
+
97
+ Seven fresh Chrome profiles record table update samples of `[0.4, 0.4, 0.5,
98
+ 0.4, 0.5, 0.5, 0.5]` ms, a 0.5 ms median and 0.4/0.5 ms range. Navigation
99
+ samples are `[4, 2.4, 2.6, 2.5, 2.5, 2.8, 2.4]` ms, a 2.5 ms median and 2.4/4
100
+ ms range. These ranges overlap prior retained-path evidence; no timing
101
+ improvement or regression is claimed.
102
+
5
103
  ## 0.14.3 10,000-Item Browser Decision Intake
6
104
 
7
105
  Measured 2026-08-27 on Linux x64 with Node 24.14.0 and Chrome
package/README.md CHANGED
@@ -12,9 +12,9 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
12
12
 
13
13
  *Watch: React-shaped TSX to static HTML and route-specific ESM in 22 seconds.*
14
14
 
15
- > Experimental `0.14.x`: the compiler API and supported TSX surface may change.
15
+ > Experimental `0.15.x`: the compiler API and supported TSX surface may change.
16
16
 
17
- **Latest release: 0.14.3 - 10,000-item browser decision.** Measured direct DOM, pagination, and scroll-window alternatives select native pagination for large project tables without adding virtualization or range runtime semantics. Read the [release notes](./RELEASES.md#0143---10000-item-browser-decision), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.14.3), or follow the [architecture packet](./docs/next-architecture/README.md).
17
+ **Latest release: 0.16.1 - Retained external editor ownership.** A CodeMirror-class editor can retain one package instance across mount and dependency effects while Kudzu keeps disposal, remount, errors, and static exclusion owner-bounded. Read the [release notes](./RELEASES.md#0161---retained-external-editor-ownership), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.16.1), or follow the [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,75 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.16.1 - Retained External Editor Ownership
4
+
5
+ Kudzu 0.16.1 lets one CodeMirror-class package instance remain owned across a
6
+ mount effect and later dependency update effects without a React island or
7
+ generic widget runtime.
8
+
9
+ ### Changed in 0.16.1
10
+
11
+ - Lowers one proven unattached `useRef(null)` retained-instance handle to
12
+ existing owner-scoped object state.
13
+ - Requires one empty-dependency mount effect, one direct setup assignment,
14
+ direct null-reset cleanup, and read-only later dependency effects.
15
+ - Preserves editor DOM identity across application-state and editor-to-state
16
+ updates, including accessible package errors and recovery.
17
+ - Disposes on conditional and document release; remount receives a fresh handle.
18
+ - Rejects malformed cleanup, update-effect writes, multiple retained handles,
19
+ aliases, event use, and attached retained refs.
20
+ - Adds no IR kind, runtime concept, resource registry, React, or widget runtime.
21
+ - Updates `create-kudzu@0.1.127` to generate projects on
22
+ `@kudzujs/core@^0.16.1`.
23
+
24
+ ### Output And Browser Evidence
25
+
26
+ - The CodeMirror fixture emits 7 JavaScript files totaling 214,968 raw /
27
+ 71,607 aggregate gzip bytes; its static sibling emits zero JavaScript.
28
+ - Required Chrome proves initial mount, retained application updates,
29
+ editor-to-state updates, package-error recovery, disposal, and fresh remount.
30
+ - The smaller Typed.js boundary fixture proves the same existing effect owner
31
+ without any production compiler change.
32
+
33
+ ### Upgrade
34
+
35
+ ```sh
36
+ npm install @kudzujs/core@^0.16.1
37
+ ```
38
+
39
+ ## 0.15.1 - Key-Scoped Native Popovers
40
+
41
+ Kudzu 0.15.1 lets ordinary keyed row components use `useId()` for native
42
+ Popover triggers and intrinsic ID relationships without duplicate cloned IDs.
43
+
44
+ ### Changed in 0.15.1
45
+
46
+ - Key-scoped keyed-row `useId()` through the existing list ownership path.
47
+ - Rewrites cloned `id`, `htmlFor`, static ARIA IDREF, `form`, `headers`, `list`,
48
+ and `popovertarget` attributes.
49
+ - Rejects keyed IDs used in text, handlers, aliases, arbitrary attributes, or
50
+ component prop forwarding.
51
+ - Proves native Popover Escape, light dismiss, trigger focus restoration,
52
+ retained reorder identity, inserted and remounted IDs, and route cleanup.
53
+ - Adds no overlay runtime, focus manager, positioning engine, Portal/Slot
54
+ reconciliation, retained component tree, or public API.
55
+ - Updates `create-kudzu@0.1.126` to generate projects on
56
+ `@kudzujs/core@^0.15.1`.
57
+
58
+ ### Output And Browser Evidence
59
+
60
+ - The maintained application emits 44 files totaling 171,202 raw / 53,629
61
+ aggregate gzip bytes; `/help` remains 0 B JavaScript.
62
+ - The two-route session remains 17 JavaScript files totaling 77,779 raw /
63
+ 27,050 aggregate gzip bytes.
64
+ - Seven Chrome profiles record a 0.5 ms table-update median and 2.5 ms
65
+ navigation median, within prior retained-path ranges.
66
+
67
+ ### Upgrade
68
+
69
+ ```sh
70
+ npm install @kudzujs/core@^0.15.1
71
+ ```
72
+
3
73
  ## 0.14.3 - 10,000-Item Browser Decision
4
74
 
5
75
  Kudzu 0.14.3 measures direct DOM, pagination, and scroll-window alternatives
@@ -2,9 +2,9 @@
2
2
 
3
3
  ## Document Role
4
4
 
5
- This document is the detailed delivery plan for [`0.9-semantic-compression.md`](./0.9-semantic-compression.md). The Semantic Compression document defines direction, work order, and release gates. This document decomposes that work into executable sessions, artifacts, tests, measurements, reviews, and stop conditions.
5
+ This document is the completed detailed delivery record for [`0.9-semantic-compression.md`](./0.9-semantic-compression.md). It retains the executable sessions, artifacts, tests, measurements, reviews, and stop conditions that produced `0.9.0`; current execution follows the application capability release plan.
6
6
 
7
- This plan does not mark `0.9.0` as released. The current package version remains the version recorded in `package.json`. Package metadata, release notes, public support claims, and generator ranges change only during the final release procedure after every gate passes.
7
+ `0.9.0` is released and this plan is historical. The current package version remains the version recorded in `package.json`; package metadata, release notes, public support claims, and generator ranges still change only through the active release procedure after every gate passes.
8
8
 
9
9
  Use this document to answer:
10
10
 
@@ -4,12 +4,12 @@
4
4
 
5
5
  This is the completed execution record after `0.8.62`. Every release gate in this document is satisfied by `0.9.0`; follow-on model-driven delivery work remains separately gated for 1.0.
6
6
 
7
- The executable session-by-session decomposition, current implementation seams, benchmark restoration work, report templates, and release procedure are maintained in [`0.9-implementation-plan.md`](./0.9-implementation-plan.md). This document remains authoritative for direction, order, and release gates; the detailed plan is authoritative for execution within each ordered item.
7
+ The historical session-by-session decomposition, implementation seams, benchmark restoration work, report templates, and release procedure are retained in [`0.9-implementation-plan.md`](./0.9-implementation-plan.md). This document remains authoritative for the completed 0.9 direction, order, and release gates; current execution follows the application capability release plan.
8
8
 
9
- Read documents in this order before 0.9 work:
9
+ Read documents in this order when reviewing the completed 0.9 work:
10
10
 
11
11
  1. [`MIGRATION_ROADMAP.md`](../../MIGRATION_ROADMAP.md) for product invariants and feature selection.
12
- 2. This document for the active 0.9 scope and work order.
12
+ 2. This document for the completed 0.9 scope and work order.
13
13
  3. [`0.9-implementation-plan.md`](./0.9-implementation-plan.md) for executable session packets and evidence.
14
14
  4. [`compiler-current-architecture.md`](./compiler-current-architecture.md) for current owners and data flow.
15
15
  5. [`performance-gates.md`](./performance-gates.md) for required measurements.
@@ -7,7 +7,7 @@ The top-level [`GOAL_A.md`](../../GOAL_A.md) and [`GOAL_B.md`](../../GOAL_B.md)
7
7
  | Goal | Decision | Start condition |
8
8
  |---|---|---|
9
9
  | A: compiler foundation | Complete in `0.8.23` | The no-write source compiler, RouteIR v1, CapabilityIR v1, generator boundaries, and output baseline are recorded. |
10
- | B: optimization benchmarks | Active | Keyed restoration, no-op normalization, exact route-entry transform reuse, and identical route-entry file sharing are retained; continue only when another current fixture reproduces a material loss. |
10
+ | B: optimization benchmarks | Maintained gate | Keyed restoration, no-op normalization, exact route-entry transform reuse, and identical route-entry file sharing are retained; continue only when another current fixture reproduces a material loss. |
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 |
13
13
 
@@ -15,7 +15,7 @@ The completed `0.9.0` milestone is recorded in [`0.9-semantic-compression.md`](.
15
15
 
16
16
  [`1.0-large-application-compatibility-audit.md`](./1.0-large-application-compatibility-audit.md) records the first post-0.9 probes against Memos, Apache Answer, and Actual Budget. The audit finds that reduced slices build but whole-application source retention and behavior parity do not yet pass.
17
17
 
18
- [`application-capability-release-plan.md`](./application-capability-release-plan.md) is the authoritative post-0.9 execution queue. It assigns one application-capability section to each minor release and one independently accepted evidence packet to each patch release from `0.10.0` through the `1.0.0` gate. It supersedes the provisional 0.10/0.11/0.12 tool-first ordering in the completed 0.9 handoff without rewriting that historical record.
18
+ [`application-capability-release-plan.md`](./application-capability-release-plan.md) is the authoritative post-0.9 execution queue, currently at `0.16.2`. It assigns one application-capability section to each minor release and one independently accepted evidence packet to each patch release from `0.10.0` through the `1.0.0` gate. It supersedes the provisional 0.10/0.11/0.12 tool-first ordering in the completed 0.9 handoff without rewriting that historical record.
19
19
 
20
20
  ## Required Invariants
21
21
 
@@ -959,7 +959,7 @@ projects on `@kudzujs/core@^0.14.1`.
959
959
  - **Done condition:** the full journey passes with explicit network and memory
960
960
  bounds.
961
961
 
962
- Release candidate evidence complete. The maintained project application adds one
962
+ Published release evidence complete. The maintained project application adds one
963
963
  intrinsic sentinel, an owned native `IntersectionObserver`, cursor and request
964
964
  primitive state, an owned fetch with `AbortController`, immutable keyed append,
965
965
  duplicate ID suppression, and explicit loading/error/retry/end UI. The authored
@@ -1030,6 +1030,13 @@ virtual range ownership.
1030
1030
  - **Done condition:** range semantics are package-neutral, optional, measured,
1031
1031
  and absent from non-virtual routes.
1032
1032
 
1033
+ Closed by stop condition. The `0.14.3` decision selected pagination over both
1034
+ direct 10,000-row DOM and the authored fixed-height scroll window. No independent
1035
+ application fixture proves pagination or application-owned bounded ranges
1036
+ insufficient, against the required three. Kudzu therefore adds no visible-range
1037
+ owner, measurement policy, range primitive, or virtualization runtime and moves
1038
+ to the next evidence-ready application capability.
1039
+
1033
1040
  ## `0.15.x`: Overlay And Layer Ownership
1034
1041
 
1035
1042
  ### `0.15.0`: Native Dialog Baseline
@@ -1040,6 +1047,32 @@ virtual range ownership.
1040
1047
  - **Stop condition:** a custom modal/focus-trap runtime replaces `<dialog>`.
1041
1048
  - **Done condition:** project delete and edit confirmation pass accessibly.
1042
1049
 
1050
+ Validation evidence is complete and the packet closed without consuming a release
1051
+ because no production compiler or runtime changed. The maintained
1052
+ project application replaces immediate keyed project deletion with one shared
1053
+ native `<dialog>`, one route-owned object ref, and one pending project ID state.
1054
+ Existing row callback specialization opens the browser top layer; native cancel,
1055
+ explicit cancel, and confirmation handlers close it without a Portal, focus trap,
1056
+ overlay runtime, or retained component tree.
1057
+
1058
+ Required Chrome proves modal top-layer behavior, native initial focus, explicit
1059
+ cancel, Escape-equivalent `requestClose()`, trigger-focus restoration, confirmed
1060
+ keyed deletion with fallback focus, retained sibling identity, and exact top-layer
1061
+ release on enhanced route navigation. The static `/help` sibling remains 0 B
1062
+ JavaScript. The slice adds no semantic primitive, IR kind, compiler pass,
1063
+ production compiler/runtime line, normalization rule, adapter, runtime concept,
1064
+ or public API. The application emits 44 files totaling 169,778 raw / 53,294
1065
+ aggregate gzip bytes with deploy SHA-256
1066
+ `91c5517e69c341bbd2051f07b3f0ad7df627098ba8e1984561268ea8666f3407`.
1067
+ The maintained two-route session remains 17 JavaScript files totaling 77,453 raw
1068
+ / 26,905 aggregate gzip bytes.
1069
+
1070
+ Seven fresh macOS arm64 Chrome 151 profiles record table update samples of
1071
+ `[0.5, 0.6, 0.6, 0.6, 0.6, 0.4, 0.5]` ms, a 0.6 ms median and 0.4/0.6 ms range,
1072
+ plus navigation samples of `[2.3, 2.6, 2.9, 2.6, 2.5, 2.7, 2.5]` ms, a 2.6 ms
1073
+ median and 2.3/2.9 ms range. These retained-path ranges overlap the previous
1074
+ same-environment evidence, so no timing improvement or regression is claimed.
1075
+
1043
1076
  ### `0.15.1`: Popover, Dropdown, And Menu Behavior
1044
1077
 
1045
1078
  - **Purpose:** establish the native-first non-modal overlay boundary.
@@ -1050,6 +1083,48 @@ virtual range ownership.
1050
1083
  - **Stop condition:** package Portal/Slot reconciliation enters the runtime.
1051
1084
  - **Done condition:** supported native and custom boundaries are explicit.
1052
1085
 
1086
+ Completed with the maintained project application as the qualifying fixture. A
1087
+ keyed project row now authors one top-level `useId()`, a native
1088
+ `popovertarget`, and a native `popover="auto"` action group. The compiler scopes
1089
+ the generated ID by the existing keyed ownership path and the list runtime
1090
+ rewrites only intrinsic `id`, `for`, static ARIA IDREF, `form`, `headers`,
1091
+ `list`, and `popovertarget` attributes. Unsupported ID escapes receive a source
1092
+ diagnostic. Native Chrome behavior supplies the top layer, Escape, light
1093
+ dismiss, and trigger focus restoration; ordinary keyed ownership preserves IDs
1094
+ across reorder, scopes inserted rows, recreates deterministic IDs after
1095
+ remove/re-add, and releases an open popover during enhanced navigation. The
1096
+ authored overlay uses static anchor positioning and `role="group"`, so menu
1097
+ roving focus is not applicable.
1098
+
1099
+ The slice adds no semantic primitive, IR kind, compiler pass, runtime concept,
1100
+ public API, Portal/Slot reconciliation, focus manager, positioning engine, or
1101
+ retained overlay tree. It adds 52 and removes 24 production lines across the
1102
+ source compiler, RouteIR validation, capability projection, build renderer, and
1103
+ existing list runtime. The maintained application emits 44 files totaling
1104
+ 171,202 raw / 53,629 aggregate gzip bytes with deploy SHA-256
1105
+ `46ba45b4a423b9627c6ede5f8dd3a667f593cb5401454ae42ecc66207b07b323`.
1106
+ The two-route session remains 17 JavaScript files totaling 77,779 raw / 27,050
1107
+ aggregate gzip bytes, a 326 raw / 145 gzip byte increase from `0.15.0`; the
1108
+ static `/help` sibling remains 0 B JavaScript. Familiar React-shaped `useId()`
1109
+ authoring remains intact, so no application adapter or migration-only source
1110
+ structure is added.
1111
+
1112
+ Seven fresh macOS arm64 Chrome 151 profiles record table update samples of
1113
+ `[0.4, 0.4, 0.5, 0.4, 0.5, 0.5, 0.5]` ms, a 0.5 ms median and 0.4/0.5 ms range,
1114
+ plus navigation samples of `[4, 2.4, 2.6, 2.5, 2.5, 2.8, 2.4]` ms, a 2.5 ms
1115
+ median and 2.4/4 ms range. These retained-path ranges overlap prior evidence,
1116
+ so no timing improvement or regression is claimed.
1117
+
1118
+ Release transaction complete at commit
1119
+ `2dd5c409d3553cdf0b04080f1ba7c39b4541c523` and immutable tag `v0.15.1`.
1120
+ CI run `33069675599` passed both jobs. The GitHub release is
1121
+ [`v0.15.1`](https://github.com/kudzujs/kudzu/releases/tag/v0.15.1), and publish
1122
+ workflow run `33071439012` published and verified `@kudzujs/core@0.15.1` and
1123
+ `create-kudzu@0.1.126`. A fresh registry install imported `useId()` from core
1124
+ and confirmed both exact package versions. The first publish attempt stopped at
1125
+ the existing concurrent Chrome timing gate before any publish step; the exact
1126
+ same immutable tag passed on the release-event retry.
1127
+
1053
1128
  ### `0.15.2`: Toast And Notification Ownership
1054
1129
 
1055
1130
  - **Purpose:** provide application feedback across route mutations.
@@ -1061,6 +1136,15 @@ virtual range ownership.
1061
1136
  - **Done condition:** notifications remain owner-bounded and absent from unused
1062
1137
  route output.
1063
1138
 
1139
+ **Result:** closed by ordinary layout composition. The maintained project
1140
+ application now owns one deduplicated notification queue in layout state, renders
1141
+ keyed rows in a native polite live region, and uses one cleanup-owned sequential
1142
+ timeout plus explicit dismissal. Required Chrome preserves the live-region and
1143
+ row identities through enhanced route replacement, proves timeout removal and
1144
+ disposal, and keeps `/help` at zero JavaScript. The 45-file deploy totals 193,770
1145
+ raw / 60,227 aggregate gzip bytes. No scheduler, primitive, runtime concept, or
1146
+ production source changed, so no release was consumed.
1147
+
1064
1148
  ### `0.15.3`: Layer Owner Decision
1065
1149
 
1066
1150
  - **Purpose:** decide whether logical ownership of DOM rendered outside its
@@ -1071,6 +1155,13 @@ virtual range ownership.
1071
1155
  - **Done condition:** no new concept is added, or a minimal owner edge with exact
1072
1156
  cleanup/focus order is approved.
1073
1157
 
1158
+ **Result:** closed with no owner edge. Native dialog and Popover already satisfy
1159
+ modal and non-modal top-layer behavior, while maintained dropdown, tooltip,
1160
+ notification, and menu fixtures compose in their authored ranges. Zero fixtures
1161
+ fail because DOM must render outside its authored range, below the required three
1162
+ independent failures. Portal source compatibility alone remains insufficient;
1163
+ no production source or release changed.
1164
+
1074
1165
  ## `0.16.x`: External Complex UI
1075
1166
 
1076
1167
  ### `0.16.0`: Bounded External UI Contract
@@ -1084,6 +1175,12 @@ virtual range ownership.
1084
1175
  - **Done condition:** package ownership remains inside effect/handler ESM and no
1085
1176
  generic widget runtime is added.
1086
1177
 
1178
+ **Result:** closed through existing effect ownership. A real Typed.js instance
1179
+ creates DOM from one attached host ref, replaces on application-state dependency
1180
+ change, disposes on conditional/document release, and remounts fresh. The package
1181
+ is absent from the complete zero-JavaScript static sibling. No compiler, IR,
1182
+ runtime concept, or production source changed, so no release was consumed.
1183
+
1087
1184
  ### `0.16.1`: Real Editor Lifecycle
1088
1185
 
1089
1186
  - **Purpose:** integrate one real CodeMirror-class editor.
@@ -1093,6 +1190,18 @@ virtual range ownership.
1093
1190
  - **Done condition:** the native package owns its DOM and Kudzu owns only its
1094
1191
  bounded lifecycle.
1095
1192
 
1193
+ **Result:** complete. The Apache Answer-derived CodeMirror fixture exposes the
1194
+ first real retained-instance failure: an unattached `useRef(null)` shared by one
1195
+ mount/cleanup effect and a later dependency update effect was incorrectly treated
1196
+ as a DOM ref. Kudzu now lowers exactly one proven retained handle to existing
1197
+ owner-scoped object state. Required Chrome proves initial value, retained
1198
+ application updates, editor-to-state updates, package-error recovery, accessible
1199
+ textbox naming, conditional disposal, fresh remount, and document disposal. The
1200
+ fixture emits 214,968 raw / 71,607 aggregate gzip JavaScript bytes while its
1201
+ static sibling emits zero. One existing normalization pass grows; semantic
1202
+ concepts, IR kinds, runtime concepts, runtime files, and public APIs remain
1203
+ unchanged.
1204
+
1096
1205
  ### `0.16.2`: Chart And Map Lifecycle
1097
1206
 
1098
1207
  - **Purpose:** validate the same contract against unrelated canvas/SVG/map DOM.
@@ -1477,5 +1586,12 @@ release transaction where possible or document and publish a forward-fix patch.
1477
1586
  | `0.14.0` | Released | Preserve native table CRUD, retained identity, keyboard access, and measured update evidence. | None |
1478
1587
  | `0.14.1` | Released | Preserve immutable object-state replacement, nested keyed identity, latest handlers, and exact row release. | None |
1479
1588
  | `0.14.2` | Released | Preserve bounded cursor loading, duplicate suppression, retry/end behavior, retained identity, and route cleanup. | None |
1480
- | `0.14.3` | Release candidate | Complete commit, CI, tag, GitHub release, npm publication, and registry verification. | Release transaction |
1481
- | `0.14.4` onward | Blocked | Complete or explicitly close `0.14.3` first. | Ordered patch boundary |
1589
+ | `0.14.3` | Released | Preserve the published 10,000-item pagination decision and measurement evidence. | None |
1590
+ | `0.14.4` | Closed by stop condition | Preserve pagination as the selected strategy until three independent fixtures authorize virtual ownership review. | Evidence favors pagination; zero qualifying fixtures |
1591
+ | `0.15.0` | Closed by native composition | Preserve the native dialog, focus, cancellation, keyed deletion, and route cleanup evidence. | No production change; no release consumed |
1592
+ | `0.15.1` | Released | Preserve key-scoped IDs, native Popover behavior, retained keyed identity, and route cleanup evidence. | None |
1593
+ | `0.15.2` | Closed by layout composition | Preserve owner-bounded queue, deduplication, dismissal, route persistence, live-region, and timer cleanup evidence. | No production change; no release consumed |
1594
+ | `0.15.3` | Closed by stop condition | Preserve native top-layer and in-place composition until three independent authored-range failures exist. | Zero qualifying failures; no release consumed |
1595
+ | `0.16.0` | Closed by existing effect ownership | Preserve real package mount, replacement, disposal, remount, and static exclusion evidence. | No production change; no release consumed |
1596
+ | `0.16.1` | Released | Preserve the retained CodeMirror instance boundary, bidirectional updates, error recovery, accessibility, and exact cleanup. | None |
1597
+ | `0.16.2` | Active | Validate the same lifecycle against one unrelated real chart or map package. | None |
@@ -1,6 +1,6 @@
1
1
  # Current Compiler Architecture
2
2
 
3
- This maps the current `0.9.0` architecture, built on the completed `0.8.23` Goal A compiler foundation. File and function names are the stable references; line numbers are intentionally omitted because later work may still move code.
3
+ This maps the current `0.16.1` architecture, built on the completed `0.9.0` semantic-compression release and `0.8.23` Goal A compiler foundation. The active application packet is `0.16.2`; file and function names are the stable references, while line numbers are intentionally omitted because later work may still move code.
4
4
 
5
5
  ## Responsibility Map
6
6
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Completed compiler-foundation record and longer-term plan after `0.8.35`. The active post-`0.8.62` execution queue is [`0.9-semantic-compression.md`](./0.9-semantic-compression.md). This document does not mark any remaining planned capability as supported and does not authorize a React runtime, VDOM, hydration, retained browser component tree, generic rerenderer, public store/query/resource API, SPA router, or islands.
5
+ Completed compiler-foundation record and longer-term plan after `0.8.35`. The [`0.9-semantic-compression.md`](./0.9-semantic-compression.md) execution queue is complete. The active queue for current work is [`application-capability-release-plan.md`](./application-capability-release-plan.md) at packet `0.16.2`. This document does not mark any remaining planned capability as supported and does not authorize a React runtime, VDOM, hydration, retained browser component tree, generic rerenderer, public store/query/resource API, SPA router, or islands.
6
6
 
7
- [`MIGRATION_ROADMAP.md`](../../MIGRATION_ROADMAP.md) remains authoritative for product invariants and fixture-driven feature selection. [`0.9-semantic-compression.md`](./0.9-semantic-compression.md) is authoritative for current work order and evidence; this plan retains the completed foundation, deferred program, and long-term production gates. If implementation evidence changes either boundary, update the relevant document before broadening a patch.
7
+ [`MIGRATION_ROADMAP.md`](../../MIGRATION_ROADMAP.md) remains the product authority for product invariants and fixture-driven feature selection. [`application-capability-release-plan.md`](./application-capability-release-plan.md) is authoritative for current work order and evidence at packet `0.16.2`; this plan retains the completed foundation, deferred program, and long-term production gates. If implementation evidence changes either boundary, update the relevant document before broadening a patch.
8
8
 
9
9
  ## Product Outcome
10
10
 
@@ -453,7 +453,7 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
453
453
 
454
454
  ## Production Gates Before 1.0
455
455
 
456
- - The active `0.9.0` cross-framework gate proves lower browser cost and matched user-facing performance against React + Vite, Vue, Svelte, and Astro before AI productivity claims are considered.
456
+ - The completed `0.9.0` cross-framework gate proves lower browser cost and matched user-facing performance against React + Vite, Vue, Svelte, and Astro before AI productivity claims are considered.
457
457
  - The maintained AI delivery suite uses the same model, tools, requirements, budgets, and acceptance checks across frameworks, includes failed attempts, and establishes the highest success rate plus lowest median cost per successful task before `1.0.0`.
458
458
  - Async native and effect work cannot write after ownership release.
459
459
  - Build output is staged, collision-safe, and rollback/recovery guarded.
@@ -483,4 +483,4 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
483
483
 
484
484
  ## Immediate Decision
485
485
 
486
- PR 1 through PR 12, the `0.8.40` property-dependency slice, the `0.8.41` and `0.8.43` direct multi-boundary callback/ref slices, the `0.8.44` Context alias slice, the `0.8.46` action-only Provider setter slice, the `0.8.47` direct primitive, `0.8.57` plain-object prop, `0.8.58` direct keyed item, `0.8.59` direct array prop initializer, `0.8.60` matching array-draft setter-effect, `0.8.61` parameterized primitive debounce-hook, and `0.8.62` direct-ref outside-click-hook slices, the `0.8.48` repeated direct leaf-handler callback slice, the `0.8.49` direct child callback fan-out slice, the `0.8.50` package-neutral shared-state/action slice, the `0.8.51` owned-effect package import slice, the `0.8.52` effect-private mutable-ref slice, the `0.8.53` route/layout CSS closure slice, the `0.8.54` structural per-route capability/chunk report, the `0.8.55` signature-keyed runtime families, item 7 incremental source and affected-route builds, and the `0.8.42` measured route-output optimization are complete. Continue with `0.9.0-01` in the active Semantic Compression plan; keep ResourceIR limited to qualifying independent fixtures, and do not add range ownership, virtualization, optimistic transactions, a public adapter/store API, or a router before evidence justifies them.
486
+ All listed foundation and `0.9` slices are complete. Current work continues at `0.16.2` Chart And Map Lifecycle under the [`application-capability-release-plan.md`](./application-capability-release-plan.md); keep ResourceIR limited to qualifying independent fixtures, and do not add range ownership, virtualization, optimistic transactions, a public adapter/store API, or a router before evidence justifies them.
@@ -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.62` are completed scopes represented by package/release records. The active `0.10.0` through `1.0.0` minor/patch sequence is maintained in [`application-capability-release-plan.md`](./application-capability-release-plan.md); that plan supersedes the provisional tool-first 0.10/0.11/0.12 ordering in the completed 0.9 handoff.
3
+ This is an execution sequence, not release history. `0.8.16` through `0.8.62` are completed scopes represented by package/release records. The `0.10.0` through `1.0.0` minor/patch sequence is maintained in [`application-capability-release-plan.md`](./application-capability-release-plan.md), currently at `0.16.2`; that plan supersedes the provisional tool-first 0.10/0.11/0.12 ordering in the completed 0.9 handoff.
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
 
@@ -66,7 +66,7 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
66
66
 
67
67
  ## Generator Versions
68
68
 
69
- `create-kudzu@0.1.125` retains the explicit install instructions and generates projects with `@kudzujs/core@^0.14.3`.
69
+ `create-kudzu@0.1.127` retains the explicit install instructions and generates projects with `@kudzujs/core@^0.16.1`.
70
70
 
71
71
  ## Release Boundary
72
72
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Kudzu specializes ordinary common React-shaped TSX so migrations need minimal source restructuring. Declarative components, collection pipelines, conditions, hooks, and handlers should be lowered at build time rather than replaced with application-owned imperative DOM code. This principle applies across migrations and is not Stay-specific; it does not imply a React package, VDOM, hydration, or ecosystem runtime.
4
4
 
5
- Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. A named or default zero-argument custom hook imported from a relative TypeScript module may return direct shorthand state/setter pairs and callbacks that capture those states, including a callback that resets multiple states to direct primitive literals; callers use one top-level `const` object destructuring without aliases, defaults, or rest. Relative Context-hook actions may require exposed Provider state/setter fields without forcing those hidden fields into public consumer destructuring; compiler-owned collision-free aliases preserve same-named consumer locals. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array, including type-only `as const` and `satisfies` wrappers, and may read direct local state declared in their dependency array. `compiler/react-migration-pass.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
5
+ Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. A named or default zero-argument custom hook imported from a relative TypeScript module may return direct shorthand state/setter pairs and callbacks that capture those states, including a callback that resets multiple states to direct primitive literals; callers use one top-level `const` object destructuring without aliases, defaults, or rest. Relative Context-hook actions may require exposed Provider state/setter fields without forcing those hidden fields into public consumer destructuring; compiler-owned collision-free aliases preserve same-named consumer locals. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows key-scope direct intrinsic ID and ID-reference attributes through existing list ownership by row key. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array, including type-only `as const` and `satisfies` wrappers, and may read direct local state declared in their dependency array. `compiler/react-migration-pass.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
6
6
 
7
7
  Compilation begins from page entries and follows relative runtime imports, re-exports, and validated Worker references; unreachable TypeScript migration files are not transformed. Direct maps over imported immutable JSON-safe arrays fold to literals for zero-JavaScript static rows. Synchronous relative calculation functions may return objects whose direct static fields feed reactive JSX bindings; build rendering uses current signal values and route-specific binding ESM reevaluates the same helper after state commits. One direct array field may instead feed a keyed intrinsic map: its evaluator refreshes a compiler-owned array anchor before the existing list reconciler runs, preserving keyed DOM and SVG identity without a calculation runtime. That field must remain a JSON-safe array after every source-state commit. Package imports have a separate narrow boundary: direct references inside intrinsic JSX event callbacks are erased from build modules and bundled into route handler ESM, while render-time, effect, helper-indirect, and mixed package use fails.
8
8
 
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript"
2
- import { effectReturns, importDeclarationNames, isNodeWithin, isShadowedIdentifier, nearestFunction, referenceIdentifiers, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
2
+ import { effectReturns, importDeclarationNames, isNodeWithin, isShadowedIdentifier, nearestFunction, referenceIdentifiers, referencesIdentifier, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
3
3
 
4
4
  export function normalizeEffectPrivateRefs(sourceFile, factory, context) {
5
5
  const frameCall = (node, name) => ts.isCallExpression(node) && (
@@ -24,8 +24,10 @@ export function normalizeEffectPrivateRefs(sourceFile, factory, context) {
24
24
  }
25
25
  const hasUseRefImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useRef"))
26
26
  const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
27
+ const hasUseStateImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useState"))
27
28
  const privateRefs = new Map()
28
29
  const effectRefs = new Map()
30
+ const retainedRefs = new Map()
29
31
  const registerPrivateRef = (node, callback) => {
30
32
  const initializer = ts.isNumericLiteral(node.initializer.arguments[0]) ? factory.createNumericLiteral(0) : factory.createNull()
31
33
  privateRefs.set(node, callback)
@@ -42,20 +44,60 @@ export function normalizeEffectPrivateRefs(sourceFile, factory, context) {
42
44
  const owner = nearestFunction(node)
43
45
  if (!owner?.body || !ts.isBlock(owner.body)) return
44
46
  const references = referenceIdentifiers(owner.body, node.name.text)
47
+ const attachedToJsx = references.some(reference => ts.isJsxExpression(reference.parent) && ts.isJsxAttribute(reference.parent.parent) && reference.parent.parent.name.text === "ref")
45
48
  const invalidReference = references.find(reference => !ts.isPropertyAccessExpression(reference.parent) || reference.parent.expression !== reference || reference.parent.name.text !== "current")
46
49
  const accesses = references.filter(reference => ts.isPropertyAccessExpression(reference.parent) && reference.parent.expression === reference && reference.parent.name.text === "current").map(reference => reference.parent)
50
+ const mutations = accesses.filter(access =>
51
+ (ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && access.parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ||
52
+ ((ts.isPrefixUnaryExpression(access.parent) || ts.isPostfixUnaryExpression(access.parent)) && access.parent.operand === access && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(access.parent.operator)) ||
53
+ (ts.isDeleteExpression(access.parent) && access.parent.expression === access)
54
+ )
47
55
  const frameAssignments = accesses.filter(access => ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && frameCall(unwrapExpression(access.parent.right), "requestAnimationFrame") && unshadowedFrameCall(unwrapExpression(access.parent.right), owner))
48
56
  const statement = node.parent?.parent
49
57
  const topLevelOwner = owner.parent === sourceFile || ts.isVariableDeclaration(owner.parent) && owner.parent.parent?.parent?.parent === sourceFile
50
58
  const topLevelConst = topLevelOwner && ts.isVariableStatement(statement) && (statement.declarationList.flags & ts.NodeFlags.Const) && statement.declarationList.declarations.length === 1 && statement.parent === owner.body
51
59
  if (frameAssignments.length && invalidReference) throw sourceNodeError(invalidReference, sourceFile, "Animation frame refs may only use direct .current reads and assignments")
52
60
  if (frameAssignments.length && !topLevelConst) throw sourceNodeError(node, sourceFile, "Animation frame refs must be one top-level component const")
61
+ if (attachedToJsx && mutations.length) throw sourceNodeError(mutations[0], sourceFile, "JSX object refs may not assign to ref.current")
53
62
  if (!topLevelConst || invalidReference || !accesses.length) return
54
63
  const effectCalls = owner.body.statements.flatMap(statement => hasUseEffectImport && ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect" && !isShadowedIdentifier(statement.expression.expression, sourceFile) ? [statement.expression] : [])
55
64
  const effects = effectCalls.filter(effect => {
56
65
  const callback = effect.arguments[0]
57
66
  return callback && accesses.every(access => isNodeWithin(access, callback))
58
67
  })
68
+ if (!frameAssignments.length && refInitializer?.kind === ts.SyntaxKind.NullKeyword && !invalidReference && !effects.length) {
69
+ const owners = effectCalls.filter(effect => {
70
+ const callback = effect.arguments[0]
71
+ return callback && accesses.some(access => isNodeWithin(access, callback))
72
+ })
73
+ const callbacks = owners.map(effect => effect.arguments[0])
74
+ const directCallbacks = callbacks.every(callback => (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) && ts.isBlock(callback.body))
75
+ const everyAccessOwned = accesses.every(access => callbacks.filter(callback => isNodeWithin(access, callback)).length === 1)
76
+ const mountEffects = owners.filter(effect => ts.isArrayLiteralExpression(effect.arguments[1]) && !effect.arguments[1].elements.length)
77
+ const updateEffects = owners.filter(effect => ts.isArrayLiteralExpression(effect.arguments[1]) && effect.arguments[1].elements.length)
78
+ if (owners.length >= 2 && directCallbacks && everyAccessOwned && mountEffects.length === 1 && updateEffects.length === owners.length - 1 && owners[0] === mountEffects[0]) {
79
+ const mount = mountEffects[0]
80
+ const callback = mount.arguments[0]
81
+ const returns = effectReturns(callback)
82
+ const cleanup = returns.cleanups.length === 1 ? returns.cleanups[0] : undefined
83
+ const writes = accesses.filter(access => ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken)
84
+ const setupWrites = writes.filter(access => isNodeWithin(access, callback) && (!cleanup || !isNodeWithin(access, cleanup)))
85
+ const cleanupWrites = cleanup ? writes.filter(access => isNodeWithin(access, cleanup)) : []
86
+ const setupStatement = setupWrites[0]?.parent.parent
87
+ const cleanupStatement = cleanupWrites[0]?.parent.parent
88
+ const directSetup = setupWrites.length === 1 && ts.isExpressionStatement(setupStatement) && setupStatement.parent === callback.body && unwrapExpression(setupWrites[0].parent.right).kind !== ts.SyntaxKind.NullKeyword
89
+ const directCleanup = cleanup && ts.isBlock(cleanup.body) && cleanupWrites.length === 1 && ts.isExpressionStatement(cleanupStatement) && cleanupStatement.parent === cleanup.body && unwrapExpression(cleanupWrites[0].parent.right).kind === ts.SyntaxKind.NullKeyword
90
+ const updateWrites = mutations.some(access => updateEffects.some(effect => isNodeWithin(access, effect.arguments[0])))
91
+ if (directSetup && directCleanup && !updateWrites) {
92
+ if (!hasUseStateImport) throw sourceNodeError(node, sourceFile, "Retained instance refs require a named useState import")
93
+ const setterName = `__kSetRetainedRef_${node.name.text}_${node.pos}`
94
+ if (referencesIdentifier(owner.body, setterName)) throw sourceNodeError(node, sourceFile, "Retained instance ref conflicts with a compiler-owned binding")
95
+ retainedRefs.set(node, setterName)
96
+ return
97
+ }
98
+ }
99
+ if (owners.length >= 2) throw sourceNodeError(node, sourceFile, "Retained instance refs require one empty-dependency mount effect with one direct assignment and null-reset cleanup followed by read-only dependency effects")
100
+ }
59
101
  if (!frameAssignments.length) {
60
102
  const callback = effects.length === 1 ? effects[0].arguments[0] : undefined
61
103
  if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) && ts.isBlock(callback.body)) {
@@ -107,9 +149,27 @@ export function normalizeEffectPrivateRefs(sourceFile, factory, context) {
107
149
  registerPrivateRef(node, callback)
108
150
  }
109
151
  inspect(sourceFile)
110
- if (!privateRefs.size) return sourceFile
152
+ const retainedByOwner = new Map()
153
+ for (const declaration of retainedRefs.keys()) {
154
+ const owner = nearestFunction(declaration)
155
+ const refs = retainedByOwner.get(owner) ?? []
156
+ refs.push(declaration)
157
+ retainedByOwner.set(owner, refs)
158
+ }
159
+ for (const refs of retainedByOwner.values()) if (refs.length > 1) throw sourceNodeError(refs[1], sourceFile, "Components may own only one retained instance ref")
160
+ if (!privateRefs.size && !retainedRefs.size) return sourceFile
111
161
  const visitor = node => {
112
162
  if (ts.isVariableStatement(node) && node.declarationList.declarations.length === 1 && privateRefs.has(node.declarationList.declarations[0])) return undefined
163
+ if (ts.isVariableDeclaration(node) && retainedRefs.has(node)) {
164
+ const binding = factory.createArrayBindingPattern([
165
+ factory.createBindingElement(undefined, undefined, node.name),
166
+ factory.createBindingElement(undefined, undefined, retainedRefs.get(node))
167
+ ])
168
+ const initializer = factory.createCallExpression(factory.createIdentifier("useState"), undefined, [
169
+ factory.createObjectLiteralExpression([factory.createPropertyAssignment("current", factory.createNull())])
170
+ ])
171
+ return factory.updateVariableDeclaration(node, binding, node.exclamationToken, undefined, initializer)
172
+ }
113
173
  const refs = effectRefs.get(node)
114
174
  if (refs && (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && ts.isBlock(node.body)) {
115
175
  const body = ts.visitEachChild(node.body, visitor, context)
@@ -68,7 +68,7 @@ export function planRouteCapabilities(records, { navigationRouteCount = 0 } = {}
68
68
  lists.expressionAttributes ||= Boolean(list.expressionAttributes)
69
69
  lists.seeds ||= Boolean(list.seed || list.valueSeed)
70
70
  lists.effects ||= Boolean(list.effects)
71
- lists.rowHooks ||= Boolean(list.rowStates?.length || list.rowRefs?.length)
71
+ lists.rowHooks ||= Boolean(list.rowStates?.length || list.rowRefs?.length || list.rowIds?.length)
72
72
  lists.rowRefs ||= Boolean(list.rowRefs?.length)
73
73
  lists.complexRowState ||= Boolean(list.rowStates?.some(state => state.initializer === "list-item" || state.initialValue !== null && typeof state.initialValue === "object"))
74
74
  lists.nested ||= Boolean(list.ownerField)
@@ -77,7 +77,7 @@ export function planRouteCapabilities(records, { navigationRouteCount = 0 } = {}
77
77
  lists.static ||= Boolean(list.static)
78
78
  lists.indexes ||= Boolean(list.indexed)
79
79
  lists.stableFastPaths ||= !list.children && !list.ownerField && list.key !== null && !list.indexed && !list.reducer && !list.selector
80
- lists.generalRowHooks ||= Boolean(list.ownerField && (list.rowStates?.length || list.rowRefs?.length))
80
+ lists.generalRowHooks ||= Boolean(list.rowIds?.length || list.ownerField && (list.rowStates?.length || list.rowRefs?.length))
81
81
  lists.mounts ||= Boolean(list.mount)
82
82
  }
83
83
  }
@@ -164,7 +164,7 @@ function assertList(list, index, ids, stateIds, listIds, parentByChild) {
164
164
  parentByChild.set(child.id, list.id)
165
165
  }
166
166
  for (const row of list.rowStates ?? []) if (!isRecord(row) || !rowTemplate(row.id) || !Object.hasOwn(row, "initialValue") || row.initializer !== undefined && row.initializer !== "list-item") throw new Error(`${label} has invalid row state`)
167
- for (const row of [...(list.rowRefs ?? []), ...(list.rowConditions ?? [])]) if (!rowTemplate(row)) throw new Error(`${label} has invalid row ownership ID`)
167
+ for (const row of [...(list.rowRefs ?? []), ...(list.rowIds ?? []), ...(list.rowConditions ?? [])]) if (!rowTemplate(row)) throw new Error(`${label} has invalid row ownership ID`)
168
168
  if (list.seed && list.valueSeed || list.seed !== undefined && !validSeed(list.seed) || list.valueSeed !== undefined && !validSeed(list.valueSeed)) throw new Error(`${label} has invalid seed`)
169
169
  }
170
170
 
@@ -517,6 +517,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
517
517
  let usesListItem = false
518
518
  let usesRowState = false
519
519
  let usesRowRef = false
520
+ let usesRowId = false
520
521
  let usesComponentState = false
521
522
  let usesComponentId = false
522
523
  let usesComponentRef = false
@@ -1199,7 +1200,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1199
1200
  ...result.ordinaryStates.map(({ state, setter, source }) => ({ name: state, setter, kind: "component", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) }))
1200
1201
  ],
1201
1202
  refs: [...result.rowRefs.map(({ name, source }) => ({ name, kind: "row", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) })), ...result.ordinaryRefs.map(({ name, source }) => ({ name, kind: "component", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) }))],
1202
- ids: result.ordinaryIds.map(({ name, source }) => ({ name, ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) }))
1203
+ ids: [...result.rowIds.map(({ name, source }) => ({ name, kind: "row", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) })), ...result.ordinaryIds.map(({ name, source }) => ({ name, kind: "component", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) }))]
1203
1204
  })
1204
1205
  result.propStateOwners = new Map(result.props.flatMap(prop => {
1205
1206
  const expression = result.propExpressions.get(prop.name)
@@ -1216,7 +1217,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1216
1217
  return result
1217
1218
  }
1218
1219
  const registerRowHooks = (call, specialization) => {
1219
- if (!specialization.rowStates.length && !specialization.rowRefs.length) return
1220
+ if (!specialization.rowStates.length && !specialization.rowRefs.length && !specialization.rowIds.length) return
1220
1221
  let owner
1221
1222
  for (let current = call.parent; current; current = current.parent) {
1222
1223
  if (isFunctionLike(current) && settersByFunction.has(current)) {
@@ -1236,6 +1237,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1236
1237
  rowHookCalls.push(call)
1237
1238
  usesRowState ||= specialization.rowStates.length > 0
1238
1239
  usesRowRef ||= specialization.rowRefs.length > 0
1240
+ usesRowId ||= specialization.rowIds.length > 0
1239
1241
  }
1240
1242
  const mergeSpecializedImports = (root, componentSource, call, effects = []) => {
1241
1243
  const componentImports = clientImportBindings(componentSource, componentSource.fileName, sourceFiles)
@@ -1716,7 +1718,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1716
1718
  let count = 0
1717
1719
  const visit = (node, currentAggregate = aggregate) => {
1718
1720
  if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) {
1719
- const nestedAggregate = { calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [], specializations: [] }
1721
+ const nestedAggregate = { calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [], rowIds: [], specializations: [] }
1720
1722
  for (const argument of node.arguments) visit(argument, nestedAggregate)
1721
1723
  if (nestedAggregate.hookDeclarations.length || nestedAggregate.effects.length) nestedRowSpecializations.set(`${node.pos}:${node.end}`, nestedAggregate)
1722
1724
  return
@@ -1749,6 +1751,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1749
1751
  currentAggregate.hookDeclarations.push(...specialization.hookDeclarations)
1750
1752
  currentAggregate.rowStates.push(...specialization.rowStates)
1751
1753
  currentAggregate.rowRefs.push(...specialization.rowRefs)
1754
+ currentAggregate.rowIds.push(...specialization.rowIds)
1752
1755
  }
1753
1756
  replacements.set(node, specialization.root)
1754
1757
  count++
@@ -1784,7 +1787,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1784
1787
  }
1785
1788
  for (const { node, parts: originalParts } of rawRenderedLists) {
1786
1789
  if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
1787
- const specialization = componentSpecializations.get(originalParts.root) ?? { root: originalParts.root, calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [], ordinaryStates: [] }
1790
+ const specialization = componentSpecializations.get(originalParts.root) ?? { root: originalParts.root, calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [], rowIds: [], ordinaryStates: [] }
1788
1791
  const componentSource = specialization.componentSource ?? sourceFile
1789
1792
  specialization.root = expandKeyedComponents(specialization.root, componentSource, specialization.component ? [specialization.component] : [], specialization)
1790
1793
  if (specialization.imported) synthesizeTree(specialization.root = mergeSpecializedImports(specialization.root, componentSource, originalParts.root, specialization.effects))
@@ -1811,6 +1814,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1811
1814
  specializations: [specialization.analysis?.slot, ...(specialization.specializations ?? [])].filter(slot => slot !== undefined),
1812
1815
  rowStates: [...specialization.rowStates, ...specialization.ordinaryStates],
1813
1816
  rowRefs: specialization.rowRefs,
1817
+ rowIds: specialization.rowIds,
1814
1818
  analysisStateOwners: new Map([...stateOwnersForNode(originalParts.root), ...(specialization.propStateOwners ?? []), ...[...specialization.rowStates, ...specialization.ordinaryStates].map(state => [state.state, state.analysisReference])])
1815
1819
  }
1816
1820
  for (const calculation of specialization.calculations) {
@@ -2297,6 +2301,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2297
2301
  if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
2298
2302
  if (usesRowState) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useState"), factory.createIdentifier("__kRowUseState")))
2299
2303
  if (usesRowRef) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useRef"), factory.createIdentifier("__kRowUseRef")))
2304
+ if (usesRowId) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useId"), factory.createIdentifier("__kRowUseId")))
2300
2305
  if (usesComponentState) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useState"), factory.createIdentifier("__kComponentUseState")))
2301
2306
  if (usesComponentId) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useId"), factory.createIdentifier("__kComponentUseId")))
2302
2307
  if (usesComponentRef) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useRef"), factory.createIdentifier("__kComponentUseRef")))
@@ -2656,7 +2661,6 @@ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpeci
2656
2661
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
2657
2662
  }
2658
2663
  const visit = node => {
2659
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useId") fail(node, "useId() is not supported in keyed rows")
2660
2664
  if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
2661
2665
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
2662
2666
  if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, nestedDiagnostic)
@@ -2697,6 +2701,7 @@ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpeci
2697
2701
  specializations: [specialization?.analysis?.slot, ...(specialization?.specializations ?? [])].filter(slot => slot !== undefined),
2698
2702
  rowStates: specializedStates,
2699
2703
  rowRefs: specialization?.rowRefs ?? [],
2704
+ rowIds: specialization?.rowIds ?? [],
2700
2705
  analysisStateOwners: new Map([...(parts.analysisStateOwners ?? []), ...(specialization?.propStateOwners ?? []), ...specializedStates.map(state => [state.state, state.analysisReference])])
2701
2706
  }
2702
2707
  for (const calculation of specialization?.calculations ?? []) {
@@ -2747,6 +2752,8 @@ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpeci
2747
2752
  return analysis
2748
2753
  }
2749
2754
 
2755
+ const keyedIdReferenceAttributes = new Set(["id", "htmlFor", "aria-activedescendant", "aria-controls", "aria-describedby", "aria-details", "aria-errormessage", "aria-flowto", "aria-labelledby", "aria-owns", "form", "headers", "list", "popovertarget"])
2756
+
2750
2757
  function directConstObjectLiteral(expression, call) {
2751
2758
  expression = unwrapExpression(expression)
2752
2759
  if (ts.isObjectLiteralExpression(expression)) return expression
@@ -2947,6 +2954,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2947
2954
  const hookDeclarations = []
2948
2955
  const rowStates = []
2949
2956
  const rowRefs = []
2957
+ const rowIds = []
2950
2958
  const ordinaryStates = []
2951
2959
  const ordinaryRefs = []
2952
2960
  const ordinaryIds = []
@@ -3012,13 +3020,14 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3012
3020
  continue
3013
3021
  }
3014
3022
  if (declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useId") {
3015
- if (!ordinaryHooks) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "useId() is not supported in keyed row components")
3016
- if (declaration.initializer.arguments.length || !ts.isIdentifier(declaration.name)) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "Setter-callback component useId() must initialize one top-level const identifier without arguments")
3017
- const name = `__kComponentId${Math.max(0, call.pos)}_${hookDeclarations.length}`
3023
+ const hookLabel = ordinaryHooks ? "Setter-callback component" : "Keyed row"
3024
+ if (declaration.initializer.arguments.length || !ts.isIdentifier(declaration.name)) throw sourceNodeError(declaration.initializer, component.getSourceFile(), `${hookLabel} useId() must initialize one top-level const identifier without arguments`)
3025
+ const ids = ordinaryHooks ? ordinaryIds : rowIds
3026
+ const name = `${ordinaryHooks ? "__kComponentId" : "__kRowId"}${Math.max(0, call.pos)}_${ids.length}`
3018
3027
  substitutions.set(declaration.name.text, factory.createIdentifier(name))
3019
- const initializer = factory.createCallExpression(factory.createIdentifier("__kComponentUseId"), undefined, [])
3028
+ const initializer = factory.createCallExpression(factory.createIdentifier(ordinaryHooks ? "__kComponentUseId" : "__kRowUseId"), undefined, [])
3020
3029
  hookDeclarations.push(factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(factory.createIdentifier(name), undefined, undefined, initializer)], ts.NodeFlags.Const)))
3021
- ordinaryIds.push({ name, source: declaration })
3030
+ ids.push({ name, local: declaration.name.text, source: declaration })
3022
3031
  continue
3023
3032
  }
3024
3033
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, `${label} component locals must be initialized identifiers`)
@@ -3029,6 +3038,10 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3029
3038
  }
3030
3039
  returned = last.expression
3031
3040
  }
3041
+ for (const id of rowIds) {
3042
+ for (const reference of referenceIdentifiers(returned, id.local)) if (!validKeyedIdReference(reference)) throw sourceNodeError(reference, component.getSourceFile(), "Keyed row useId() values may only be used in intrinsic id and ID-reference attributes")
3043
+ for (const calculation of calculations) if (referenceIdentifiers(calculation.expression, id.local).length) throw sourceNodeError(calculation.expression, component.getSourceFile(), "Keyed row useId() values may only be used directly in intrinsic id and ID-reference attributes")
3044
+ }
3032
3045
  let unsupportedHook
3033
3046
  const findUnsupportedHook = node => {
3034
3047
  if (unsupportedHook) return
@@ -3058,6 +3071,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3058
3071
  hookDeclarations,
3059
3072
  rowStates,
3060
3073
  rowRefs,
3074
+ rowIds,
3061
3075
  ordinaryStates,
3062
3076
  ordinaryRefs,
3063
3077
  ordinaryIds,
@@ -3068,6 +3082,16 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3068
3082
  }
3069
3083
  }
3070
3084
 
3085
+ function validKeyedIdReference(reference) {
3086
+ for (let current = reference.parent; current; current = current.parent) {
3087
+ if (!ts.isJsxAttribute(current)) continue
3088
+ const element = current.parent?.parent
3089
+ const tag = element?.tagName
3090
+ return keyedIdReferenceAttributes.has(current.name.text) && ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toLowerCase()
3091
+ }
3092
+ return false
3093
+ }
3094
+
3071
3095
  function isSerializableStateLiteral(node) {
3072
3096
  const value = unwrapExpression(node)
3073
3097
  if (isPrimitiveDefaultLiteral(value)) return true
@@ -49,8 +49,7 @@ let renderContext
49
49
 
50
50
  export function useId() {
51
51
  if (!renderContext) throw new Error("useId() can only run while rendering a Kudzu component")
52
- if (renderContext.listRoot || renderContext.listRowRoot || renderContext.listTemplate) throw new Error("useId() is not supported in keyed rows")
53
- return `k-${nextRenderId("i")}`
52
+ return `k-${renderContext.listRoot || renderContext.listRowRoot ? nextRowRenderId("i") : nextRenderId("i")}`
54
53
  }
55
54
 
56
55
  export function useState(initialValue, name, initializer) {
@@ -484,7 +483,7 @@ function serializeCapture(name, value, seen) {
484
483
  }
485
484
 
486
485
  export async function renderPage(component, metadata = {}, props = {}, layout) {
487
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", applicationRoute: metadata.applicationRoute, counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], captureCache: new WeakMap(), sharedStates: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerReferences: new Map(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], searchParamsWritable: false, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
486
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", applicationRoute: metadata.applicationRoute, counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowIds: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], captureCache: new WeakMap(), sharedStates: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerReferences: new Map(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], searchParamsWritable: false, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
488
487
 
489
488
  try {
490
489
  const page = { [routeScopeMarker]: true, component, props }
@@ -941,6 +940,7 @@ async function renderList(node, namespace, selectValue) {
941
940
  const previousListEffectOwners = renderContext.listEffectOwners
942
941
  const previousListRowStates = renderContext.listRowStates
943
942
  const previousListRowRefs = renderContext.listRowRefs
943
+ const previousListRowIds = renderContext.listRowIds
944
944
  const previousListRowConditions = renderContext.listRowConditions
945
945
  const previousListRowLists = renderContext.listRowLists
946
946
  try {
@@ -948,10 +948,11 @@ async function renderList(node, namespace, selectValue) {
948
948
  renderContext.listEffectOwners = rowList?.effectOwners ?? []
949
949
  renderContext.listRowStates = rowList?.rowStates ?? []
950
950
  renderContext.listRowRefs = rowList?.rowRefs ?? []
951
+ renderContext.listRowIds = rowList?.rowIds ?? []
951
952
  renderContext.listRowConditions = rowList?.rowConditions ?? []
952
953
  renderContext.listRowLists = rowList?.rowLists ?? []
953
954
  renderContext.listFields = new Set([node.keyField])
954
- const templateRoot = { id, state: node.items.id, descriptor, template: true, effects: [], item: {}, path: ownerRoot?.path ?? [], rowIndexes: { s: 0, r: 0, c: 0, l: 0 } }
955
+ const templateRoot = { id, state: node.items.id, descriptor, template: true, effects: [], item: {}, path: ownerRoot?.path ?? [], rowIndexes: { s: 0, r: 0, i: 0, c: 0, l: 0 } }
955
956
  renderContext.listRoot = templateRoot
956
957
  renderContext.listRowRoot = templateRoot
957
958
  const template = await renderNode(node.render({}, 0), namespace, selectValue)
@@ -974,6 +975,7 @@ async function renderList(node, namespace, selectValue) {
974
975
  descriptor.rowRefs = renderContext.listRowRefs.map(({ id }) => id)
975
976
  descriptor.mount = true
976
977
  }
978
+ if (renderContext.listRowIds.length) descriptor.rowIds = renderContext.listRowIds.map(({ id }) => id)
977
979
  if (descriptor.rowStates && !descriptor.effects && !descriptor.nested && !template.includes("data-k-text")) descriptor.fastRelease = true
978
980
  const valueSeed = node.ownerField || node.keyField === null ? undefined : listSeed(node.values, renderContext.listFields)
979
981
  const seed = node.selector.length ? undefined : valueSeed
@@ -984,7 +986,7 @@ async function renderList(node, namespace, selectValue) {
984
986
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
985
987
  for (const [index, item] of node.values.entries()) {
986
988
  const key = node.keyField === null ? index : item[node.keyField]
987
- renderContext.listRoot = { id, state: node.items.id, descriptor, key, template: false, effects: [], item, path: [...(ownerRoot?.path ?? []), `${id}=${typeof key}:${key}`], rowIndexes: { s: 0, r: 0, c: 0, l: 0 } }
989
+ renderContext.listRoot = { id, state: node.items.id, descriptor, key, template: false, effects: [], item, path: [...(ownerRoot?.path ?? []), `${id}=${typeof key}:${key}`], rowIndexes: { s: 0, r: 0, i: 0, c: 0, l: 0 } }
988
990
  renderContext.listRowRoot = renderContext.listRoot
989
991
  current += await renderNode(node.render(item, index), namespace, selectValue)
990
992
  }
@@ -1007,6 +1009,7 @@ async function renderList(node, namespace, selectValue) {
1007
1009
  renderContext.listEffectOwners = previousListEffectOwners
1008
1010
  renderContext.listRowStates = previousListRowStates
1009
1011
  renderContext.listRowRefs = previousListRowRefs
1012
+ renderContext.listRowIds = previousListRowIds
1010
1013
  renderContext.listRowConditions = previousListRowConditions
1011
1014
  renderContext.listRowLists = previousListRowLists
1012
1015
  renderContext.listDepth--
@@ -1017,7 +1020,7 @@ function nextRowList() {
1017
1020
  const root = renderContext.listRoot ?? renderContext.listRowRoot
1018
1021
  const index = root.rowIndexes.l++
1019
1022
  if (renderContext.listTemplate) {
1020
- const entry = renderContext.listRowLists[index] ?? { id: nextRenderId("l"), rowLists: [], rowStates: [], rowRefs: [], rowConditions: [], effectOwners: [], planned: false }
1023
+ const entry = renderContext.listRowLists[index] ?? { id: nextRenderId("l"), rowLists: [], rowStates: [], rowRefs: [], rowIds: [], rowConditions: [], effectOwners: [], planned: false }
1021
1024
  renderContext.listRowLists[index] = entry
1022
1025
  return entry
1023
1026
  }
@@ -1035,7 +1038,7 @@ function nextRenderId(kind) {
1035
1038
  function nextRowRenderId(kind, initialValue, initializer) {
1036
1039
  const root = renderContext.listRoot ?? renderContext.listRowRoot
1037
1040
  const index = root.rowIndexes[kind]++
1038
- const entries = kind === "s" ? renderContext.listRowStates : kind === "r" ? renderContext.listRowRefs : renderContext.listRowConditions
1041
+ const entries = kind === "s" ? renderContext.listRowStates : kind === "r" ? renderContext.listRowRefs : kind === "i" ? renderContext.listRowIds : renderContext.listRowConditions
1039
1042
  if (renderContext.listTemplate) {
1040
1043
  const entry = entries[index]
1041
1044
  if (entry) return entry.id
@@ -1044,7 +1047,7 @@ function nextRowRenderId(kind, initialValue, initializer) {
1044
1047
  return id
1045
1048
  }
1046
1049
  const entry = entries[index]
1047
- if (!entry) throw new Error(`Keyed row ${kind === "s" ? "state hooks" : kind === "r" ? "ref hooks" : "conditionals"} must have the same order for every item`)
1050
+ if (!entry) throw new Error(`Keyed row ${kind === "s" ? "state hooks" : kind === "r" ? "ref hooks" : kind === "i" ? "ID hooks" : "conditionals"} must have the same order for every item`)
1048
1051
  return rowRenderId(entry.id, root.descriptor.ownerField ? root.path : [`${typeof root.key}:${root.key}`])
1049
1052
  }
1050
1053
 
@@ -19,6 +19,7 @@ const conditionTemplates = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
19
19
  const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}${__KUDZU_LIST_EFFECTS__ ? ",[data-k-effects]" : ""}`
20
20
  const childPrototypes = __KUDZU_NESTED_LISTS__ ? new WeakMap() : undefined
21
21
  const itemPartPlans = __KUDZU_NESTED_LISTS__ ? new WeakMap() : undefined
22
+ const rowIdAttributes = new Set(["id", "for", "aria-activedescendant", "aria-controls", "aria-describedby", "aria-details", "aria-errormessage", "aria-flowto", "aria-labelledby", "aria-owns", "form", "headers", "list", "popovertarget"])
22
23
 
23
24
  function commitLists(id) {
24
25
  const lists = listTargets.get(id)
@@ -742,7 +743,7 @@ function listItemPartPlan(template, nested = false, descriptor) {
742
743
  conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [location(node), descriptor, node]) : [],
743
744
  effects: __KUDZU_LIST_EFFECTS__ ? parts.effects.map(location) : [],
744
745
  rowIds: structural && __KUDZU_GENERAL_ROW_HOOKS__ && hasRowHooks(descriptor) ? source.flatMap(node => {
745
- const attributes = [...node.attributes].filter(attribute => attribute.name.startsWith("data-k-") && attribute.value.includes("$k")).map(attribute => attribute.name)
746
+ const attributes = [...node.attributes].filter(attribute => (attribute.name.startsWith("data-k-") || rowIdAttributes.has(attribute.name)) && attribute.value.includes("$k")).map(attribute => attribute.name)
746
747
  return attributes.length ? [[location(node), attributes]] : []
747
748
  }) : []
748
749
  }
@@ -1086,7 +1087,7 @@ function replaceOwnedRowIds(root) {
1086
1087
  if (!directRowReplacements.delete(root)) return replaceRowIds(root, rowReplacements.get(root))
1087
1088
  const path = listRowPaths.get(root)
1088
1089
  const replace = node => {
1089
- for (const attribute of [...node.attributes]) if (attribute.name.startsWith("data-k-") && attribute.value.includes("$k")) attribute.value = attribute.value.replaceAll("$k", path)
1090
+ for (const attribute of [...node.attributes]) if ((attribute.name.startsWith("data-k-") || rowIdAttributes.has(attribute.name)) && attribute.value.includes("$k")) attribute.value = attribute.value.replaceAll("$k", path)
1090
1091
  for (const child of node.children) replace(child)
1091
1092
  for (const child of node.content?.children ?? []) replace(child)
1092
1093
  }
@@ -1095,7 +1096,7 @@ function replaceOwnedRowIds(root) {
1095
1096
  /* general-row-hooks-end */
1096
1097
 
1097
1098
  function hasRowHooks(descriptor) {
1098
- return Boolean(descriptor.rowStates?.length || descriptor.rowRefs?.length || descriptor.rowConditions?.length)
1099
+ return Boolean(descriptor.rowStates?.length || descriptor.rowRefs?.length || descriptor.rowIds?.length || descriptor.rowConditions?.length)
1099
1100
  }
1100
1101
 
1101
1102
  function listLifecycle(descriptor, template) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.14.3",
3
+ "version": "0.16.1",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,6 +73,8 @@
73
73
  "typescript": "^5.9.2"
74
74
  },
75
75
  "devDependencies": {
76
+ "@codemirror/view": "^6.43.9",
77
+ "typed.js": "^3.0.0",
76
78
  "wrangler": "^4.112.0"
77
79
  }
78
80
  }