@kudzujs/core 0.6.3 → 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 +6 -4
- package/README.md +10 -12
- package/framework/README.md +2 -2
- package/framework/build.mjs +244 -3
- package/framework/core.mjs +0 -1
- package/package.json +1 -1
package/GOAL_A.md
CHANGED
|
@@ -8,12 +8,12 @@ The benchmark runner, framework fixtures, generated artifacts, and raw arrays ar
|
|
|
8
8
|
|
|
9
9
|
## Implementation Status
|
|
10
10
|
|
|
11
|
-
**Goal A is complete for one explicitly configured emitted-route group with one shared layout.** Complete standalone documents, exact and runtime-parameter navigation, native fallback, persistent layout state/effects, disposable route state/effects, optimistic workflows, desktop/mobile performance gates, and the dashboard expansion seam are covered. Multiple independent shared-layout groups
|
|
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**:
|
|
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.
|
|
@@ -27,7 +27,9 @@ The post-Goal-A runtime-pattern matcher increases the exact-only commerce naviga
|
|
|
27
27
|
|
|
28
28
|
The post-Goal-A multiple-group fixture emits a 7,448 B raw / 3,099 B gzip (`gzip -9`) mixed runtime/effect group asset, including one native exclusion for an overlapping ungrouped exact route, and a separately specialized 5,681 B raw / 2,448 B gzip exact effect-free group asset. These measurements do not revise the historical Goal A benchmark.
|
|
29
29
|
|
|
30
|
-
|
|
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.
|
|
31
33
|
|
|
32
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.
|
|
33
35
|
|
|
@@ -114,7 +116,7 @@ Each phase starts with one failing fixture and ends with correctness, browser, s
|
|
|
114
116
|
1. **Benchmark harness**: freeze the commerce journey, network profiles, framework versions, generated artifacts, and measurement scripts before optimizing Kudzu.
|
|
115
117
|
2. **Owned effects**: complete cleanup for conditional ranges and keyed items using the existing mount and unmount hooks. **Complete.**
|
|
116
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.**
|
|
117
|
-
4. **Opt-in navigation**: support eligible links, history, aborts, stale responses, focus, scroll, metadata, and native fallback. **Complete for emitted exact/runtime-parameter routes and
|
|
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.**
|
|
118
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.**
|
|
119
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.**
|
|
120
122
|
|
package/README.md
CHANGED
|
@@ -470,9 +470,7 @@ export default {
|
|
|
470
470
|
|
|
471
471
|
Every configured identity must be a unique emitted exact route or `runtimeParams` bracket pattern. Routes within each group must export the same layout function identity; different groups may export different layouts. Kudzu emits one deterministic, route-set-hashed navigation asset per group containing only that group's records and capabilities. Path domains may overlap within a group, where exact and more-specific matching wins, but overlapping exact/runtime or runtime/runtime domains across groups fail the build.
|
|
472
472
|
|
|
473
|
-
The layout DOM, state, and
|
|
474
|
-
|
|
475
|
-
Conditional or keyed effects inside a navigation group are not supported yet.
|
|
473
|
+
The layout DOM, state, and effects persist within its group; route state, parameters, and effects reset after cleanup on each transition. Conditional effects mount only while their DOM is connected. Keyed row effects mount per connected row, survive reorder, 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.
|
|
476
474
|
|
|
477
475
|
This produces fast same-document route changes, but it does not add a coordinated transition animation. CSS entry animations can style newly inserted route content; exit and shared-element View Transitions are not integrated yet.
|
|
478
476
|
|
|
@@ -513,6 +511,7 @@ Supported:
|
|
|
513
511
|
- Page-exported shared layouts with layout/route state lifetimes
|
|
514
512
|
- Opt-in exact/runtime-route navigation with complete-document prefetch and native fallback
|
|
515
513
|
- Layout- and route-lifetime effect mounts in navigation groups
|
|
514
|
+
- Conditional/keyed DOM-owned effects in navigation groups
|
|
516
515
|
|
|
517
516
|
Not implemented yet:
|
|
518
517
|
|
|
@@ -520,7 +519,6 @@ Not implemented yet:
|
|
|
520
519
|
- Server actions and request-time SSR
|
|
521
520
|
- React package islands
|
|
522
521
|
- HMR and framework DevTools
|
|
523
|
-
- Conditional/keyed DOM-owned effects inside navigation groups
|
|
524
522
|
|
|
525
523
|
## Benchmarks
|
|
526
524
|
|
|
@@ -532,22 +530,22 @@ Browser medians use seven rotating fresh Chrome profiles per target with 4x CPU
|
|
|
532
530
|
|
|
533
531
|
| Target | Product JS gzip | Cold transfer | Cold LCP | Warm LCP | Startup task | Heap | Interaction | Product → cart |
|
|
534
532
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
535
|
-
| Kudzu | **7,334 B** | **35,260 B** |
|
|
536
|
-
| React + Vite | 61,464 B | 202,842 B |
|
|
537
|
-
| Next.js | 190,090 B |
|
|
538
|
-
| Nuxt | 67,620 B | 195,953 B |
|
|
539
|
-
| SvelteKit | 32,
|
|
533
|
+
| Kudzu | **7,334 B** | **35,260 B** | 332 ms | **156 ms** | **122.6 ms** | **650,708 B** | **4.8 ms** | **5.6 ms** |
|
|
534
|
+
| React + Vite | 61,464 B | 202,842 B | 332 ms | 264 ms | 179.9 ms | 1,062,520 B | 10.2 ms | 9.9 ms |
|
|
535
|
+
| Next.js | 190,090 B | 547,615 B | **324 ms** | 176 ms | 434.4 ms | 2,168,412 B | 14.0 ms | 30.0 ms |
|
|
536
|
+
| Nuxt | 67,620 B | 195,953 B | **324 ms** | 224 ms | 247.9 ms | 1,721,348 B | **4.5 ms** | 29.6 ms |
|
|
537
|
+
| SvelteKit | 32,474 B | 90,939 B | 376 ms | 184 ms | 143.8 ms | 999,496 B | 6.3 ms | 21.8 ms |
|
|
540
538
|
|
|
541
|
-
The current Kudzu application emits 35,355 deploy bytes. Its 7,334 B gzip product graph includes the 2,425 B navigation capability. The first implementation paid a 128.7 ms HTML round trip during product-to-cart navigation; validated near-viewport document prefetch measured 6
|
|
539
|
+
The current Kudzu application emits 35,355 deploy bytes. Its 7,334 B gzip product graph includes the 2,425 B navigation capability; all three sizes are unchanged by conditional/keyed navigation effects because this top-level-only fixture retains the smaller specialized path. The first implementation paid a 128.7 ms HTML round trip during product-to-cart navigation; validated near-viewport document prefetch measured 5.6 ms in the current run while preserving complete documents and native fallback.
|
|
542
540
|
|
|
543
541
|
The mobile row is retained from the previous matched run using a 390x844 viewport, 6x CPU slowdown, 150 ms latency, and 150 KiB/s throughput:
|
|
544
542
|
|
|
545
543
|
| Profile | Cold LCP | Warm LCP | Interaction | Product → cart | Reject feedback | Rollback/error | CLS |
|
|
546
544
|
|---|---:|---:|---:|---:|---:|---:|---:|
|
|
547
|
-
| Desktop |
|
|
545
|
+
| Desktop | 332 ms | 156 ms | 4.8 ms | 5.6 ms | 4.0 ms | 112.1 ms | 0 |
|
|
548
546
|
| Mobile | 420 ms | 220 ms | 5.6 ms | 8.7 ms | 4.1 ms | 158 ms | 0 |
|
|
549
547
|
|
|
550
|
-
Initial runs found a repeatable 6–7% small-build loss from TypeScript and esbuild module startup. Kudzu now enables Node's native module compile cache before lazily loading the compiler. The current seven-run matched commerce build measured Kudzu at
|
|
548
|
+
Initial runs found a repeatable 6–7% small-build loss from TypeScript and esbuild module startup. Kudzu now enables Node's native module compile cache before lazily loading the compiler. The current seven-run matched commerce build measured Kudzu at 486.8 ms and React at 545.4 ms, making Kudzu 10.7% faster in that run. Kudzu also shipped 88.1% less product JavaScript, used 38.8% less measured heap, and measured 52.9% faster interaction and 43.4% faster product-to-cart navigation than React. Disabling the cache preserves byte-for-byte output. Attempts to replace generated-handler lowering or share one TypeScript Program did not improve the combined median and were not retained.
|
|
551
549
|
|
|
552
550
|
These results describe this six-route fixture on one machine, not framework ecosystem size or every rendering mode. Prefetch improves an eligible warm application transition; it does not hide cold transfer, and direct loads remain complete standalone documents.
|
|
553
551
|
|
package/framework/README.md
CHANGED
|
@@ -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 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
|
|
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 current matched commerce profile emits 35,355 deploy bytes and loads 7,334 B gzip of product-route JavaScript, including 2,425 B for navigation. Validated prefetch reduced the original 128.7 ms product-to-cart navigation to 6
|
|
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.
|
package/framework/build.mjs
CHANGED
|
@@ -181,6 +181,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
181
181
|
const hasNativeHandlers = nativeModules.length > 0
|
|
182
182
|
const hasEffects = effectEntries.length > 0
|
|
183
183
|
const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
|
|
184
|
+
const hasNavigableOwners = effectEntries.some(entry => entry.navigable && entry.effects.some(effect => effect.owner))
|
|
184
185
|
const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers || navigationRoutes.length
|
|
185
186
|
const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
|
|
186
187
|
const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
|
|
@@ -194,6 +195,9 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
194
195
|
const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
|
|
195
196
|
let runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
|
|
196
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}")
|
|
197
201
|
await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
|
|
198
202
|
}
|
|
199
203
|
if (hasDependencyRuntime) {
|
|
@@ -282,7 +286,9 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
282
286
|
const output = join(assetsDirectory, entry.path)
|
|
283
287
|
await mkdir(dirname(output), { recursive: true })
|
|
284
288
|
await writeJavaScript(output, entry.navigable
|
|
285
|
-
?
|
|
289
|
+
? entry.effects.some(effect => effect.owner)
|
|
290
|
+
? printOwnedNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
|
|
291
|
+
: printNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
|
|
286
292
|
: printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
|
|
287
293
|
}
|
|
288
294
|
const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
|
|
@@ -677,6 +683,237 @@ function mount(lifetime) {
|
|
|
677
683
|
}`
|
|
678
684
|
}
|
|
679
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
|
+
|
|
680
917
|
function runtimeEffects(effects, lifetimes = false) {
|
|
681
918
|
return effects.map(effect => ({
|
|
682
919
|
module: effect.module,
|
|
@@ -707,7 +944,7 @@ let flushing = false
|
|
|
707
944
|
let active = true
|
|
708
945
|
for (const record of records) registerDependencies(record)
|
|
709
946
|
function createRecord(effect, index) {
|
|
710
|
-
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 }
|
|
711
948
|
}
|
|
712
949
|
function registerDependencies(record) {
|
|
713
950
|
for (const id of record.effect.dependencies ?? []) {
|
|
@@ -845,12 +1082,14 @@ function readDependencies(record) {
|
|
|
845
1082
|
})
|
|
846
1083
|
}
|
|
847
1084
|
function invoke(record) {
|
|
1085
|
+
const token = { active: true }
|
|
1086
|
+
record.token = token
|
|
848
1087
|
try {
|
|
849
1088
|
const effect = record.effect
|
|
850
1089
|
const scope = effect.list
|
|
851
1090
|
? Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, value?.type === "list-item" ? JSON.parse(record.marker.dataset.kEffectItem) : value]))
|
|
852
1091
|
: effect.scope
|
|
853
|
-
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))
|
|
854
1093
|
if (effect.cleanup && typeof result === "function") record.cleanup = result
|
|
855
1094
|
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
856
1095
|
} catch (error) {
|
|
@@ -858,6 +1097,8 @@ function invoke(record) {
|
|
|
858
1097
|
}
|
|
859
1098
|
}
|
|
860
1099
|
function invokeCleanup(record) {
|
|
1100
|
+
if (record.token) record.token.active = false
|
|
1101
|
+
record.token = undefined
|
|
861
1102
|
if (record.disposal) return record.disposal
|
|
862
1103
|
const cleanup = record.cleanup
|
|
863
1104
|
record.cleanup = undefined
|
package/framework/core.mjs
CHANGED
|
@@ -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,
|