@kudzujs/core 0.8.51 → 0.8.53

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.51` 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.53` 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
 
@@ -59,6 +59,7 @@ Do not add `getStaticProps`, request-time SSR, an SPA router, a stream runtime,
59
59
  The following are available building blocks, not future vertical roadmaps:
60
60
 
61
61
  - Async build-time components, `getStaticPaths()`, runtime path parameters, metadata, base paths, public assets, CSS, CSS Modules, and post-build hooks.
62
+ - Relative source CSS imports close over each page's reachable TypeScript import/re-export graph; configured styles remain explicitly global. Enhanced navigation loads destination CSS before route DOM replacement, reuses shared layout links, and removes outgoing route links.
62
63
  - React import normalization for supported named/aliased hooks, direct `React.*` members, fragments, same-file `memo`, inline `useCallback`, direct intrinsic `forwardRef`, top-level `useId`, and analyzable `useMemo` expressions and collections.
63
64
  - Function components, props, children, context, direct bindings, conditions, controlled form properties, refs, and synchronous or async handlers.
64
65
  - `useState`, independent repeated non-keyed child state with conditional mount ownership, reduced relative-imported `useReducer`, direct dispatch specialization, and reduced Zustand-shaped shared state lowered through package-neutral shared-state/action IR and proven by migration fixtures.
@@ -173,7 +174,7 @@ This queue orders the next investigations by general migration value. Start only
173
174
  ### Completed In 0.8.13
174
175
 
175
176
  - A reduced fixture from [Memos](https://github.com/usememos/memos) preserves its memo-outline scroll spy: native heading links, active `aria-current`, smooth scrolling, hash replacement, capture-phase scroll/resize listeners, and animation-frame coalescing with cleanup.
176
- - One effect-owned `useRef(0)` lowers to a serializable mutable effect capture when a local frame callback resets it and cleanup cancels the pending frame. Existing effect ownership handles setup, listener removal, and document disposal without state, a retained component, or an animation runtime.
177
+ - Top-level `useRef(null)` and `useRef(0)` values used exclusively through direct `.current` references in one inline effect graph lower to invocation-private closure objects in effect ESM. Existing effect ownership handles SDK/WebSocket handles, generation invalidation, animation frames, dependency replacement, listener removal, BFCache-aware document disposal, and cleanup without serialized captures, ResourceIR, or a retained component.
177
178
  - Chrome validation covers burst coalescing, active-heading updates, outline clicks, hash replacement, pending-frame cancellation, listener cleanup, and a zero-JavaScript static sibling. Ref aliases, cross-effect/event use, nonzero initializers, multiple scheduling assignments, and missing cancellation remain unsupported.
178
179
  - A reduced fixture from [Excalidraw](https://github.com/excalidraw/excalidraw) preserves its active-room progressive sharing shape: a readonly collaboration URL, a Web Share button gated by `"share" in navigator`, direct `navigator.share()`, clipboard fallback, and accessible application-owned status.
179
180
  - Browser capability conditions lower to a false static state plus one existing mount effect and state-owned conditional branch. Supported browsers mount the Share DOM and handler; unsupported browsers keep both out of the document and accessibility tree. Node's build-time `navigator` can no longer fold browser capability UI incorrectly.
@@ -272,7 +273,7 @@ This queue orders the next investigations by general migration value. Start only
272
273
 
273
274
  ### Characterized In 0.8.27
274
275
 
275
- - A reduced E2B Dashboard terminal fixture establishes callback-shared mutable browser resources, asynchronous generation invalidation, and BFCache retain/resume/discard behavior as a concrete Goal C boundary.
276
+ - Reduced E2B Dashboard terminal and route-owned WebSocket fixtures establish callback-shared mutable browser resources, asynchronous generation invalidation, dependency replacement, exact cleanup, and BFCache retain/resume/discard behavior. Exclusive effect-private refs now compile through ordinary effect ESM; shared transports and cross-owner subscriptions remain Goal C research.
276
277
  - Unsupported page-level mutable value refs fail during source analysis with a source location and the existing effect-owned animation-frame exception; no resource API or runtime is added.
277
278
  - The large-application and AI-native execution plan orders symbol resolution, semantic operations, project/module analysis, IR authority, application foundations, ecosystem compatibility, AI tooling, and scale validation before broad feature work.
278
279
 
package/PERFORMANCE.md CHANGED
@@ -6,7 +6,15 @@ Reproducibility classes: `npm run benchmark`, `npm run benchmark:keyed`, `npm ru
6
6
 
7
7
  The maintained 2026-08-13 comparison used Node 24.14.0 and an Intel Core i5-9500 Linux x64 host, one warm-up, and seven alternating fresh-process samples against clean `v0.8.44`. A narrow fast path skips Kudzu semantic transformation for 450 plain `.ts` modules whose runtime edges are exclusively resolvable relative TypeScript imports or exports; all other modules retain the existing transformer. Compile median fell from 2,323.9 ms to 1,413.2 ms (39.2%) and clean-build median from 3,325.3 ms to 2,382.4 ms (28.4%); every paired sample improved. Compile peak-RSS median fell from 571.2 MiB to 552.6 MiB, while build peak RSS was 570.9 MiB versus 568.8 MiB. Compiler scratch fell from 7,328,390 to 1,971,061 bytes. Both targets emitted the same 50 static HTML files, 10,980 bytes, and deploy SHA-256 `e107d78a7f55bc8a1af0ea6e53efeffa19b3d44d21c892484d103fa346e7ba7b`. This is a source-scale compiler comparison, not a cross-framework result.
8
8
 
9
- ## Current 0.8.51 Release Snapshot
9
+ ## Current 0.8.53 Release Snapshot
10
+
11
+ Kudzu 0.8.53 removes unrelated source stylesheet links from route HTML and reuses shared layout links during enhanced navigation. Static routes add no JavaScript, and no new performance claim is made.
12
+
13
+ ## Maintained 0.8.52 Release Snapshot
14
+
15
+ Kudzu 0.8.52 moves proven effect-private mutable refs from component scope into existing setup-invocation closures. The refs add no serialized route scope, shared runtime, or static-sibling JavaScript; no new performance claim is made.
16
+
17
+ ## Maintained 0.8.51 Release Snapshot
10
18
 
11
19
  Kudzu 0.8.51 broadens compiler-only package-reference routing into existing owned effect ESM and bundling. Static siblings and routes without the effect retain zero package bytes; no new performance claim is made.
12
20
 
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.51 - Owned effect package imports.** Browser-only package bindings may be referenced directly inside inline effect setup and cleanup callbacks. Kudzu bundles them only into route-owned effect ESM while build-time component modules and static siblings omit the package. Read the [release notes](./RELEASES.md#0851---owned-effect-package-imports), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.8.51), or follow the [architecture packet](./docs/next-architecture/README.md).
17
+ **Latest release: 0.8.53 - Route-aware CSS closure.** Relative source CSS follows each page's reachable TypeScript import and re-export graph, while configured styles remain explicitly global. Enhanced navigation loads destination styles before route replacement, retains shared layout links, removes outgoing styles, and rolls back cancelled loads. Read the [release notes](./RELEASES.md#0853---route-aware-css-closure), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.8.53), 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,84 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.53 - Route-aware CSS closure
4
+
5
+ Kudzu 0.8.53 links source styles only from routes whose reachable TypeScript graph imports them.
6
+
7
+ ### Changed in 0.8.53
8
+
9
+ - Relative CSS imports follow each page's runtime import and re-export graph in deterministic source order.
10
+ - RouteBuildRecord receives each route's exact source stylesheet URLs instead of one site-wide CSS list.
11
+ - `kudzu.config styles` remains explicitly global, deduplicated, and excluded from route-managed ownership.
12
+ - CSS `?url` imports emit downloadable assets without creating stylesheet links.
13
+ - Unimported source CSS is no longer implicitly linked; applications import route/layout styles or configure true globals.
14
+
15
+ ### Enhanced navigation
16
+
17
+ - Navigable documents mark route-managed source styles separately from configured global links.
18
+ - Destination styles load before route cleanup and DOM replacement.
19
+ - Shared layout stylesheet links retain URL, DOM identity, and cascade order across navigation.
20
+ - Outgoing route styles are removed after successful preparation.
21
+ - Overlapping navigation cancels and rolls back provisional stylesheet transactions without disturbing the newer route.
22
+ - Invalid, duplicate, cross-origin, or failed managed stylesheets fall back to native document navigation.
23
+
24
+ ### Migration evidence
25
+
26
+ - The React/Vite static sibling excludes the interactive app stylesheet while retaining zero JavaScript.
27
+ - Independent navigation groups exclude one another's layout and route styles.
28
+ - A delayed stylesheet browser test verifies cancellation rollback, shared-link identity, destination activation, and outgoing-link removal.
29
+ - Global style fixtures prove configured source/URL deduplication, while dynamic routes now import their intended CSS explicitly.
30
+ - The starter imports shared CSS through its common header so both generated routes reach the style intentionally.
31
+
32
+ ### Output and performance
33
+
34
+ - Route HTML omits unrelated feature CSS and its cascade effects.
35
+ - Static pages remain JavaScript-free; only configured enhanced-navigation routes receive managed-style reconciliation.
36
+ - No CSS bundler, style runtime, SPA router, or new performance claim is added.
37
+
38
+ ### Validation
39
+
40
+ - `npm run check`, `npm test`, and `npm run test:package` pass with all 219 tests and 168 generated pages.
41
+
42
+ ### Upgrade
43
+
44
+ ```bash
45
+ npm install @kudzujs/core@^0.8.53
46
+ ```
47
+
48
+ ## 0.8.52 - Effect-private mutable refs
49
+
50
+ Kudzu 0.8.52 compiles component-authored mutable refs when one inline effect exclusively owns their complete lifecycle.
51
+
52
+ ### Changed in 0.8.52
53
+
54
+ - Top-level `useRef(null)` and `useRef(0)` values lower when every direct `.current` reference belongs to one inline block-bodied effect setup, its nested callbacks, and cleanup.
55
+ - The compiler removes those component declarations and creates fresh mutable objects inside each effect setup invocation.
56
+ - Existing effect ownership handles dependency replacement, stale setter invalidation, conditional/keyed ownership, navigation, document disposal, and BFCache preservation.
57
+ - Animation-frame refs use the same invocation-private lowering while retaining exact scheduler, reset, and cancellation validation.
58
+ - JSX DOM refs remain component-owned; aliases, nonzero initializers, cross-effect/event use, missing cleanup invalidation, and escaped ref objects remain source-diagnosed.
59
+
60
+ ### Migration evidence
61
+
62
+ - The E2B terminal fixture now preserves asynchronous generation invalidation, BFCache retain/resume, discard cleanup, and a static sibling without ResourceIR.
63
+ - A native WebSocket fixture verifies dependency replacement, listener removal, stale callback rejection, distinct connection ownership, and one close per connection.
64
+ - Mutable refs shared by multiple effects retain a focused source diagnostic.
65
+
66
+ ### Output and performance
67
+
68
+ - Private refs stay inside existing route-specific effect handler ESM and are absent from build-time captures and RouteIR scope.
69
+ - Static sibling routes remain zero JavaScript.
70
+ - No ResourceIR record, component runtime, resource API, scheduler, or new performance claim is added.
71
+
72
+ ### Validation
73
+
74
+ - `npm run check`, `npm test`, and `npm run test:package` pass with all 219 tests and 167 generated pages.
75
+
76
+ ### Upgrade
77
+
78
+ ```bash
79
+ npm install @kudzujs/core@^0.8.52
80
+ ```
81
+
3
82
  ## 0.8.51 - Owned effect package imports
4
83
 
5
84
  Kudzu 0.8.51 lets inline owned effect setup and cleanup callbacks reference browser-only package imports directly.
@@ -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.51` implementation sequence is [`large-application-ai-native-roadmap.md`](./large-application-ai-native-roadmap.md). P0.12 validation, property-level object-state dependencies, direct three-boundary callback/ref ownership, direct primitive prop state initialization, repeated direct leaf-handler callback use, direct child callback fan-out, collision-free and private Context action setters, package-neutral shared-state/action IR, browser-only package imports in owned effects, measured route-entry output optimization, and the plain TypeScript source-scale fast path are complete. ResourceIR remains research requiring two independent lifecycle fixtures. The plan 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.53` implementation sequence is [`large-application-ai-native-roadmap.md`](./large-application-ai-native-roadmap.md). P0.12 validation, property-level object-state dependencies, direct three-boundary callback/ref ownership, direct primitive prop state initialization, repeated direct leaf-handler callback use, direct child callback fan-out, collision-free and private Context action setters, package-neutral shared-state/action IR, browser-only package imports and private mutable refs in owned effects, route/layout CSS closure, measured route-entry output optimization, and the plain TypeScript source-scale fast path are complete. Route capability signatures and emitted shared-chunk reporting are next; ResourceIR remains research for cross-owner transports and subscriptions. 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.51` 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.53` 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
 
@@ -58,17 +58,21 @@ The repository-owned fixture at `test/fixtures/goal-c-e2b-terminal` preserves th
58
58
  - `pageshow.persisted` resuming the retained handle;
59
59
  - one unrelated static sibling route.
60
60
 
61
- The build fails before BFCache behavior can be evaluated:
61
+ The fixture now compiles through a narrow effect-private ref specialization. Because both refs are initialized with `null` or `0` and every direct `.current` reference belongs to one inline effect graph, the compiler removes their component declarations and recreates them inside each setup invocation. Promise continuations, page lifecycle listeners, and cleanup share the same ordinary JavaScript closure. Persisted `pagehide` retains that invocation; non-persisted disposal invalidates late setters and runs the authored close path. No ref capture, ResourceIR record, or resource runtime is emitted, and the static sibling remains JavaScript-free.
62
62
 
63
- ```text
64
- src/pages/index.tsx:7:25 Mutable value useRef() is unsupported except for an effect-owned useRef(0) animation-frame handle; otherwise keep resource-private mutable values inside the owning effect
65
- ```
63
+ ### Route-Owned WebSocket Reduced Fixture
66
64
 
67
- The failure is triggered by the ordinary `useRef(0)` generation token during source analysis. Existing animation-frame specialization cannot safely cover this shape: the generation token has different writes and callback ownership, while the remote handle is a browser-only non-serializable value rather than a DOM ref. Supporting only the token would expose incorrect DOM-ref semantics for the handle.
65
+ The repository-owned fixture at `test/fixtures/goal-c-route-websocket` adds an independent native WebSocket case with a narrower route lifetime:
68
66
 
69
- The remaining question before any state/resource API discussion is whether independent fixtures justify a narrow specialization for callback-shared effect resources with document and BFCache ownership. Until then, the source-located diagnostic preserves the current boundary.
67
+ - one `null`-initialized socket handle ref shared by setup and cleanup;
68
+ - one numeric generation ref invalidating callbacks from replaced connections;
69
+ - a primitive room dependency that replaces the connection;
70
+ - exact listener removal and socket closure on replacement or route unmount;
71
+ - one unrelated static sibling route.
72
+
73
+ This fixture uses the same effect-private lowering. On dependency replacement, the previous invocation is invalidated and cleaned before a new closure and socket are created. The regression test executes the bundled effect handler with a fake WebSocket and verifies listener removal, stale callback rejection, one close per connection, and distinct replacement sockets. Its static sibling remains JavaScript-free.
70
74
 
71
- An effect-local rewrite is a useful control, but requiring applications to restructure ordinary callback-shared refs is not automatically an acceptable migration solution. The fixture remains an expected failure until research answers that boundary.
75
+ Both fixtures therefore resolve without ResourceIR: their mutable handles are private implementation details of one effect invocation, while existing effect ownership already supplies replacement, cleanup, navigation, and BFCache disposal. The remaining Goal C pressure is cross-owner sharing: document/layout transports with independently mounted subscribers, reconnect/replay state, or handles intentionally shared with event handlers cannot use this specialization.
72
76
 
73
77
  ## Decision Boundary
74
78
 
@@ -81,4 +85,4 @@ Research may produce notes, fixtures, measurements, or a narrow compiler-special
81
85
  - [ ] Classify build-known, browser-only, layout-owned, route-owned, and DOM-owned data.
82
86
  - [ ] Test existing compiler capabilities before proposing a new one.
83
87
  - [ ] Record unresolved semantics instead of filling them with a generic runtime.
84
- - [ ] Stop at research unless a separate implementation decision is approved.
88
+ - [x] Stop at research unless a separate implementation decision is approved; the effect-private specialization was explicitly approved without approving ResourceIR.
@@ -42,7 +42,7 @@ The current limiting architecture is observable in these files:
42
42
  | Lists/ranges | `source-compiler.mjs`, `list-runtime.js` | Strong keyed ownership, but syntax recognition and runtime branches are list-specific and not a general range/window primitive |
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
- | Build/codegen | `framework/build.mjs`, runtime generators | Full destructive builds, serialized-string handler reachability, source-text runtime surgery, no route chunk/CSS closure report |
45
+ | Build/codegen | `framework/build.mjs`, runtime generators | Full destructive builds, source-text runtime surgery, and no route capability/chunk closure report; route CSS closure is structural |
46
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 |
@@ -382,8 +382,8 @@ After the relevant P0 foundations, investigate capabilities in this order:
382
382
  2. Multi-boundary component/prop/callback/ref/context dataflow. **Three-boundary callback/ref ownership completed in `0.8.43`; collision-free Context action-private state completed in `0.8.44`; action-only Provider setter exposure removed in `0.8.46`; direct primitive prop state initialization completed in `0.8.47`; repeated direct leaf-handler callback use completed in `0.8.48`; direct child callback fan-out completed in `0.8.49`:** forwarding preserves parent SignalIR, Context action lowering uses compiler-owned aliases when consumer locals reuse Provider state/setter names, action-required setters may remain compiler-only when their state is publicly exposed, specialized children may seed local state from a direct primitive parent signal, and one callback may branch through multiple component `on*` props and intrinsic handlers. A fourth callback boundary, callback aliases/non-handler uses, fully hidden Context state, and object/array prop initializers remain fail-closed. Broader prop, callback, ref, and Context graphs remain migration-led work.
383
383
  3. Package-neutral shared state/actions and migration of current Zustand internals. **Completed in `0.8.50`:** Zustand source normalization produces one generic shared-state adapter descriptor; selectors and handlers register JSON-safe SharedStateIR/SharedActionIR records, handler lowering consumes package-neutral actions, and existing RouteIR, layout ownership, same-turn updates, navigation persistence, and browser output remain unchanged. Redux/RTK and public adapter APIs remain unsupported.
384
384
  4. Browser-only package imports in owned effect/resource modules. **Completed in `0.8.51` for effects:** direct package references in inline effect setup/cleanup callbacks use existing package import records and route-owned effect ESM bundling; build-time component modules and static siblings omit the package. Helper-indirect, render-time, dynamic-import, and ResourceIR package graphs remain unsupported.
385
- 5. ResourceIR from at least two independent WebSocket/SSE/SDK fixtures with the same semantics.
386
- 6. Route/layout capability and CSS chunk closure.
385
+ 5. ResourceIR from at least two independent WebSocket/SSE/SDK fixtures with the same semantics. **Completed in `0.8.52` without ResourceIR for private ownership:** the E2B terminal and route-owned WebSocket fixtures lower refs used exclusively by one inline effect to invocation-private closure objects, while existing effect ownership supplies replacement, cleanup, stale setter invalidation, navigation, and BFCache disposal. ResourceIR remains unapproved and now requires independent cross-owner transport/subscription fixtures that cannot fit this narrower model.
386
+ 6. Route/layout capability and CSS chunk closure. **Route/layout CSS closure completed in `0.8.53`:** each page's reachable TypeScript import/re-export graph supplies exact source stylesheet edges to RouteBuildRecord, configured styles remain global, static siblings exclude unrelated feature CSS, and enhanced navigation loads incoming links before DOM replacement while retaining shared layout link identity and removing outgoing links. Route capability signatures and bundled shared-chunk reporting remain.
387
387
  7. Incremental source and affected-route builds.
388
388
  8. Range ownership and virtualization only after a real data-heavy fixture establishes direct DOM limits.
389
389
  9. Optimistic shared transactions only after independent mutation fixtures establish commit/rollback semantics.
@@ -481,4 +481,4 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
481
481
 
482
482
  ## Immediate Decision
483
483
 
484
- PR 1 through PR 12, the `0.8.40` property-dependency slice, the `0.8.41` and `0.8.43` direct multi-boundary callback/ref slices, the `0.8.44` Context alias slice, the `0.8.46` action-only Provider setter slice, the `0.8.47` direct primitive prop initializer slice, the `0.8.48` repeated direct leaf-handler callback slice, the `0.8.49` direct child callback fan-out slice, the `0.8.50` package-neutral shared-state/action slice, the `0.8.51` owned-effect package import slice, and the `0.8.42` measured route-output optimization are complete. Continue with **ResourceIR only after two independent WebSocket/SSE/SDK fixtures prove the same lifecycle semantics**. Do not skip directly to a public adapter/store API, router, virtualization, or unsupported ecosystem package feature.
484
+ PR 1 through PR 12, the `0.8.40` property-dependency slice, the `0.8.41` and `0.8.43` direct multi-boundary callback/ref slices, the `0.8.44` Context alias slice, the `0.8.46` action-only Provider setter slice, the `0.8.47` direct primitive prop initializer slice, the `0.8.48` repeated direct leaf-handler callback slice, the `0.8.49` direct child callback fan-out slice, the `0.8.50` package-neutral shared-state/action slice, the `0.8.51` owned-effect package import slice, the `0.8.52` effect-private mutable-ref slice, the `0.8.53` route/layout CSS closure slice, and the `0.8.42` measured route-output optimization are complete. Continue item 6 with route capability signatures and emitted shared-chunk reporting; keep ResourceIR limited to cross-owner fixtures and do not skip directly to a public adapter/store API, router, virtualization, or unsupported 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.51` are completed scopes represented by package/release records.
3
+ This is an execution sequence, not release history. `0.8.16` through `0.8.53` 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
 
@@ -42,6 +42,8 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
42
42
  | `0.8.49` | Fan one setter-callback prop out through multiple direct child component event props. | Every branch lowers to the original parent signal; aliases, ordinary props, spreads, and the fourth boundary fail without a runtime change. |
43
43
  | `0.8.50` | Isolate reduced Zustand migration behind package-neutral shared-state and action IR. | Validated shared identities/actions preserve RouteIR, layout lifetime, same-turn updates, diagnostics, browser behavior, and zero store runtime. |
44
44
  | `0.8.51` | Bundle direct browser-only package references from inline owned effect setup/cleanup callbacks. | Package code stays in referenced route effect ESM; build scratch and static siblings omit it while indirect/render-time uses fail. |
45
+ | `0.8.52` | Lower effect-exclusive mutable refs into setup-invocation closures. | E2B and WebSocket fixtures preserve generation, replacement, BFCache, and cleanup without captures, ResourceIR, or static-sibling JavaScript. |
46
+ | `0.8.53` | Close source CSS over each route's reachable TypeScript graph and reconcile managed styles during navigation. | Routes exclude unrelated CSS; destination styles load before replacement, shared layout links retain identity, and cancelled loads roll back safely. |
45
47
 
46
48
  ## Sequence Rules
47
49
 
@@ -6,7 +6,7 @@ Migration source may retain conventional `react` imports for supported named or
6
6
 
7
7
  Compilation begins from page entries and follows relative runtime imports, re-exports, and validated Worker references; unreachable TypeScript migration files are not transformed. Direct maps over imported immutable JSON-safe arrays fold to literals for zero-JavaScript static rows. Synchronous relative calculation functions may return objects whose direct static fields feed reactive JSX bindings; build rendering uses current signal values and route-specific binding ESM reevaluates the same helper after state commits. One direct array field may instead feed a keyed intrinsic map: its evaluator refreshes a compiler-owned array anchor before the existing list reconciler runs, preserving keyed DOM and SVG identity without a calculation runtime. That field must remain a JSON-safe array after every source-state commit. Package imports have a separate narrow boundary: direct references inside intrinsic JSX event callbacks are erased from build modules and bundled into route handler ESM, while render-time, effect, helper-indirect, and mixed package use fails.
8
8
 
9
- Native platform work remains ordinary source. A direct async handler or directly returned relative custom-hook callback may call `navigator.clipboard.writeText()` and update application-owned success/failure state; Kudzu emits only its existing route handler ESM. Debounced synchronization uses a dependency effect that creates `setTimeout()` work and directly returns `clearTimeout()` cleanup, reusing dependency, conditional, keyed, and route ownership. One directly returned relative custom-hook callback may own one `null`-initialized private timeout ref when it directly clears the previous value, assigns a numeric-literal-delay `setTimeout()`, and an empty-dependency effect directly clears the timer on cleanup. Kudzu lowers that ref to compiler-owned state shared by existing handler and effect contexts. An ordinary effect may instead exclusively own `useRef(0)` as one animation-frame handle when a local callback resets it, one scheduler assigns `requestAnimationFrame()`, and cleanup cancels the pending frame; Kudzu lowers it to the existing effect scope without an animation runtime. Multiple timers, dynamic delays, intervals, ref aliases, cross-effect frame handles, unowned delayed writes, and arbitrary timed callback graphs remain unsupported.
9
+ Native platform work remains ordinary source. A direct async handler or directly returned relative custom-hook callback may call `navigator.clipboard.writeText()` and update application-owned success/failure state; Kudzu emits only its existing route handler ESM. Debounced synchronization uses a dependency effect that creates `setTimeout()` work and directly returns `clearTimeout()` cleanup, reusing dependency, conditional, keyed, and route ownership. One directly returned relative custom-hook callback may own one `null`-initialized private timeout ref when it directly clears the previous value, assigns a numeric-literal-delay `setTimeout()`, and an empty-dependency effect directly clears the timer on cleanup. Kudzu lowers that ref to compiler-owned state shared by existing handler and effect contexts. An ordinary inline effect may exclusively own top-level `useRef(null)` and `useRef(0)` values through direct `.current` references; Kudzu moves them into the setup invocation's closure for browser SDK handles, WebSockets, generation tokens, and animation frames without serialized captures or a resource runtime. Multiple timers, dynamic delays, intervals, ref aliases, cross-effect/event mutable refs, unowned delayed writes, and arbitrary timed callback graphs remain unsupported.
10
10
 
11
11
  Imperative canvas migrations use the same effect ownership rather than a component or canvas runtime. One `null`-initialized canvas DOM ref may feed an inline effect whose local variables persist across a recursive animation-frame callback, an `IntersectionObserver`, and native canvas/window listeners; the returned cleanup must cancel the latest frame, disconnect the observer, and remove every listener. Bare `IntersectionObserver` and `performance` identifiers remain browser globals in emitted effect ESM. Component-level mutable value refs and callbacks shared across effects or JSX handlers remain unsupported; move resource-private state and listeners into the owning effect.
12
12
 
@@ -76,7 +76,7 @@ Static routes receive no browser runtime. Command routes receive `runtime.js`; d
76
76
 
77
77
  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.
78
78
 
79
- 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.
79
+ Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Relative source CSS imports follow each page's reachable TypeScript graph and link only from those routes; `kudzu.config` styles remain explicitly global. Enhanced navigation loads incoming managed styles before route replacement, reuses shared links, and removes outgoing links. All styles are emitted before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
80
80
 
81
81
  Inline SVG rendering normalizes an explicit set of common React presentation aliases before static serialization and binding descriptor creation. Reactive aliases use the existing generic `setAttribute` path. Reactive conditionals and flat intrinsic keyed lists store inert branch or row markup on SVG markers and parse it in the actual parent namespace only when replacement nodes are needed; existing condition/list ownership then handles insertion, identity, updates, and removal. Focus, keyboard, and click handlers on keyed SVG points may update parent state for an external accessible HTML tooltip, and retained handlers read the latest item after list updates. Builds without structural SVG compile out that fragment path, and static SVG adds no JavaScript. Keyed-item conditions, nested SVG lists, MathML structures, and namespaced attributes remain unsupported.
82
82
 
@@ -62,6 +62,8 @@ async function buildInto(project, outputDirectory, { minify }) {
62
62
  const config = await loadConfig(root)
63
63
  const base = normalizeBase(config.base)
64
64
  const configuredStyles = normalizeStyles(config.styles, base, project)
65
+ const globalStyleUrls = [...new Set(configuredStyles.urls)]
66
+ const globalStyleUrlSet = new Set(globalStyleUrls)
65
67
  const publicDirectory = normalizePublicDirectory(config.publicDir, project)
66
68
  const navigationGroups = normalizeNavigation(config.navigation)
67
69
  const navigationRoutes = navigationGroups.flatMap(group => group.routes)
@@ -82,7 +84,6 @@ async function buildInto(project, outputDirectory, { minify }) {
82
84
  const projectFiles = await walk(sourceDirectory)
83
85
  const allSourceFiles = projectFiles.filter(file => /\.(?:ts|tsx)$/.test(file) && !file.endsWith(".d.ts")).sort()
84
86
  const configuredStyleSources = new Set(configuredStyles.sources.map(style => style.source))
85
- const discoveredCssFiles = projectFiles.filter(file => file.toLowerCase().endsWith(".css") && !configuredStyleSources.has(file)).sort()
86
87
  if (!allSourceFiles.length) throw new Error("No TypeScript files found in src/")
87
88
  const allSourceFileSet = new Set(allSourceFiles)
88
89
  const sourceIndex = project.sourceIndex
@@ -93,7 +94,8 @@ async function buildInto(project, outputDirectory, { minify }) {
93
94
  const sourceFileSet = project.sourceFiles
94
95
  for (const file of sourceFiles) sourceFileSet.add(file)
95
96
  const staticFiles = await safeStaticFiles(projectFiles)
96
- const cssFiles = orderSourceStyles(discoveredCssFiles, sourceFiles, sourceIndex, staticFiles)
97
+ const stylesByPage = new Map(pageFiles.map(file => [file, orderSourceStyles([file], sourceFiles, sourceIndex, staticFiles).filter(style => !configuredStyleSources.has(style))]))
98
+ const cssFiles = [...new Set([...stylesByPage.values()].flat())]
97
99
  const importedAssets = new Set()
98
100
  const { cssModules, cssOutputs } = await prepareSourceStyles(cssFiles, staticFiles, importedAssets, base, project)
99
101
 
@@ -122,13 +124,11 @@ async function buildInto(project, outputDirectory, { minify }) {
122
124
  const emittedRoutes = new Set()
123
125
  const emittedApplicationRoutes = new Set()
124
126
  const emittedNavigationRecords = []
125
- const styleUrls = [...new Set([
126
- ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
127
- ...configuredStyles.urls
128
- ])]
129
127
  const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
130
128
 
131
129
  for (const pageFile of pageFiles) {
130
+ const sourceStyleUrls = stylesByPage.get(pageFile).map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)).filter(url => !globalStyleUrlSet.has(url))
131
+ const styleUrls = [...sourceStyleUrls, ...globalStyleUrls]
132
132
  const compiledFile = compiledPath(pageFile)
133
133
  const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
134
134
  if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
@@ -179,6 +179,7 @@ async function buildInto(project, outputDirectory, { minify }) {
179
179
  ...configuredMetadata,
180
180
  ...pageMetadata,
181
181
  styles: styleUrls.length ? styleUrls : false,
182
+ managedStyles: navigable ? sourceStyleUrls : [],
182
183
  base,
183
184
  runtimeAsset: runtimePlaceholder,
184
185
  effectAsset: assetPath(base, `assets/${effectPath}`),
@@ -352,16 +353,19 @@ async function buildInto(project, outputDirectory, { minify }) {
352
353
  }
353
354
  const sortedRewrites = rewrites.sort((left, right) => runtimeSpecificity(right) - runtimeSpecificity(left) || left.pattern.localeCompare(right.pattern))
354
355
  await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans, rewrites: sortedRewrites }, null, 2))
356
+ const emittedCssFiles = new Set()
355
357
  for (const file of cssFiles.filter(file => renderedStyleUrls.has(assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)))) {
356
358
  const output = join(assetsDirectory, relative(sourceDirectory, file))
357
359
  await mkdir(dirname(output), { recursive: true })
358
360
  await writeFile(output, cssOutputs.get(file))
361
+ emittedCssFiles.add(file)
359
362
  }
360
363
  for (const file of [...importedAssets].sort()) {
361
- if (cssOutputs.has(file)) continue
362
364
  const output = join(assetsDirectory, relative(sourceDirectory, file))
363
365
  await mkdir(dirname(output), { recursive: true })
364
- await writeFile(output, await readFile(file))
366
+ if (cssOutputs.has(file)) {
367
+ if (!emittedCssFiles.has(file)) await writeFile(output, cssOutputs.get(file))
368
+ } else await writeFile(output, await readFile(file))
365
369
  }
366
370
  for (const style of configuredStyles.sources.filter(style => renderedStyleUrls.has(withBase(base, style.output)))) {
367
371
  let css = await readFile(style.source, "utf8")
@@ -1,7 +1,7 @@
1
1
  import ts from "typescript"
2
2
  import { effectReturns, importDeclarationNames, isShadowedIdentifier, nearestFunction, referenceIdentifiers, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
3
3
 
4
- export function normalizeEffectAnimationFrameRefs(sourceFile, factory, context) {
4
+ export function normalizeEffectPrivateRefs(sourceFile, factory, context) {
5
5
  const frameCall = (node, name) => ts.isCallExpression(node) && (
6
6
  ts.isIdentifier(node.expression) && node.expression.text === name ||
7
7
  ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "window" && node.expression.name.text === name
@@ -28,32 +28,51 @@ export function normalizeEffectAnimationFrameRefs(sourceFile, factory, context)
28
28
  }
29
29
  const hasUseRefImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useRef"))
30
30
  const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
31
- const replacements = new Set()
31
+ const privateRefs = new Map()
32
+ const effectRefs = new Map()
33
+ const registerPrivateRef = (node, callback) => {
34
+ const initializer = ts.isNumericLiteral(node.initializer.arguments[0]) ? factory.createNumericLiteral(0) : factory.createNull()
35
+ privateRefs.set(node, callback)
36
+ const refs = effectRefs.get(callback) ?? []
37
+ refs.push({ name: node.name.text, initializer })
38
+ effectRefs.set(callback, refs)
39
+ }
32
40
  const inspect = node => {
33
- if (!hasUseRefImport || !ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer || !ts.isCallExpression(node.initializer) || !ts.isIdentifier(node.initializer.expression) || node.initializer.expression.text !== "useRef" || isShadowedIdentifier(node.initializer.expression, sourceFile) || node.initializer.arguments.length !== 1 || !ts.isNumericLiteral(node.initializer.arguments[0]) || Number(node.initializer.arguments[0].text) !== 0) {
41
+ const refInitializer = ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer) ? node.initializer.arguments[0] : undefined
42
+ if (!hasUseRefImport || !ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer || !ts.isCallExpression(node.initializer) || !ts.isIdentifier(node.initializer.expression) || node.initializer.expression.text !== "useRef" || isShadowedIdentifier(node.initializer.expression, sourceFile) || node.initializer.arguments.length !== 1 || !(refInitializer?.kind === ts.SyntaxKind.NullKeyword || ts.isNumericLiteral(refInitializer) && Number(refInitializer.text) === 0)) {
34
43
  ts.forEachChild(node, inspect)
35
44
  return
36
45
  }
37
46
  const owner = nearestFunction(node)
38
47
  if (!owner?.body || !ts.isBlock(owner.body)) return
39
- const accesses = []
40
- const collect = current => {
41
- if (currentAccess(current, node.name.text) && !isShadowedIdentifier(current.expression, owner.body)) accesses.push(current)
42
- ts.forEachChild(current, collect)
43
- }
44
- collect(owner.body)
48
+ const references = referenceIdentifiers(owner.body, node.name.text)
49
+ const invalidReference = references.find(reference => !ts.isPropertyAccessExpression(reference.parent) || reference.parent.expression !== reference || reference.parent.name.text !== "current")
50
+ const accesses = references.filter(reference => ts.isPropertyAccessExpression(reference.parent) && reference.parent.expression === reference && reference.parent.name.text === "current").map(reference => reference.parent)
45
51
  const frameAssignments = accesses.filter(access => ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && frameCall(unwrapExpression(access.parent.right), "requestAnimationFrame") && unshadowedFrameCall(unwrapExpression(access.parent.right), owner))
46
- if (!frameAssignments.length) return
47
- const invalidReference = referenceIdentifiers(owner.body, node.name.text).find(reference => !ts.isPropertyAccessExpression(reference.parent) || reference.parent.expression !== reference || reference.parent.name.text !== "current")
48
- if (invalidReference) throw sourceNodeError(invalidReference, sourceFile, "Animation frame refs may only use direct .current reads and assignments")
49
52
  const statement = node.parent?.parent
50
53
  const topLevelOwner = owner.parent === sourceFile || ts.isVariableDeclaration(owner.parent) && owner.parent.parent?.parent?.parent === sourceFile
51
- if (!topLevelOwner || !ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || statement.declarationList.declarations.length !== 1 || statement.parent !== owner.body) throw sourceNodeError(node, sourceFile, "Animation frame refs must be one top-level component const")
54
+ const topLevelConst = topLevelOwner && ts.isVariableStatement(statement) && (statement.declarationList.flags & ts.NodeFlags.Const) && statement.declarationList.declarations.length === 1 && statement.parent === owner.body
55
+ if (frameAssignments.length && invalidReference) throw sourceNodeError(invalidReference, sourceFile, "Animation frame refs may only use direct .current reads and assignments")
56
+ if (frameAssignments.length && !topLevelConst) throw sourceNodeError(node, sourceFile, "Animation frame refs must be one top-level component const")
57
+ if (!topLevelConst || invalidReference || !accesses.length) return
58
+ const effectCalls = owner.body.statements.flatMap(statement => hasUseEffectImport && ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect" && !isShadowedIdentifier(statement.expression.expression, sourceFile) ? [statement.expression] : [])
59
+ const effects = effectCalls.filter(effect => {
60
+ const callback = effect.arguments[0]
61
+ return callback && accesses.every(access => inside(access, callback))
62
+ })
63
+ if (!frameAssignments.length) {
64
+ const callback = effects.length === 1 ? effects[0].arguments[0] : undefined
65
+ if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) && ts.isBlock(callback.body)) {
66
+ const cleanups = effectReturns(callback).cleanups
67
+ const cleanupWrites = cleanups.length === 1 && accesses.some(access => inside(access, cleanups[0]) && ts.isBinaryExpression(access.parent) && unwrapExpression(access.parent.left) === access && access.parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && access.parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment)
68
+ if (!cleanupWrites) throw sourceNodeError(node, sourceFile, "Effect-private refs require one cleanup that directly resets or invalidates ref.current")
69
+ registerPrivateRef(node, callback)
70
+ }
71
+ return
72
+ }
52
73
  if (frameAssignments.length !== 1) throw sourceNodeError(node, sourceFile, "Animation frame refs require one direct ref.current = requestAnimationFrame(callback) assignment")
53
74
  const frame = unwrapExpression(frameAssignments[0].parent.right)
54
75
  const frameCallback = frame.arguments.length === 1 && ts.isIdentifier(unwrapExpression(frame.arguments[0])) ? unwrapExpression(frame.arguments[0]) : undefined
55
- const effectCalls = owner.body.statements.flatMap(statement => hasUseEffectImport && ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect" && !isShadowedIdentifier(statement.expression.expression, sourceFile) ? [statement.expression] : [])
56
- const effects = effectCalls.filter(effect => effect.arguments[0] && inside(frameAssignments[0], effect.arguments[0]))
57
76
  if (effects.length !== 1) throw sourceNodeError(node, sourceFile, "Animation frame refs must belong to one inline component effect")
58
77
  const effect = effects[0]
59
78
  const callback = effect.arguments[0]
@@ -89,14 +108,24 @@ export function normalizeEffectAnimationFrameRefs(sourceFile, factory, context)
89
108
  const cleanup = cancellation && returns.cleanups.find(candidate => nearestFunction(cancellation) === candidate)
90
109
  const cancellationStatement = cancellation?.parent
91
110
  if (cancellations.length !== 1 || !cleanup || !ts.isBlock(cleanup.body) || !ts.isExpressionStatement(cancellationStatement) || !directOrGuarded(cancellationStatement, cleanup.body, node.name.text, false)) throw sourceNodeError(node, sourceFile, "Animation frame refs require direct cancellation in effect cleanup")
92
- replacements.add(node)
111
+ registerPrivateRef(node, callback)
93
112
  }
94
113
  inspect(sourceFile)
95
- if (!replacements.size) return sourceFile
114
+ if (!privateRefs.size) return sourceFile
96
115
  const visitor = node => {
97
- if (replacements.has(node)) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createObjectLiteralExpression([
98
- factory.createPropertyAssignment("current", factory.createNumericLiteral(0))
99
- ]))
116
+ if (ts.isVariableStatement(node) && node.declarationList.declarations.length === 1 && privateRefs.has(node.declarationList.declarations[0])) return undefined
117
+ const refs = effectRefs.get(node)
118
+ if (refs && (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && ts.isBlock(node.body)) {
119
+ const body = ts.visitEachChild(node.body, visitor, context)
120
+ const declarations = refs.map(ref => factory.createVariableStatement(undefined, factory.createVariableDeclarationList([
121
+ factory.createVariableDeclaration(ref.name, undefined, undefined, factory.createObjectLiteralExpression([
122
+ factory.createPropertyAssignment("current", ref.initializer)
123
+ ]))
124
+ ], ts.NodeFlags.Const)))
125
+ const nextBody = factory.updateBlock(body, [...declarations, ...body.statements])
126
+ if (ts.isArrowFunction(node)) return factory.updateArrowFunction(node, node.modifiers, node.typeParameters, node.parameters, node.type, node.equalsGreaterThanToken, nextBody)
127
+ return factory.updateFunctionExpression(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, node.parameters, node.type, nextBody)
128
+ }
100
129
  return ts.visitEachChild(node, visitor, context)
101
130
  }
102
131
  return ts.visitNode(sourceFile, visitor)
@@ -113,7 +113,7 @@ async function mountInitial() {
113
113
  `, "", "initial effect mount"],
114
114
  [" await ready\n", "", "initial effect readiness"],
115
115
  [" const { incoming, parsed, capabilities } = documentResult\n", " const { incoming, parsed } = documentResult\n", "navigation capability result"],
116
- [" await routeDispose()\n if (current !== revision) return\n", "", "route effect disposal"],
116
+ [" await routeDispose()\n if (current !== revision) {\n styleUpdate.rollback()\n return\n }\n", "", "route effect disposal"],
117
117
  [" commit(incoming, parsed.nodes, capabilities.params, url.pathname, url.search)\n", " commit(incoming, parsed.nodes)\n", "navigation capability commit"],
118
118
  [" routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose\n", "", "route effect mount"],
119
119
  [" return { incoming, parsed, capabilities: await loadCapabilities(parsed), record }\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n return { incoming, parsed, record }\n", "navigation capability load"],
@@ -3,7 +3,7 @@ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node
3
3
  import ts from "typescript"
4
4
  import { createBindingIndex } from "./analysis/binding-index.mjs"
5
5
  import { createComponentAnalysisSession } from "./analysis/component-analysis.mjs"
6
- import { normalizeEffectAnimationFrameRefs } from "./animation-frame-pass.mjs"
6
+ import { normalizeEffectPrivateRefs } from "./effect-private-ref-pass.mjs"
7
7
  import { bindingNames, containsJsx, effectReturns, functionVarDeclaresName, importDeclarationNames, isFunctionLike, isLocalConst, isReferenceIdentifier, isShadowedByParameter, isShadowedIdentifier, isUnshadowedGlobal, nearestFunction, referenceIdentifiers, referencesIdentifier, sourceLocation, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
8
8
  import { normalizeMediaQueryExternalStores, normalizeNavigatorCapabilityConditions } from "./browser-signal-passes.mjs"
9
9
  import { analyzeCollectionPipeline, collectionExpression, collectionParameters, isArrayFromCall, mutatingCollectionMethods as mutatingListMethods, pureCollectionMathMethods as pureMathMethods, pureCollectionMethods as pureListMethods } from "./collection-analysis.mjs"
@@ -249,7 +249,7 @@ function normalizeCompilerSource(sourceFile, { base, context, file, importedColl
249
249
  source => normalizeMediaQueryExternalStores(source, factory, context),
250
250
  source => normalizeReactMigrationSyntax(source, factory, context, importedCollections ?? importedSerializableCollectionNames(source, file, sourceFiles, sourceIndex)),
251
251
  source => normalizeNavigatorCapabilityConditions(source, factory, context),
252
- source => normalizeEffectAnimationFrameRefs(source, factory, context),
252
+ source => normalizeEffectPrivateRefs(source, factory, context),
253
253
  source => {
254
254
  const result = normalizeCustomHookTimerRefs(source, factory, context)
255
255
  customHookTimerStates = result.timerStates
@@ -723,7 +723,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
723
723
  const owner = nearestFunction(node)
724
724
  if (owner && node.initializer.expression.text === "useRef") {
725
725
  const nullInitializer = node.initializer.arguments.length === 1 && node.initializer.arguments[0].kind === ts.SyntaxKind.NullKeyword
726
- if (!nullInitializer && owner.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) throw sourceNodeError(node.initializer, sourceFile, "Mutable value useRef() is unsupported except for an effect-owned useRef(0) animation-frame handle; otherwise keep resource-private mutable values inside the owning effect")
726
+ if (!nullInitializer && owner.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) throw sourceNodeError(node.initializer, sourceFile, "Mutable useRef() values must be referenced exclusively inside one owned effect; DOM refs require useRef(null)")
727
727
  if (nullInitializer) {
728
728
  ensureOwner(owner)
729
729
  componentAnalysis.registerRef(owner, { name: node.name.text, site: analysisSite(node, "hook"), source: analysisSource(node) })
@@ -3059,7 +3059,7 @@ async function safeStaticFiles(files) {
3059
3059
  return new Set(entries.filter(Boolean))
3060
3060
  }
3061
3061
 
3062
- function orderSourceStyles(cssFiles, sourceFiles, sourceIndex, staticFiles) {
3062
+ function orderSourceStyles(entryFiles, sourceFiles, sourceIndex, staticFiles) {
3063
3063
  const ordered = []
3064
3064
  const seenStyles = new Set()
3065
3065
  const seenSources = new Set()
@@ -3069,11 +3069,13 @@ function orderSourceStyles(cssFiles, sourceFiles, sourceIndex, staticFiles) {
3069
3069
  seenSources.add(file)
3070
3070
  const sourceFile = parseSourceFile(file, sourceIndex.get(file))
3071
3071
  for (const statement of sourceFile.statements) {
3072
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.moduleSpecifier.text.startsWith(".")) continue
3072
+ if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || !runtimeModuleReference(statement) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.moduleSpecifier.text.startsWith(".")) continue
3073
3073
  const specifier = statement.moduleSpecifier.text
3074
- if (staticImportExtension(specifier) === ".css") {
3075
- let target
3076
- try { target = resolveStaticImport(file, specifier, staticFiles) } catch { continue }
3074
+ const queryIndex = specifier.indexOf("?")
3075
+ const query = queryIndex === -1 ? "" : specifier.slice(queryIndex + 1)
3076
+ if (ts.isImportDeclaration(statement) && staticImportExtension(specifier) === ".css") {
3077
+ if (query) continue
3078
+ const target = resolveStaticImport(file, specifier, staticFiles)
3077
3079
  if (!seenStyles.has(target)) {
3078
3080
  seenStyles.add(target)
3079
3081
  ordered.push(target)
@@ -3081,12 +3083,11 @@ function orderSourceStyles(cssFiles, sourceFiles, sourceIndex, staticFiles) {
3081
3083
  continue
3082
3084
  }
3083
3085
  if (isStaticImport(specifier)) continue
3084
- try { visit(resolveSourceImport(file, specifier, sourceSet)) } catch {}
3086
+ visit(resolveSourceImport(file, specifier, sourceSet))
3085
3087
  }
3086
3088
  }
3087
- for (const file of sourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))) visit(file)
3088
- for (const file of sourceFiles) visit(file)
3089
- return [...ordered, ...cssFiles.filter(file => !seenStyles.has(file))]
3089
+ for (const file of entryFiles) visit(file)
3090
+ return ordered
3090
3091
  }
3091
3092
 
3092
3093
  function staticImportEntry(node, sourceFile, file, staticFiles, importedAssets, cssModules, base, factory) {
@@ -3105,7 +3106,7 @@ function staticImportEntry(node, sourceFile, file, staticFiles, importedAssets,
3105
3106
  const extension = staticImportExtension(specifier)
3106
3107
  if (query === "url") {
3107
3108
  if (!node.importClause?.name || node.importClause.isTypeOnly || node.importClause.namedBindings) throw sourceNodeError(node, sourceFile, "Static assets require one default import")
3108
- if (extension !== ".css") importedAssets.add(target)
3109
+ importedAssets.add(target)
3109
3110
  const value = factory.createStringLiteral(assetPath(base, `assets/${relative(sourceDirectory, target).replaceAll(sep, "/")}`))
3110
3111
  return staticImportReplacement(node.importClause.name.text, value, factory)
3111
3112
  }
@@ -486,9 +486,11 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
486
486
  const title = escapeHtml(metadata.title ?? "Kudzu")
487
487
  const head = renderMetadata(metadata)
488
488
  const capability = metadata.navigationAsset ? " data-k-capability" : ""
489
+ const managedStyles = new Set(metadata.managedStyles ?? [])
490
+ const styleAnchor = metadata.navigationAsset ? "<meta data-k-style-anchor>" : ""
489
491
  const styles = metadata.styles === false
490
492
  ? ""
491
- : (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join("")
493
+ : (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}"${managedStyles.has(href) ? " data-k-route-style" : ""}>`).join("")
492
494
  const runtime = renderContext.hasBehaviors
493
495
  ? `<script type="module"${capability} src="${escapeAttribute(metadata.runtimeAsset ?? assetPath(metadata.base, "assets/kudzu.js"))}"></script>`
494
496
  : ""
@@ -526,7 +528,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
526
528
  : ""
527
529
 
528
530
  return {
529
- html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}${navigationRuntime}</head><body${state}${textBindings}${metadata.applicationId ? ` data-k-application="${escapeAttribute(metadata.applicationId)}" data-k-layout="${escapeAttribute(metadata.layoutId)}" data-k-route="${escapeAttribute(metadata.routeId)}"` : ""}>${body}</body></html>`,
531
+ html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styleAnchor}${styles}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}${navigationRuntime}</head><body${state}${textBindings}${metadata.applicationId ? ` data-k-application="${escapeAttribute(metadata.applicationId)}" data-k-layout="${escapeAttribute(metadata.layoutId)}" data-k-route="${escapeAttribute(metadata.routeId)}"` : ""}>${body}</body></html>`,
530
532
  hasBehaviors: renderContext.hasBehaviors,
531
533
  hasEffects: renderContext.hasEffects,
532
534
  hasParams: renderContext.hasParams,
@@ -12,6 +12,7 @@ status.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;padding
12
12
  document.body.append(status)
13
13
 
14
14
  let request
15
+ let pendingStyleUpdate
15
16
  let revision = 0
16
17
  const documents = new Map()
17
18
  let observer
@@ -115,9 +116,12 @@ async function navigate(url, push) {
115
116
  const record = matchRoute(url.pathname)
116
117
  if (!record) return fallback(url, push)
117
118
  const current = ++revision
119
+ pendingStyleUpdate?.rollback()
120
+ pendingStyleUpdate = undefined
118
121
  request?.abort()
119
122
  request = new AbortController()
120
123
  let committed = false
124
+ let styleUpdate
121
125
  try {
122
126
  let documentResult
123
127
  const cached = documents.get(url.href)
@@ -128,8 +132,20 @@ async function navigate(url, push) {
128
132
  documents.set(url.href, Promise.resolve(documentResult))
129
133
  const { incoming, parsed, capabilities } = documentResult
130
134
  if (current !== revision) return
135
+ styleUpdate = prepareStyles(parsed.styles)
136
+ pendingStyleUpdate = styleUpdate
137
+ await styleUpdate.ready
138
+ if (current !== revision) {
139
+ styleUpdate.rollback()
140
+ return
141
+ }
131
142
  await routeDispose()
132
- if (current !== revision) return
143
+ if (current !== revision) {
144
+ styleUpdate.rollback()
145
+ return
146
+ }
147
+ styleUpdate.commit()
148
+ if (pendingStyleUpdate === styleUpdate) pendingStyleUpdate = undefined
133
149
  commit(incoming, parsed.nodes, capabilities.params, url.pathname, url.search)
134
150
  committed = true
135
151
  routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
@@ -139,6 +155,8 @@ async function navigate(url, push) {
139
155
  status.textContent = `Navigated to ${document.title}`
140
156
  discover()
141
157
  } catch (error) {
158
+ styleUpdate?.rollback()
159
+ if (pendingStyleUpdate === styleUpdate) pendingStyleUpdate = undefined
142
160
  if (current !== revision || error.name === "AbortError") return
143
161
  fallback(url, push)
144
162
  if (committed) return
@@ -173,7 +191,73 @@ function validate(incoming, record) {
173
191
  return url.pathname
174
192
  })
175
193
  if (!assets.includes(navigationAsset)) throw new Error("Navigation capability asset is missing")
176
- return { nodes, assets: [...new Set(assets)] }
194
+ const styles = [...incoming.head.querySelectorAll('link[data-k-route-style][rel="stylesheet"][href]')]
195
+ const styleUrls = styles.map(link => {
196
+ const url = new URL(link.href)
197
+ if (url.origin !== location.origin) throw new Error("Navigation stylesheet must be same-origin")
198
+ return url.href
199
+ })
200
+ if (new Set(styleUrls).size !== styleUrls.length) throw new Error("Navigation document has duplicate route stylesheets")
201
+ return { nodes, assets: [...new Set(assets)], styles }
202
+ }
203
+
204
+ function prepareStyles(incoming) {
205
+ const anchor = document.head.querySelector("meta[data-k-style-anchor]")
206
+ if (!anchor || document.head.querySelectorAll("meta[data-k-style-anchor]").length !== 1) throw new Error("Current navigation style anchor is invalid")
207
+ const current = [...document.head.querySelectorAll('link[data-k-route-style][rel="stylesheet"][href]')]
208
+ const place = links => {
209
+ let previous = anchor
210
+ for (const link of links) {
211
+ if (link.previousSibling !== previous) previous.after(link)
212
+ previous = link
213
+ }
214
+ }
215
+ const byUrl = new Map(current.map(link => [new URL(link.href).href, link]))
216
+ const next = []
217
+ const created = []
218
+ const loads = []
219
+ for (const source of incoming) {
220
+ const href = new URL(source.href).href
221
+ let link = byUrl.get(href)
222
+ if (link) byUrl.delete(href)
223
+ else {
224
+ link = document.importNode(source, true)
225
+ created.push(link)
226
+ loads.push(new Promise((resolve, reject) => {
227
+ link.addEventListener("load", resolve, { once: true })
228
+ link.addEventListener("error", () => reject(new Error("Navigation stylesheet failed to load")), { once: true })
229
+ }))
230
+ }
231
+ next.push(link)
232
+ }
233
+ let settled = false
234
+ let cancel
235
+ const update = {
236
+ ready: Promise.race([
237
+ Promise.all(loads),
238
+ new Promise((resolve, reject) => {
239
+ cancel = () => {
240
+ const error = new Error("Navigation stylesheet load was cancelled")
241
+ error.name = "AbortError"
242
+ reject(error)
243
+ }
244
+ })
245
+ ]),
246
+ commit() {
247
+ if (settled) return
248
+ settled = true
249
+ for (const link of byUrl.values()) link.remove()
250
+ },
251
+ rollback() {
252
+ if (settled) return
253
+ settled = true
254
+ place(current)
255
+ for (const link of created) link.remove()
256
+ cancel()
257
+ }
258
+ }
259
+ place(next)
260
+ return update
177
261
  }
178
262
 
179
263
  function commit(incoming, incomingNodes, initializeParams, pathname, search) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.51",
3
+ "version": "0.8.53",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",