@kudzujs/core 0.6.4 → 0.6.6

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
@@ -13,7 +13,7 @@ The benchmark runner, framework fixtures, generated artifacts, and raw arrays ar
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**: 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. Disposed effect setters and queued commits are inactive. Keyed item-property dependencies remain unsupported.
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.
@@ -29,6 +29,10 @@ The post-Goal-A multiple-group fixture emits a 7,448 B raw / 3,099 B gzip (`gzip
29
29
 
30
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
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
+
32
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.
33
37
 
34
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.
package/GOAL_B.md ADDED
@@ -0,0 +1,153 @@
1
+ # Goal B: Static Realtime Dashboards
2
+
3
+ Goal B makes Kudzu sufficient for a ThingsBoard-shaped realtime device dashboard without turning Kudzu into a stream runtime, widget framework, or server platform. It preserves complete static documents, zero-JavaScript routes that use no browser capabilities, direct DOM ownership, native navigation fallback, and the absence of React, a VDOM, hydration, or a retained browser component tree.
4
+
5
+ `MIGRATION_ROADMAP.md` remains the source of truth for compiler invariants and fixture-first development. This document is the implementation contract for realtime dashboard work.
6
+
7
+ ## Product Target
8
+
9
+ A user can statically deploy this flow:
10
+
11
+ ```text
12
+ dashboard -> devices -> device detail -> alarms -> settings
13
+ ```
14
+
15
+ The first vertical slice is deliberately smaller:
16
+
17
+ ```text
18
+ plain route <-> realtime device dashboard
19
+ ```
20
+
21
+ The dashboard receives a logical 1,000 telemetry samples per second, keeps a bounded history in a module Worker, downsamples it, and updates one imperative chart without routing samples through `useState()`.
22
+
23
+ ## Runtime Model
24
+
25
+ ```text
26
+ Complete static dashboard shell
27
+ -> route-specific effect ESM
28
+ -> route-owned module Worker
29
+ -> bounded telemetry buffer and downsampling
30
+ -> batched imperative chart updates
31
+ ```
32
+
33
+ Kudzu state is for low-frequency UI state such as selected device, time range, filters, tabs, connection status, alarm status, and widget configuration. High-frequency samples belong in a Worker or imperative browser module.
34
+
35
+ The Worker is a capability, not a framework runtime. Routes that do not create one must not load its graph. Static routes must remain JavaScript-free.
36
+
37
+ ## Milestone 1: Relative TypeScript Workers
38
+
39
+ Status: implemented and verified. The compiler recognizes only the exact inline-effect form below, emits its validated graph separately, rewrites the constructor to the base-aware same-origin asset, and leaves routes without this capability on their existing output paths.
40
+
41
+ Support this exact shape inside a compiled inline `useEffect` callback:
42
+
43
+ ```tsx
44
+ useEffect(() => {
45
+ const worker = new Worker(
46
+ new URL("../telemetry.worker.ts", import.meta.url),
47
+ { type: "module" },
48
+ )
49
+
50
+ const onMessage = (event: MessageEvent<ChartFrame>) => chart.render(event.data)
51
+ worker.addEventListener("message", onMessage)
52
+
53
+ return () => {
54
+ worker.removeEventListener("message", onMessage)
55
+ worker.terminate()
56
+ chart.dispose()
57
+ }
58
+ }, [])
59
+ ```
60
+
61
+ Compiler requirements:
62
+
63
+ - accept only an unshadowed `Worker` with `new URL(relativeLiteral, import.meta.url)` and literal `{ type: "module" }`;
64
+ - resolve one `.worker.ts` entry under project source and bundle its relative TypeScript graph separately from window code;
65
+ - emit deterministic content-hashed ESM under `assets/workers` and rewrite the constructor to the base-aware emitted URL;
66
+ - reject package imports, JSX, TypeScript import-equals declarations, dynamic imports, `require()`, paths outside source, malformed options, and unsupported Worker forms with source locations;
67
+ - reject ordinary runtime imports or re-exports of `.worker.ts`; type-only imports may erase normally;
68
+ - do not mark Worker files as navigation capability scripts or import them into the window;
69
+ - create the Worker only when the owning effect mounts;
70
+ - preserve byte-for-byte generated shared/list/effect paths for builds without relative TypeScript Workers where practical;
71
+ - reject generated/public asset collisions instead of silently overwriting output.
72
+
73
+ Worker construction inside imported helpers or imported keyed-row effects, `SharedWorker`, classic workers, inline Blob workers, and arbitrary dynamic Worker URLs are outside Milestone 1. Imported keyed rows must move Worker ownership to a directly compiled page or local component effect so lexical global analysis remains tied to the original source tree.
74
+
75
+ ## Ownership
76
+
77
+ | Owner | Lifetime | Dashboard responsibility |
78
+ |---|---|---|
79
+ | Document | Full document | authentication expiry and global diagnostics |
80
+ | Layout | Enhanced navigation session | tenant session or shared transport |
81
+ | Route | Current dashboard/device | Worker, telemetry subscription, request cancellation |
82
+ | DOM range | Current widget | chart, gauge, map, table, animation frame |
83
+ | Worker | Explicit owner cleanup | parsing, bounded buffering, aggregation, downsampling |
84
+
85
+ Leaving a route must remove message listeners, stop chart work, terminate its Worker, and invalidate stale UI writes before another route mounts. BFCache-preserved documents must retain their live ownership until a real document exit.
86
+
87
+ ## Fixture Contract
88
+
89
+ The first fixture must provide:
90
+
91
+ - one complete realtime dashboard document and one complete plain document in an opt-in navigation group;
92
+ - one unrelated static route with zero JavaScript;
93
+ - a relative TypeScript Worker importing at least one relative helper;
94
+ - a fixed-capacity ring buffer with deterministic eviction;
95
+ - logical 1,000 samples/second input in batches rather than a 1 ms browser timer;
96
+ - bounded downsampled frames delivered at no more than display cadence;
97
+ - one imperative canvas or DOM chart updated without sample-level Kudzu setters;
98
+ - direct load, dashboard-to-plain navigation, back/forward, and repeated cached revisits;
99
+ - exact counters for Worker starts, terminations, messages, renders, listeners, and stale post-cleanup work;
100
+ - native document fallback when JavaScript or Worker creation fails.
101
+
102
+ ## Acceptance Criteria
103
+
104
+ - the Worker graph is absent from static and plain route HTML and is fetched only after the dashboard effect mounts;
105
+ - two unchanged production builds emit identical Worker names and bytes;
106
+ - changing Worker source changes its content hash;
107
+ - base-prefixed deployment produces a valid same-origin Worker URL;
108
+ - 30 dashboard/plain cycles create and terminate exactly 30 route Workers with no growing listener or chart ownership;
109
+ - messages arriving after cleanup cannot update removed route DOM;
110
+ - the ring buffer remains at its configured capacity under sustained input;
111
+ - chart rendering is batched and sample ingestion does not call `useState()`;
112
+ - routes without Worker capabilities remain byte-for-byte unaffected;
113
+ - output raw/gzip cost, clean build time, sample throughput, render cadence, and lifecycle counters are recorded;
114
+ - `npm run check`, `npm test`, package dry-run, and browser checks pass.
115
+
116
+ Verified measurements for the focused `/dash` fixture: the minified Worker graph is `assets/workers/telemetry.worker-BVG2SA55.js`, 907 B raw and 477 B gzip. The dashboard window graph is 11,388 B raw and 5,148 B gzip across its shared runtime, effect runtime, navigation, route effect entry, and handler module; the Worker is not part of that graph. Seven clean minified builds measured 455.1, 459.9, 460.7, 463.5, 467.6, 472.6, and 475.2 ms, with a 463.5 ms median.
117
+
118
+ The real-Worker browser check uses real wall time and requires sustained generation beyond 1,130 samples at 700-1,300 logical samples/second, an exact 128-sample ring bound, batches of 10, exactly 24 displayed points, multiple renders, and a render ceiling below 25 Hz. Delayed Worker ticks catch up in batches to the logical 1,000 samples/second clock; frames emit no more often than every 50 ms. The imperative chart performs a minimal canvas path draw. The navigation ownership check completed 30 dashboard/plain cycles with exactly 30 starts and 30 terminations, exactly 60 listener additions and removals across message and error listeners, zero retained listeners after every cleanup, disposed every old chart canvas, fresh ownership on back/forward and cached revisits, and no render from a removed message listener invoked after cleanup. Dashboard, plain, and static HTML contain no Worker asset URL; plain does not load the route effect graph, and static contains no script, capability marker, or state payload. A no-Worker equivalent emitted byte-identical shared runtime, effect runtime, navigation, and route effect entry files with no `assets/workers` directory. An unreachable imported-row effect referencing `unused.worker.ts` emitted no Worker asset. Two unchanged builds emitted identical Worker names and bytes, and a controlled downsample-source change changed the emitted hash.
119
+
120
+ ## Delivery Order
121
+
122
+ 1. **Worker compiler capability**: exact syntax, graph bundling, hashing, base rewriting, diagnostics, and zero-cost exclusion.
123
+ 2. **Realtime vertical slice**: mock telemetry Worker, bounded buffer, downsampling, imperative chart, and route cleanup.
124
+ 3. **Shared transport**: add a layout-owned mock connection only if multiple routes prove that one Worker per route is wasteful.
125
+ 4. **Device workflows**: filters, commands, timeout/error handling, and stale response suppression.
126
+ 5. **Alarm workflows**: active/history views and optimistic acknowledgement with rollback.
127
+ 6. **Widget expansion**: add one gauge, table, map, or real chart engine at a time only when a fixture requires it.
128
+
129
+ Each phase starts with one failing fixture and ends with correctness, lifecycle, browser, size, and build measurements.
130
+
131
+ ## Performance Gates
132
+
133
+ - sustained 1,000 samples/second does not create one main-thread task or Kudzu state commit per sample;
134
+ - Worker memory is bounded by declared buffer capacity;
135
+ - chart updates are batched to at most one per display frame;
136
+ - dashboard departure stops observable messages and renders before the next route mounts;
137
+ - repeated navigation leaves no growing Worker, timer, listener, chart, state, DOM, or heap ownership;
138
+ - Worker support adds no bytes to routes and builds that do not use it;
139
+ - material losses are profiled and fixed or documented as explicit tradeoffs using matched initial content and behavior.
140
+
141
+ ## Non-Goals
142
+
143
+ - implementing ThingsBoard's server, protocol, rule engine, database, or complete UI;
144
+ - storing telemetry samples in Kudzu component state;
145
+ - adding a general observable, scheduler, stream, state, or widget runtime;
146
+ - retaining a browser component tree;
147
+ - request-time SSR, Server Actions, or a hidden application server;
148
+ - a plugin marketplace or arbitrary third-party React widgets;
149
+ - claiming Worker isolation as a security sandbox.
150
+
151
+ ## Completion Definition
152
+
153
+ Goal B Milestone 1 is complete when the focused realtime fixture proves deterministic relative TypeScript Worker emission, bounded high-frequency processing, imperative chart updates, exact route ownership and cleanup across repeated navigation, native/static fallback, zero-cost exclusion, source diagnostics, and recorded production measurements.
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
 
@@ -380,6 +380,20 @@ Dependency values are limited to JSON-safe strings, finite numbers, booleans, an
380
380
 
381
381
  Effect callbacks must be inline and block-bodied. Named or dynamically obtained cleanup functions, cleanup parameters or generators, other return values, callback parameters, and non-serializable captures are rejected. Async effects cannot return cleanup functions; the cleanup itself may be async. Pages without effects receive no effect entry. Empty-dependency effects retain their smaller output, and dependency-only capability code is isolated to the routes that use `kudzu-deps.js` unless another capability already requires the shared runtime.
382
382
 
383
+ An inline effect may own an exact relative TypeScript module Worker:
384
+
385
+ ```tsx
386
+ useEffect(() => {
387
+ const worker = new Worker(
388
+ new URL("../telemetry.worker.ts", import.meta.url),
389
+ { type: "module" },
390
+ )
391
+ return () => worker.terminate()
392
+ }, [])
393
+ ```
394
+
395
+ Kudzu resolves the path from the callback source, bundles the Worker and its relative TypeScript imports separately as content-hashed ESM under `assets/workers`, and rewrites the constructor to the base-prefixed same-origin asset URL. The Worker is fetched only when the effect mounts; it is not a capability script, preload, or window import. Unrendered effect handlers do not cause their Worker root to be emitted. This slice requires unshadowed global `Worker` and `URL`, exact `import.meta.url`, a relative `.worker.ts` string literal, and exactly `{ type: "module" }`. Worker graphs reject JSX, package runtime imports, TypeScript import-equals declarations, dynamic imports, `require()`, missing files, and paths outside `src`. Worker source cannot be imported or re-exported as an ordinary runtime module. Construction in event handlers, imported helpers, or imported keyed-row effects is rejected; move keyed-row Worker ownership to a directly compiled page or local component effect. Public or absolute JavaScript Workers remain ordinary browser code and are not transformed.
396
+
383
397
  A matched mount-fetch benchmark renders a title and two keyed rows from local JSON. With one warm-up and seven rotating clean builds, Kudzu shipped initial HTML, 3.4 KB initial JS gzip, 8.1 KB total output, and built in 374 ms. React CSR shipped no initial content, 59.3 KB initial JS gzip, 189.2 KB total output, and built in 992 ms. Hand-written ESM shipped 534 B initial JS gzip, 1.2 KB total output, and built in 210 ms. Fresh-profile Chrome medians to loaded data were 157.9 ms, 166.5 ms, and 153.4 ms respectively.
384
398
 
385
399
  A matched resize-listener cleanup fixture, measured with the same warm-up and seven rotating clean builds, shipped 1.2 KB JavaScript gzip and built in 402 ms with Kudzu. Svelte shipped 10.1 KB and built in 861 ms, Vue shipped 23.6 KB and built in 768 ms, and React shipped 59.1 KB and built in 1,058 ms. Kudzu and the 127 B hand-written Astro baseline emitted initial HTML; the CSR fixtures did not.
@@ -470,7 +484,7 @@ export default {
470
484
 
471
485
  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
486
 
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, read the latest row item when a supported state dependency reruns, and clean up on removal. Cached route modules create fresh route owner records and subscriptions on every revisit. Keyed item-property dependencies remain unsupported. 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.
487
+ 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.
474
488
 
475
489
  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.
476
490
 
@@ -495,6 +509,7 @@ Supported:
495
509
  - Base-path deployments, multiple CSS files, and `afterBuild`
496
510
  - Primitive `useState` bindings
497
511
  - Mount-only `useEffect(fn, [])` compiled to route-specific ESM
512
+ - Relative TypeScript module Workers owned by inline effects
498
513
  - Conditional and keyed-row effect ownership with cleanup on DOM removal
499
514
  - Synchronous and async event handlers
500
515
  - Relative imported helpers in native handlers
@@ -645,7 +660,21 @@ An intrinsic-root versus projected-prop row-component A/B build produced byte-fo
645
660
 
646
661
  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.
647
662
 
648
- 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.
663
+ ### 1,000-item Keyed Effect
664
+
665
+ 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.
666
+
667
+ | Framework | Initial rows | Initial JS gzip | Total output | Build | Selected update | Unrelated update | Reverse |
668
+ |---|---:|---:|---:|---:|---:|---:|---:|
669
+ | Astro native | Yes | **381 B** | **90,734 B** | 1,022 ms | **0.4 ms** | **0.2 ms** | **5.5 ms** |
670
+ | Kudzu | Yes | 7,070 B | 221,056 B | **437 ms** | 3.4 ms | 2.9 ms | 7.8 ms |
671
+ | Vue CSR | No | 25,091 B | 63,368 B | 893 ms | 4.7 ms | **2.3 ms** | 10.1 ms |
672
+ | Svelte CSR | No | 12,848 B | 33,222 B | 1,012 ms | 5.2 ms | 3.0 ms | 48.1 ms |
673
+ | React CSR | No | 60,921 B | 194,301 B | 1,132 ms | 12.3 ms | 6.8 ms | 19.0 ms |
674
+
675
+ 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.
676
+
677
+ 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.
649
678
 
650
679
  ## Development
651
680
 
@@ -17,8 +17,16 @@
17
17
 
18
18
  Static routes receive no browser runtime. Command routes receive `runtime.js`; dependency effects use route-specific `kudzu-deps.js` unless that route already requires shared commit hooks; runtime bracket pages using `useParams()` add one route-specific pathname matcher; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; effects add `effect-runtime.js` and one route-specific entry. Generated module scripts live in the document head, so cold downloads overlap HTML transfer while standard module deferral preserves execution after parsing. A single effect with one dependency compiles to a direct runner; generic maps, sets, and ordering are reserved for larger effect graphs. Dependency commits coalesce in a microtask; affected cleanups are awaited in declaration order before replacement setups run. Document cleanup integrates with shared unmount hooks when present and otherwise disposes directly on non-persisted `pagehide`. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. Runtime fallback rewrites are ordered by specificity in `.kudzu/kudzu-plan.json` and passed to `afterBuild()`; exact static files take precedence in development. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
19
19
 
20
+ Exact relative `.worker.ts` constructors in inline effects are validated and bundled in a separate content-hashed ESM graph under `dist/assets/workers/`. Those files are referenced only by rendered effect handlers and never become document capability scripts, preloads, or window imports; unreachable source effects do not emit their Worker roots. Worker graphs allow relative TypeScript ESM runtime imports only and reject JSX, package runtime imports, import-equals declarations, dynamic imports, `require()`, and paths outside `src`. Ordinary runtime imports or re-exports of `.worker.ts` and Worker construction in imported keyed-row effects are rejected.
21
+
20
22
  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
23
 
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, 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. Primitive dependencies and cleanup are supported, while keyed item-property dependencies remain unsupported. Fragment payloads and coordinated View Transitions are not implemented.
24
+ 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.
25
+
26
+ `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.
23
27
 
24
28
  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.
29
+
30
+ 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.
31
+
32
+ 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).
@@ -45,8 +45,10 @@ export async function build({ quiet = false, minify = true } = {}) {
45
45
  const sourceIndex = new Map(await Promise.all(sourceFiles.map(async file => [file, await readFile(file, "utf8")])))
46
46
 
47
47
  const handlerModules = []
48
+ const workerReferences = []
48
49
  for (const file of sourceFiles) {
49
- const handlerModule = await compile(file, sourceFileSet, sourceIndex, base)
50
+ if (file.endsWith(".worker.ts")) continue
51
+ const handlerModule = await compile(file, sourceFileSet, sourceIndex, base, workerReferences)
50
52
  if (handlerModule) handlerModules.push(handlerModule)
51
53
  }
52
54
 
@@ -161,6 +163,18 @@ export async function build({ quiet = false, minify = true } = {}) {
161
163
 
162
164
  const assetsDirectory = join(outputDirectory, "assets")
163
165
  await mkdir(assetsDirectory, { recursive: true })
166
+ const renderedEffects = new Set(plans.flatMap(plan => plan.effects.map(effect => `${effect.module}:${effect.handler}`)))
167
+ const renderedWorkerReferences = workerReferences.filter(reference => renderedEffects.has(`${reference.module}:${reference.handler}`))
168
+ if (renderedWorkerReferences.length && await exists(join(root, "public", "assets", "workers"))) throw new Error("public/assets/workers collides with Kudzu's generated Worker asset namespace")
169
+ const workerAssets = await emitWorkers(renderedWorkerReferences, sourceFileSet, assetsDirectory, base, minify)
170
+ for (const module of handlerModules) {
171
+ for (const reference of workerReferences) {
172
+ if (reference.module !== assetPath(base, `assets/${module.path}`)) continue
173
+ const url = workerAssets.get(reference.placeholder) ?? "about:blank"
174
+ module.code = module.code.replaceAll(JSON.stringify(reference.placeholder), JSON.stringify(url))
175
+ }
176
+ if (module.code.includes("/__kudzu_worker_")) throw new Error(`Worker URL placeholder survived in ${module.path}`)
177
+ }
164
178
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
165
179
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
166
180
  const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
@@ -172,6 +186,7 @@ export async function build({ quiet = false, minify = true } = {}) {
172
186
  const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
173
187
  const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
174
188
  const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
189
+ const hasItemDependencies = plans.some(plan => plan.effects.some(effect => effect.itemDependencies?.length))
175
190
  const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
176
191
  const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
177
192
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
@@ -194,6 +209,7 @@ export async function build({ quiet = false, minify = true } = {}) {
194
209
  if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
195
210
  const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
196
211
  let runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
212
+ if (!hasItemDependencies) runtime = runtime.replace(/\/\* list-item-hooks \*\/[\s\S]*?\/\* list-item-hooks-end \*\/\n/, "")
197
213
  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}")
198
214
  if (hasNavigableOwners) runtime = runtime
199
215
  .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}")
@@ -231,6 +247,7 @@ export async function build({ quiet = false, minify = true } = {}) {
231
247
  if (listCount) {
232
248
  let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
233
249
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
250
+ if (!hasItemDependencies) listRuntime = listRuntime.replace(", notifyListItem", "")
234
251
  const stylePatch = ` if (target === "style") {
235
252
  const style = serializeStyle(value)
236
253
  if (style) node.setAttribute("style", style)
@@ -249,7 +266,8 @@ export async function build({ quiet = false, minify = true } = {}) {
249
266
  __KUDZU_LIST_SEEDS__: String(hasListSeeds),
250
267
  __KUDZU_LIST_EFFECTS__: String(hasListEffects),
251
268
  __KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
252
- __KUDZU_LIST_MOUNTS__: String(hasListMounts)
269
+ __KUDZU_LIST_MOUNTS__: String(hasListMounts),
270
+ __KUDZU_LIST_ITEM_HOOKS__: String(hasItemDependencies)
253
271
  })
254
272
  }
255
273
  if (hasNativeHandlers) {
@@ -396,7 +414,7 @@ function specializeNativeRuntime(source, events, modules) {
396
414
 
397
415
  function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
398
416
  const hasCleanup = effects.some(effect => effect.cleanup)
399
- const hasDependencies = effects.some(effect => effect.dependencies?.length)
417
+ const hasDependencies = effects.some(effect => effect.dependencies?.length || effect.itemDependencies?.length)
400
418
  const hasOwners = effects.some(effect => effect.owner)
401
419
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
402
420
  const modules = moduleUrls.map(url => {
@@ -684,6 +702,7 @@ function mount(lifetime) {
684
702
  }
685
703
 
686
704
  function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
705
+ const hasItemDependencies = effects.some(effect => effect.itemDependencies?.length)
687
706
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
688
707
  const modules = moduleUrls.map(url => {
689
708
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -728,7 +747,12 @@ function mount(lifetime) {
728
747
  }) : undefined
729
748
  const unsubscribeMount = __kRuntime.registerMountHook(mountOwned)
730
749
  const unsubscribeUnmount = __kRuntime.registerUnmountHook(unmountOwned)
731
- for (const record of records) if (record.mounted) start(record)
750
+ ${hasItemDependencies ? `const unsubscribeItems = [...new Set(selectedEffects.filter(({ effect }) => effect.itemDependencies?.length).map(({ effect }) => effect.listState))].map(listState => __kRuntime.registerListItemHook(listState, root => {
751
+ if (!active) return
752
+ for (const record of registrations.get(root) ?? []) if (record.mounted && record.effect.itemDependencies) pending.add(record)
753
+ schedule()
754
+ }))
755
+ ` : ""}for (const record of records) if (record.mounted) start(record)
732
756
  mountOwned(document)
733
757
  function createRecord(template, mounted = true) {
734
758
  const record = { ...template, order: order++, mounted, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined, token: undefined }
@@ -833,16 +857,21 @@ function mount(lifetime) {
833
857
  for (const record of selected) {
834
858
  try {
835
859
  const values = readDependencies(record)
836
- if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
837
- record.values = values
838
- changed.push([record, record.version])
839
- }
860
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) changed.push([record, record.version])
840
861
  } catch (error) {
841
862
  console.error(error)
842
863
  }
843
864
  }
844
865
  for (const [record] of changed) await cleanup(record)
845
- if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) invoke(record)
866
+ if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) {
867
+ try {
868
+ record.values = readDependencies(record)
869
+ invoke(record)
870
+ } catch (error) {
871
+ record.values = undefined
872
+ console.error(error)
873
+ }
874
+ }
846
875
  })()
847
876
  flushing = operation
848
877
  try { await operation } finally {
@@ -851,11 +880,20 @@ function mount(lifetime) {
851
880
  }
852
881
  }
853
882
  function readDependencies(record) {
854
- return (record.effect.dependencies ?? []).map(id => {
883
+ const values = (record.effect.dependencies ?? []).map(id => {
855
884
  const value = __kRuntime.browserState.get(id)
856
885
  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")
857
886
  return value
858
887
  })
888
+ ${hasItemDependencies ? `if (record.effect.itemDependencies) {
889
+ const item = JSON.parse(record.marker.dataset.kEffectItem)
890
+ for (const field of record.effect.itemDependencies) {
891
+ const value = item[field]
892
+ 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\`)
893
+ values.push(value)
894
+ }
895
+ }` : ""}
896
+ return values
859
897
  }
860
898
  function invoke(record) {
861
899
  const token = { active: true }
@@ -896,7 +934,7 @@ function mount(lifetime) {
896
934
  disposal = (async () => {
897
935
  active = false
898
936
  unsubscribeCommitter?.()
899
- unsubscribeMount()
937
+ ${hasItemDependencies ? "for (const unsubscribe of unsubscribeItems) unsubscribe()\n " : ""}unsubscribeMount()
900
938
  unsubscribeUnmount()
901
939
  pending.clear()
902
940
  for (const record of records) if (record.token) record.token.active = false
@@ -919,6 +957,7 @@ function runtimeEffects(effects, lifetimes = false) {
919
957
  module: effect.module,
920
958
  handler: effect.handler,
921
959
  ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
960
+ ...(effect.itemDependencies ? { itemDependencies: effect.itemDependencies, listState: effect.listState } : {}),
922
961
  ...(effect.cleanup ? { cleanup: true } : {}),
923
962
  ...(effect.owner ? { owner: effect.owner } : {}),
924
963
  ...(effect.list ? { list: true } : {}),
@@ -929,10 +968,12 @@ function runtimeEffects(effects, lifetimes = false) {
929
968
  }
930
969
 
931
970
  function printOwnedEffectEntry(imports, effects, entries) {
971
+ const hasItemDependencies = effects.some(effect => effect.itemDependencies?.length)
972
+ const hasOrdinaryDependencies = effects.some(effect => effect.dependencies?.length)
932
973
  return `${imports.join("\n")}
933
974
  const effects = ${inlineJson(effects)}
934
975
  const modules = new Map([${entries}])
935
- const records = effects.map((effect, index) => effect.list ? undefined : createRecord(effect, index)).filter(Boolean)
976
+ ${hasItemDependencies ? "let order = 0\n" : ""}const records = effects.map((effect, index) => effect.list ? undefined : createRecord(effect, index)).filter(Boolean)
936
977
  const listTemplates = new Map(effects.map((effect, index) => effect.list ? [effect.owner, { effect, index }] : undefined).filter(Boolean))
937
978
  const owners = new Map(records.filter(record => record.effect.owner).map(record => [record.effect.owner, record]))
938
979
  const listRegistrations = new WeakMap()
@@ -944,7 +985,7 @@ let flushing = false
944
985
  let active = true
945
986
  for (const record of records) registerDependencies(record)
946
987
  function createRecord(effect, index) {
947
- return { effect, index, mounted: !effect.owner, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined, token: undefined }
988
+ return { effect, index, ${hasItemDependencies ? "order: order++, " : ""}mounted: !effect.owner, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined, token: undefined }
948
989
  }
949
990
  function registerDependencies(record) {
950
991
  for (const id of record.effect.dependencies ?? []) {
@@ -960,12 +1001,17 @@ function unregisterDependencies(record) {
960
1001
  if (!subscribers?.size) dependencies.delete(id)
961
1002
  }
962
1003
  }
963
- __kRuntime.registerCommitter(id => {
1004
+ ${hasItemDependencies ? `if (${hasOrdinaryDependencies}) ` : ""}__kRuntime.registerCommitter(id => {
964
1005
  if (!active) return
965
1006
  for (const record of dependencies.get(id) ?? []) if (record.mounted) pending.add(record)
966
1007
  schedule()
967
1008
  })
968
- __kRuntime.registerMountHook(root => {
1009
+ ${hasItemDependencies ? `for (const listState of new Set(effects.filter(effect => effect.itemDependencies?.length).map(effect => effect.listState))) __kRuntime.registerListItemHook(listState, root => {
1010
+ if (!active) return
1011
+ for (const record of listRegistrations.get(root) ?? []) if (record.mounted && record.effect.itemDependencies) pending.add(record)
1012
+ schedule()
1013
+ })
1014
+ ` : ""}__kRuntime.registerMountHook(root => {
969
1015
  if (!active) return
970
1016
  for (const marker of matching(root)) {
971
1017
  if (marker.dataset.kEffects) {
@@ -1053,33 +1099,47 @@ async function flush() {
1053
1099
  if (!active) return pending.clear()
1054
1100
  flushing = true
1055
1101
  try {
1056
- const selected = [...pending].filter(record => record.mounted).sort((left, right) => left.index - right.index)
1102
+ const selected = [...pending].filter(record => record.mounted).sort((left, right) => left.index - right.index${hasItemDependencies ? " || left.order - right.order" : ""})
1057
1103
  pending.clear()
1058
1104
  const changed = []
1059
1105
  for (const record of selected) {
1060
1106
  try {
1061
1107
  const values = readDependencies(record)
1062
- if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
1063
- record.values = values
1064
- changed.push([record, record.version])
1065
- }
1108
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) changed.push([record, record.version])
1066
1109
  } catch (error) {
1067
1110
  console.error(error)
1068
1111
  }
1069
1112
  }
1070
1113
  for (const [record] of changed) await invokeCleanup(record)
1071
- if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) invoke(record)
1114
+ if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) {
1115
+ try {
1116
+ record.values = readDependencies(record)
1117
+ invoke(record)
1118
+ } catch (error) {
1119
+ record.values = undefined
1120
+ console.error(error)
1121
+ }
1122
+ }
1072
1123
  } finally {
1073
1124
  flushing = false
1074
1125
  if (active) schedule()
1075
1126
  }
1076
1127
  }
1077
1128
  function readDependencies(record) {
1078
- return (record.effect.dependencies ?? []).map(id => {
1129
+ const values = (record.effect.dependencies ?? []).map(id => {
1079
1130
  const value = browserState.get(id)
1080
1131
  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")
1081
1132
  return value
1082
1133
  })
1134
+ ${hasItemDependencies ? `if (record.effect.itemDependencies) {
1135
+ const item = JSON.parse(record.marker.dataset.kEffectItem)
1136
+ for (const field of record.effect.itemDependencies) {
1137
+ const value = item[field]
1138
+ 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\`)
1139
+ values.push(value)
1140
+ }
1141
+ }` : ""}
1142
+ return values
1083
1143
  }
1084
1144
  function invoke(record) {
1085
1145
  const token = { active: true }
@@ -1482,7 +1542,7 @@ function escapeAttribute(value) {
1482
1542
  return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;")
1483
1543
  }
1484
1544
 
1485
- async function compile(file, sourceFiles, sourceIndex, base) {
1545
+ async function compile(file, sourceFiles, sourceIndex, base, workerReferences) {
1486
1546
  const source = sourceIndex.get(file)
1487
1547
  const nativeHandlers = []
1488
1548
  const effectHandlers = []
@@ -1498,7 +1558,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
1498
1558
  jsx: ts.JsxEmit.ReactJSX,
1499
1559
  jsxImportSource: "@kudzujs/core"
1500
1560
  },
1501
- transformers: { before: [createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
1561
+ transformers: { before: [createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports, workerReferences)] },
1502
1562
  reportDiagnostics: true
1503
1563
  })
1504
1564
 
@@ -1528,12 +1588,13 @@ async function compile(file, sourceFiles, sourceIndex, base) {
1528
1588
  return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
1529
1589
  }
1530
1590
 
1531
- function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
1591
+ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports, workerReferences) {
1532
1592
  return context => sourceFile => {
1533
1593
  const factory = context.factory
1534
1594
  const hasLinkElements = /<link/i.test(sourceFile.text)
1535
1595
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
1536
1596
  ts.setParentRecursive(sourceFile, false)
1597
+ rejectOrdinaryWorkerImports(sourceFile, file, sourceFiles)
1537
1598
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
1538
1599
  const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && statement.moduleSpecifier.text === "@kudzujs/core" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
1539
1600
  const importedSources = new Map()
@@ -1563,6 +1624,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1563
1624
  let usesConditional = false
1564
1625
  let usesList = false
1565
1626
  let usesListEffects = false
1627
+ let usesListItem = false
1566
1628
 
1567
1629
  const collect = node => {
1568
1630
  if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
@@ -1782,10 +1844,23 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1782
1844
  if (callback.asteriskToken) effectFail(callback, "useEffect() callback cannot be a generator")
1783
1845
  if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
1784
1846
  if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
1785
- if (listEffect && dependencies.elements.some(dependency => referencesIdentifier(dependency, listEffect.item))) {
1786
- effectFail(dependencies, "useEffect() item-property dependencies are not supported in keyed lists; use [] or primitive Kudzu state identifiers")
1847
+ const itemDependencies = []
1848
+ const ordinaryDependencies = []
1849
+ let dependencyItem = listEffect?.item
1850
+ for (const dependency of dependencies.elements) {
1851
+ const value = unwrapExpression(dependency)
1852
+ if (!dependencyItem && ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && isDestructuredParameter(value.expression, nearestFunction(node))) dependencyItem = value.expression.text
1853
+ const field = dependencyItem && directProperty(dependency, dependencyItem)
1854
+ if (field) {
1855
+ if (["__proto__", "constructor", "prototype"].includes(field)) effectFail(dependency, `useEffect() keyed item property "${field}" is not supported`)
1856
+ itemDependencies.push(field)
1857
+ } else if (dependencyItem && referencesIdentifier(dependency, dependencyItem)) {
1858
+ effectFail(dependency, "useEffect() keyed item dependencies must be direct item.<field> properties")
1859
+ } else {
1860
+ ordinaryDependencies.push(dependency)
1861
+ }
1787
1862
  }
1788
- const invalidDependency = dependencies.elements.find(dependency => !ts.isIdentifier(dependency))
1863
+ const invalidDependency = ordinaryDependencies.find(dependency => !ts.isIdentifier(dependency))
1789
1864
  if (invalidDependency) effectFail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
1790
1865
  if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
1791
1866
  if (!ts.isBlock(callback.body)) effectFail(callback, "useEffect() callback must use a block body")
@@ -1795,17 +1870,31 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1795
1870
  if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
1796
1871
  if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
1797
1872
  const setters = settersForNode(node, settersByFunction)
1798
- const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", listEffect?.item, true, returns.cleanup)
1873
+ const callbackSource = listEffect?.sourceFile ?? sourceFile
1874
+ const callbackFile = callbackSource.fileName
1875
+ const workerStart = workerReferences.length
1876
+ let compiledCallback
1877
+ if (listEffect && callbackFile !== file) {
1878
+ const originalCallback = listEffect.source.arguments[0]
1879
+ rejectWorkerConstructions(originalCallback, callbackSource, "Relative TypeScript Worker construction in imported keyed-row effects is not supported; construct the Worker in a directly compiled page or local component effect")
1880
+ compiledCallback = callback
1881
+ } else {
1882
+ compiledCallback = rewriteEffectWorkers(callback, callbackFile, callbackSource, sourceFiles, workerReferences, factory, context)
1883
+ }
1884
+ const descriptor = compileNativeCallback(compiledCallback, setters, factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", dependencyItem, true, returns.cleanup)
1885
+ for (const reference of workerReferences.slice(workerStart)) Object.assign(reference, { module: handlerUrl, handler: descriptor.exportName })
1886
+ usesListItem ||= Boolean(itemDependencies.length && !listEffect)
1799
1887
  usesBehavior = true
1800
1888
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
1801
1889
  callback,
1802
- dependencies,
1890
+ factory.createArrayLiteralExpression(ordinaryDependencies),
1803
1891
  factory.createStringLiteral(handlerUrl),
1804
1892
  factory.createStringLiteral(descriptor.exportName),
1805
1893
  descriptor.states,
1806
1894
  descriptor.scope,
1807
1895
  factory.createStringLiteral(listEffect ? sourceLocation(listEffect.source, listEffect.sourceFile) : sourceLocation(node, sourceFile)),
1808
- returns.cleanup ? factory.createTrue() : factory.createFalse()
1896
+ returns.cleanup ? factory.createTrue() : factory.createFalse(),
1897
+ factory.createArrayLiteralExpression(itemDependencies.map(field => factory.createStringLiteral(field)))
1809
1898
  ])
1810
1899
  }
1811
1900
 
@@ -1918,6 +2007,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1918
2007
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
1919
2008
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listConditional"), factory.createIdentifier("__kListConditional")))
1920
2009
  }
2010
+ if (usesListItem && !usesList) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
1921
2011
  if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
1922
2012
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
1923
2013
  const behaviorImport = factory.createImportDeclaration(
@@ -2484,6 +2574,7 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
2484
2574
  const optimized = compileOptimizedEvent(expression, setters, factory)
2485
2575
  if (optimized) return optimized
2486
2576
 
2577
+ rejectWorkerConstructions(expression, expression.getSourceFile(), "Relative TypeScript Worker construction is only supported directly inside an inline useEffect() callback")
2487
2578
  const descriptor = compileNativeCallback(expression, setters, factory, nativeHandlers, importBindings, clientImports, "handler", listItem)
2488
2579
  return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
2489
2580
  factory.createStringLiteral(handlerUrl),
@@ -2546,9 +2637,81 @@ function compileOptimizedEvent(expression, setters, factory) {
2546
2637
  }
2547
2638
 
2548
2639
  const nativeGlobals = new Set([
2549
- "Array", "ArrayBuffer", "BigInt", "Boolean", "Date", "Error", "Event", "FormData", "Infinity", "Intl", "JSON", "Map", "Math", "NaN", "Number", "Object", "Promise", "Proxy", "RangeError", "ReferenceError", "Reflect", "RegExp", "Set", "String", "Symbol", "TypeError", "URL", "URLSearchParams", "WeakMap", "WeakSet", "WebSocket", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
2640
+ "Array", "ArrayBuffer", "BigInt", "Boolean", "Date", "Error", "Event", "FormData", "Infinity", "Intl", "JSON", "Map", "Math", "NaN", "Number", "Object", "Promise", "Proxy", "RangeError", "ReferenceError", "Reflect", "RegExp", "Set", "String", "Symbol", "TypeError", "URL", "URLSearchParams", "WeakMap", "WeakSet", "WebSocket", "Worker", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
2550
2641
  ])
2551
2642
 
2643
+ function rewriteEffectWorkers(callback, file, sourceFile, sourceFiles, workerReferences, factory, context) {
2644
+ const visit = node => {
2645
+ const candidate = relativeWorkerCandidate(node, sourceFile)
2646
+ if (candidate) {
2647
+ if (nearestFunction(node) !== callback) throw sourceNodeError(node, sourceFile, "Relative TypeScript Worker construction must be directly inside the inline useEffect() callback, not a nested function")
2648
+ const { worker, url, specifier, options } = validateWorkerCandidate(candidate, sourceFile)
2649
+ const target = resolve(dirname(file), specifier)
2650
+ const sourceRelative = relative(sourceDirectory, target)
2651
+ if (sourceRelative.startsWith(`..${sep}`) || sourceRelative === ".." || resolve(sourceDirectory, sourceRelative) !== target) throw sourceNodeError(url.arguments[0], sourceFile, "Relative TypeScript Worker source must remain under src/")
2652
+ if (!sourceFiles.has(target)) throw sourceNodeError(url.arguments[0], sourceFile, `Relative TypeScript Worker ${JSON.stringify(specifier)} must resolve to an existing .worker.ts file under src/`)
2653
+ const identity = `${sourceRelative.replaceAll(sep, "/")}:${ts.getOriginalNode(node).getStart(sourceFile)}`
2654
+ const placeholder = `/__kudzu_worker_${createHash("sha256").update(identity).digest("hex").slice(0, 16)}__.js`
2655
+ workerReferences.push({ root: target, placeholder })
2656
+ return factory.updateNewExpression(worker, worker.expression, worker.typeArguments, [factory.createStringLiteral(placeholder), options])
2657
+ }
2658
+ return ts.visitEachChild(node, visit, context)
2659
+ }
2660
+ return ts.visitEachChild(callback, visit, context)
2661
+ }
2662
+
2663
+ function rejectWorkerConstructions(expression, sourceFile, message) {
2664
+ const visit = node => {
2665
+ if (relativeWorkerCandidate(node, sourceFile)) throw sourceNodeError(node, sourceFile, message)
2666
+ ts.forEachChild(node, visit)
2667
+ }
2668
+ visit(expression.body ?? expression)
2669
+ }
2670
+
2671
+ function relativeWorkerCandidate(node, sourceFile) {
2672
+ if (!ts.isNewExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== "Worker") return undefined
2673
+ const first = node.arguments?.[0]
2674
+ if (!first || !ts.isNewExpression(first) || !ts.isIdentifier(first.expression) || first.expression.text !== "URL") return undefined
2675
+ const specifier = first.arguments?.[0]
2676
+ const base = first.arguments?.[1]
2677
+ const relativeLiteral = ts.isStringLiteral(specifier) && (specifier.text.startsWith("./") || specifier.text.startsWith("../"))
2678
+ if (!relativeLiteral && !(specifier && !ts.isStringLiteral(specifier) && base && isImportMetaUrl(base))) return undefined
2679
+ return { worker: node, url: first, sourceFile }
2680
+ }
2681
+
2682
+ function validateWorkerCandidate(candidate, sourceFile) {
2683
+ const { worker, url } = candidate
2684
+ if (!isUnshadowedGlobal(worker.expression, sourceFile)) throw sourceNodeError(worker.expression, sourceFile, "Relative TypeScript Workers require the unshadowed global Worker constructor")
2685
+ if (!isUnshadowedGlobal(url.expression, sourceFile)) throw sourceNodeError(url.expression, sourceFile, "Relative TypeScript Workers require the unshadowed global URL constructor")
2686
+ if (url.arguments?.length !== 2 || !isImportMetaUrl(url.arguments[1])) throw sourceNodeError(url, sourceFile, "Relative TypeScript Workers require new URL(relativeLiteral, import.meta.url)")
2687
+ const specifierNode = url.arguments[0]
2688
+ if (!ts.isStringLiteral(specifierNode) || !(specifierNode.text.startsWith("./") || specifierNode.text.startsWith("../"))) throw sourceNodeError(specifierNode, sourceFile, "Relative TypeScript Worker paths must be relative string literals")
2689
+ if (/[\\?#]/.test(specifierNode.text) || !specifierNode.text.endsWith(".worker.ts")) throw sourceNodeError(specifierNode, sourceFile, "Relative TypeScript Worker paths must end in .worker.ts")
2690
+ if (worker.arguments?.length !== 2) throw sourceNodeError(worker, sourceFile, 'Relative TypeScript Workers require exactly { type: "module" } as the second argument')
2691
+ const options = worker.arguments[1]
2692
+ if (!ts.isObjectLiteralExpression(options) || options.properties.length !== 1) throw sourceNodeError(options, sourceFile, 'Relative TypeScript Workers require exactly { type: "module" } as the second argument')
2693
+ const property = options.properties[0]
2694
+ const name = ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined
2695
+ if (name !== "type" || !ts.isStringLiteral(property.initializer) || property.initializer.text !== "module") throw sourceNodeError(property, sourceFile, 'Relative TypeScript Workers require exactly { type: "module" } as the second argument')
2696
+ return { worker, url, specifier: specifierNode.text, options }
2697
+ }
2698
+
2699
+ function isImportMetaUrl(node) {
2700
+ return ts.isPropertyAccessExpression(node) && node.name.text === "url" && ts.isMetaProperty(node.expression) && node.expression.keywordToken === ts.SyntaxKind.ImportKeyword && node.expression.name.text === "meta"
2701
+ }
2702
+
2703
+ function isUnshadowedGlobal(identifier, sourceFile) {
2704
+ if (isShadowedIdentifier(identifier, sourceFile)) return false
2705
+ return !sourceFile.statements.some(statement => {
2706
+ if (statementDeclaresName(statement, identifier.text)) return true
2707
+ if (!ts.isImportDeclaration(statement) || !statement.importClause) return false
2708
+ const clause = statement.importClause
2709
+ if (clause.name?.text === identifier.text) return true
2710
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) return clause.namedBindings.name.text === identifier.text
2711
+ return clause.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements.some(entry => entry.name.text === identifier.text) : false
2712
+ })
2713
+ }
2714
+
2552
2715
  function nativeCaptureNames(expression, setters) {
2553
2716
  return captureNames(expression, expression.body, setters)
2554
2717
  }
@@ -2641,7 +2804,31 @@ function isShadowedIdentifier(node, scopeRoot) {
2641
2804
 
2642
2805
  function statementDeclaresName(statement, name) {
2643
2806
  if (ts.isVariableStatement(statement)) return statement.declarationList.declarations.some(declaration => bindingNames(declaration.name).includes(name))
2644
- return (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name
2807
+ if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isImportEqualsDeclaration(statement)) return statement.name?.text === name
2808
+ if ((ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) && !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DeclareKeyword)) return ts.isIdentifier(statement.name) && statement.name.text === name
2809
+ return false
2810
+ }
2811
+
2812
+ function rejectOrdinaryWorkerImports(sourceFile, file, sourceFiles) {
2813
+ for (const node of sourceFile.statements) {
2814
+ let specifier
2815
+ let runtime = false
2816
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
2817
+ specifier = node.moduleSpecifier
2818
+ runtime = runtimeModuleReference(node)
2819
+ } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && ts.isStringLiteral(node.moduleReference.expression)) {
2820
+ specifier = node.moduleReference.expression
2821
+ runtime = !node.isTypeOnly
2822
+ }
2823
+ if (!runtime || !specifier?.text.startsWith(".")) continue
2824
+ let target
2825
+ try {
2826
+ target = resolveSourceImport(file, specifier.text, sourceFiles)
2827
+ } catch {
2828
+ continue
2829
+ }
2830
+ if (target.endsWith(".worker.ts")) throw sourceNodeError(specifier, sourceFile, "Worker source modules cannot be imported or re-exported as ordinary runtime modules; use new Worker(new URL(relative.worker.ts, import.meta.url), { type: \"module\" }) inside an inline useEffect() callback")
2831
+ }
2645
2832
  }
2646
2833
 
2647
2834
  function loopDeclaresName(loop, name) {
@@ -2673,7 +2860,12 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
2673
2860
  const bindings = new Map()
2674
2861
  for (const node of sourceFile.statements) {
2675
2862
  if (!ts.isImportDeclaration(node) || !node.importClause || node.importClause.isTypeOnly || !ts.isStringLiteral(node.moduleSpecifier) || !node.moduleSpecifier.text.startsWith(".")) continue
2676
- const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
2863
+ let target
2864
+ try {
2865
+ target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
2866
+ } catch (error) {
2867
+ throw sourceNodeError(node.moduleSpecifier, sourceFile, error.message)
2868
+ }
2677
2869
  if (node.importClause.name) bindings.set(node.importClause.name.text, { kind: "default", local: node.importClause.name.text, target })
2678
2870
  const named = node.importClause.namedBindings
2679
2871
  if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { kind: "namespace", local: named.name.text, target })
@@ -2782,6 +2974,71 @@ function printClientImports(entries, handlerPath) {
2782
2974
  return imports.join("\n")
2783
2975
  }
2784
2976
 
2977
+ async function emitWorkers(references, sourceFiles, assetsDirectory, base, minify) {
2978
+ const roots = [...new Set(references.map(reference => reference.root))].sort()
2979
+ if (!roots.length) return new Map()
2980
+ await validateWorkerGraphs(roots, sourceFiles)
2981
+ const workerDirectory = join(assetsDirectory, "workers")
2982
+ await mkdir(workerDirectory, { recursive: true })
2983
+ const result = await bundle({
2984
+ absWorkingDir: root,
2985
+ entryPoints: roots,
2986
+ outbase: sourceDirectory,
2987
+ outdir: workerDirectory,
2988
+ entryNames: "[dir]/[name]-[hash]",
2989
+ chunkNames: "chunks/[name]-[hash]",
2990
+ bundle: true,
2991
+ splitting: true,
2992
+ format: "esm",
2993
+ platform: "browser",
2994
+ target: "es2022",
2995
+ minify,
2996
+ legalComments: "none",
2997
+ metafile: true,
2998
+ logLevel: "silent"
2999
+ })
3000
+ const emitted = new Map()
3001
+ for (const [output, metadata] of Object.entries(result.metafile.outputs)) {
3002
+ if (!metadata.entryPoint) continue
3003
+ const entry = resolve(root, metadata.entryPoint)
3004
+ const rootReferences = references.filter(reference => reference.root === entry)
3005
+ const outputFile = resolve(root, output)
3006
+ const url = assetPath(base, relative(outputDirectory, outputFile).replaceAll(sep, "/"))
3007
+ for (const reference of rootReferences) emitted.set(reference.placeholder, url)
3008
+ }
3009
+ for (const reference of references) if (!emitted.has(reference.placeholder)) throw new Error(`Worker entry was not emitted: ${relative(root, reference.root)}`)
3010
+ return emitted
3011
+ }
3012
+
3013
+ async function validateWorkerGraphs(roots, sourceFiles) {
3014
+ const visited = new Set()
3015
+ const queue = [...roots]
3016
+ while (queue.length) {
3017
+ const file = queue.shift()
3018
+ if (visited.has(file)) continue
3019
+ visited.add(file)
3020
+ const sourceFile = parseSourceFile(file, await readFile(file, "utf8"))
3021
+ if (containsJsx(sourceFile)) throw sourceNodeError(sourceFile, sourceFile, "Worker modules must not contain JSX")
3022
+ const visit = node => {
3023
+ if (ts.isImportEqualsDeclaration(node)) throw sourceNodeError(node, sourceFile, "TypeScript import-equals declarations are not supported in Worker modules; use a relative ESM import")
3024
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) throw sourceNodeError(node, sourceFile, "Dynamic imports are not supported in Worker modules")
3025
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") throw sourceNodeError(node, sourceFile, "require() is not supported in Worker modules")
3026
+ ts.forEachChild(node, visit)
3027
+ }
3028
+ visit(sourceFile)
3029
+ for (const node of sourceFile.statements) {
3030
+ if ((!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier) || !runtimeModuleReference(node)) continue
3031
+ if (!node.moduleSpecifier.text.startsWith(".")) throw sourceNodeError(node.moduleSpecifier, sourceFile, "Worker modules may only use relative runtime imports")
3032
+ try {
3033
+ queue.push(resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles))
3034
+ } catch (error) {
3035
+ const message = error.message.slice(error.message.indexOf("Relative import"))
3036
+ throw sourceNodeError(node.moduleSpecifier, sourceFile, message)
3037
+ }
3038
+ }
3039
+ }
3040
+ }
3041
+
2785
3042
  async function collectClientModules(entries, sourceFiles) {
2786
3043
  const modules = new Set()
2787
3044
  const queue = [...new Set(entries)]
@@ -2790,6 +3047,7 @@ async function collectClientModules(entries, sourceFiles) {
2790
3047
  if (modules.has(file)) continue
2791
3048
  const source = await readFile(file, "utf8")
2792
3049
  const sourceFile = parseSourceFile(file, source)
3050
+ rejectWorkerConstructions(sourceFile, sourceFile, "Relative TypeScript Worker construction is only supported directly inside an inline useEffect() callback, not imported client helpers")
2793
3051
  if (containsJsx(sourceFile)) throw new Error(`${relative(root, file)} Imported client helpers must not contain JSX`)
2794
3052
  rejectUnsupportedClientImports(sourceFile, file)
2795
3053
  modules.add(file)
@@ -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
  }
@@ -303,6 +307,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
303
307
  module: effect.module,
304
308
  handler: effect.handler,
305
309
  ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
310
+ ...(effect.itemDependencies ? { itemDependencies: effect.itemDependencies, listState: effect.listState } : {}),
306
311
  ...(effect.cleanup ? { cleanup: true } : {}),
307
312
  ...(effect.owner ? { owner: effect.owner } : {}),
308
313
  ...(effect.list ? { list: true } : {}),
@@ -682,7 +687,7 @@ async function renderList(node, namespace, selectValue) {
682
687
  renderContext.listTemplate = true
683
688
  renderContext.listEffectOwners = []
684
689
  renderContext.listFields = new Set([node.keyField])
685
- renderContext.listRoot = { id, template: true, effects: [], item: {} }
690
+ renderContext.listRoot = { id, state: node.items.id, template: true, effects: [], item: {} }
686
691
  const template = await renderNode(node.render({}), namespace, selectValue)
687
692
  if (template.includes("data-k-native-") || template.includes("data-k-effects=")) descriptor.mount = true
688
693
  if (template.includes("data-k-effects=")) descriptor.effects = true
@@ -698,7 +703,7 @@ async function renderList(node, namespace, selectValue) {
698
703
  renderContext.listTemplate = false
699
704
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
700
705
  for (const item of node.items.value) {
701
- 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 }
702
707
  current += await renderNode(node.render(item), namespace, selectValue)
703
708
  }
704
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.4",
3
+ "version": "0.6.6",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "bin/",
26
26
  "framework/",
27
27
  "GOAL_A.md",
28
+ "GOAL_B.md",
28
29
  "README.md",
29
30
  "LICENSE"
30
31
  ],