@kudzujs/core 0.8.30 → 0.8.32

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  This document is the source of truth for Kudzu's product direction, architecture invariants, and future development order. Read it before extending React-shaped syntax or browser capabilities.
4
4
 
5
- The executable post-`0.8.30` compiler and large-application sequence is maintained in [`docs/next-architecture/large-application-ai-native-roadmap.md`](./docs/next-architecture/large-application-ai-native-roadmap.md). Follow its PR dependencies for implementation work; this document remains authoritative when selecting or accepting a migration capability.
5
+ The executable post-`0.8.32` compiler and large-application sequence is maintained in [`docs/next-architecture/large-application-ai-native-roadmap.md`](./docs/next-architecture/large-application-ai-native-roadmap.md). Follow its PR dependencies for implementation work; this document remains authoritative when selecting or accepting a migration capability.
6
6
 
7
7
  [`GOAL_A.md`](./GOAL_A.md) and [`GOAL_B.md`](./GOAL_B.md) are completed capability-validation records. Their commerce and realtime dashboard fixtures prove general lifecycle, navigation, async-workflow, and Worker capabilities; they are not separate product verticals or future priority lists.
8
8
 
@@ -28,6 +28,7 @@ Syntax compatibility does not mean reproducing React wholesale. Kudzu accepts th
28
28
  - Interactive routes ship only the capabilities they use.
29
29
  - Prefer build-time execution whenever all inputs are available during the build.
30
30
  - State setters update logical state immediately and batch direct DOM writes at synchronous-turn boundaries.
31
+ - Async handler state writes, queued commits, and captured ref resolution end when its mounted DOM owner is released; application promises and ordinary reads may finish without reviving that ownership.
31
32
  - Preserve familiar function components, props, children, JSX, hooks, conditions, collections, and event handlers where Kudzu can compile them safely.
32
33
  - Prefer compiler specialization over asking migrated applications to replace ordinary declarative React UI with imperative DOM code.
33
34
  - Unknown browser-only data receives a static shell plus only the ESM needed to obtain and patch it.
@@ -288,6 +289,19 @@ This queue orders the next investigations by general migration value. Start only
288
289
  - Ordinary dynamic `import()` fails during graph discovery rather than surviving into generated `.kudzu` modules; relative, package, template, and computed forms share one source-located boundary.
289
290
  - Ordinary and Worker graph ownership remain separate, while type-only and unreachable source stay excluded. Export-name validation, ProjectSession, runtime behavior, and public APIs remain unchanged.
290
291
 
292
+ ### Completed In 0.8.31
293
+
294
+ - Pending async native handlers lose state-write, queued-commit, and captured-ref authority when enhanced navigation, keyed removal, conditional removal, or document disposal releases their mounted DOM owner.
295
+ - Direct and captured setters cannot recreate released state, queued commits clear without touching replacement DOM, and captured object refs resolve to `null` after release.
296
+ - Chrome coverage removes and recreates the same route and keyed row before old work resolves, proving replacement ownership stays fresh without cancelling application promises or adding a scheduler.
297
+ - The focused 5,000-event Chrome dispatch median remains 6.4 ms; the native fixture retains its emitted paths with a measured 209 B raw / 94 B aggregate gzip correctness cost.
298
+
299
+ ### Completed In 0.8.32
300
+
301
+ - Production artifacts complete in staging; public/generated collisions and late `afterBuild()` failure preserve the previous successful `dist`.
302
+ - Same-root builds use an exclusive PID lock, stale locks fail closed, interrupted promotion backups recover on the next admitted build after lock removal, and successful replacement removes stale output.
303
+ - Route HTML writes use bounded batches, keyed reverse/remove paths avoid repeated map reconstruction, and binding/condition commits share one dispatch without adding a runtime or public API.
304
+
291
305
  ## Cross-Cutting Performance Gates
292
306
 
293
307
  Every migration feature must preserve:
package/PERFORMANCE.md CHANGED
@@ -1,6 +1,117 @@
1
1
  # Performance Records
2
2
 
3
- Reproducibility classes: `npm run benchmark` and `npm run benchmark:keyed` are maintained in this repository; `npm run benchmark:commerce` is a maintained paired runner over the public external storefront; older excluded-workspace sections are historical provenance only and are not current framework rankings.
3
+ Reproducibility classes: `npm run benchmark`, `npm run benchmark:keyed`, and `npm run benchmark:native` are maintained in this repository; `npm run benchmark:commerce` is a maintained paired runner over the public external storefront; older excluded-workspace sections are historical provenance only and are not current framework rankings.
4
+
5
+ ## 2026-08-11 React, Vue, And Svelte Check
6
+
7
+ This is a current local check, not a maintained framework ranking. The external `/home/kft/Documents/etc/demo/benchmarks` workspace is not Git-provenanced, but its matched fixtures, validators, dependencies, and raw result files were present and reused without source changes. The Kudzu target used this P0.5 candidate with `0.8.31` package metadata; controls were React 19.2.7, Vue 3.5.40, Svelte 5.56.6, and Vite 7.3.6 on Node 24.14.0, Linux x64, and Chrome 142.0.7444.175.
8
+
9
+ Each fixture received one warm-up and seven rotating clean production builds. Seven fresh headless Chrome profiles per target validated exact row count/order/text, retained and released DOM identity, fresh state on re-entry, and effect lifecycle counts before recording MutationObserver completion. React, Vue, and Svelte are CSR controls with no initial rows in HTML; Kudzu emits all initial rows, so total deploy size is reported but is not architecture-equivalent.
10
+
11
+ | Fixture | Framework | Build median | Initial JS gzip | Total deploy raw |
12
+ |---|---|---:|---:|---:|
13
+ | 1,000 keyed rows with local state | Kudzu | 1,212 ms | 10.2 KB | 576.0 KB |
14
+ | | Svelte | 1,745 ms | 13.1 KB | 33.8 KB |
15
+ | | Vue | 1,584 ms | 24.4 KB | 61.6 KB |
16
+ | | React | 1,939 ms | 59.4 KB | 189.5 KB |
17
+ | 1,000 keyed effects | Kudzu | 735 ms | 9.8 KB | 226.0 KB |
18
+ | | Svelte | 1,554 ms | 12.5 KB | 32.4 KB |
19
+ | | Vue | 1,444 ms | 24.5 KB | 61.9 KB |
20
+ | | React | 1,741 ms | 59.5 KB | 189.7 KB |
21
+ | 100 parents x 10 keyed children | Kudzu | 732 ms | 8.2 KB | 354.6 KB |
22
+ | | Svelte | 1,613 ms | 13.1 KB | 33.8 KB |
23
+ | | Vue | 1,437 ms | 24.6 KB | 62.1 KB |
24
+ | | React | 1,718 ms | 59.5 KB | 190.0 KB |
25
+
26
+ Kudzu's build medians were 23% to 58% lower and its initial JavaScript gzip was 21% to 86% lower than the three CSR controls. A separate seven-run GNU `time` check on the keyed-row fixture measured median build peak RSS of 157.4 MiB for Kudzu, 209.2 MiB for Svelte, 208.0 MiB for Vue, and 281.3 MiB for React.
27
+
28
+ | Fixture operation | Kudzu | Svelte | Vue | React |
29
+ |---|---:|---:|---:|---:|
30
+ | Row edit | 5.6 ms | 4.9 ms | 5.7 ms | 11.9 ms |
31
+ | Reverse 1,000 rows | 28.2 ms | 94.3 ms | 23.2 ms | 51.8 ms |
32
+ | Remove row | 5.0 ms | 8.7 ms | 7.6 ms | 15.6 ms |
33
+ | Re-add row | 7.7 ms | 16.8 ms | 8.1 ms | 18.5 ms |
34
+ | Effect dependency update | 4.8 ms | 9.8 ms | 10.0 ms | 14.9 ms |
35
+ | Effect-unrelated update | 2.8 ms | 7.3 ms | 2.7 ms | 10.1 ms |
36
+ | Reverse effect rows | 11.7 ms | 71.5 ms | 13.4 ms | 29.5 ms |
37
+ | Nested child update | 2.7 ms | 3.4 ms | 6.5 ms | 12.0 ms |
38
+ | Reverse 10 children | 0.7 ms | 1.1 ms | 3.1 ms | 9.0 ms |
39
+ | Reverse 100 parents | 6.8 ms | 7.7 ms | 7.3 ms | 11.1 ms |
40
+ | Remove parent | 1.1 ms | 1.5 ms | 3.4 ms | 6.7 ms |
41
+
42
+ These initial browser runs were grouped by target and some seven-sample ranges were wide, so they were treated as directional rather than a framework ranking. The candidate then removed repeated keyed-root lookups and redundant removal-map reconstruction across the generic, nested, and reducer list paths, and merged binding/condition state dispatch into one committer. The keyed-row JavaScript graph decreased from 26,962 B raw / 10,508 B gzip to 26,768 B raw / 10,485 B gzip.
43
+
44
+ A 31-round rotating follow-up ran fresh Chrome profiles in alternating and periodically reversed framework order. Its full-list completion predicate retained the exact 1,000-row content and identity checks inside timing:
45
+
46
+ | Framework | Edit | Reverse | Remove | Re-add |
47
+ |---|---:|---:|---:|---:|
48
+ | Kudzu | 5.9 ms | 23.4 ms | 5.6 ms | 8.0 ms |
49
+ | Svelte | 5.5 ms | 97.0 ms | 9.2 ms | 14.7 ms |
50
+ | Vue | 6.8 ms | 26.6 ms | 9.6 ms | 10.0 ms |
51
+ | React | 14.7 ms | 51.0 ms | 20.2 ms | 14.3 ms |
52
+
53
+ Paired sign tests established Kudzu's reverse, remove, and re-add advantage over Vue at 6.0 ms (`p=0.00143`), 4.1 ms (`p=0.000192`), and 2.1 ms (`p=0.0107`) median differences. Kudzu also beat Svelte for those operations in 31/31, 28/31, and 27/31 rounds. The full-list edit result remained dominated by the validator: Kudzu versus Svelte had a 0.8 ms directional loss with `p=0.281`, while Kudzu versus Vue had a 0.4 ms directional gain with `p=0.720`.
54
+
55
+ A final edit-only protocol kept complete 1,000-row correctness validation after each measurement but used only the edited row's class/input identity as the completion signal. Over 31 rotating rounds, Kudzu measured 0.7 ms versus Svelte 1.4 ms and Vue 1.8 ms. Paired differences favored Kudzu by 0.7 ms in 30/31 rounds (`p=2.98e-8`) versus Svelte and by 0.9 ms in 31/31 rounds (`p=9.31e-10`) versus Vue. Complete seven-run suite samples remain in the external workspace's `benchmarks/results/*-current.{json,md}` files; rotating raw samples are temporary measurement artifacts and this section remains a current local check rather than a maintained general ranking.
56
+
57
+ ## 0.8.32 Staged Output Emission
58
+
59
+ Measured UTC 2026-08-11 on an Intel Core i5-9500 with 6 cores, Linux 6.17.0-19-generic, Node 24.14.0, and npm 11.9.0. The baseline was clean tag `v0.8.31` at `06b436e`; baseline and the pre-release P0.5 candidate used the same installed dependencies and the public 1,000-product storefront fixture at `f2d5be1a516c539e30f7125f6870d42b1dd02ecd`. The later same-root lock and interrupted-backup recovery hardening was correctness-tested but not included in this timing array.
60
+
61
+ One warm-up followed by 21 alternating replacement builds preserved the preceding output so the candidate exercised staging, promotion, and prior-tree removal on every run. Generated `.kudzu` scratch was cleaned outside timing. The candidate folds collision validation into one public copy traversal and writes the already-rendered route HTML in bounded batches of 64.
62
+
63
+ | Target | Build median | Output |
64
+ |---|---:|---:|
65
+ | `v0.8.31` | 20,392.7 ms | 3,056 files / 11,137,074 B |
66
+ | P0.5 candidate | 19,229.1 ms | 3,056 files / 11,137,074 B |
67
+
68
+ The candidate median is 5.71% lower. Every relative artifact path and SHA-256 hash matched, so deploy raw and gzip sizes are unchanged and no browser bytes are added.
69
+
70
+ ```text
71
+ v0.8.31: [20868.5,19436.9,22023.6,21433.5,20634.0,20410.1,20392.7,21408.8,20252.0,19858.3,20489.9,20835.2,20450.2,18727.3,17239.1,17578.2,17672.1,17465.1,21087.0,18892.8,18727.9]
72
+ candidate: [20481.6,18692.6,21060.7,19881.9,19510.8,20844.1,20348.6,19229.1,20304.8,19852.9,18929.3,20472.5,17384.9,18628.6,17890.2,18841.0,19755.6,17805.9,18282.5,17770.5,17370.3]
73
+ ```
74
+
75
+ ```bash
76
+ APP_ROOT=/tmp/opencode/kudzu-based-bench/apps/shop-kudzu \
77
+ BASELINE_ROOT=/tmp/opencode/kudzu-p05-profile \
78
+ PRESERVE_OUTPUT=1 RUNS=21 npm run benchmark:commerce
79
+ ```
80
+
81
+ ## 0.8.31 Async Native Handler Ownership
82
+
83
+ Measured UTC 2026-08-10 on Apple M3, 8 logical CPUs, 8 GiB RAM, macOS 26.5.2 / Darwin 25.5.0, Node 25.6.1, npm 11.18.0, and Chrome 151.0.7922.76. The baseline was clean tag `v0.8.30` at `9bb5ce8`; baseline and candidate used the same installed dependencies.
84
+
85
+ The implementation and maintained-check patch over `v0.8.30` had SHA-256 `c67956b67b558052885dd48a9dbe35f238b8ce042876537952355aa05b8cdc10`, produced by:
86
+
87
+ ```bash
88
+ git diff --binary v0.8.30 -- framework/native-runtime.js framework/serialization.js test/fixtures/navigation/src/Shell.tsx test/fixtures/navigation/src/pages/product.tsx test/fixtures/navigation/public/browser-test.js test/framework.test.mjs test/native-performance.mjs package.json | shasum -a 256
89
+ ```
90
+
91
+ The maintained browser runner built the same `test/fixtures/native-bubbling` source with each framework root. After one warm-up, 21 alternating headless Chrome processes dispatched 5,000 synchronous clicks through the real DOM listener, handler-module lookup, generated handler, state context, and queued-flush scheduling path. Both medians were 6.4 ms; the 6.1-6.8 ms baseline and 6.2-6.7 ms candidate ranges overlap, so no dispatch regression is established.
92
+
93
+ ```bash
94
+ git worktree add --detach /tmp/kudzu-v0.8.30 v0.8.30
95
+ ln -s "$PWD/node_modules" /tmp/kudzu-v0.8.30/node_modules
96
+ BASELINE_ROOT=/tmp/kudzu-v0.8.30 RUNS=21 ITERATIONS=5000 \
97
+ CHROME_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
98
+ npm run benchmark:native
99
+ ```
100
+
101
+ ```text
102
+ 0.8.30: [6.6,6.3,6.4,6.3,6.3,6.8,6.3,6.6,6.3,6.4,6.5,6.4,6.5,6.5,6.4,6.1,6.3,6.2,6.5,6.4,6.4]
103
+ 0.8.31: [6.4,6.7,6.2,6.4,6.6,6.6,6.5,6.4,6.5,6.4,6.4,6.4,6.6,6.5,6.4,6.3,6.4,6.4,6.6,6.2,6.4]
104
+ ```
105
+
106
+ The maintained `native-bubbling` fixture retained the same eight JavaScript paths. The browser runner hashes every file and permits only the ownership-bearing native and capture-deserialization runtimes to change:
107
+
108
+ | Artifact | 0.8.30 raw / gzip | 0.8.31 raw / gzip | Delta raw / gzip |
109
+ |---|---:|---:|---:|
110
+ | `assets/kudzu-native.js` | 1,528 B / 842 B | 1,715 B / 925 B | +187 B / +83 B |
111
+ | `assets/kudzu-serialization.js` | 675 B / 381 B | 697 B / 392 B | +22 B / +11 B |
112
+ | Complete native fixture JavaScript | 13,629 B / 6,177 B | 13,838 B / 6,271 B | +209 B / +94 B |
113
+
114
+ The timing is a focused synchronous event-dispatch measurement, not a general interaction or asynchronous task benchmark. The runtime does not cancel application promises or arbitrary browser API work; it invalidates Kudzu state writes, queued commits, and captured ref resolution after the listener's DOM ownership is released.
4
115
 
5
116
  ## 0.8.26 Goal B Benchmark Hardening
6
117
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
14
14
 
15
15
  > Experimental `0.8.x`: the compiler API and supported TSX surface may change.
16
16
 
17
- **Latest release: 0.8.30 - Graph failure diagnostics.** Reachable ordinary modules now validate relative runtime imports and re-exports and reject dynamic `import()` at the original importer location before generated module loading. Worker ownership, type-only edges, and unreachable source remain distinct. Read the [release notes](./RELEASES.md#0830---graph-failure-diagnostics), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.8.30), or follow the [architecture packet](./docs/next-architecture/README.md).
17
+ **Latest release: 0.8.32 - Staged and collision-safe output.** Builds complete in an isolated staging tree, reject public/generated collisions, preserve the previous `dist` on ordinary build and hook failures, recover interrupted promotion backups after a stale lock is cleared, and reject overlapping same-root builds. Read the [release notes](./RELEASES.md#0832---staged-and-collision-safe-output), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.8.32), or follow the [architecture packet](./docs/next-architecture/README.md).
18
18
 
19
19
  - [Documentation](https://kudzujs.cloud/docs)
20
20
  - [Installation guide](https://kudzujs.cloud/docs#install)
package/RELEASES.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.32 - Staged and collision-safe output
4
+
5
+ Kudzu 0.8.32 completes P0.5 by building production artifacts away from the active deploy tree, rejecting public/generated collisions, and replacing `dist` only after generation and trusted `afterBuild()` work succeed.
6
+
7
+ ### Changed in 0.8.32
8
+
9
+ - Route HTML, runtime, handler, chunk, Worker, source CSS, and configured CSS artifacts complete in a project-local staging tree before promotion.
10
+ - Public files merge only into unowned paths. A public file cannot replace a generated file or directory, and duplicate configured stylesheet outputs fail before publication.
11
+ - Ordinary compiler, bundler, public-copy, and `afterBuild()` failures leave the previous successful `dist` unchanged.
12
+ - Same-root builds use an exclusive PID lock. Active overlap and dead/invalid lock files fail closed; after an operator removes a stale lock, the next admitted build recovers a backup left by interrupted promotion. Promotion is a guarded rollback/recovery sequence rather than a lock-free atomic directory exchange.
13
+ - Successful replacement removes stale output and staging/backup artifacts. Development rebuild errors retain the previous on-disk HTML while the existing error overlay reports the source failure.
14
+ - Route HTML writes use bounded batches. Keyed reverse/remove paths avoid repeated root-map work, and binding plus condition updates share one state commit dispatch.
15
+
16
+ ### Performance
17
+
18
+ - Before final lock/recovery hardening, twenty-one alternating local replacement builds of the 1,011-page commerce fixture observed a 20,392.7 ms to 19,229.1 ms median change, 5.71% lower, while all 3,056 paths and 11,137,074 deploy bytes retained identical SHA-256 hashes.
19
+ - The optimized 1,000-row browser graph decreased by 194 B raw / 23 B gzip. External exploratory browser comparisons and their non-publication limitations are documented in `PERFORMANCE.md`; they are not a release gate or maintained general ranking.
20
+
21
+ ### Validation
22
+
23
+ - `npm run check`, `npm test`, `npm run test:package`, and all 190 tests pass.
24
+ - Focused integration coverage verifies seven public/generated namespaces, configured-style collisions, late hook failure, successful stale-file removal, same-root overlap rejection, stale-lock diagnosis, interrupted-backup recovery after lock removal, and no staging/backup remnants.
25
+ - Existing keyed identity/state, nested lists, reducer rows, SVG, effects, Workers, navigation, async ownership, and static zero-JavaScript behavior remain covered.
26
+ - P0.6 ProjectSession and explicit root is next.
27
+
28
+ ### Upgrade
29
+
30
+ ```bash
31
+ npm install @kudzujs/core@^0.8.32
32
+ ```
33
+
34
+ ## 0.8.31 - Async native-handler ownership
35
+
36
+ Kudzu 0.8.31 completes P0.4 by tying every mounted native-handler context to its DOM registration lifetime. Async work may finish after route, keyed, or conditional removal, but it cannot write through ownership that no longer exists.
37
+
38
+ ### Changed in 0.8.31
39
+
40
+ - Native registrations expose one active lifetime to direct setters, captured setters, queued commits, and captured object refs.
41
+ - `unmountNative()` invalidates the registration before removing its listener. Enhanced navigation, keyed removal, and conditional removal use the shared unmount hook; non-persisted `pagehide` directly releases document registrations.
42
+ - Late state writes become no-ops instead of recreating released state IDs; already queued commits clear without touching replacement DOM.
43
+ - Captured object refs resolve to `null` after release, so a late handler cannot find a replacement node that reused the same compiler-owned ref ID.
44
+ - Application promises are not cancelled, and no scheduler, task registry, component runtime, or public API is added.
45
+
46
+ ### Performance
47
+
48
+ - Twenty-one alternating headless Chrome processes measured 5,000 synchronous native event dispatches at a 6.4 ms median for both `0.8.30` and `0.8.31`; the recorded ranges overlap.
49
+ - The maintained `native-bubbling` fixture keeps the same eight JavaScript paths. `kudzu-native.js` and `kudzu-serialization.js` add 209 B raw / 94 B aggregate gzip; the runner rejects any other artifact change.
50
+ - Environment, raw arrays, artifact bytes, methodology, and limitations are recorded in `PERFORMANCE.md`.
51
+
52
+ ### Validation
53
+
54
+ - `npm run check`, `npm test`, `npm run test:package`, and all 189 tests pass.
55
+ - Chrome starts async route and keyed-row handlers, releases and recreates their owners, and proves late state writes and refs cannot mutate replacement ownership. A persistent layout handler also proves non-persisted document disposal invalidates pending work.
56
+ - Focused context checks cover queued direct commits and captured setters. The browser suite retains normal synchronous native event semantics; unaffected static routes remain zero JavaScript.
57
+ - P0.5 atomic and collision-safe output is next.
58
+
59
+ ### Upgrade
60
+
61
+ ```bash
62
+ npm install @kudzujs/core@^0.8.31
63
+ ```
64
+
3
65
  ## 0.8.30 - Graph failure diagnostics
4
66
 
5
67
  Kudzu 0.8.30 completes P0.3 by making ordinary source-graph failures stop at the original importer before compilation, generated module loading, or `.kudzu` paths can obscure the source error.
@@ -11,7 +11,7 @@ The top-level [`GOAL_A.md`](../../GOAL_A.md) and [`GOAL_B.md`](../../GOAL_B.md)
11
11
  | C: state/resource model | Research only | Reduced fixtures expose a limitation |
12
12
  | D: routing compatibility | Current behavior preserved | Revisit only with migration evidence and invariant review |
13
13
 
14
- The active post-`0.8.30` implementation sequence is [`large-application-ai-native-roadmap.md`](./large-application-ai-native-roadmap.md). It orders compiler semantic generalization, large-application foundations, compatibility boundaries, AI tooling, and production validation without changing the invariants below.
14
+ The active post-`0.8.32` implementation sequence is [`large-application-ai-native-roadmap.md`](./large-application-ai-native-roadmap.md). P0.5 staged and collision-safe output is complete; P0.6 ProjectSession and explicit root is next. The plan orders compiler semantic generalization, large-application foundations, compatibility boundaries, AI tooling, and production validation without changing the invariants below.
15
15
 
16
16
  ## Required Invariants
17
17
 
@@ -1,6 +1,6 @@
1
1
  # Current Compiler Architecture
2
2
 
3
- This maps the current `0.8.30` architecture built on the completed `0.8.23` Goal A compiler foundation. File and function names are the stable references; line numbers are intentionally omitted because later work may still move code.
3
+ This maps the current `0.8.32` architecture built on the completed `0.8.23` Goal A compiler foundation. File and function names are the stable references; line numbers are intentionally omitted because later work may still move code.
4
4
 
5
5
  ## Responsibility Map
6
6
 
@@ -28,10 +28,10 @@ This maps the current `0.8.30` architecture built on the completed `0.8.23` Goal
28
28
  | Route capability projection | [`framework/compiler/route-capability-planner.mjs`](../../framework/compiler/route-capability-planner.mjs), `planRouteCapabilities()` | Validates RouteIR v1 and purely folds rendered plans and route facts into CapabilityIR v1. |
29
29
  | Effect entry generation | [`framework/compiler/effect-codegen.mjs`](../../framework/compiler/effect-codegen.mjs) | Generates ordinary, dependency, owned, and navigable effect entries from rendered descriptors. |
30
30
  | Runtime generation | [`framework/compiler/runtime-codegen.mjs`](../../framework/compiler/runtime-codegen.mjs), [`framework/compiler/list-runtime-codegen.mjs`](../../framework/compiler/list-runtime-codegen.mjs), [`framework/compiler/param-codegen.mjs`](../../framework/compiler/param-codegen.mjs) | Consumes versioned contracts, specializes authored capability sources with fail-closed anchors, and returns source/define results without filesystem ownership. |
31
- | Artifact emission | `framework/build.mjs` | Selects required files from CapabilityIR and writes or bundles generated sources with esbuild. Byte-identical native, parameter, and effect route entries reuse one exact-source transform result within the current build only. |
32
- | Browser capabilities | [`framework/*.js`](../../framework/) | Small optional modules for commands, bindings, lists, effects, native handlers, serialization, parameters, and navigation; no component runtime. |
31
+ | Artifact emission | `framework/build.mjs` | Selects required files from CapabilityIR, writes route HTML in bounded batches, writes and bundles the complete generation in a project-local staging sibling, copies public subtrees without replacing generated paths, runs `afterBuild`, then promotes with rollback so failed builds preserve the prior `dist`. Byte-identical native, parameter, and effect route entries reuse one exact-source transform result within the current build only. |
32
+ | Browser capabilities | [`framework/*.js`](../../framework/) | Small optional modules for commands, bindings, lists, effects, native handlers, serialization, parameters, and navigation; native contexts invalidate writes and refs at DOM ownership release, with no component runtime. |
33
33
  | Opt-in navigation | [`framework/navigation-runtime.js`](../../framework/navigation-runtime.js) plus `framework/build.mjs` navigation configuration/emission | Fetches and validates complete same-origin documents, replaces only the marked route range, manages route/layout disposal, history, focus, finite prefetch retention, and native fallback. |
34
- | Development serving | [`framework/dev-server.mjs`](../../framework/dev-server.mjs) and [`framework/dev-state.js`](../../framework/dev-state.js) | Rebuild/watch/SSE and response-only short-lived state restoration; never changes production `dist/`. |
34
+ | Development serving | [`framework/dev-server.mjs`](../../framework/dev-server.mjs) and [`framework/dev-state.js`](../../framework/dev-state.js) | Rebuild/watch/SSE and response-only short-lived state restoration; failed rebuilds show the existing error overlay while preserving the previous on-disk output. |
35
35
 
36
36
  ## Current Data Flow
37
37
 
@@ -54,8 +54,10 @@ src/pages entries + config
54
54
  -> CapabilityIR v1
55
55
  -> specialize and emit only selected runtime/capability ESM
56
56
  -> reuse exact generated route-entry transforms within this build
57
- -> write route index.html, CSS/assets, Worker graphs, rewrites, and .kudzu/kudzu-plan.json
58
- -> optional afterBuild()
57
+ -> write route index.html, CSS/assets, Worker graphs, and rewrites into staging
58
+ -> copy public paths only where they do not replace generated artifacts
59
+ -> optional afterBuild() against staging
60
+ -> rollback-safe promotion to dist; recover an interrupted backup on the next admitted build after stale-lock removal
59
61
  ```
60
62
 
61
63
  The browser consumes static HTML first. State seeds and descriptors in that HTML connect only to emitted command, binding, list, native-handler, effect, parameter, or navigation modules. Browser execution patches owned DOM directly; it does not invoke component functions or reconstruct a component tree.
@@ -67,7 +69,7 @@ The browser consumes static HTML first. State seeds and descriptors in that HTML
67
69
  - `build()` still owns explicit artifact selection and filesystem writes after generator results are produced.
68
70
  - Runtime generators intentionally specialize readable authored sources through exact anchors; every required anchor fails closed, but a future generator format may remove this transitional dependency.
69
71
  - Source reachability and source compilation share one module because both consume the same normalization and import graph contracts.
70
- - Native handler, effect, state/setter, list, Router, React, and Zustand discovery still contains name-based analysis. The binding index is intentionally adopted one consumer family at a time; P0.2 moves the next descriptor consumers without changing cross-module semantics.
72
+ - Imported, specialized, and compiler-synthesized trees still use conservative name/scope fallback where the source-local binding index does not own the complete AST; cross-module semantics remain deferred to stable ModuleSymbol and SiteId work.
71
73
 
72
74
  These are future simplification opportunities, not incomplete Goal A contracts. Goal A changed no source support, browser output semantics, or browser architecture.
73
75
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Active execution plan after `0.8.30`. This document turns the current compiler audit into an ordered implementation program. It does not mark any planned capability as supported and does not authorize a React runtime, VDOM, hydration, retained browser component tree, generic rerenderer, public store/query/resource API, SPA router, or islands.
5
+ Active execution plan after `0.8.32`. This document turns the current compiler audit into an ordered implementation program. It does not mark any planned capability as supported and does not authorize a React runtime, VDOM, hydration, retained browser component tree, generic rerenderer, public store/query/resource API, SPA router, or islands.
6
6
 
7
7
  [`MIGRATION_ROADMAP.md`](../../MIGRATION_ROADMAP.md) remains authoritative for product invariants and fixture-driven feature selection. This plan is authoritative for the order and completion evidence of compiler generalization, large-application foundations, compatibility boundaries, AI tooling, and scale validation. If implementation evidence changes a boundary, update this document before broadening a patch.
8
8
 
@@ -43,7 +43,7 @@ The current limiting architecture is observable in these files:
43
43
  | ModuleIR | `compiler/ir/module-ir.mjs` | Numeric slots, names, export strings, and formatted owner strings coexist; several sections are descriptive rather than authoritative inputs |
44
44
  | RouteIR/CapabilityIR | `route-capability-planner.mjs` | Validation is shallow and capability selection is site-wide across interactive routes |
45
45
  | Build/codegen | `framework/build.mjs`, runtime generators | Full destructive builds, serialized-string handler reachability, source-text runtime surgery, no route chunk/CSS closure report |
46
- | Browser lifetime | `native-runtime.js`, `effect-runtime.js`, `navigation-runtime.js` | Effects invalidate stale work, but pending native handlers can write after route unmount |
46
+ | Browser lifetime | `native-runtime.js`, `effect-runtime.js`, `navigation-runtime.js` | Effects and native handlers invalidate stale writes at their existing effect or DOM ownership boundary |
47
47
  | Compatibility | React/Router/Zustand passes plus core branches | Package-specific knowledge is not contained behind one adapter boundary |
48
48
  | AI interface | CLI and string diagnostics | No stable diagnostic codes, semantic index, explain, fix, or migration analysis output |
49
49
 
@@ -112,7 +112,7 @@ This is an incremental evolution of the current repository:
112
112
  - Resource-specific ownership passes into a package-neutral ResourceIR when evidence permits.
113
113
  - Serialized handler URL searches into explicit artifact references.
114
114
  - Site-wide capability unions into route capability signatures.
115
- - Full destructive development builds into project-session invalidation and atomic output.
115
+ - Full destructive development builds into project-session invalidation and rollback-safe output.
116
116
  - String-only diagnostics into structured diagnostics suitable for machines and AI agents.
117
117
 
118
118
  ### Avoid
@@ -132,7 +132,9 @@ This is an incremental evolution of the current repository:
132
132
  - [x] P0.1 Source-local binding index is complete in `0.8.28`. Reactive binding capture/import discovery and lowering use the index only when the complete expression is indexed; synthesized keyed expressions retain the existing fail-safe path. Focused scope tests, a 1,000-reference guard, browser integration, all 185 tests, and packed-package smoke pass.
133
133
  - [x] P0.2 Symbol-aware descriptor discovery is complete in `0.8.29`. Native handler, effect, remaining binding, list evaluator, optimized-command, and effect-resource discovery/lowering use the source-local binding index when it owns the complete AST; synthesized trees retain the existing fail-safe path. Focused lexical-shadow, JSON-safe IR, resource-ownership checks, all 187 tests, and packed-package smoke pass.
134
134
  - [x] P0.3 Graph diagnostics is complete in `0.8.30`. Reachable ordinary modules validate relative runtime imports/re-exports and reject every dynamic `import()` at the importer source location before compilation or generated module loading. Ordinary and Worker traversal retain separate ownership, type-only and unreachable edges remain excluded, focused page/helper/re-export/dynamic fixtures pass with all 189 tests and packed-package smoke, and no export-symbol graph, ProjectSession, runtime, or public API is added.
135
- - [ ] P0.4 Async native handler ownership is next. Pending handlers must not write after route or DOM ownership is released.
135
+ - [x] P0.4 Async native handler ownership is complete in `0.8.31`. Mounted native registrations invalidate direct/captured setters, queued commits, and captured refs before listener removal. Chrome route, keyed-row, document-disposal, and synchronous event-dispatch checks, all 189 tests, and packed-package smoke pass without cancelling application promises or adding a scheduler.
136
+ - [x] P0.5 Staged and collision-safe output is complete in `0.8.32`. Production output completes in one project-local staging tree, public files are compared against generated route/runtime/handler/chunk/Worker/CSS paths while copying, `afterBuild` runs before rollback-safe promotion, and ordinary failures preserve the prior `dist`. Same-root overlap and stale locks fail closed; after stale-lock removal, an interrupted promotion backup recovers on the next admitted build. Focused collision/replacement/dev checks pass with equivalent `v0.8.31` commerce deploy output; `.kudzu` remains compiler scratch for P0.6.
137
+ - [ ] P0.6 Explicit ProjectSession is next. Root and caches must become build-scoped so independent projects can compile safely in one process.
136
138
 
137
139
  ### P0: Semantic Correctness And Compiler Foundation
138
140
 
@@ -144,7 +146,7 @@ P0 creates the semantic base required by every later large-application capabilit
144
146
  | P0.2 | Descriptor consumers use SymbolRef | P0.1 | Handler/effect/binding capture and import discovery no longer depends only on text names |
145
147
  | P0.3 | Graph diagnostics | P0.1 | Relative dynamic imports and unresolved runtime edges fail at the source location |
146
148
  | P0.4 | Async native handler ownership | None | Pending handlers cannot write after route or DOM ownership is released |
147
- | P0.5 | Atomic collision-safe build | None | Failed builds preserve prior output and public files cannot overwrite generated artifacts |
149
+ | P0.5 | Staged collision-safe build | None | Failed builds preserve prior output and public files cannot overwrite generated artifacts |
148
150
  | P0.6 | Explicit ProjectSession | P0.1 | Root and caches are build-scoped; two projects can compile safely in one process |
149
151
  | P0.7 | Parsed module/export cache | P0.6 | Shared modules are parsed and summarized once per build |
150
152
  | P0.8 | Stable ModuleSymbol and SiteId | P0.6-P0.7 | Imports, re-exports, aliases, owners, and call sites use stable identities outside a pass |
@@ -272,7 +274,9 @@ Every result carries a stable source-local binding slot, debug name, declaration
272
274
 
273
275
  **Done condition:** a handler resolving after enhanced navigation or row removal cannot recreate or mutate released state, refs, or DOM.
274
276
 
275
- ### PR 5: Atomic And Collision-Safe Output
277
+ **Completed in `0.8.31`:** the existing native registration lifetime now guards state mutation, queued commits, and captured ref lookup. Route and keyed-row browser checks recreate the same ownership IDs before old work resolves, and document disposal invalidates a pending layout handler. Replacement state and DOM remain fresh. Both 5,000-event Chrome dispatch medians are 6.4 ms, and the 209 B raw / 94 B aggregate gzip runtime cost is recorded in `PERFORMANCE.md`.
278
+
279
+ ### PR 5: Staged And Collision-Safe Output
276
280
 
277
281
  **Objective:** make production builds safe before scaling the build graph.
278
282
 
@@ -280,7 +284,9 @@ Every result carries a stable source-local binding slot, debug name, declaration
280
284
 
281
285
  **Tests:** public collisions with route HTML, core runtime, handler entry, chunk, Worker namespace, source CSS, and configured CSS; failed build preserves prior `dist`.
282
286
 
283
- **Done condition:** output is staged, validated, and atomically promoted; public content cannot silently replace generated artifacts.
287
+ **Done condition:** output is staged, validated, and promoted with rollback/recovery; public content cannot silently replace generated artifacts.
288
+
289
+ **Completed in `0.8.32`:** Kudzu emits into a locked project-local staging sibling, validates public files during one collision-safe copy traversal, runs trusted `afterBuild` mutation there, and promotes with backup rollback. Active overlap and stale locks fail closed; after stale-lock removal, the next admitted build restores an interrupted promotion backup. This is a guarded two-rename replacement, not a lock-free atomic directory exchange. Route HTML is written in bounded batches. Route HTML, core runtime, handler entry, actual esbuild chunk, Worker namespace, source CSS, and configured CSS collisions preserve the complete prior manifest; late hook failure also preserves it and successful replacement removes stale files. Before final lock/recovery hardening, twenty-one alternating 1,011-page replacement builds observed a 5.71% lower median with identical 3,056-file / 11,137,074-byte commerce deploy output.
284
290
 
285
291
  ### PR 6: ProjectSession And Explicit Root
286
292
 
@@ -427,7 +433,7 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
427
433
  ## Production Gates Before 1.0
428
434
 
429
435
  - Async native and effect work cannot write after ownership release.
430
- - Build output is atomic and collision-safe.
436
+ - Build output is staged, collision-safe, and rollback/recovery guarded.
431
437
  - Source maps connect generated route code to TS/TSX diagnostics.
432
438
  - Chrome, Firefox, and WebKit pass required journeys.
433
439
  - Accessibility automation and keyboard checks cover forms, navigation, dialogs, menus, charts, and errors.
@@ -454,6 +460,4 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
454
460
 
455
461
  ## Immediate Decision
456
462
 
457
- The first implementation PR is **PR 1: Source-Local Binding Index**. It is compile-time only, adds no browser bytes or public API, fixes an existing correctness class, and creates the stable semantic foundation required by component graphs, state operations, cross-module analysis, compatibility adapters, structured diagnostics, and AI explanation tooling.
458
-
459
- The next PR is **PR 4: Async Native Handler Invalidation**. Do not skip directly to a store, resource, router, virtualization, or ecosystem package feature.
463
+ PR 1 through PR 5 are complete. The next PR is **PR 6: ProjectSession And Explicit Root**. Do not skip directly to a store, resource, router, virtualization, or ecosystem package feature.
@@ -1,6 +1,6 @@
1
1
  # Planned Version Sequence
2
2
 
3
- This is an execution sequence, not release history. `0.8.16` through `0.8.30` are completed scopes represented by package/release records.
3
+ This is an execution sequence, not release history. `0.8.16` through `0.8.32` are completed scopes represented by package/release records.
4
4
 
5
5
  Keep each patch behavior-preserving and independently reviewable. If a boundary proves inseparable, revise this plan before combining releases; do not silently broaden a patch.
6
6
 
@@ -21,6 +21,8 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
21
21
  | `0.8.28` | Add the source-local binding index and adopt it for reactive binding capture/import discovery and lowering. | Lexical shadowing is identity-based for complete indexed bindings, synthesized keyed expressions retain their fallback, the 1,000-reference guard passes, and no browser bytes or public API are added. |
22
22
  | `0.8.29` | Move native handler, effect, remaining binding, list evaluator, optimized-command, and effect-resource discovery/lowering onto source-local lexical identity. | Shadowed imports/globals/state/resources remain distinct, HandlerIR/BindingIR round trips pass, synthesized trees retain the fallback, and no runtime or public API is added. |
23
23
  | `0.8.30` | Validate ordinary runtime graph edges before code generation and reject dynamic imports at the importer source. | Page/helper/re-export/dynamic fixtures report original file/range/specifier without `.kudzu` paths; Worker, type-only, unreachable, output, and runtime behavior remain unchanged. |
24
+ | `0.8.31` | Invalidate pending native-handler contexts when their route, keyed, conditional, or document DOM owner is released. | Late setters, queued commits, and captured refs cannot mutate replacement ownership; synchronous dispatch remains within the performance gate and runtime bytes are recorded. |
25
+ | `0.8.32` | Stage and validate production output before rollback-safe promotion, reject same-root overlap, and tighten keyed browser paths. | Build/hook failures preserve the prior output, stale locks fail closed, interrupted backups recover on the next admitted build after lock removal, public/generated collisions fail, and deploy output remains equivalent. |
24
26
 
25
27
  ## Sequence Rules
26
28
 
@@ -63,7 +63,7 @@ Reduced Zustand migration stores lower to one ordinary layout-lifetime state slo
63
63
  - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles, item handler/effect scopes, moves, and cleanup.
64
64
  - `serialization.js`: capture deserialization shared by binding and native handlers.
65
65
  - `effect-runtime.js`: optional state and capture context for route-specific mount-effect entries.
66
- - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
66
+ - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers; DOM unmount invalidates late state writes, queued commits, and captured refs.
67
67
  - `navigation-runtime.js`: optional emitted-route complete-document prefetch, runtime segment matching, validation, finite memory caching, route-range replacement, history, focus, and native fallback, specialized once per configured shared-layout group.
68
68
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
69
69
  - `*.d.ts`: public TypeScript and JSX declarations.
@@ -90,4 +90,4 @@ A direct setter may cross one same-file or relative-imported component boundary
90
90
 
91
91
  `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.
92
92
 
93
- Cross-framework performance tables are historical snapshots from excluded workspaces and are not current rankings. `npm run benchmark` tracks the Worker build/graph fixture; `RUNS=21 npm run benchmark:keyed` tracks large keyed restoration with fresh Chrome profiles. Current methodology, raw arrays, external-fixture limits, and artifact deltas live in `PERFORMANCE.md` and the web docs.
93
+ Cross-framework performance tables are historical snapshots from excluded workspaces and are not current rankings. `npm run benchmark` tracks the Worker build/graph fixture; `RUNS=21 npm run benchmark:keyed` tracks large keyed restoration; `BASELINE_ROOT=... npm run benchmark:native` tracks native dispatch and exact artifact changes. Current methodology, raw arrays, external-fixture limits, and artifact deltas live in `PERFORMANCE.md` and the web docs.
@@ -41,14 +41,12 @@ export function patchBinding(node, target, value) {
41
41
 
42
42
  function commitBindings(id) {
43
43
  const bindings = bindingTargets.get(id)
44
- if (!bindings) return
45
- for (const binding of bindings) {
46
- if (!binding.node.isConnected) bindings.delete(binding)
47
- else patchBinding(binding.node, binding.target, binding.read())
44
+ if (bindings) {
45
+ for (const binding of bindings) {
46
+ if (!binding.node.isConnected) bindings.delete(binding)
47
+ else patchBinding(binding.node, binding.target, binding.read())
48
+ }
48
49
  }
49
- }
50
-
51
- function commitConditions(id) {
52
50
  const conditions = conditionTargets.get(id)
53
51
  if (!conditions) return
54
52
  for (const condition of conditions) {
@@ -58,7 +56,6 @@ function commitConditions(id) {
58
56
  }
59
57
 
60
58
  registerCommitter(commitBindings)
61
- registerCommitter(commitConditions)
62
59
  registerMountHook(mountBindings)
63
60
  registerMountHook(mountConditions)
64
61
  registerUnmountHook(unmountBindings)
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto"
2
- import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
2
+ import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
3
3
  import { dirname, join, relative, resolve, sep } from "node:path"
4
4
  import { pathToFileURL } from "node:url"
5
5
  import { build as bundle, transform } from "esbuild"
@@ -35,6 +35,31 @@ async function loadConfig() {
35
35
  }
36
36
 
37
37
  export async function build({ quiet = false, minify = true } = {}) {
38
+ const stagedOutput = join(root, ".kudzu-dist-staging")
39
+ const backupOutput = join(root, ".kudzu-dist-backup")
40
+ const lockPath = join(root, ".kudzu-build.lock")
41
+ const lock = await acquireBuildLock(lockPath)
42
+ try {
43
+ await recoverOutput(outputDirectory, backupOutput)
44
+ await rm(stagedOutput, { recursive: true, force: true })
45
+ const { result, pageCount, behaviorCount } = await buildInto(stagedOutput, { minify })
46
+ await promoteOutput(stagedOutput, outputDirectory, backupOutput)
47
+ if (!quiet) console.log(`Built ${pageCount} page(s), ${behaviorCount} interactive page(s) into dist/`)
48
+ return result
49
+ } finally {
50
+ try {
51
+ await rm(stagedOutput, { recursive: true, force: true })
52
+ } finally {
53
+ try {
54
+ await lock.close()
55
+ } finally {
56
+ await rm(lockPath, { force: true })
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ async function buildInto(outputDirectory, { minify }) {
38
63
  const config = await loadConfig()
39
64
  const base = normalizeBase(config.base)
40
65
  const configuredStyles = normalizeStyles(config.styles, base)
@@ -52,7 +77,6 @@ export async function build({ quiet = false, minify = true } = {}) {
52
77
  group.hasParams = false
53
78
  }
54
79
  await rm(workDirectory, { recursive: true, force: true })
55
- await rm(outputDirectory, { recursive: true, force: true })
56
80
  await mkdir(workDirectory, { recursive: true })
57
81
  await mkdir(outputDirectory, { recursive: true })
58
82
 
@@ -227,11 +251,13 @@ export async function build({ quiet = false, minify = true } = {}) {
227
251
  runtime: { shared: hasSharedRuntime, dependency: hasDependencyRuntime }
228
252
  } = capabilityIR
229
253
  const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
230
- for (const entry of pageEntries) {
231
- const routeDirectory = join(outputDirectory, entry.route)
232
- await mkdir(routeDirectory, { recursive: true })
233
- const html = preloadModules(entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`))))
234
- await writeFile(join(routeDirectory, "index.html"), html)
254
+ for (let offset = 0; offset < pageEntries.length; offset += 64) {
255
+ await Promise.all(pageEntries.slice(offset, offset + 64).map(async entry => {
256
+ const routeDirectory = join(outputDirectory, entry.route)
257
+ await mkdir(routeDirectory, { recursive: true })
258
+ const html = preloadModules(entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`))))
259
+ await writeFile(join(routeDirectory, "index.html"), html)
260
+ }))
235
261
  }
236
262
  if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
237
263
  const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
@@ -316,10 +342,6 @@ export async function build({ quiet = false, minify = true } = {}) {
316
342
  }
317
343
  const sortedRewrites = rewrites.sort((left, right) => runtimeSpecificity(right) - runtimeSpecificity(left) || left.pattern.localeCompare(right.pattern))
318
344
  await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans, rewrites: sortedRewrites }, null, 2))
319
- for (const file of new Set([...cssFiles, ...importedAssets])) {
320
- const collision = join(publicDirectory, "assets", relative(sourceDirectory, file))
321
- if (await exists(collision)) throw new Error(`${relative(root, collision)} collides with emitted source asset ${relative(root, file)}`)
322
- }
323
345
  for (const file of cssFiles) {
324
346
  const output = join(assetsDirectory, relative(sourceDirectory, file))
325
347
  await mkdir(dirname(output), { recursive: true })
@@ -339,17 +361,95 @@ export async function build({ quiet = false, minify = true } = {}) {
339
361
  if (typeof css !== "string") throw new Error(`${style.label}.transform must return CSS text or an object with a css string`)
340
362
  }
341
363
  const output = join(outputDirectory, style.output.slice(1))
364
+ if (await exists(output)) throw new Error(`${style.label}.output ${JSON.stringify(style.output)} collides with a generated artifact`)
342
365
  await mkdir(dirname(output), { recursive: true })
343
366
  await writeFile(output, css)
344
367
  }
345
- if (await exists(publicDirectory)) await cp(publicDirectory, outputDirectory, { recursive: true })
368
+ if (await exists(publicDirectory)) {
369
+ await copyPublic(publicDirectory, outputDirectory)
370
+ }
346
371
  if (config.afterBuild !== undefined) {
347
372
  if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
348
373
  await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans, rewrites: sortedRewrites })
349
374
  }
350
375
 
351
- if (!quiet) console.log(`Built ${plans.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
352
- return { sourceResults }
376
+ return { result: { sourceResults }, pageCount: plans.length, behaviorCount }
377
+ }
378
+
379
+ async function acquireBuildLock(lockPath) {
380
+ let lock
381
+ try {
382
+ lock = await open(lockPath, "wx")
383
+ } catch (error) {
384
+ if (error?.code !== "EEXIST") throw error
385
+ }
386
+ if (lock) {
387
+ try {
388
+ await lock.writeFile(String(process.pid))
389
+ return lock
390
+ } catch (error) {
391
+ await lock.close()
392
+ await rm(lockPath, { force: true })
393
+ throw error
394
+ }
395
+ }
396
+ const owner = Number(await readFile(lockPath, "utf8").catch(() => ""))
397
+ if (Number.isInteger(owner) && owner > 0) {
398
+ let active = true
399
+ try {
400
+ process.kill(owner, 0)
401
+ } catch (error) {
402
+ if (error?.code === "ESRCH") active = false
403
+ else throw error
404
+ }
405
+ if (active) throw new Error(`Another Kudzu build is already running for ${root} (PID ${owner})`)
406
+ throw new Error(`A stale Kudzu build lock for PID ${owner} exists at ${lockPath}; remove it before building`)
407
+ }
408
+ throw new Error(`An invalid Kudzu build lock exists at ${lockPath}; remove it before building`)
409
+ }
410
+
411
+ async function recoverOutput(finalOutput, backupOutput) {
412
+ if (!await exists(backupOutput)) return
413
+ if (await exists(finalOutput)) await rm(backupOutput, { recursive: true, force: true })
414
+ else await rename(backupOutput, finalOutput)
415
+ }
416
+
417
+ async function promoteOutput(stagedOutput, finalOutput, backup) {
418
+ const previous = await exists(finalOutput)
419
+ if (previous) await rename(finalOutput, backup)
420
+ try {
421
+ await rename(stagedOutput, finalOutput)
422
+ } catch (error) {
423
+ if (previous) {
424
+ try {
425
+ await rename(backup, finalOutput)
426
+ } catch (rollbackError) {
427
+ throw new AggregateError([error, rollbackError], "Kudzu output promotion and rollback both failed")
428
+ }
429
+ }
430
+ throw error
431
+ }
432
+ if (previous) await rm(backup, { recursive: true, force: true }).catch(error => console.warn(`Built output was promoted, but ${backup} could not be removed and will be retried on the next build: ${error.message}`))
433
+ }
434
+
435
+ async function copyPublic(sourceDirectory, destinationDirectory, destinationRoot = destinationDirectory) {
436
+ for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) {
437
+ const source = join(sourceDirectory, entry.name)
438
+ const destination = join(destinationDirectory, entry.name)
439
+ let generated
440
+ try {
441
+ generated = await stat(destination)
442
+ } catch (error) {
443
+ if (error?.code !== "ENOENT") throw error
444
+ }
445
+ if (!generated) {
446
+ await cp(source, destination, { recursive: entry.isDirectory(), force: false, errorOnExist: true })
447
+ } else if (entry.isDirectory() && generated.isDirectory()) {
448
+ await copyPublic(source, destination, destinationRoot)
449
+ } else {
450
+ throw new Error(`${relative(root, source)} collides with generated output ${relative(destinationRoot, destination).replaceAll(sep, "/")}`)
451
+ }
452
+ }
353
453
  }
354
454
 
355
455
  function preloadModules(html) {
@@ -551,6 +651,7 @@ function normalizeStyles(value, base) {
551
651
  if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array")
552
652
  const urls = []
553
653
  const sources = []
654
+ const outputs = new Set()
554
655
  for (let index = 0; index < value.length; index++) {
555
656
  const style = value[index]
556
657
  const label = `kudzu.config styles[${index}]`
@@ -570,6 +671,8 @@ function normalizeStyles(value, base) {
570
671
  if (typeof style.source !== "string" || !style.source) throw new Error(`${label}.source must be a non-empty file path`)
571
672
  if (typeof style.output !== "string" || !style.output.startsWith("/") || style.output.startsWith("//") || /[%?#\\\0]/.test(style.output) || style.output.split("/").includes("..") || !style.output.endsWith(".css")) throw new Error(`${label}.output must be a root-relative .css path without query, hash, or traversal`)
572
673
  if (style.transform !== undefined && typeof style.transform !== "function") throw new Error(`${label}.transform must be a function`)
674
+ if (outputs.has(style.output)) throw new Error(`${label}.output duplicates another configured style output ${JSON.stringify(style.output)}`)
675
+ outputs.add(style.output)
573
676
  const entry = { label, source: resolve(root, style.source), output: style.output, transform: style.transform }
574
677
  sources.push(entry)
575
678
  urls.push(withBase(base, style.output))
@@ -195,12 +195,13 @@ function updateList(list) {
195
195
  const currentTokens = [...list.roots.keys()]
196
196
  const nextTokens = entries.map(entry => entry.token)
197
197
  if (nextTokens.length === currentTokens.length && nextTokens.every((token, index) => token === currentTokens[currentTokens.length - index - 1])) {
198
+ const nextRoots = nextTokens.map(token => [token, list.roots.get(token)])
198
199
  const parent = list.container ?? list.start.parentNode
199
200
  const reordered = parent.ownerDocument.createDocumentFragment()
200
- reordered.append(...nextTokens.map(token => list.roots.get(token)))
201
+ reordered.append(...nextRoots.map(([, node]) => node))
201
202
  parent.insertBefore(reordered, list.boundary)
202
- list.roots = new Map(nextTokens.map(token => [token, list.roots.get(token)]))
203
- if (__KUDZU_LIST_STABLE_FAST_PATHS__) list.orderedRoots = nextTokens.map(token => list.roots.get(token))
203
+ list.roots = new Map(nextRoots)
204
+ if (__KUDZU_LIST_STABLE_FAST_PATHS__) list.orderedRoots = nextRoots.map(([, node]) => node)
204
205
  list.container ??= parent
205
206
  list.items = items
206
207
  return
@@ -210,7 +211,6 @@ function updateList(list) {
210
211
  const removedIndex = currentTokens.indexOf(removed)
211
212
  if (removed && nextTokens.every((token, index) => token === currentTokens[index >= removedIndex ? index + 1 : index])) {
212
213
  removeListRoot(list, removed)
213
- list.roots = new Map(nextTokens.map(token => [token, list.roots.get(token)]))
214
214
  if (__KUDZU_LIST_STABLE_FAST_PATHS__) list.orderedRoots = nextTokens.map(token => list.roots.get(token))
215
215
  list.items = items
216
216
  return
@@ -472,12 +472,12 @@ function updateNestedList(list, items) {
472
472
  return true
473
473
  }
474
474
  if (items.length === previous.length && items.every((item, index) => item === previous[previous.length - index - 1])) {
475
- const tokens = [...list.roots.keys()].reverse()
475
+ const roots = [...list.roots].reverse()
476
476
  const parent = list.container ?? list.start.parentNode
477
477
  const reordered = parent.ownerDocument.createDocumentFragment()
478
- reordered.append(...tokens.map(token => list.roots.get(token)))
478
+ reordered.append(...roots.map(([, node]) => node))
479
479
  parent.insertBefore(reordered, list.boundary)
480
- list.roots = new Map(tokens.map(token => [token, list.roots.get(token)]))
480
+ list.roots = new Map(roots)
481
481
  list.items = items
482
482
  list.container ??= parent
483
483
  return true
@@ -487,10 +487,6 @@ function updateNestedList(list, items) {
487
487
  while (removed < items.length && items[removed] === previous[removed]) removed++
488
488
  if (items.every((item, index) => item === previous[index >= removed ? index + 1 : index])) {
489
489
  removeListRoot(list, keyToken(previous[removed]?.[list.descriptor.key]))
490
- list.roots = new Map(items.map(item => {
491
- const token = keyToken(item[list.descriptor.key])
492
- return [token, list.roots.get(token)]
493
- }))
494
490
  list.items = items
495
491
  return true
496
492
  }
@@ -540,12 +536,12 @@ function updateReducerList(list, items) {
540
536
  return true
541
537
  }
542
538
  if (items.length === previous.length && items.every((item, index) => item === previous[previous.length - index - 1])) {
543
- const tokens = [...list.roots.keys()].reverse()
539
+ const roots = [...list.roots].reverse()
544
540
  const parent = list.container ?? list.start.parentNode
545
541
  const reordered = parent.ownerDocument.createDocumentFragment()
546
- reordered.append(...tokens.map(token => list.roots.get(token)))
542
+ reordered.append(...roots.map(([, node]) => node))
547
543
  parent.insertBefore(reordered, list.boundary)
548
- list.roots = new Map(tokens.map(token => [token, list.roots.get(token)]))
544
+ list.roots = new Map(roots)
549
545
  list.items = items
550
546
  list.container ??= parent
551
547
  return true
@@ -555,10 +551,6 @@ function updateReducerList(list, items) {
555
551
  while (removed < items.length && items[removed] === previous[removed]) removed++
556
552
  if (items.every((item, index) => item === previous[index >= removed ? index + 1 : index])) {
557
553
  removeListRoot(list, keyToken(previous[removed]?.[list.descriptor.key]))
558
- list.roots = new Map(items.map(item => {
559
- const token = keyToken(item[list.descriptor.key])
560
- return [token, list.roots.get(token)]
561
- }))
562
554
  list.items = items
563
555
  return true
564
556
  }
@@ -8,18 +8,20 @@ export function registerNativeModules(entries) {
8
8
  for (const [url, module] of entries) modules.set(url, module)
9
9
  }
10
10
 
11
- export function createNativeContext(state, stateIds, commit, serializedScope = {}) {
11
+ export function createNativeContext(state, stateIds, commit, serializedScope = {}, active = () => true) {
12
12
  const changed = new Set()
13
13
  let scheduled = false
14
14
 
15
15
  const flush = () => {
16
16
  scheduled = false
17
+ if (!active()) return changed.clear()
17
18
  const ids = [...changed]
18
19
  changed.clear()
19
20
  for (const id of ids) commit(id, state.get(id))
20
21
  }
21
22
 
22
23
  const setId = globalThis.__KUDZU_CAPTURE_SETTER__ ? (id, value) => {
24
+ if (!active()) return
23
25
  const current = state.get(id)
24
26
  state.set(id, typeof value === "function" ? value(current) : value)
25
27
  changed.add(id)
@@ -29,7 +31,7 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
29
31
  }
30
32
  } : undefined
31
33
 
32
- const scope = Object.fromEntries(Object.entries(serializedScope).map(([name, value]) => [name, deserialize(value, id => state.get(id), globalThis.__KUDZU_CAPTURE_SETTER__ ? setId : undefined)]))
34
+ const scope = Object.fromEntries(Object.entries(serializedScope).map(([name, value]) => [name, deserialize(value, id => state.get(id), globalThis.__KUDZU_CAPTURE_SETTER__ ? setId : undefined, active)]))
33
35
 
34
36
  return {
35
37
  get(name) {
@@ -43,6 +45,7 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
43
45
  setId(stateIds[name], value)
44
46
  return
45
47
  }
48
+ if (!active()) return
46
49
  const id = stateIds[name]
47
50
  const current = state.get(id)
48
51
  state.set(id, typeof value === "function" ? value(current) : value)
@@ -61,6 +64,9 @@ if (typeof document !== "undefined") {
61
64
  registerMountHook(mount)
62
65
  registerUnmountHook(unmountNative)
63
66
  mount(document)
67
+ addEventListener("pagehide", event => {
68
+ if (!event.persisted) unmountNative(document)
69
+ })
64
70
  }
65
71
 
66
72
  function mountNative(root, eventNames, modules) {
@@ -71,14 +77,16 @@ function mountNative(root, eventNames, modules) {
71
77
  let encoded = node.dataset[`kNative${capitalize(eventName)}`]
72
78
  let native = JSON.parse(encoded)
73
79
  let handler
74
- let context = createNativeContext(browserState, native.states, commitDom, native.scope)
80
+ const registration = { active: true }
81
+ const active = () => registration.active
82
+ let context = createNativeContext(browserState, native.states, commitDom, native.scope, active)
75
83
  const listener = event => {
76
84
  try {
77
85
  const current = node.dataset[`kNative${capitalize(eventName)}`]
78
86
  if (current !== encoded) {
79
87
  native = JSON.parse(current)
80
88
  encoded = current
81
- context = createNativeContext(browserState, native.states, commitDom, native.scope)
89
+ context = createNativeContext(browserState, native.states, commitDom, native.scope, active)
82
90
  handler = undefined
83
91
  }
84
92
  handler ??= modules.get(native.module)[native.handler]
@@ -88,8 +96,9 @@ function mountNative(root, eventNames, modules) {
88
96
  console.error(error)
89
97
  }
90
98
  }
99
+ registration.listener = listener
91
100
  node.addEventListener(eventName, listener)
92
- listeners.set(eventName, listener)
101
+ listeners.set(eventName, registration)
93
102
  registrations.set(node, listeners)
94
103
  }
95
104
  }
@@ -97,7 +106,10 @@ function mountNative(root, eventNames, modules) {
97
106
 
98
107
  function unmountNative(root) {
99
108
  for (const node of matching(root, "*")) {
100
- for (const [eventName, listener] of registrations.get(node) ?? []) node.removeEventListener(eventName, listener)
109
+ for (const [eventName, registration] of registrations.get(node) ?? []) {
110
+ registration.active = false
111
+ node.removeEventListener(eventName, registration.listener)
112
+ }
101
113
  registrations.delete(node)
102
114
  }
103
115
  }
@@ -1,8 +1,8 @@
1
- export function deserialize(value, getState, setState) {
1
+ export function deserialize(value, getState, setState, active) {
2
2
  if (!value || typeof value !== "object") return value
3
3
  if (value.type === "undefined") return undefined
4
4
  if (value.type === "number") return value.value === "NaN" ? NaN : value.value === "Infinity" ? Infinity : value.value === "-Infinity" ? -Infinity : -0
5
- if (value.type === "ref") return { get current() { return typeof document === "undefined" ? null : document.querySelector(`[data-k-ref="${value.id}"]`) } }
5
+ if (value.type === "ref") return { get current() { return typeof document === "undefined" || active?.() === false ? null : document.querySelector(`[data-k-ref="${value.id}"]`) } }
6
6
  if (globalThis.__KUDZU_CAPTURE_STATE__ && value.type === "state") return getState?.(value.id)
7
7
  if (globalThis.__KUDZU_CAPTURE_SETTER__ && value.type === "setter") return next => {
8
8
  if (!setState) throw new Error("Captured state setter is not available in this context")
@@ -10,20 +10,20 @@ export function deserialize(value, getState, setState) {
10
10
  }
11
11
  if (value.type === "array") {
12
12
  const array = []
13
- for (const [index, entry] of value.value.entries()) defineCapture(array, String(index), entry, getState, setState)
13
+ for (const [index, entry] of value.value.entries()) defineCapture(array, String(index), entry, getState, setState, active)
14
14
  return array
15
15
  }
16
16
  if (value.type === "object") {
17
17
  const object = value.nullPrototype ? Object.create(null) : {}
18
- for (const [key, entry] of value.value) defineCapture(object, key, entry, getState, setState)
18
+ for (const [key, entry] of value.value) defineCapture(object, key, entry, getState, setState, active)
19
19
  return object
20
20
  }
21
21
  return value
22
22
  }
23
23
 
24
- function defineCapture(target, key, entry, getState, setState) {
24
+ function defineCapture(target, key, entry, getState, setState, active) {
25
25
  const descriptor = globalThis.__KUDZU_CAPTURE_STATE__ && entry?.type === "state" && getState
26
26
  ? { get: () => getState(entry.id) }
27
- : { value: deserialize(entry, getState, setState), writable: true }
27
+ : { value: deserialize(entry, getState, setState, active), writable: true }
28
28
  Object.defineProperty(target, key, { ...descriptor, enumerable: true, configurable: true })
29
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.30",
3
+ "version": "0.8.32",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,6 +58,7 @@
58
58
  "benchmark": "node test/performance.mjs",
59
59
  "benchmark:keyed": "node test/keyed-performance.mjs",
60
60
  "benchmark:commerce": "node test/commerce-build-performance.mjs",
61
+ "benchmark:native": "node test/native-performance.mjs",
61
62
  "prepublishOnly": "npm run check && npm test",
62
63
  "deploy": "wrangler deploy",
63
64
  "preview": "wrangler dev"