@kudzujs/core 0.6.3 → 0.6.5

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.
package/GOAL_A.md CHANGED
@@ -8,12 +8,12 @@ The benchmark runner, framework fixtures, generated artifacts, and raw arrays ar
8
8
 
9
9
  ## Implementation Status
10
10
 
11
- **Goal A is complete for one explicitly configured emitted-route group with one shared layout.** Complete standalone documents, exact and runtime-parameter navigation, native fallback, persistent layout state/effects, disposable route state/effects, optimistic workflows, desktop/mobile performance gates, and the dashboard expansion seam are covered. Multiple independent shared-layout groups were added post-Goal-A; conditional/keyed DOM-owned effects inside a navigation group remain a deliberate limit.
11
+ **Goal A is complete for one explicitly configured emitted-route group with one shared layout.** Complete standalone documents, exact and runtime-parameter navigation, native fallback, persistent layout state/effects, disposable route state/effects, optimistic workflows, desktop/mobile performance gates, and the dashboard expansion seam are covered. Multiple independent shared-layout groups and conditional/keyed DOM-owned effects within layout and route lifetimes were added post-Goal-A.
12
12
 
13
13
  - **Phase 1 complete**: a local six-route commerce fixture, locked React/Next/Nuxt/SvelteKit comparisons, and a reproducible artifact/build/Chrome runner validate the implementation.
14
14
  - **Phase 2 complete**: effects inside conditional ranges and supported keyed row components mount with their DOM owner, unsubscribe and clean up on removal, and remount without affecting effect-free output.
15
15
  - **Phase 3 complete**: page-exported layouts render complete documents with compiler-owned route boundaries, collision-free layout/route IDs, and state/effect ownership metadata. Effects remain document effects and client navigation is unchanged.
16
- - **Phase 4 route/layout effects implemented**: top-level layout effects mount once per document session, top-level route effects remount after awaited route cleanup, and primitive dependency subscriptions exist only while their lifetime is mounted. Disposed effect setters and queued commits are inactive. Conditional/keyed DOM-owned effects remain unsupported inside navigation groups.
16
+ - **Phase 4 route/layout effects implemented**: layout effects mount once per document session, route effects remount after awaited route cleanup, and primitive dependency subscriptions exist only while their lifetime is mounted. Conditional/keyed effects use per-lifetime owner registries; route registries are fresh on cached revisits. Direct primitive keyed-item properties rerun only changed rows; reorder does not rerun and key changes remount. Disposed effect setters and queued commits are inactive.
17
17
  - **Phase 4 document prefetch implemented**: visible, near-visible, hovered, or focused eligible group anchors prefetch and validate complete documents without importing target capabilities. The finite in-memory full-URL cache removes the measured product-cart HTML RTT while preserving retry and native fallback.
18
18
  - **Phase 5 complete**: matched async cart success/rejection flows prove immediate optimistic updates, duplicate prevention, accessible errors, rollback, route-local reset, and stale-write suppression without new framework APIs.
19
19
  - **Phase 6 expansion probe complete**: one layout-owned mock `EventTarget` stream and one route-owned imperative chart stub use existing effects and a relative TypeScript helper across repeated navigation, with exact listener and disposal assertions. This proves only the compatibility seam; Kudzu does not provide telemetry or chart support.
@@ -27,7 +27,13 @@ The post-Goal-A runtime-pattern matcher increases the exact-only commerce naviga
27
27
 
28
28
  The post-Goal-A multiple-group fixture emits a 7,448 B raw / 3,099 B gzip (`gzip -9`) mixed runtime/effect group asset, including one native exclusion for an overlapping ungrouped exact route, and a separately specialized 5,681 B raw / 2,448 B gzip exact effect-free group asset. These measurements do not revise the historical Goal A benchmark.
29
29
 
30
- A current 0.6.2 desktop rerun of the matched six-route commerce fixture measured Kudzu at 462.1 ms build, 35,355 deploy bytes, 7,334 B gzip product JavaScript, 324/152 ms cold/warm LCP, 109.3 ms startup task, 3.9 ms interaction, and 6.1 ms product-cart navigation. React measured 508.5 ms build, 61,464 B gzip product JavaScript, 340/244 ms LCP, 166.2 ms startup task, 10.9 ms interaction, and 8.9 ms navigation. Raw arrays and the consolidated report are retained under the local demo benchmark workspace.
30
+ In a matched one-effect navigation build with the same conditional capability, moving the effect from the route body into the conditional owner changes the route effect entry from 2,181 B raw / 1,036 B gzip to 4,135 B raw / 1,807 B gzip (`+1,954 B` raw / `+771 B` gzip). Owner-hook unsubscription changes the shared runtime from 1,347 B raw / 718 B gzip to 1,459 B raw / 732 B gzip (`+112 B` raw / `+14 B` gzip). Top-level-only and effect-free navigation builds retain their smaller generators.
31
+
32
+ In matched state-only and item-property keyed-row builds, targeted notification adds 821 B raw / 255 B gzip across the route effect entry, shared runtime, and list runtime. Builds without item-property dependencies retain their previous generated path.
33
+
34
+ In the matched 1,000-row keyed-effect runtime microbenchmark, Kudzu measured 3.4 ms selected-row cleanup/update/setup, 2.9 ms unrelated-field update, and 7.8 ms reorder after all rows and effects were ready. React CSR measured 12.3, 6.8, and 19.0 ms; Vue measured 4.7, 2.3, and 10.1 ms; and Svelte measured 5.2, 3.0, and 48.1 ms. Targeted changed-root notification reduced Kudzu's selected update from 6.2 to 3.4 ms; list reconciliation remains O(n). Kudzu emits initial rows while these framework fixtures are CSR, so their JavaScript, output, and build observations are not architecture-equivalent claims.
35
+
36
+ A 0.6.4 release-tree desktop rerun of the matched six-route commerce fixture measured Kudzu at 486.8 ms build, 35,355 deploy bytes, 7,334 B gzip product JavaScript, 332/156 ms cold/warm LCP, 122.6 ms startup task, 4.8 ms interaction, and 5.6 ms product-cart navigation. React measured 545.4 ms build, 61,464 B gzip product JavaScript, 332/264 ms LCP, 179.9 ms startup task, 10.2 ms interaction, and 9.9 ms navigation. Kudzu built 10.7% faster and its top-level-only fixture retained byte-identical deploy, product-graph, and navigation-asset sizes after navigation-owned effects were added. Raw arrays and the consolidated report are retained under the local demo benchmark workspace.
31
37
 
32
38
  The Phase 6 chart probe's complete initial module graph is 11,902 B raw / 5,331 B gzip. It adds no framework API or package and does not change the commerce benchmark fixture.
33
39
 
@@ -114,7 +120,7 @@ Each phase starts with one failing fixture and ends with correctness, browser, s
114
120
  1. **Benchmark harness**: freeze the commerce journey, network profiles, framework versions, generated artifacts, and measurement scripts before optimizing Kudzu.
115
121
  2. **Owned effects**: complete cleanup for conditional ranges and keyed items using the existing mount and unmount hooks. **Complete.**
116
122
  3. **Layout and route scopes**: retain only declared layout state and dispose route-owned behavior on every completed transition. **Compiler ownership complete; transition behavior belongs to Phase 4.**
117
- 4. **Opt-in navigation**: support eligible links, history, aborts, stale responses, focus, scroll, metadata, and native fallback. **Complete for emitted exact/runtime-parameter routes and top-level layout/route effects; conditional/keyed DOM-owned effects remain excluded.**
123
+ 4. **Opt-in navigation**: support eligible links, history, aborts, stale responses, focus, scroll, metadata, and native fallback. **Complete for emitted exact/runtime-parameter routes and layout/route effects, including conditional/keyed DOM ownership.**
118
124
  5. **Business workflows**: close only fixture-proven gaps in forms, async requests, optimistic updates, and diagnostics. **Complete for the matched cart success/rejection flow.**
119
125
  6. **Expansion probe**: prove that one persistent mock stream and one imperative chart stub can mount, update, navigate, and dispose without adding a component runtime. **Compatibility probe complete; real telemetry and chart engines remain outside Goal A.**
120
126
 
package/README.md CHANGED
@@ -330,9 +330,9 @@ const rows = items.map(item => <ItemRow
330
330
  />)
331
331
  ```
332
332
 
333
- The original component remains reusable across multiple lists and ordinary JSX. No component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers and effects receive the latest JSON-safe item for their key. Effects mount after a row is connected, clean up when it is removed, and do not rerun during reorder. The item remains stored once in shared list state; runtime descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
333
+ The original component remains reusable across multiple lists and ordinary JSX. No component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers and effects receive the latest JSON-safe item for their key. Effects mount after a row is connected and clean up when it is removed. A direct primitive item dependency such as `[item.name]`, optionally mixed with state as `[version, item.name]`, reruns only rows whose selected value changed; the replacement setup receives the complete latest item. Unrelated fields and reorder do not rerun it, while a key change removes and mounts the row. The item remains stored once in shared list state; runtime descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
334
334
 
335
- Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty or direct primitive Kudzu state identifiers; item-property dependencies remain unsupported. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
335
+ Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty, direct primitive Kudzu state identifiers, or direct `item.<field>` properties whose selected values remain JSON-safe primitives. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` dependencies are rejected. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
336
336
 
337
337
  ## Effects
338
338
 
@@ -470,9 +470,7 @@ export default {
470
470
 
471
471
  Every configured identity must be a unique emitted exact route or `runtimeParams` bracket pattern. Routes within each group must export the same layout function identity; different groups may export different layouts. Kudzu emits one deterministic, route-set-hashed navigation asset per group containing only that group's records and capabilities. Path domains may overlap within a group, where exact and more-specific matching wins, but overlapping exact/runtime or runtime/runtime domains across groups fail the build.
472
472
 
473
- The layout DOM, state, and top-level effects persist within its group; route state, parameters, and top-level effects reset after cleanup on each transition. Eligible same-group anchors prefetch validated complete documents into a finite memory cache. Cross-group links, ungrouped routes, direct requests, reloads, malformed runtime paths, JavaScript failures, and unsupported links retain native document navigation.
474
-
475
- Conditional or keyed effects inside a navigation group are not supported yet.
473
+ The layout DOM, state, and effects persist within its group; route state, parameters, and effects reset after cleanup on each transition. Conditional effects mount only while their DOM is connected. Keyed row effects mount per connected row, survive reorder, rerun only rows whose selected direct primitive item dependency changed, receive the latest complete item, and clean up on removal. Cached route modules create fresh route owner records and subscriptions on every revisit. Eligible same-group anchors prefetch validated complete documents into a finite memory cache. Cross-group links, ungrouped routes, direct requests, reloads, malformed runtime paths, JavaScript failures, and unsupported links retain native document navigation.
476
474
 
477
475
  This produces fast same-document route changes, but it does not add a coordinated transition animation. CSS entry animations can style newly inserted route content; exit and shared-element View Transitions are not integrated yet.
478
476
 
@@ -513,6 +511,7 @@ Supported:
513
511
  - Page-exported shared layouts with layout/route state lifetimes
514
512
  - Opt-in exact/runtime-route navigation with complete-document prefetch and native fallback
515
513
  - Layout- and route-lifetime effect mounts in navigation groups
514
+ - Conditional/keyed DOM-owned effects in navigation groups
516
515
 
517
516
  Not implemented yet:
518
517
 
@@ -520,7 +519,6 @@ Not implemented yet:
520
519
  - Server actions and request-time SSR
521
520
  - React package islands
522
521
  - HMR and framework DevTools
523
- - Conditional/keyed DOM-owned effects inside navigation groups
524
522
 
525
523
  ## Benchmarks
526
524
 
@@ -532,22 +530,22 @@ Browser medians use seven rotating fresh Chrome profiles per target with 4x CPU
532
530
 
533
531
  | Target | Product JS gzip | Cold transfer | Cold LCP | Warm LCP | Startup task | Heap | Interaction | Product → cart |
534
532
  |---|---:|---:|---:|---:|---:|---:|---:|---:|
535
- | Kudzu | **7,334 B** | **35,260 B** | 324 ms | **152 ms** | **109.3 ms** | **650,708 B** | **3.9 ms** | **6.1 ms** |
536
- | React + Vite | 61,464 B | 202,842 B | 340 ms | 244 ms | 166.2 ms | 1,062,512 B | 10.9 ms | 8.9 ms |
537
- | Next.js | 190,090 B | 546,581 B | **320 ms** | 168 ms | 413.0 ms | 2,158,160 B | 14.3 ms | 29.8 ms |
538
- | Nuxt | 67,620 B | 195,953 B | 328 ms | 216 ms | 243.8 ms | 1,721,348 B | 4.3 ms | 28.4 ms |
539
- | SvelteKit | 32,475 B | 90,934 B | 352 ms | 176 ms | 150.5 ms | 999,496 B | 5.2 ms | 24.0 ms |
533
+ | Kudzu | **7,334 B** | **35,260 B** | 332 ms | **156 ms** | **122.6 ms** | **650,708 B** | **4.8 ms** | **5.6 ms** |
534
+ | React + Vite | 61,464 B | 202,842 B | 332 ms | 264 ms | 179.9 ms | 1,062,520 B | 10.2 ms | 9.9 ms |
535
+ | Next.js | 190,090 B | 547,615 B | **324 ms** | 176 ms | 434.4 ms | 2,168,412 B | 14.0 ms | 30.0 ms |
536
+ | Nuxt | 67,620 B | 195,953 B | **324 ms** | 224 ms | 247.9 ms | 1,721,348 B | **4.5 ms** | 29.6 ms |
537
+ | SvelteKit | 32,474 B | 90,939 B | 376 ms | 184 ms | 143.8 ms | 999,496 B | 6.3 ms | 21.8 ms |
540
538
 
541
- The current Kudzu application emits 35,355 deploy bytes. Its 7,334 B gzip product graph includes the 2,425 B navigation capability. The first implementation paid a 128.7 ms HTML round trip during product-to-cart navigation; validated near-viewport document prefetch measured 6.1 ms in the current run while preserving complete documents and native fallback.
539
+ The current Kudzu application emits 35,355 deploy bytes. Its 7,334 B gzip product graph includes the 2,425 B navigation capability; all three sizes are unchanged by conditional/keyed navigation effects because this top-level-only fixture retains the smaller specialized path. The first implementation paid a 128.7 ms HTML round trip during product-to-cart navigation; validated near-viewport document prefetch measured 5.6 ms in the current run while preserving complete documents and native fallback.
542
540
 
543
541
  The mobile row is retained from the previous matched run using a 390x844 viewport, 6x CPU slowdown, 150 ms latency, and 150 KiB/s throughput:
544
542
 
545
543
  | Profile | Cold LCP | Warm LCP | Interaction | Product → cart | Reject feedback | Rollback/error | CLS |
546
544
  |---|---:|---:|---:|---:|---:|---:|---:|
547
- | Desktop | 324 ms | 152 ms | 3.9 ms | 6.1 ms | 2.6 ms | 111.7 ms | 0 |
545
+ | Desktop | 332 ms | 156 ms | 4.8 ms | 5.6 ms | 4.0 ms | 112.1 ms | 0 |
548
546
  | Mobile | 420 ms | 220 ms | 5.6 ms | 8.7 ms | 4.1 ms | 158 ms | 0 |
549
547
 
550
- Initial runs found a repeatable 6–7% small-build loss from TypeScript and esbuild module startup. Kudzu now enables Node's native module compile cache before lazily loading the compiler. The current seven-run matched commerce build measured Kudzu at 462.1 ms and React at 508.5 ms, making Kudzu 9.1% faster in that run. Disabling the cache preserves byte-for-byte output. Attempts to replace generated-handler lowering or share one TypeScript Program did not improve the combined median and were not retained.
548
+ Initial runs found a repeatable 6–7% small-build loss from TypeScript and esbuild module startup. Kudzu now enables Node's native module compile cache before lazily loading the compiler. The current seven-run matched commerce build measured Kudzu at 486.8 ms and React at 545.4 ms, making Kudzu 10.7% faster in that run. Kudzu also shipped 88.1% less product JavaScript, used 38.8% less measured heap, and measured 52.9% faster interaction and 43.4% faster product-to-cart navigation than React. Disabling the cache preserves byte-for-byte output. Attempts to replace generated-handler lowering or share one TypeScript Program did not improve the combined median and were not retained.
551
549
 
552
550
  These results describe this six-route fixture on one machine, not framework ecosystem size or every rendering mode. Prefetch improves an eligible warm application transition; it does not hide cold transfer, and direct loads remain complete standalone documents.
553
551
 
@@ -647,7 +645,21 @@ An intrinsic-root versus projected-prop row-component A/B build produced byte-fo
647
645
 
648
646
  Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 23.2 ms, 10.7 ms behind the hand-authored Astro baseline and 7.1 ms ahead of React across all four operations.
649
647
 
650
- Benchmark snapshot collected on July 22, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
648
+ ### 1,000-item Keyed Effect
649
+
650
+ Each keyed row owns one effect depending on `item.name`. The measured actions rename only row 500 and wait for exactly one cleanup/setup, change an unrelated detail and require no lifecycle work, then reverse all rows and again require no lifecycle work. Medians use seven fresh Chrome profiles; builds use one warm-up and seven rotating clean runs.
651
+
652
+ | Framework | Initial rows | Initial JS gzip | Total output | Build | Selected update | Unrelated update | Reverse |
653
+ |---|---:|---:|---:|---:|---:|---:|---:|
654
+ | Astro native | Yes | **381 B** | **90,734 B** | 1,022 ms | **0.4 ms** | **0.2 ms** | **5.5 ms** |
655
+ | Kudzu | Yes | 7,070 B | 221,056 B | **437 ms** | 3.4 ms | 2.9 ms | 7.8 ms |
656
+ | Vue CSR | No | 25,091 B | 63,368 B | 893 ms | 4.7 ms | **2.3 ms** | 10.1 ms |
657
+ | Svelte CSR | No | 12,848 B | 33,222 B | 1,012 ms | 5.2 ms | 3.0 ms | 48.1 ms |
658
+ | React CSR | No | 60,921 B | 194,301 B | 1,132 ms | 12.3 ms | 6.8 ms | 19.0 ms |
659
+
660
+ This is a post-initialization runtime microbenchmark, not an architecture-equivalent loading comparison. Kudzu and Astro emit all 1,000 rows in HTML while React, Vue, and Svelte use empty CSR shells, so their JavaScript, output, and build columns are observations rather than framework-size or startup claims. Once every target has 1,000 rows and effects ready, targeted changed-root notification reduces Kudzu's selected update from 6.2 to 3.4 ms, versus Vue at 4.7 ms, Svelte at 5.2 ms, and React at 12.3 ms. It adds 126 B gzip to Kudzu's initial graph. List reconciliation remains O(n), which dominates unrelated-field updates; Vue measures 2.3 ms there versus Kudzu's 2.9 ms. Astro is the hand-written direct-DOM lower bound.
661
+
662
+ The general benchmark snapshot was collected on July 22, 2026 and the keyed-effect comparison on July 27, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
651
663
 
652
664
  ## Development
653
665
 
@@ -19,6 +19,12 @@ Static routes receive no browser runtime. Command routes receive `runtime.js`; d
19
19
 
20
20
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
21
21
 
22
- `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects mount once per group session, route effects remount after each route insertion, and non-persisted page disposal cleans route before layout. Primitive dependencies and cleanup are supported; conditional/keyed DOM-owned effects fail with a source-located diagnostic in navigation groups. Fragment payloads and coordinated View Transitions are not implemented.
22
+ Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal, unrelated fields do nothing, and key changes remain remove plus mount. Builds without item dependencies emit no item reader or list-state subscription code.
23
23
 
24
- The current matched commerce profile emits 35,355 deploy bytes and loads 7,334 B gzip of product-route JavaScript, including 2,425 B for navigation. Validated prefetch reduced the original 128.7 ms product-to-cart navigation to 6.1 ms in the current run. Seven interleaved artifact-clean builds after warm-up measured Kudzu at 462.1 ms and React at 508.5 ms. Cache-disabled output is byte-for-byte identical.
24
+ `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
25
+
26
+ The current matched commerce profile emits 35,355 deploy bytes and loads 7,334 B gzip of product-route JavaScript, including 2,425 B for navigation. These sizes are unchanged because its top-level-only navigation effects retain the smaller specialized path. Validated prefetch reduced the original 128.7 ms product-to-cart navigation to 5.6 ms in the current run. Seven interleaved artifact-clean builds after warm-up measured Kudzu at 486.8 ms and React at 545.4 ms, making Kudzu 10.7% faster. Cache-disabled output is byte-for-byte identical.
27
+
28
+ In matched state-only and item-property keyed-row builds, the minified route effect entry changes from 3,829 B raw/1,667 B gzip to 4,392 B raw/1,823 B gzip, the shared runtime from 1,291 B raw/671 B gzip to 1,503 B raw/751 B gzip, and the list runtime from 6,606 B raw/2,474 B gzip to 6,652 B raw/2,493 B gzip. The complete targeted-notification capability costs +821 B raw/+255 B gzip and remains absent from builds without item dependencies. Seven clean builds of the expanded three-route fixture measured 420-440 ms with a 430 ms median; this records current build cost rather than claiming a cross-version speed change.
29
+
30
+ The matched 1,000-row cross-framework effect fixture measured Kudzu at 7,070 B initial JavaScript gzip, 437 ms build, 3.4 ms selected-row cleanup/update/setup, 2.9 ms unrelated-field update, and 7.8 ms reorder. React CSR measured 60,921 B, 1,132 ms, 12.3 ms, 6.8 ms, and 19.0 ms respectively; Vue measured 4.7, 2.3, and 10.1 ms for the browser operations, and Svelte measured 5.2, 3.0, and 48.1 ms. Browser operations begin only after all targets have 1,000 rows and effects ready. Kudzu emits those rows in HTML while the framework CSR fixtures begin from empty shells, so JavaScript, output, and build values are not architecture-equivalent comparisons. Targeted changed-root notification removed the extra O(n) effect-record scan and reduced Kudzu's selected update from 6.2 to 3.4 ms. List validation, serialization, and reconciliation remain O(n).
@@ -172,6 +172,7 @@ export async function build({ quiet = false, minify = true } = {}) {
172
172
  const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
173
173
  const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
174
174
  const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
175
+ const hasItemDependencies = plans.some(plan => plan.effects.some(effect => effect.itemDependencies?.length))
175
176
  const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
176
177
  const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
177
178
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
@@ -181,6 +182,7 @@ export async function build({ quiet = false, minify = true } = {}) {
181
182
  const hasNativeHandlers = nativeModules.length > 0
182
183
  const hasEffects = effectEntries.length > 0
183
184
  const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
185
+ const hasNavigableOwners = effectEntries.some(entry => entry.navigable && entry.effects.some(effect => effect.owner))
184
186
  const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers || navigationRoutes.length
185
187
  const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
186
188
  const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
@@ -193,7 +195,11 @@ export async function build({ quiet = false, minify = true } = {}) {
193
195
  if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
194
196
  const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
195
197
  let runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
198
+ if (!hasItemDependencies) runtime = runtime.replace(/\/\* list-item-hooks \*\/[\s\S]*?\/\* list-item-hooks-end \*\/\n/, "")
196
199
  if (hasNavigableEffects) runtime = runtime.replace("export function registerCommitter(commit) {\n committers.push(commit)\n}", "export function registerCommitter(commit) {\n committers.push(commit)\n return () => {\n const index = committers.indexOf(commit)\n if (index !== -1) committers.splice(index, 1)\n }\n}")
200
+ if (hasNavigableOwners) runtime = runtime
201
+ .replace("export function registerMountHook(mount) {\n mountHooks.push(mount)\n}", "export function registerMountHook(mount) {\n mountHooks.push(mount)\n return () => {\n const index = mountHooks.indexOf(mount)\n if (index !== -1) mountHooks.splice(index, 1)\n }\n}")
202
+ .replace("export function registerUnmountHook(unmount) {\n unmountHooks.push(unmount)\n}", "export function registerUnmountHook(unmount) {\n unmountHooks.push(unmount)\n return () => {\n const index = unmountHooks.indexOf(unmount)\n if (index !== -1) unmountHooks.splice(index, 1)\n }\n}")
197
203
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
198
204
  }
199
205
  if (hasDependencyRuntime) {
@@ -227,6 +233,7 @@ export async function build({ quiet = false, minify = true } = {}) {
227
233
  if (listCount) {
228
234
  let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
229
235
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
236
+ if (!hasItemDependencies) listRuntime = listRuntime.replace(", notifyListItem", "")
230
237
  const stylePatch = ` if (target === "style") {
231
238
  const style = serializeStyle(value)
232
239
  if (style) node.setAttribute("style", style)
@@ -245,7 +252,8 @@ export async function build({ quiet = false, minify = true } = {}) {
245
252
  __KUDZU_LIST_SEEDS__: String(hasListSeeds),
246
253
  __KUDZU_LIST_EFFECTS__: String(hasListEffects),
247
254
  __KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
248
- __KUDZU_LIST_MOUNTS__: String(hasListMounts)
255
+ __KUDZU_LIST_MOUNTS__: String(hasListMounts),
256
+ __KUDZU_LIST_ITEM_HOOKS__: String(hasItemDependencies)
249
257
  })
250
258
  }
251
259
  if (hasNativeHandlers) {
@@ -282,7 +290,9 @@ export async function build({ quiet = false, minify = true } = {}) {
282
290
  const output = join(assetsDirectory, entry.path)
283
291
  await mkdir(dirname(output), { recursive: true })
284
292
  await writeJavaScript(output, entry.navigable
285
- ? printNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
293
+ ? entry.effects.some(effect => effect.owner)
294
+ ? printOwnedNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
295
+ : printNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
286
296
  : printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
287
297
  }
288
298
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
@@ -390,7 +400,7 @@ function specializeNativeRuntime(source, events, modules) {
390
400
 
391
401
  function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
392
402
  const hasCleanup = effects.some(effect => effect.cleanup)
393
- const hasDependencies = effects.some(effect => effect.dependencies?.length)
403
+ const hasDependencies = effects.some(effect => effect.dependencies?.length || effect.itemDependencies?.length)
394
404
  const hasOwners = effects.some(effect => effect.owner)
395
405
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
396
406
  const modules = moduleUrls.map(url => {
@@ -677,11 +687,263 @@ function mount(lifetime) {
677
687
  }`
678
688
  }
679
689
 
690
+ function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
691
+ const hasItemDependencies = effects.some(effect => effect.itemDependencies?.length)
692
+ const moduleUrls = [...new Set(effects.map(effect => effect.module))]
693
+ const modules = moduleUrls.map(url => {
694
+ const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
695
+ if (!module) throw new Error(`Effect handler module was not emitted: ${url}`)
696
+ return module
697
+ })
698
+ const imports = [
699
+ `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
700
+ `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
701
+ ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
702
+ ]
703
+ const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
704
+ return `${imports.join("\n")}
705
+ const effects = ${inlineJson(effects)}
706
+ const modules = new Map([${entries}])
707
+ export const mountLayoutEffects = () => mount("layout")
708
+ export const mountRouteEffects = () => mount("route")
709
+ function mount(lifetime) {
710
+ let active = true
711
+ let flushing
712
+ let order = 0
713
+ const selectedEffects = effects.map((effect, index) => ({ effect, index })).filter(entry => entry.effect.lifetime === lifetime)
714
+ const records = new Set()
715
+ const owners = new Map()
716
+ const listTemplates = new Map()
717
+ const registrations = new WeakMap()
718
+ const dependencies = new Map()
719
+ const pending = new Set()
720
+ const startedCleanups = new Set()
721
+ let scheduled = false
722
+ for (const template of selectedEffects) {
723
+ if (template.effect.list) listTemplates.set(template.effect.owner, template)
724
+ else {
725
+ const record = createRecord(template, !template.effect.owner)
726
+ if (template.effect.owner) owners.set(template.effect.owner, record)
727
+ }
728
+ }
729
+ const unsubscribeCommitter = selectedEffects.some(({ effect }) => effect.dependencies?.length) ? __kRuntime.registerCommitter(id => {
730
+ if (!active) return
731
+ for (const record of dependencies.get(id) ?? []) if (record.mounted) pending.add(record)
732
+ schedule()
733
+ }) : undefined
734
+ const unsubscribeMount = __kRuntime.registerMountHook(mountOwned)
735
+ const unsubscribeUnmount = __kRuntime.registerUnmountHook(unmountOwned)
736
+ ${hasItemDependencies ? `const unsubscribeItems = [...new Set(selectedEffects.filter(({ effect }) => effect.itemDependencies?.length).map(({ effect }) => effect.listState))].map(listState => __kRuntime.registerListItemHook(listState, root => {
737
+ if (!active) return
738
+ for (const record of registrations.get(root) ?? []) if (record.mounted && record.effect.itemDependencies) pending.add(record)
739
+ schedule()
740
+ }))
741
+ ` : ""}for (const record of records) if (record.mounted) start(record)
742
+ mountOwned(document)
743
+ function createRecord(template, mounted = true) {
744
+ const record = { ...template, order: order++, mounted, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined, token: undefined }
745
+ records.add(record)
746
+ registerDependencies(record)
747
+ return record
748
+ }
749
+ function registerDependencies(record) {
750
+ for (const id of record.effect.dependencies ?? []) {
751
+ const subscribers = dependencies.get(id) ?? new Set()
752
+ subscribers.add(record)
753
+ dependencies.set(id, subscribers)
754
+ }
755
+ }
756
+ function unregisterDependencies(record) {
757
+ for (const id of record.effect.dependencies ?? []) {
758
+ const subscribers = dependencies.get(id)
759
+ subscribers?.delete(record)
760
+ if (!subscribers?.size) dependencies.delete(id)
761
+ }
762
+ }
763
+ function mountOwned(root) {
764
+ if (!active) return
765
+ for (const marker of matching(root)) {
766
+ if (!marker.isConnected) continue
767
+ if (marker.dataset.kEffects) {
768
+ if (registrations.has(marker)) continue
769
+ const rowRecords = JSON.parse(marker.dataset.kEffects).flatMap(owner => {
770
+ const template = listTemplates.get(owner)
771
+ if (!template) return []
772
+ const record = createRecord(template)
773
+ record.marker = marker
774
+ start(record)
775
+ return [record]
776
+ })
777
+ if (rowRecords.length) registrations.set(marker, rowRecords)
778
+ continue
779
+ }
780
+ const record = owners.get(marker.dataset.kEffect)
781
+ if (record && !record.mounted) mountRecord(record, marker)
782
+ }
783
+ }
784
+ function unmountOwned(root) {
785
+ if (!active) return
786
+ for (const marker of matching(root)) {
787
+ const rowRecords = registrations.get(marker)
788
+ if (rowRecords) {
789
+ for (const record of rowRecords) unmountRecord(record, true)
790
+ registrations.delete(marker)
791
+ continue
792
+ }
793
+ const record = owners.get(marker.dataset.kEffect)
794
+ if (record?.marker === marker) unmountRecord(record)
795
+ }
796
+ }
797
+ function matching(root) {
798
+ const selector = "template[data-k-effect],[data-k-effects]"
799
+ return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
800
+ }
801
+ function mountRecord(record, marker) {
802
+ record.mounted = true
803
+ record.marker = marker
804
+ const version = ++record.version
805
+ const begin = () => {
806
+ if (active && record.mounted && record.version === version && marker.isConnected) start(record)
807
+ }
808
+ if (record.disposal) record.disposal.then(begin)
809
+ else begin()
810
+ }
811
+ function unmountRecord(record, dynamic = false) {
812
+ if (!record.mounted) return
813
+ record.mounted = false
814
+ record.marker = undefined
815
+ record.version++
816
+ pending.delete(record)
817
+ if (dynamic) {
818
+ unregisterDependencies(record)
819
+ records.delete(record)
820
+ }
821
+ void cleanup(record)
822
+ }
823
+ function start(record) {
824
+ try {
825
+ record.values = readDependencies(record)
826
+ invoke(record)
827
+ } catch (error) {
828
+ console.error(error)
829
+ }
830
+ }
831
+ function schedule() {
832
+ if (!pending.size || scheduled || flushing) return
833
+ scheduled = true
834
+ queueMicrotask(flush)
835
+ }
836
+ async function flush() {
837
+ scheduled = false
838
+ if (!active) return pending.clear()
839
+ const operation = (async () => {
840
+ const changed = []
841
+ const selected = [...pending].filter(record => record.mounted).sort((left, right) => left.index - right.index || left.order - right.order)
842
+ pending.clear()
843
+ for (const record of selected) {
844
+ try {
845
+ const values = readDependencies(record)
846
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) changed.push([record, record.version])
847
+ } catch (error) {
848
+ console.error(error)
849
+ }
850
+ }
851
+ for (const [record] of changed) await cleanup(record)
852
+ if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) {
853
+ try {
854
+ record.values = readDependencies(record)
855
+ invoke(record)
856
+ } catch (error) {
857
+ record.values = undefined
858
+ console.error(error)
859
+ }
860
+ }
861
+ })()
862
+ flushing = operation
863
+ try { await operation } finally {
864
+ if (flushing === operation) flushing = undefined
865
+ if (active) schedule()
866
+ }
867
+ }
868
+ function readDependencies(record) {
869
+ const values = (record.effect.dependencies ?? []).map(id => {
870
+ const value = __kRuntime.browserState.get(id)
871
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
872
+ return value
873
+ })
874
+ ${hasItemDependencies ? `if (record.effect.itemDependencies) {
875
+ const item = JSON.parse(record.marker.dataset.kEffectItem)
876
+ for (const field of record.effect.itemDependencies) {
877
+ const value = item[field]
878
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error(\`useEffect() keyed item dependency "\${field}" must remain a JSON-safe primitive\`)
879
+ values.push(value)
880
+ }
881
+ }` : ""}
882
+ return values
883
+ }
884
+ function invoke(record) {
885
+ const token = { active: true }
886
+ record.token = token
887
+ try {
888
+ const effect = record.effect
889
+ const scope = effect.list
890
+ ? Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, value?.type === "list-item" ? JSON.parse(record.marker.dataset.kEffectItem) : value]))
891
+ : effect.scope
892
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(__kRuntime.browserState, effect.states, __kRuntime.commitDom, scope, () => active && token.active && record.token === token))
893
+ if (effect.cleanup && typeof result === "function") record.cleanup = result
894
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
895
+ } catch (error) {
896
+ console.error(error)
897
+ }
898
+ }
899
+ function cleanup(record) {
900
+ if (record.token) record.token.active = false
901
+ record.token = undefined
902
+ if (record.disposal) return record.disposal
903
+ const current = record.cleanup
904
+ record.cleanup = undefined
905
+ if (!current) return Promise.resolve()
906
+ const disposal = (async () => {
907
+ try { await current() } catch (error) { console.error(error) }
908
+ })()
909
+ record.disposal = disposal
910
+ startedCleanups.add(disposal)
911
+ disposal.finally(() => {
912
+ startedCleanups.delete(disposal)
913
+ if (record.disposal === disposal) record.disposal = undefined
914
+ })
915
+ return disposal
916
+ }
917
+ let disposal
918
+ return async function dispose() {
919
+ if (disposal) return disposal
920
+ disposal = (async () => {
921
+ active = false
922
+ unsubscribeCommitter?.()
923
+ ${hasItemDependencies ? "for (const unsubscribe of unsubscribeItems) unsubscribe()\n " : ""}unsubscribeMount()
924
+ unsubscribeUnmount()
925
+ pending.clear()
926
+ for (const record of records) if (record.token) record.token.active = false
927
+ if (flushing) await flushing
928
+ const mounted = [...records].filter(record => record.mounted).sort((left, right) => left.index - right.index || left.order - right.order)
929
+ for (const record of mounted) {
930
+ record.mounted = false
931
+ await cleanup(record)
932
+ }
933
+ await Promise.all([...startedCleanups])
934
+ records.clear()
935
+ })()
936
+ return disposal
937
+ }
938
+ }`
939
+ }
940
+
680
941
  function runtimeEffects(effects, lifetimes = false) {
681
942
  return effects.map(effect => ({
682
943
  module: effect.module,
683
944
  handler: effect.handler,
684
945
  ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
946
+ ...(effect.itemDependencies ? { itemDependencies: effect.itemDependencies, listState: effect.listState } : {}),
685
947
  ...(effect.cleanup ? { cleanup: true } : {}),
686
948
  ...(effect.owner ? { owner: effect.owner } : {}),
687
949
  ...(effect.list ? { list: true } : {}),
@@ -692,10 +954,12 @@ function runtimeEffects(effects, lifetimes = false) {
692
954
  }
693
955
 
694
956
  function printOwnedEffectEntry(imports, effects, entries) {
957
+ const hasItemDependencies = effects.some(effect => effect.itemDependencies?.length)
958
+ const hasOrdinaryDependencies = effects.some(effect => effect.dependencies?.length)
695
959
  return `${imports.join("\n")}
696
960
  const effects = ${inlineJson(effects)}
697
961
  const modules = new Map([${entries}])
698
- const records = effects.map((effect, index) => effect.list ? undefined : createRecord(effect, index)).filter(Boolean)
962
+ ${hasItemDependencies ? "let order = 0\n" : ""}const records = effects.map((effect, index) => effect.list ? undefined : createRecord(effect, index)).filter(Boolean)
699
963
  const listTemplates = new Map(effects.map((effect, index) => effect.list ? [effect.owner, { effect, index }] : undefined).filter(Boolean))
700
964
  const owners = new Map(records.filter(record => record.effect.owner).map(record => [record.effect.owner, record]))
701
965
  const listRegistrations = new WeakMap()
@@ -707,7 +971,7 @@ let flushing = false
707
971
  let active = true
708
972
  for (const record of records) registerDependencies(record)
709
973
  function createRecord(effect, index) {
710
- return { effect, index, mounted: !effect.owner, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined }
974
+ return { effect, index, ${hasItemDependencies ? "order: order++, " : ""}mounted: !effect.owner, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined, token: undefined }
711
975
  }
712
976
  function registerDependencies(record) {
713
977
  for (const id of record.effect.dependencies ?? []) {
@@ -723,12 +987,17 @@ function unregisterDependencies(record) {
723
987
  if (!subscribers?.size) dependencies.delete(id)
724
988
  }
725
989
  }
726
- __kRuntime.registerCommitter(id => {
990
+ ${hasItemDependencies ? `if (${hasOrdinaryDependencies}) ` : ""}__kRuntime.registerCommitter(id => {
727
991
  if (!active) return
728
992
  for (const record of dependencies.get(id) ?? []) if (record.mounted) pending.add(record)
729
993
  schedule()
730
994
  })
731
- __kRuntime.registerMountHook(root => {
995
+ ${hasItemDependencies ? `for (const listState of new Set(effects.filter(effect => effect.itemDependencies?.length).map(effect => effect.listState))) __kRuntime.registerListItemHook(listState, root => {
996
+ if (!active) return
997
+ for (const record of listRegistrations.get(root) ?? []) if (record.mounted && record.effect.itemDependencies) pending.add(record)
998
+ schedule()
999
+ })
1000
+ ` : ""}__kRuntime.registerMountHook(root => {
732
1001
  if (!active) return
733
1002
  for (const marker of matching(root)) {
734
1003
  if (marker.dataset.kEffects) {
@@ -816,41 +1085,57 @@ async function flush() {
816
1085
  if (!active) return pending.clear()
817
1086
  flushing = true
818
1087
  try {
819
- const selected = [...pending].filter(record => record.mounted).sort((left, right) => left.index - right.index)
1088
+ const selected = [...pending].filter(record => record.mounted).sort((left, right) => left.index - right.index${hasItemDependencies ? " || left.order - right.order" : ""})
820
1089
  pending.clear()
821
1090
  const changed = []
822
1091
  for (const record of selected) {
823
1092
  try {
824
1093
  const values = readDependencies(record)
825
- if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
826
- record.values = values
827
- changed.push([record, record.version])
828
- }
1094
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) changed.push([record, record.version])
829
1095
  } catch (error) {
830
1096
  console.error(error)
831
1097
  }
832
1098
  }
833
1099
  for (const [record] of changed) await invokeCleanup(record)
834
- if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) invoke(record)
1100
+ if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) {
1101
+ try {
1102
+ record.values = readDependencies(record)
1103
+ invoke(record)
1104
+ } catch (error) {
1105
+ record.values = undefined
1106
+ console.error(error)
1107
+ }
1108
+ }
835
1109
  } finally {
836
1110
  flushing = false
837
1111
  if (active) schedule()
838
1112
  }
839
1113
  }
840
1114
  function readDependencies(record) {
841
- return (record.effect.dependencies ?? []).map(id => {
1115
+ const values = (record.effect.dependencies ?? []).map(id => {
842
1116
  const value = browserState.get(id)
843
1117
  if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
844
1118
  return value
845
1119
  })
1120
+ ${hasItemDependencies ? `if (record.effect.itemDependencies) {
1121
+ const item = JSON.parse(record.marker.dataset.kEffectItem)
1122
+ for (const field of record.effect.itemDependencies) {
1123
+ const value = item[field]
1124
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error(\`useEffect() keyed item dependency "\${field}" must remain a JSON-safe primitive\`)
1125
+ values.push(value)
1126
+ }
1127
+ }` : ""}
1128
+ return values
846
1129
  }
847
1130
  function invoke(record) {
1131
+ const token = { active: true }
1132
+ record.token = token
848
1133
  try {
849
1134
  const effect = record.effect
850
1135
  const scope = effect.list
851
1136
  ? Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, value?.type === "list-item" ? JSON.parse(record.marker.dataset.kEffectItem) : value]))
852
1137
  : effect.scope
853
- const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, scope))
1138
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, scope, () => active && token.active && record.token === token))
854
1139
  if (effect.cleanup && typeof result === "function") record.cleanup = result
855
1140
  else if (result && typeof result.then === "function") result.catch(error => console.error(error))
856
1141
  } catch (error) {
@@ -858,6 +1143,8 @@ function invoke(record) {
858
1143
  }
859
1144
  }
860
1145
  function invokeCleanup(record) {
1146
+ if (record.token) record.token.active = false
1147
+ record.token = undefined
861
1148
  if (record.disposal) return record.disposal
862
1149
  const cleanup = record.cleanup
863
1150
  record.cleanup = undefined
@@ -1322,6 +1609,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1322
1609
  let usesConditional = false
1323
1610
  let usesList = false
1324
1611
  let usesListEffects = false
1612
+ let usesListItem = false
1325
1613
 
1326
1614
  const collect = node => {
1327
1615
  if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
@@ -1541,10 +1829,23 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1541
1829
  if (callback.asteriskToken) effectFail(callback, "useEffect() callback cannot be a generator")
1542
1830
  if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
1543
1831
  if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
1544
- if (listEffect && dependencies.elements.some(dependency => referencesIdentifier(dependency, listEffect.item))) {
1545
- effectFail(dependencies, "useEffect() item-property dependencies are not supported in keyed lists; use [] or primitive Kudzu state identifiers")
1832
+ const itemDependencies = []
1833
+ const ordinaryDependencies = []
1834
+ let dependencyItem = listEffect?.item
1835
+ for (const dependency of dependencies.elements) {
1836
+ const value = unwrapExpression(dependency)
1837
+ if (!dependencyItem && ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && isDestructuredParameter(value.expression, nearestFunction(node))) dependencyItem = value.expression.text
1838
+ const field = dependencyItem && directProperty(dependency, dependencyItem)
1839
+ if (field) {
1840
+ if (["__proto__", "constructor", "prototype"].includes(field)) effectFail(dependency, `useEffect() keyed item property "${field}" is not supported`)
1841
+ itemDependencies.push(field)
1842
+ } else if (dependencyItem && referencesIdentifier(dependency, dependencyItem)) {
1843
+ effectFail(dependency, "useEffect() keyed item dependencies must be direct item.<field> properties")
1844
+ } else {
1845
+ ordinaryDependencies.push(dependency)
1846
+ }
1546
1847
  }
1547
- const invalidDependency = dependencies.elements.find(dependency => !ts.isIdentifier(dependency))
1848
+ const invalidDependency = ordinaryDependencies.find(dependency => !ts.isIdentifier(dependency))
1548
1849
  if (invalidDependency) effectFail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
1549
1850
  if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
1550
1851
  if (!ts.isBlock(callback.body)) effectFail(callback, "useEffect() callback must use a block body")
@@ -1554,17 +1855,19 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1554
1855
  if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
1555
1856
  if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
1556
1857
  const setters = settersForNode(node, settersByFunction)
1557
- const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", listEffect?.item, true, returns.cleanup)
1858
+ const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", dependencyItem, true, returns.cleanup)
1859
+ usesListItem ||= Boolean(itemDependencies.length && !listEffect)
1558
1860
  usesBehavior = true
1559
1861
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
1560
1862
  callback,
1561
- dependencies,
1863
+ factory.createArrayLiteralExpression(ordinaryDependencies),
1562
1864
  factory.createStringLiteral(handlerUrl),
1563
1865
  factory.createStringLiteral(descriptor.exportName),
1564
1866
  descriptor.states,
1565
1867
  descriptor.scope,
1566
1868
  factory.createStringLiteral(listEffect ? sourceLocation(listEffect.source, listEffect.sourceFile) : sourceLocation(node, sourceFile)),
1567
- returns.cleanup ? factory.createTrue() : factory.createFalse()
1869
+ returns.cleanup ? factory.createTrue() : factory.createFalse(),
1870
+ factory.createArrayLiteralExpression(itemDependencies.map(field => factory.createStringLiteral(field)))
1568
1871
  ])
1569
1872
  }
1570
1873
 
@@ -1677,6 +1980,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1677
1980
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
1678
1981
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listConditional"), factory.createIdentifier("__kListConditional")))
1679
1982
  }
1983
+ if (usesListItem && !usesList) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
1680
1984
  if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
1681
1985
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
1682
1986
  const behaviorImport = factory.createImportDeclaration(
@@ -78,7 +78,7 @@ export function renderPage<Props = Record<string, never>>(
78
78
  commands?: Array<[string, string, unknown]>
79
79
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
80
80
  }>
81
- effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; lifetime?: "layout" | "route"; dependencies?: string[]; cleanup?: true; owner?: string; list?: true }>
81
+ effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; lifetime?: "layout" | "route"; dependencies?: string[]; itemDependencies?: string[]; listState?: string; cleanup?: true; owner?: string; list?: true }>
82
82
  bindings: Array<{
83
83
  target: string
84
84
  state?: string
@@ -66,9 +66,10 @@ function createSignal(id, value) {
66
66
  }
67
67
  }
68
68
 
69
- export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup) {
69
+ export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup, itemDependencies = []) {
70
70
  if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
71
71
  if (typeof callback !== "function" || !Array.isArray(dependencies) || !module || !handler) throw new Error("useEffect() must be compiled with a literal dependency array")
72
+ if (itemDependencies.length && !renderContext.listDepth) throw new Error(`${source} useEffect() item-property dependencies are only supported in direct keyed row components`)
72
73
  const dependencyIds = dependencies.map(dependency => {
73
74
  if (!dependency?.[signalMarker] || !validEffectDependency(dependency.value)) throw new Error(`${source} useEffect() dependencies must be primitive Kudzu state or runtime parameter identifiers`)
74
75
  return dependency.id
@@ -88,13 +89,16 @@ export function useEffect(callback, dependencies, module, handler, states, scope
88
89
  if (!owner) throw new Error(`${source} Keyed row effects must have the same hook order for every item`)
89
90
  }
90
91
  effects.push(owner)
92
+ if (!renderContext.listRoot.template) for (const field of itemDependencies) {
93
+ if (!validEffectDependency(renderContext.listRoot.item[field])) throw new Error(`${source} useEffect() keyed item dependency "${field}" must be a JSON-safe primitive`)
94
+ }
91
95
  } else if (renderContext.conditionDepth) {
92
96
  const owners = renderContext.effectOwners.at(-1)
93
97
  if (!owners) throw new Error(`${source} useEffect() inside conditional DOM must belong to a rendered function component`)
94
98
  owner = nextRenderId("e")
95
99
  owners.push(owner)
96
100
  }
97
- if (!renderContext.listDepth || list) renderContext.effects.push({ module, handler, states, scope, source, renderScope: renderContext.renderScope, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(cleanup ? { cleanup: true } : {}), ...(owner ? { owner } : {}), ...(list ? { list: true } : {}) })
101
+ if (!renderContext.listDepth || list) renderContext.effects.push({ module, handler, states, scope, source, renderScope: renderContext.renderScope, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(itemDependencies.length ? { itemDependencies, listState: renderContext.listRoot.state } : {}), ...(cleanup ? { cleanup: true } : {}), ...(owner ? { owner } : {}), ...(list ? { list: true } : {}) })
98
102
  renderContext.hasBehaviors = true
99
103
  renderContext.hasEffects = true
100
104
  }
@@ -298,12 +302,12 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
298
302
  const body = await renderNode(layout ? { type: layout, props: { children: page } } : { type: component, props })
299
303
  renderContext.effects = renderContext.effects.map(effect => {
300
304
  try {
301
- if (metadata.navigationAsset && effect.owner) throw new Error("useEffect() inside conditional or keyed DOM is not supported in a configured navigation group yet; move the effect to the layout or route component body, or remove the route from navigation")
302
305
  const descriptor = nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, typeof read === "function" ? read() : read]))
303
306
  return {
304
307
  module: effect.module,
305
308
  handler: effect.handler,
306
309
  ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
310
+ ...(effect.itemDependencies ? { itemDependencies: effect.itemDependencies, listState: effect.listState } : {}),
307
311
  ...(effect.cleanup ? { cleanup: true } : {}),
308
312
  ...(effect.owner ? { owner: effect.owner } : {}),
309
313
  ...(effect.list ? { list: true } : {}),
@@ -683,7 +687,7 @@ async function renderList(node, namespace, selectValue) {
683
687
  renderContext.listTemplate = true
684
688
  renderContext.listEffectOwners = []
685
689
  renderContext.listFields = new Set([node.keyField])
686
- renderContext.listRoot = { id, template: true, effects: [], item: {} }
690
+ renderContext.listRoot = { id, state: node.items.id, template: true, effects: [], item: {} }
687
691
  const template = await renderNode(node.render({}), namespace, selectValue)
688
692
  if (template.includes("data-k-native-") || template.includes("data-k-effects=")) descriptor.mount = true
689
693
  if (template.includes("data-k-effects=")) descriptor.effects = true
@@ -699,7 +703,7 @@ async function renderList(node, namespace, selectValue) {
699
703
  renderContext.listTemplate = false
700
704
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
701
705
  for (const item of node.items.value) {
702
- renderContext.listRoot = { id, key: item[node.keyField], template: false, effects: [], item }
706
+ renderContext.listRoot = { id, state: node.items.id, key: item[node.keyField], template: false, effects: [], item }
703
707
  current += await renderNode(node.render(item), namespace, selectValue)
704
708
  }
705
709
  renderContext.lists.push(descriptor)
@@ -1,4 +1,4 @@
1
- import { browserState, mountDom, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
1
+ import { browserState, mountDom, notifyListItem, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
2
2
 
3
3
  const listTargets = new Map()
4
4
  const listRegistrations = new WeakMap()
@@ -102,6 +102,7 @@ function updateList(list) {
102
102
  added = true
103
103
  } else if (list.values.get(token) !== value) {
104
104
  fillListItem(node, item)
105
+ if (__KUDZU_LIST_ITEM_HOOKS__) notifyListItem(list.descriptor.state, node)
105
106
  }
106
107
  next.push([token, node])
107
108
  values.set(token, value)
@@ -19,6 +19,24 @@ const committers = []
19
19
  const mountHooks = []
20
20
  const unmountHooks = []
21
21
 
22
+ /* list-item-hooks */
23
+ const listItemHooks = new Map()
24
+
25
+ export function registerListItemHook(id, hook) {
26
+ const hooks = listItemHooks.get(id) ?? new Set()
27
+ hooks.add(hook)
28
+ listItemHooks.set(id, hooks)
29
+ return () => {
30
+ hooks.delete(hook)
31
+ if (!hooks.size) listItemHooks.delete(id)
32
+ }
33
+ }
34
+
35
+ export function notifyListItem(id, root) {
36
+ for (const hook of listItemHooks.get(id) ?? []) hook(root)
37
+ }
38
+ /* list-item-hooks-end */
39
+
22
40
  export function registerCommitter(commit) {
23
41
  committers.push(commit)
24
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",