@kudzujs/core 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/GOAL_A.md CHANGED
@@ -8,12 +8,12 @@ The benchmark runner, framework fixtures, generated artifacts, and raw arrays ar
8
8
 
9
9
  ## Implementation Status
10
10
 
11
- **Goal A is complete for one explicitly configured emitted-route group with one shared layout.** Complete standalone documents, exact and runtime-parameter navigation, native fallback, persistent layout state/effects, disposable route state/effects, optimistic workflows, desktop/mobile performance gates, and the dashboard expansion seam are covered. Multiple layout groups and conditional/keyed DOM-owned effects inside a navigation group remain deliberate post-Goal-A limits.
11
+ **Goal A is complete for one explicitly configured emitted-route group with one shared layout.** Complete standalone documents, exact and runtime-parameter navigation, native fallback, persistent layout state/effects, disposable route state/effects, optimistic workflows, desktop/mobile performance gates, and the dashboard expansion seam are covered. Multiple independent shared-layout groups and conditional/keyed DOM-owned effects within layout and route lifetimes were added post-Goal-A.
12
12
 
13
13
  - **Phase 1 complete**: a local six-route commerce fixture, locked React/Next/Nuxt/SvelteKit comparisons, and a reproducible artifact/build/Chrome runner validate the implementation.
14
14
  - **Phase 2 complete**: effects inside conditional ranges and supported keyed row components mount with their DOM owner, unsubscribe and clean up on removal, and remount without affecting effect-free output.
15
15
  - **Phase 3 complete**: page-exported layouts render complete documents with compiler-owned route boundaries, collision-free layout/route IDs, and state/effect ownership metadata. Effects remain document effects and client navigation is unchanged.
16
- - **Phase 4 route/layout effects implemented**: top-level layout effects mount once per document session, top-level route effects remount after awaited route cleanup, and primitive dependency subscriptions exist only while their lifetime is mounted. Disposed effect setters and queued commits are inactive. Conditional/keyed DOM-owned effects remain unsupported inside navigation groups.
16
+ - **Phase 4 route/layout effects implemented**: layout effects mount once per document session, route effects remount after awaited route cleanup, and primitive dependency subscriptions exist only while their lifetime is mounted. Conditional/keyed effects use per-lifetime owner registries; route registries are fresh on cached revisits. Disposed effect setters and queued commits are inactive. Keyed item-property dependencies remain unsupported.
17
17
  - **Phase 4 document prefetch implemented**: visible, near-visible, hovered, or focused eligible group anchors prefetch and validate complete documents without importing target capabilities. The finite in-memory full-URL cache removes the measured product-cart HTML RTT while preserving retry and native fallback.
18
18
  - **Phase 5 complete**: matched async cart success/rejection flows prove immediate optimistic updates, duplicate prevention, accessible errors, rollback, route-local reset, and stale-write suppression without new framework APIs.
19
19
  - **Phase 6 expansion probe complete**: one layout-owned mock `EventTarget` stream and one route-owned imperative chart stub use existing effects and a relative TypeScript helper across repeated navigation, with exact listener and disposal assertions. This proves only the compatibility seam; Kudzu does not provide telemetry or chart support.
@@ -25,6 +25,12 @@ In the focused effect-enabled navigation fixture, mount support adds 171 B gzip
25
25
 
26
26
  The post-Goal-A runtime-pattern matcher increases the exact-only commerce navigation asset from 2,306 B to 2,461 B gzip. The mixed exact/runtime navigation fixture emits a 3,105 B gzip navigation asset and a 703 B gzip cache-safe parameter initializer.
27
27
 
28
+ The post-Goal-A multiple-group fixture emits a 7,448 B raw / 3,099 B gzip (`gzip -9`) mixed runtime/effect group asset, including one native exclusion for an overlapping ungrouped exact route, and a separately specialized 5,681 B raw / 2,448 B gzip exact effect-free group asset. These measurements do not revise the historical Goal A benchmark.
29
+
30
+ In a matched one-effect navigation build with the same conditional capability, moving the effect from the route body into the conditional owner changes the route effect entry from 2,181 B raw / 1,036 B gzip to 4,135 B raw / 1,807 B gzip (`+1,954 B` raw / `+771 B` gzip). Owner-hook unsubscription changes the shared runtime from 1,347 B raw / 718 B gzip to 1,459 B raw / 732 B gzip (`+112 B` raw / `+14 B` gzip). Top-level-only and effect-free navigation builds retain their smaller generators.
31
+
32
+ A 0.6.4 release-tree desktop rerun of the matched six-route commerce fixture measured Kudzu at 486.8 ms build, 35,355 deploy bytes, 7,334 B gzip product JavaScript, 332/156 ms cold/warm LCP, 122.6 ms startup task, 4.8 ms interaction, and 5.6 ms product-cart navigation. React measured 545.4 ms build, 61,464 B gzip product JavaScript, 332/264 ms LCP, 179.9 ms startup task, 10.2 ms interaction, and 9.9 ms navigation. Kudzu built 10.7% faster and its top-level-only fixture retained byte-identical deploy, product-graph, and navigation-asset sizes after navigation-owned effects were added. Raw arrays and the consolidated report are retained under the local demo benchmark workspace.
33
+
28
34
  The Phase 6 chart probe's complete initial module graph is 11,902 B raw / 5,331 B gzip. It adds no framework API or package and does not change the commerce benchmark fixture.
29
35
 
30
36
  The app-mode Kudzu fixture emits 34,879 deploy bytes. Its product route loads 15,800 raw / 7,215 gzip bytes of initial JavaScript, including the 2,306 B gzip navigation capability. Routes outside a configured application group retain the static zero-JavaScript and byte-for-byte gates.
@@ -110,7 +116,7 @@ Each phase starts with one failing fixture and ends with correctness, browser, s
110
116
  1. **Benchmark harness**: freeze the commerce journey, network profiles, framework versions, generated artifacts, and measurement scripts before optimizing Kudzu.
111
117
  2. **Owned effects**: complete cleanup for conditional ranges and keyed items using the existing mount and unmount hooks. **Complete.**
112
118
  3. **Layout and route scopes**: retain only declared layout state and dispose route-owned behavior on every completed transition. **Compiler ownership complete; transition behavior belongs to Phase 4.**
113
- 4. **Opt-in navigation**: support eligible links, history, aborts, stale responses, focus, scroll, metadata, and native fallback. **Complete for emitted exact/runtime-parameter routes and top-level layout/route effects; conditional/keyed DOM-owned effects remain excluded.**
119
+ 4. **Opt-in navigation**: support eligible links, history, aborts, stale responses, focus, scroll, metadata, and native fallback. **Complete for emitted exact/runtime-parameter routes and layout/route effects, including conditional/keyed DOM ownership.**
114
120
  5. **Business workflows**: close only fixture-proven gaps in forms, async requests, optimistic updates, and diagnostics. **Complete for the matched cart success/rejection flow.**
115
121
  6. **Expansion probe**: prove that one persistent mock stream and one imperative chart stub can mount, update, navigate, and dispose without adding a component runtime. **Compatibility probe complete; real telemetry and chart engines remain outside Goal A.**
116
122
 
package/README.md CHANGED
@@ -457,9 +457,20 @@ export default {
457
457
  }
458
458
  ```
459
459
 
460
- Every configured identity must be an emitted exact route or `runtimeParams` bracket pattern and export the same layout function. The layout DOM, state, and top-level effects persist; route state, parameters, and top-level effects reset after cleanup on each transition. Eligible ordinary anchors prefetch validated complete documents into a finite memory cache. Direct requests, reloads, malformed runtime paths, JavaScript failures, unsupported links, and routes outside the group retain native document navigation.
460
+ The legacy single-group form remains supported. Applications with multiple shared layouts use mutually exclusive `groups`:
461
461
 
462
- Multiple layout groups and conditional or keyed effects inside a navigation group are not supported yet.
462
+ ```js
463
+ export default {
464
+ navigation: { groups: [
465
+ { routes: ["/product", "/items/[id]"] },
466
+ { routes: ["/account", "/settings"] }
467
+ ] }
468
+ }
469
+ ```
470
+
471
+ Every configured identity must be a unique emitted exact route or `runtimeParams` bracket pattern. Routes within each group must export the same layout function identity; different groups may export different layouts. Kudzu emits one deterministic, route-set-hashed navigation asset per group containing only that group's records and capabilities. Path domains may overlap within a group, where exact and more-specific matching wins, but overlapping exact/runtime or runtime/runtime domains across groups fail the build.
472
+
473
+ The layout DOM, state, and effects persist within its group; route state, parameters, and effects reset after cleanup on each transition. Conditional effects mount only while their DOM is connected. Keyed row effects mount per connected row, survive reorder, read the latest row item when a supported state dependency reruns, and clean up on removal. Cached route modules create fresh route owner records and subscriptions on every revisit. Keyed item-property dependencies remain unsupported. Eligible same-group anchors prefetch validated complete documents into a finite memory cache. Cross-group links, ungrouped routes, direct requests, reloads, malformed runtime paths, JavaScript failures, and unsupported links retain native document navigation.
463
474
 
464
475
  This produces fast same-document route changes, but it does not add a coordinated transition animation. CSS entry animations can style newly inserted route content; exit and shared-element View Transitions are not integrated yet.
465
476
 
@@ -500,6 +511,7 @@ Supported:
500
511
  - Page-exported shared layouts with layout/route state lifetimes
501
512
  - Opt-in exact/runtime-route navigation with complete-document prefetch and native fallback
502
513
  - Layout- and route-lifetime effect mounts in navigation groups
514
+ - Conditional/keyed DOM-owned effects in navigation groups
503
515
 
504
516
  Not implemented yet:
505
517
 
@@ -507,7 +519,6 @@ Not implemented yet:
507
519
  - Server actions and request-time SSR
508
520
  - React package islands
509
521
  - HMR and framework DevTools
510
- - Conditional/keyed DOM-owned effects inside navigation groups
511
522
 
512
523
  ## Benchmarks
513
524
 
@@ -519,22 +530,22 @@ Browser medians use seven rotating fresh Chrome profiles per target with 4x CPU
519
530
 
520
531
  | Target | Product JS gzip | Cold transfer | Cold LCP | Warm LCP | Startup task | Heap | Interaction | Product → cart |
521
532
  |---|---:|---:|---:|---:|---:|---:|---:|---:|
522
- | Kudzu | **7,215 B** | **34,809 B** | 324 ms | **140 ms** | **103.3 ms** | **648,844 B** | **3.7 ms** | **5.7 ms** |
523
- | React + Vite | 61,464 B | 202,842 B | 324 ms | 232 ms | 160.0 ms | 1,062,520 B | 10.5 ms | 8.3 ms |
524
- | Next.js | 190,090 B | 546,581 B | **316 ms** | 156 ms | 357.7 ms | 2,157,020 B | 11.4 ms | 26.9 ms |
525
- | Nuxt | 67,620 B | 195,953 B | 320 ms | 204 ms | 205.1 ms | 1,721,348 B | 3.9 ms | 33.0 ms |
526
- | SvelteKit | 32,473 B | 90,929 B | 340 ms | 172 ms | 125.8 ms | 999,496 B | 4.7 ms | 20.3 ms |
533
+ | Kudzu | **7,334 B** | **35,260 B** | 332 ms | **156 ms** | **122.6 ms** | **650,708 B** | **4.8 ms** | **5.6 ms** |
534
+ | React + Vite | 61,464 B | 202,842 B | 332 ms | 264 ms | 179.9 ms | 1,062,520 B | 10.2 ms | 9.9 ms |
535
+ | Next.js | 190,090 B | 547,615 B | **324 ms** | 176 ms | 434.4 ms | 2,168,412 B | 14.0 ms | 30.0 ms |
536
+ | Nuxt | 67,620 B | 195,953 B | **324 ms** | 224 ms | 247.9 ms | 1,721,348 B | **4.5 ms** | 29.6 ms |
537
+ | SvelteKit | 32,474 B | 90,939 B | 376 ms | 184 ms | 143.8 ms | 999,496 B | 6.3 ms | 21.8 ms |
527
538
 
528
- The Kudzu application emits 34,879 deploy bytes. Its 7,215 B gzip product graph includes the 2,306 B navigation capability. The first implementation paid a 128.7 ms HTML round trip during product-to-cart navigation; validated near-viewport document prefetch reduced the measured median to 5.7 ms while preserving complete documents and native fallback.
539
+ The current Kudzu application emits 35,355 deploy bytes. Its 7,334 B gzip product graph includes the 2,425 B navigation capability; all three sizes are unchanged by conditional/keyed navigation effects because this top-level-only fixture retains the smaller specialized path. The first implementation paid a 128.7 ms HTML round trip during product-to-cart navigation; validated near-viewport document prefetch measured 5.6 ms in the current run while preserving complete documents and native fallback.
529
540
 
530
- The mobile profile uses a 390x844 viewport, 6x CPU slowdown, 150 ms latency, and 150 KiB/s throughput:
541
+ The mobile row is retained from the previous matched run using a 390x844 viewport, 6x CPU slowdown, 150 ms latency, and 150 KiB/s throughput:
531
542
 
532
543
  | Profile | Cold LCP | Warm LCP | Interaction | Product → cart | Reject feedback | Rollback/error | CLS |
533
544
  |---|---:|---:|---:|---:|---:|---:|---:|
534
- | Desktop | 324 ms | 140 ms | 3.7 ms | 5.7 ms | 3.3 ms | 110.8 ms | 0 |
545
+ | Desktop | 332 ms | 156 ms | 4.8 ms | 5.6 ms | 4.0 ms | 112.1 ms | 0 |
535
546
  | Mobile | 420 ms | 220 ms | 5.6 ms | 8.7 ms | 4.1 ms | 158 ms | 0 |
536
547
 
537
- Initial runs found a repeatable 6–7% small-build loss from TypeScript and esbuild module startup. Kudzu now enables Node's native module compile cache before lazily loading the compiler. An empty-cache build measured 541.4 ms against the previous 547.6 ms path; after one warm-up, 31 interleaved artifact-clean builds measured Kudzu at 425.8 ms and React at 483.4 ms, making Kudzu 11.9% faster in that run. Disabling the cache preserves byte-for-byte output. Attempts to replace generated-handler lowering or share one TypeScript Program did not improve the combined median and were not retained.
548
+ Initial runs found a repeatable 6–7% small-build loss from TypeScript and esbuild module startup. Kudzu now enables Node's native module compile cache before lazily loading the compiler. The current seven-run matched commerce build measured Kudzu at 486.8 ms and React at 545.4 ms, making Kudzu 10.7% faster in that run. Kudzu also shipped 88.1% less product JavaScript, used 38.8% less measured heap, and measured 52.9% faster interaction and 43.4% faster product-to-cart navigation than React. Disabling the cache preserves byte-for-byte output. Attempts to replace generated-handler lowering or share one TypeScript Program did not improve the combined median and were not retained.
538
549
 
539
550
  These results describe this six-route fixture on one machine, not framework ecosystem size or every rendering mode. Prefetch improves an eligible warm application transition; it does not hide cold transfer, and direct loads remain complete standalone documents.
540
551
 
@@ -11,7 +11,7 @@
11
11
  - `serialization.js`: capture deserialization shared by binding and native handlers.
12
12
  - `effect-runtime.js`: optional state and capture context for route-specific mount-effect entries.
13
13
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
14
- - `navigation-runtime.js`: optional emitted-route complete-document prefetch, runtime segment matching, validation, finite memory caching, route-range replacement, history, focus, and native fallback for one configured shared layout.
14
+ - `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.
15
15
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
16
16
  - `*.d.ts`: public TypeScript and JSX declarations.
17
17
 
@@ -19,6 +19,6 @@ Static routes receive no browser runtime. Command routes receive `runtime.js`; d
19
19
 
20
20
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
21
21
 
22
- `kudzu.config` may opt one emitted shared-layout group into same-document navigation with `navigation: { routes: ["/product", "/items/[id]"] }`. The configured identities must be unique emitted exact paths or `runtimeParams` patterns and export the same 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. Route effect entries export cache-safe layout and route mount functions: layout effects mount once per document session, route effects remount after each route insertion, and non-persisted page disposal cleans route before layout. Primitive dependencies and cleanup are supported; conditional/keyed DOM-owned effects fail with a source-located diagnostic in navigation groups. Eligible same-origin anchors prefetch complete documents when near the viewport, hovered, or focused; validated documents are cached only in memory by full URL while represented by the current route. Multiple groups and fragment payloads are not implemented.
22
+ `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Primitive dependencies and cleanup are supported, while keyed item-property dependencies remain unsupported. Fragment payloads and coordinated View Transitions are not implemented.
23
23
 
24
- The matched Goal A commerce profile emits 34,879 deploy bytes and loads 7,215 B gzip of product-route JavaScript, including 2,306 B for navigation. Validated prefetch reduced product-to-cart navigation from 128.7 ms to 5.7 ms. The CLI enables Node's native module compile cache before lazily importing the compiler: 31 interleaved artifact-clean builds after one warm-up measured Kudzu at 425.8 ms and React at 483.4 ms. The first empty-cache invocation remains unchanged within noise, and cache-disabled output is byte-for-byte identical.
24
+ The current matched commerce profile emits 35,355 deploy bytes and loads 7,334 B gzip of product-route JavaScript, including 2,425 B for navigation. These sizes are unchanged because its top-level-only navigation effects retain the smaller specialized path. Validated prefetch reduced the original 128.7 ms product-to-cart navigation to 5.6 ms in the current run. Seven interleaved artifact-clean builds after warm-up measured Kudzu at 486.8 ms and React at 545.4 ms, making Kudzu 10.7% faster. Cache-disabled output is byte-for-byte identical.
@@ -20,13 +20,18 @@ export async function build({ quiet = false, minify = true } = {}) {
20
20
  const config = await loadConfig()
21
21
  const base = normalizeBase(config.base)
22
22
  const configuredStyles = normalizeStyles(config.styles, base)
23
- const navigationRoutes = normalizeNavigation(config.navigation)
24
- const navigationSet = new Set(navigationRoutes)
25
- const navigationAsset = assetPath(base, "assets/kudzu-navigation.js")
26
- const navigationId = navigationRoutes.length ? createHash("sha256").update(JSON.stringify([...navigationRoutes].sort())).digest("hex").slice(0, 16) : undefined
27
- const applicationId = navigationId ? `a-${navigationId}` : undefined
28
- const layoutId = navigationId ? `l-${navigationId}` : undefined
29
- let navigationLayout
23
+ const navigationGroups = normalizeNavigation(config.navigation)
24
+ const navigationRoutes = navigationGroups.flatMap(group => group.routes)
25
+ const navigationByRoute = new Map(navigationGroups.flatMap(group => group.routes.map(route => [route, group])))
26
+ for (const group of navigationGroups) {
27
+ group.assetPath = assetPath(base, `assets/${group.assetName}`)
28
+ group.applicationId = `a-${group.id}`
29
+ group.layoutId = `l-${group.id}`
30
+ group.records = []
31
+ group.routeRecords = []
32
+ group.hasEffects = false
33
+ group.hasParams = false
34
+ }
30
35
  await rm(workDirectory, { recursive: true, force: true })
31
36
  await rm(outputDirectory, { recursive: true, force: true })
32
37
  await mkdir(workDirectory, { recursive: true })
@@ -62,7 +67,7 @@ export async function build({ quiet = false, minify = true } = {}) {
62
67
  const rewrites = []
63
68
  const emittedRoutes = new Set()
64
69
  const emittedApplicationRoutes = new Set()
65
- const navigationRecords = []
70
+ const emittedNavigationRecords = []
66
71
  const styleUrls = [...new Set([
67
72
  ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
68
73
  ...configuredStyles
@@ -92,19 +97,25 @@ export async function build({ quiet = false, minify = true } = {}) {
92
97
  const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
93
98
  const applicationRoute = `/${route}`
94
99
  const routePath = withBase(base, `/${route}`)
95
- const navigable = navigationSet.has(applicationRoute)
100
+ const navigationGroup = navigationByRoute.get(applicationRoute)
101
+ const navigable = Boolean(navigationGroup)
96
102
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
97
103
  const paramPath = `params/${route}/index.js`
98
104
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
99
105
  emittedRoutes.add(routePath)
100
106
  emittedApplicationRoutes.add(applicationRoute)
107
+ const navigationRecord = runtimeSchema
108
+ ? { id: applicationRoute, base: browserPath(base), segments: runtimeSchema.segments.map(segment => segment.literal ?? null) }
109
+ : { id: applicationRoute, path: routePath }
110
+ const routeRecord = { route: applicationRoute, segments: runtimeSchema ? navigationRecord.segments : exactRouteSegments(applicationRoute), record: navigationRecord, group: navigationGroup }
111
+ emittedNavigationRecords.push(routeRecord)
101
112
  if (navigable) {
102
- if (typeof module.layout !== "function") throw new Error(`kudzu.config navigation emitted route ${JSON.stringify(routePath)} must export a layout function so Kudzu can emit route markers`)
103
- if (navigationLayout && navigationLayout !== module.layout) throw new Error("kudzu.config navigation routes must export the same layout function identity")
104
- navigationLayout = module.layout
105
- navigationRecords.push(runtimeSchema
106
- ? { id: applicationRoute, base: browserPath(base), segments: runtimeSchema.segments.map(segment => segment.literal ?? null) }
107
- : { id: applicationRoute, path: routePath })
113
+ if (typeof module.layout !== "function") throw new Error(`${navigationGroup.label} emitted route ${JSON.stringify(routePath)} must export a layout function so Kudzu can emit route markers`)
114
+ if (navigationGroup.layoutIdentity && navigationGroup.layoutIdentity !== module.layout) throw new Error(`${navigationGroup.label} routes ${JSON.stringify(navigationGroup.layoutRoute)} and ${JSON.stringify(applicationRoute)} must export the same layout function identity`)
115
+ navigationGroup.layoutIdentity = module.layout
116
+ navigationGroup.layoutRoute ??= applicationRoute
117
+ navigationGroup.records.push(navigationRecord)
118
+ navigationGroup.routeRecords.push(routeRecord)
108
119
  }
109
120
  const result = await renderPage(module.default, {
110
121
  ...(module.metadata ?? {}),
@@ -114,8 +125,12 @@ export async function build({ quiet = false, minify = true } = {}) {
114
125
  effectAsset: assetPath(base, `assets/${effectPath}`),
115
126
  paramAsset: assetPath(base, `assets/${paramPath}`),
116
127
  runtimeParams: runtimeSchema?.params,
117
- ...(navigable ? { navigationAsset, applicationId, layoutId, routeId: applicationRoute } : {})
128
+ ...(navigable ? { navigationAsset: navigationGroup.assetPath, applicationId: navigationGroup.applicationId, layoutId: navigationGroup.layoutId, routeId: applicationRoute } : {})
118
129
  }, props, module.layout)
130
+ if (navigationGroup) {
131
+ navigationGroup.hasEffects ||= result.hasEffects
132
+ navigationGroup.hasParams ||= result.hasParams
133
+ }
119
134
  const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
120
135
  const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
121
136
  pageEntries.push({ route, html: result.html, usesDependencyRuntime })
@@ -136,8 +151,13 @@ export async function build({ quiet = false, minify = true } = {}) {
136
151
  }
137
152
  }
138
153
 
139
- for (const route of navigationRoutes) if (!emittedApplicationRoutes.has(route)) throw new Error(`kudzu.config navigation route ${JSON.stringify(route)} is not an emitted route`)
140
- navigationRecords.sort((left, right) => (right.segments?.filter(segment => segment !== null).length ?? 0) - (left.segments?.filter(segment => segment !== null).length ?? 0) || left.id.localeCompare(right.id))
154
+ for (const group of navigationGroups) for (const route of group.routes) if (!emittedApplicationRoutes.has(route)) throw new Error(`${group.label} route ${JSON.stringify(route)} is not an emitted route`)
155
+ rejectNavigationOverlap(navigationGroups)
156
+ for (const group of navigationGroups) {
157
+ const runtimeRecords = group.routeRecords.filter(record => record.record.segments)
158
+ for (const entry of emittedNavigationRecords) if (!entry.group && runtimeRecords.some(record => navigationDomainsOverlap(record, entry))) group.records.push({ ...entry.record, native: true })
159
+ group.records.sort((left, right) => (right.segments?.filter(segment => segment !== null).length ?? 0) - (left.segments?.filter(segment => segment !== null).length ?? 0) || left.id.localeCompare(right.id))
160
+ }
141
161
 
142
162
  const assetsDirectory = join(outputDirectory, "assets")
143
163
  await mkdir(assetsDirectory, { recursive: true })
@@ -161,7 +181,7 @@ export async function build({ quiet = false, minify = true } = {}) {
161
181
  const hasNativeHandlers = nativeModules.length > 0
162
182
  const hasEffects = effectEntries.length > 0
163
183
  const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
164
- const hasNavigableParams = paramEntries.some(entry => entry.navigable)
184
+ const hasNavigableOwners = effectEntries.some(entry => entry.navigable && entry.effects.some(effect => effect.owner))
165
185
  const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers || navigationRoutes.length
166
186
  const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
167
187
  const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
@@ -175,6 +195,9 @@ export async function build({ quiet = false, minify = true } = {}) {
175
195
  const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
176
196
  let runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
177
197
  if (hasNavigableEffects) runtime = runtime.replace("export function registerCommitter(commit) {\n committers.push(commit)\n}", "export function registerCommitter(commit) {\n committers.push(commit)\n return () => {\n const index = committers.indexOf(commit)\n if (index !== -1) committers.splice(index, 1)\n }\n}")
198
+ if (hasNavigableOwners) runtime = runtime
199
+ .replace("export function registerMountHook(mount) {\n mountHooks.push(mount)\n}", "export function registerMountHook(mount) {\n mountHooks.push(mount)\n return () => {\n const index = mountHooks.indexOf(mount)\n if (index !== -1) mountHooks.splice(index, 1)\n }\n}")
200
+ .replace("export function registerUnmountHook(unmount) {\n unmountHooks.push(unmount)\n}", "export function registerUnmountHook(unmount) {\n unmountHooks.push(unmount)\n return () => {\n const index = unmountHooks.indexOf(unmount)\n if (index !== -1) unmountHooks.splice(index, 1)\n }\n}")
178
201
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
179
202
  }
180
203
  if (hasDependencyRuntime) {
@@ -237,14 +260,17 @@ export async function build({ quiet = false, minify = true } = {}) {
237
260
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
238
261
  })
239
262
  }
240
- if (navigationRoutes.length) {
241
- let navigationRuntime = (await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8"))
242
- .replace("__KUDZU_NAVIGATION_ROUTES__", inlineJson(navigationRecords))
243
- .replace("__KUDZU_APPLICATION_ID__", JSON.stringify(applicationId))
244
- .replace("__KUDZU_LAYOUT_ID__", JSON.stringify(layoutId))
245
- .replace('"./shared-runtime.js"', '"./kudzu.js"')
246
- navigationRuntime = specializeNavigationPatterns(navigationRuntime, navigationRecords.some(record => record.segments))
247
- await writeJavaScript(join(assetsDirectory, "kudzu-navigation.js"), specializeNavigationEffects(navigationRuntime, hasNavigableEffects || hasNavigableParams), minify)
263
+ if (navigationGroups.length) {
264
+ const navigationSource = await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8")
265
+ for (const group of navigationGroups) {
266
+ let navigationRuntime = navigationSource
267
+ .replace("__KUDZU_NAVIGATION_ROUTES__", inlineJson(group.records))
268
+ .replace("__KUDZU_APPLICATION_ID__", JSON.stringify(group.applicationId))
269
+ .replace("__KUDZU_LAYOUT_ID__", JSON.stringify(group.layoutId))
270
+ .replace('"./shared-runtime.js"', '"./kudzu.js"')
271
+ navigationRuntime = specializeNavigationPatterns(navigationRuntime, group.records.some(record => record.segments))
272
+ await writeJavaScript(join(assetsDirectory, group.assetName), specializeNavigationEffects(navigationRuntime, group.hasEffects || group.hasParams), minify)
273
+ }
248
274
  }
249
275
  for (const handlerModule of handlerModules) {
250
276
  const output = join(assetsDirectory, handlerModule.path)
@@ -260,7 +286,9 @@ export async function build({ quiet = false, minify = true } = {}) {
260
286
  const output = join(assetsDirectory, entry.path)
261
287
  await mkdir(dirname(output), { recursive: true })
262
288
  await writeJavaScript(output, entry.navigable
263
- ? printNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
289
+ ? entry.effects.some(effect => effect.owner)
290
+ ? printOwnedNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
291
+ : printNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
264
292
  : printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
265
293
  }
266
294
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
@@ -655,6 +683,237 @@ function mount(lifetime) {
655
683
  }`
656
684
  }
657
685
 
686
+ function printOwnedNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
687
+ const moduleUrls = [...new Set(effects.map(effect => effect.module))]
688
+ const modules = moduleUrls.map(url => {
689
+ const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
690
+ if (!module) throw new Error(`Effect handler module was not emitted: ${url}`)
691
+ return module
692
+ })
693
+ const imports = [
694
+ `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
695
+ `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
696
+ ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
697
+ ]
698
+ const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
699
+ return `${imports.join("\n")}
700
+ const effects = ${inlineJson(effects)}
701
+ const modules = new Map([${entries}])
702
+ export const mountLayoutEffects = () => mount("layout")
703
+ export const mountRouteEffects = () => mount("route")
704
+ function mount(lifetime) {
705
+ let active = true
706
+ let flushing
707
+ let order = 0
708
+ const selectedEffects = effects.map((effect, index) => ({ effect, index })).filter(entry => entry.effect.lifetime === lifetime)
709
+ const records = new Set()
710
+ const owners = new Map()
711
+ const listTemplates = new Map()
712
+ const registrations = new WeakMap()
713
+ const dependencies = new Map()
714
+ const pending = new Set()
715
+ const startedCleanups = new Set()
716
+ let scheduled = false
717
+ for (const template of selectedEffects) {
718
+ if (template.effect.list) listTemplates.set(template.effect.owner, template)
719
+ else {
720
+ const record = createRecord(template, !template.effect.owner)
721
+ if (template.effect.owner) owners.set(template.effect.owner, record)
722
+ }
723
+ }
724
+ const unsubscribeCommitter = selectedEffects.some(({ effect }) => effect.dependencies?.length) ? __kRuntime.registerCommitter(id => {
725
+ if (!active) return
726
+ for (const record of dependencies.get(id) ?? []) if (record.mounted) pending.add(record)
727
+ schedule()
728
+ }) : undefined
729
+ const unsubscribeMount = __kRuntime.registerMountHook(mountOwned)
730
+ const unsubscribeUnmount = __kRuntime.registerUnmountHook(unmountOwned)
731
+ for (const record of records) if (record.mounted) start(record)
732
+ mountOwned(document)
733
+ function createRecord(template, mounted = true) {
734
+ const record = { ...template, order: order++, mounted, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined, token: undefined }
735
+ records.add(record)
736
+ registerDependencies(record)
737
+ return record
738
+ }
739
+ function registerDependencies(record) {
740
+ for (const id of record.effect.dependencies ?? []) {
741
+ const subscribers = dependencies.get(id) ?? new Set()
742
+ subscribers.add(record)
743
+ dependencies.set(id, subscribers)
744
+ }
745
+ }
746
+ function unregisterDependencies(record) {
747
+ for (const id of record.effect.dependencies ?? []) {
748
+ const subscribers = dependencies.get(id)
749
+ subscribers?.delete(record)
750
+ if (!subscribers?.size) dependencies.delete(id)
751
+ }
752
+ }
753
+ function mountOwned(root) {
754
+ if (!active) return
755
+ for (const marker of matching(root)) {
756
+ if (!marker.isConnected) continue
757
+ if (marker.dataset.kEffects) {
758
+ if (registrations.has(marker)) continue
759
+ const rowRecords = JSON.parse(marker.dataset.kEffects).flatMap(owner => {
760
+ const template = listTemplates.get(owner)
761
+ if (!template) return []
762
+ const record = createRecord(template)
763
+ record.marker = marker
764
+ start(record)
765
+ return [record]
766
+ })
767
+ if (rowRecords.length) registrations.set(marker, rowRecords)
768
+ continue
769
+ }
770
+ const record = owners.get(marker.dataset.kEffect)
771
+ if (record && !record.mounted) mountRecord(record, marker)
772
+ }
773
+ }
774
+ function unmountOwned(root) {
775
+ if (!active) return
776
+ for (const marker of matching(root)) {
777
+ const rowRecords = registrations.get(marker)
778
+ if (rowRecords) {
779
+ for (const record of rowRecords) unmountRecord(record, true)
780
+ registrations.delete(marker)
781
+ continue
782
+ }
783
+ const record = owners.get(marker.dataset.kEffect)
784
+ if (record?.marker === marker) unmountRecord(record)
785
+ }
786
+ }
787
+ function matching(root) {
788
+ const selector = "template[data-k-effect],[data-k-effects]"
789
+ return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
790
+ }
791
+ function mountRecord(record, marker) {
792
+ record.mounted = true
793
+ record.marker = marker
794
+ const version = ++record.version
795
+ const begin = () => {
796
+ if (active && record.mounted && record.version === version && marker.isConnected) start(record)
797
+ }
798
+ if (record.disposal) record.disposal.then(begin)
799
+ else begin()
800
+ }
801
+ function unmountRecord(record, dynamic = false) {
802
+ if (!record.mounted) return
803
+ record.mounted = false
804
+ record.marker = undefined
805
+ record.version++
806
+ pending.delete(record)
807
+ if (dynamic) {
808
+ unregisterDependencies(record)
809
+ records.delete(record)
810
+ }
811
+ void cleanup(record)
812
+ }
813
+ function start(record) {
814
+ try {
815
+ record.values = readDependencies(record)
816
+ invoke(record)
817
+ } catch (error) {
818
+ console.error(error)
819
+ }
820
+ }
821
+ function schedule() {
822
+ if (!pending.size || scheduled || flushing) return
823
+ scheduled = true
824
+ queueMicrotask(flush)
825
+ }
826
+ async function flush() {
827
+ scheduled = false
828
+ if (!active) return pending.clear()
829
+ const operation = (async () => {
830
+ const changed = []
831
+ const selected = [...pending].filter(record => record.mounted).sort((left, right) => left.index - right.index || left.order - right.order)
832
+ pending.clear()
833
+ for (const record of selected) {
834
+ try {
835
+ const values = readDependencies(record)
836
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
837
+ record.values = values
838
+ changed.push([record, record.version])
839
+ }
840
+ } catch (error) {
841
+ console.error(error)
842
+ }
843
+ }
844
+ for (const [record] of changed) await cleanup(record)
845
+ if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) invoke(record)
846
+ })()
847
+ flushing = operation
848
+ try { await operation } finally {
849
+ if (flushing === operation) flushing = undefined
850
+ if (active) schedule()
851
+ }
852
+ }
853
+ function readDependencies(record) {
854
+ return (record.effect.dependencies ?? []).map(id => {
855
+ const value = __kRuntime.browserState.get(id)
856
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
857
+ return value
858
+ })
859
+ }
860
+ function invoke(record) {
861
+ const token = { active: true }
862
+ record.token = token
863
+ try {
864
+ const effect = record.effect
865
+ const scope = effect.list
866
+ ? Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, value?.type === "list-item" ? JSON.parse(record.marker.dataset.kEffectItem) : value]))
867
+ : effect.scope
868
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(__kRuntime.browserState, effect.states, __kRuntime.commitDom, scope, () => active && token.active && record.token === token))
869
+ if (effect.cleanup && typeof result === "function") record.cleanup = result
870
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
871
+ } catch (error) {
872
+ console.error(error)
873
+ }
874
+ }
875
+ function cleanup(record) {
876
+ if (record.token) record.token.active = false
877
+ record.token = undefined
878
+ if (record.disposal) return record.disposal
879
+ const current = record.cleanup
880
+ record.cleanup = undefined
881
+ if (!current) return Promise.resolve()
882
+ const disposal = (async () => {
883
+ try { await current() } catch (error) { console.error(error) }
884
+ })()
885
+ record.disposal = disposal
886
+ startedCleanups.add(disposal)
887
+ disposal.finally(() => {
888
+ startedCleanups.delete(disposal)
889
+ if (record.disposal === disposal) record.disposal = undefined
890
+ })
891
+ return disposal
892
+ }
893
+ let disposal
894
+ return async function dispose() {
895
+ if (disposal) return disposal
896
+ disposal = (async () => {
897
+ active = false
898
+ unsubscribeCommitter?.()
899
+ unsubscribeMount()
900
+ unsubscribeUnmount()
901
+ pending.clear()
902
+ for (const record of records) if (record.token) record.token.active = false
903
+ if (flushing) await flushing
904
+ const mounted = [...records].filter(record => record.mounted).sort((left, right) => left.index - right.index || left.order - right.order)
905
+ for (const record of mounted) {
906
+ record.mounted = false
907
+ await cleanup(record)
908
+ }
909
+ await Promise.all([...startedCleanups])
910
+ records.clear()
911
+ })()
912
+ return disposal
913
+ }
914
+ }`
915
+ }
916
+
658
917
  function runtimeEffects(effects, lifetimes = false) {
659
918
  return effects.map(effect => ({
660
919
  module: effect.module,
@@ -685,7 +944,7 @@ let flushing = false
685
944
  let active = true
686
945
  for (const record of records) registerDependencies(record)
687
946
  function createRecord(effect, index) {
688
- return { effect, index, mounted: !effect.owner, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined }
947
+ return { effect, index, mounted: !effect.owner, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined, token: undefined }
689
948
  }
690
949
  function registerDependencies(record) {
691
950
  for (const id of record.effect.dependencies ?? []) {
@@ -823,12 +1082,14 @@ function readDependencies(record) {
823
1082
  })
824
1083
  }
825
1084
  function invoke(record) {
1085
+ const token = { active: true }
1086
+ record.token = token
826
1087
  try {
827
1088
  const effect = record.effect
828
1089
  const scope = effect.list
829
1090
  ? Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, value?.type === "list-item" ? JSON.parse(record.marker.dataset.kEffectItem) : value]))
830
1091
  : effect.scope
831
- const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, scope))
1092
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, scope, () => active && token.active && record.token === token))
832
1093
  if (effect.cleanup && typeof result === "function") record.cleanup = result
833
1094
  else if (result && typeof result.then === "function") result.catch(error => console.error(error))
834
1095
  } catch (error) {
@@ -836,6 +1097,8 @@ function invoke(record) {
836
1097
  }
837
1098
  }
838
1099
  function invokeCleanup(record) {
1100
+ if (record.token) record.token.active = false
1101
+ record.token = undefined
839
1102
  if (record.disposal) return record.disposal
840
1103
  const cleanup = record.cleanup
841
1104
  record.cleanup = undefined
@@ -2883,19 +3146,64 @@ function normalizeStyles(value, base) {
2883
3146
  export function normalizeNavigation(value) {
2884
3147
  if (value === undefined) return []
2885
3148
  if (!isPlainRecord(value)) throw new Error("kudzu.config navigation must be a plain object")
2886
- if (Object.keys(value).some(key => key !== "routes")) throw new Error("kudzu.config navigation only supports routes")
2887
- if (!Array.isArray(value.routes) || !value.routes.length) throw new Error("kudzu.config navigation.routes must be a nonempty array")
2888
- const routes = value.routes.map((route, index) => {
2889
- if (typeof route !== "string" || !route.startsWith("/") || route.startsWith("//") || /[?#\\\0]/.test(route) || /%(?:2f|5c)/i.test(route)) throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`)
3149
+ if (Object.keys(value).some(key => !["routes", "groups"].includes(key))) throw new Error("kudzu.config navigation only supports routes or groups")
3150
+ if ((value.routes === undefined) === (value.groups === undefined)) throw new Error("kudzu.config navigation must define exactly one of routes or groups")
3151
+ const inputs = value.routes === undefined ? value.groups : [{ routes: value.routes }]
3152
+ if (!Array.isArray(inputs) || !inputs.length) throw new Error("kudzu.config navigation.groups must be a nonempty array")
3153
+ const groups = inputs.map((group, groupIndex) => {
3154
+ const label = value.routes === undefined ? `kudzu.config navigation.groups[${groupIndex}]` : "kudzu.config navigation"
3155
+ if (!isPlainRecord(group)) throw new Error(`${label} must be a plain object`)
3156
+ if (Object.keys(group).some(key => key !== "routes")) throw new Error(`${label} only supports routes`)
3157
+ if (!Array.isArray(group.routes) || !group.routes.length) throw new Error(`${label}.routes must be a nonempty array`)
3158
+ const routes = normalizeNavigationRoutes(group.routes, `${label}.routes`)
3159
+ const id = createHash("sha256").update(JSON.stringify([...routes].sort())).digest("hex").slice(0, 16)
3160
+ return { label, index: groupIndex, routes, routeSet: new Set(routes), id, assetName: value.routes === undefined ? `kudzu-navigation-${id}.js` : "kudzu-navigation.js" }
3161
+ })
3162
+ const identities = groups.flatMap(group => group.routes.map(route => [route, group.label]))
3163
+ const seenRoutes = new Map()
3164
+ for (const [route, label] of identities) {
3165
+ if (seenRoutes.has(route)) throw new Error(`${label} route ${JSON.stringify(route)} duplicates ${seenRoutes.get(route)}`)
3166
+ seenRoutes.set(route, label)
3167
+ }
3168
+ const seenAssets = new Map()
3169
+ for (const group of groups) {
3170
+ if (seenAssets.has(group.assetName)) throw new Error(`${group.label} navigation hash/asset collision with ${seenAssets.get(group.assetName)}`)
3171
+ seenAssets.set(group.assetName, group.label)
3172
+ }
3173
+ return groups
3174
+ }
3175
+
3176
+ function normalizeNavigationRoutes(values, label) {
3177
+ const routes = values.map((route, index) => {
3178
+ if (typeof route !== "string" || !route.startsWith("/") || route.startsWith("//") || /[?#\\\0]/.test(route) || /%(?:2f|5c)/i.test(route)) throw new Error(`${label}[${index}] must be a root-relative path without query, hash, or traversal`)
2890
3179
  let decoded
2891
- try { decoded = decodeURIComponent(route) } catch { throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`) }
2892
- if (decoded.split("/").includes("..") || /[?#\\\0]/.test(decoded)) throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`)
3180
+ try { decoded = decodeURIComponent(route) } catch { throw new Error(`${label}[${index}] must be a root-relative path without query, hash, or traversal`) }
3181
+ if (decoded.split("/").includes("..") || /[?#\\\0]/.test(decoded)) throw new Error(`${label}[${index}] must be a root-relative path without query, hash, or traversal`)
2893
3182
  return route
2894
3183
  })
2895
- if (new Set(routes).size !== routes.length) throw new Error("kudzu.config navigation.routes must contain unique paths")
3184
+ if (new Set(routes).size !== routes.length) throw new Error(`${label} must contain unique paths`)
2896
3185
  return routes
2897
3186
  }
2898
3187
 
3188
+ function exactRouteSegments(route) {
3189
+ return route.slice(1).split("/").map(segment => decodeURIComponent(segment))
3190
+ }
3191
+
3192
+ function rejectNavigationOverlap(groups) {
3193
+ for (let leftIndex = 0; leftIndex < groups.length; leftIndex++) for (let rightIndex = leftIndex + 1; rightIndex < groups.length; rightIndex++) {
3194
+ const leftGroup = groups[leftIndex]
3195
+ const rightGroup = groups[rightIndex]
3196
+ for (const left of leftGroup.routeRecords) for (const right of rightGroup.routeRecords) {
3197
+ if (!navigationDomainsOverlap(left, right)) continue
3198
+ throw new Error(`Navigation path domains overlap between ${leftGroup.label} route ${JSON.stringify(left.route)} and ${rightGroup.label} route ${JSON.stringify(right.route)}`)
3199
+ }
3200
+ }
3201
+ }
3202
+
3203
+ function navigationDomainsOverlap(left, right) {
3204
+ return left.segments.length === right.segments.length && left.segments.every((segment, index) => segment === null || right.segments[index] === null || segment === right.segments[index])
3205
+ }
3206
+
2899
3207
  function specializeNavigationTextDescriptors(source) {
2900
3208
  const dynamic = source
2901
3209
  .replace("const textDescriptors = globalThis.__KUDZU_TEXT_BINDINGS__ && typeof document !== \"undefined\" ? JSON.parse(document.body.dataset.kTextBindings ?? \"[]\") : []", "const textDescriptors = () => globalThis.__KUDZU_TEXT_BINDINGS__ ? JSON.parse(document.body.dataset.kTextBindings ?? \"[]\") : []")
@@ -57,6 +57,7 @@ export function renderPage<Props = Record<string, never>>(
57
57
  navigationAsset?: string
58
58
  applicationId?: string
59
59
  layoutId?: string
60
+ routeId?: string
60
61
  },
61
62
  props?: Props,
62
63
  layout?: (props: { children: unknown }) => unknown | Promise<unknown>
@@ -298,7 +298,6 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
298
298
  const body = await renderNode(layout ? { type: layout, props: { children: page } } : { type: component, props })
299
299
  renderContext.effects = renderContext.effects.map(effect => {
300
300
  try {
301
- if (metadata.navigationAsset && effect.owner) throw new Error("useEffect() inside conditional or keyed DOM is not supported in a configured navigation group yet; move the effect to the layout or route component body, or remove the route from navigation")
302
301
  const descriptor = nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, typeof read === "function" ? read() : read]))
303
302
  return {
304
303
  module: effect.module,
@@ -195,7 +195,7 @@ function commit(incoming, incomingNodes, initializeParams, pathname) {
195
195
 
196
196
  function matchRoute(pathname) {
197
197
  const exact = routes.find(record => record.path === pathname)
198
- if (exact) return exact
198
+ if (exact) return exact.native ? undefined : exact
199
199
  for (const record of routes) {
200
200
  if (!record.segments) continue
201
201
  try {
@@ -212,7 +212,7 @@ function matchRoute(pathname) {
212
212
  if (record.segments.every((literal, index) => {
213
213
  const value = decodeSegment(segments[index], literal === null)
214
214
  return literal === null || value === literal
215
- })) return record
215
+ })) return record.native ? undefined : record
216
216
  } catch {}
217
217
  }
218
218
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",