@kudzujs/core 0.8.20 → 0.8.21

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/PERFORMANCE.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Performance Records
2
2
 
3
+ ## 0.8.21 Explicit Effect Ownership
4
+
5
+ Measured UTC 2026-08-09 on Apple M3, 8 logical CPUs, 8 GiB RAM, macOS 26.5.2 / Darwin 25.5.0, Node 25.6.1, and npm 11.18.0. Baseline tag `v0.8.20` at `7fb6e37` and the current `0.8.21` compiler candidate used the same local volume and identical installed dependencies.
6
+
7
+ The candidate implementation patch over `v0.8.20` had SHA-256 `ac0b1921bbbfb72f45d9b53338bec96bf9ab3d1680446148dfd8548b871bcbe4`, produced by:
8
+
9
+ ```bash
10
+ git diff --binary v0.8.20 -- framework/build.mjs framework/compiler/descriptor-session.mjs framework/compiler/effect-analysis.mjs framework/compiler/ir/module-ir.mjs framework/compiler/worker-compiler.mjs framework/core.d.ts | shasum -a 256
11
+ ```
12
+
13
+ Both targets received one warm-up followed by seven clean `worker-effects` production builds in alternating round-robin order. Cleanup remained outside timing. The distributions overlap; the 1.02% lower candidate median does not establish a material change.
14
+
15
+ | Target | Build median | Worker raw / gzip | Window raw / gzip |
16
+ |---|---:|---:|---:|
17
+ | 0.8.20 baseline | 255.6 ms | 907 B / 475 B | 12,148 B / 5,427 B |
18
+ | 0.8.21 candidate | 253.0 ms | 907 B / 475 B | 12,148 B / 5,427 B |
19
+
20
+ ```text
21
+ 0.8.20: [253.2,263.4,276.5,255.6,251.3,252.1,266.1]
22
+ 0.8.21: [253.7,249.7,260.4,253.0,251.1,250.1,265.7]
23
+ ```
24
+
25
+ The complete site `dist` and the `effect-dependencies`, `keyed-effects`, and `worker-effects` fixture output trees had identical file lists and bytes before release-content updates. The Worker graph and every window graph file were byte-identical, including the content-hashed Worker name. This measurement covers compiler clean-build startup and generated artifact size; lifecycle behavior remains covered by the complete effect, Worker, conditional, keyed, and navigation integration tests.
26
+
3
27
  ## 0.8.20 Explicit Keyed Ownership
4
28
 
5
29
  Measured UTC 2026-08-09 on Apple M3, 8 logical CPUs, 8 GiB RAM, macOS 26.5.2 / Darwin 25.5.0, Node 25.6.1, and npm 11.18.0. Baseline `0.8.19` commit `c516173` and the current `0.8.20` compiler candidate used the same local volume and identical installed dependencies.
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.20 - Explicit keyed ownership.** Keyed collections now retain deterministic JSON-safe parent/child, selector, component-specialization, row state/ref, handler, and binding ownership while final DOM allocation remains unchanged. Read the [release notes](./RELEASES.md#0820---explicit-keyed-ownership), open the [release page](https://kudzujs.cloud/releases/0.8.20), or follow the [next architecture packet](./docs/next-architecture/README.md).
17
+ **Latest release: 0.8.21 - Explicit effect ownership.** Effects now retain deterministic JSON-safe setup, cleanup, dependency, component/keyed ownership, source, and Worker-edge records while final route lifecycle allocation remains unchanged. Read the [release notes](./RELEASES.md#0821---explicit-effect-ownership), open the [release page](https://kudzujs.cloud/releases/0.8.21), or follow the [next 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,40 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.21 - Explicit effect ownership
4
+
5
+ Kudzu 0.8.21 completes the next Goal A source-analysis seam: every supported effect now registers deterministic JSON-safe setup, cleanup, dependency, ownership, source, and Worker-edge data before existing build-time rendering allocates concrete lifecycle IDs.
6
+
7
+ ### Changed in 0.8.21
8
+
9
+ - EffectIR links setup to finalized HandlerIR and records cleanup, ordered signal and DerivedIR dependencies, subscriptions, dependency state snapshots, and keyed-item fields.
10
+ - Component and keyed ownership retain lexical component provenance and KeyedBlockIR slots, including specialized imported rows.
11
+ - Relative TypeScript Worker rewriting returns functional callback and edge results; rendered Worker emission derives only from EffectIR rather than a build-wide mutable reference array.
12
+ - Transformed effect calls consume the registered EffectIR dependency and ownership record before final setup HandlerIR slot resolution.
13
+ - Effect dependency classification and cleanup-owned browser resource validation moved to `compiler/effect-analysis.mjs`; transformer-wide component and keyed effect AST side tables were removed.
14
+ - Mixed direct and derived dependencies create DerivedIR only for derived expressions while preserving authored dependency order.
15
+
16
+ ### Goal A boundary
17
+
18
+ - `core.mjs` and the existing route plan remain authoritative for concrete route/layout/conditional/keyed effect IDs, mounting, stale-write invalidation, and cleanup order; ModuleIR does not duplicate RouteIR.
19
+ - EffectIR owns source-analysis facts and Worker graph edges. Route-specific effect codegen continues to consume rendered descriptors without rediscovering TSX semantics.
20
+ - No accepted syntax, public API, browser capability, VDOM, hydration, component rerender, or retained browser component tree was added.
21
+ - Goal A continues with the `0.8.22` RouteIR, CapabilityIR, numeric-slot, and final architecture/output audit.
22
+
23
+ ### Validation
24
+
25
+ - The complete suite passes 170/170 tests, including EffectIR JSON round-trip, mixed dependencies, imported keyed provenance, Worker rendered exclusion and `about:blank` replacement, conditional/navigation ownership, stale-write isolation, and cleanup.
26
+ - `npm run check`, focused Node 22 compiler tests, and a Node 22 complete-site build pass.
27
+ - Before release-content updates, the complete 135-page site plus `effect-dependencies`, `keyed-effects`, and `worker-effects` output trees were byte-identical to `v0.8.20`.
28
+ - Worker and window graph files remain byte-identical. Seven interleaved clean builds measured a 253.0 ms candidate median against 255.6 ms for `v0.8.20`; raw arrays and provenance are recorded in `PERFORMANCE.md`.
29
+ - A release-blocking architecture audit found no implementation or runtime-correctness blockers.
30
+ - `create-kudzu` remains 0.1.101 because its unchanged template already accepts `@kudzujs/core@^0.8.15`.
31
+
32
+ ### Upgrade
33
+
34
+ ```bash
35
+ npm install @kudzujs/core@^0.8.21
36
+ ```
37
+
3
38
  ## 0.8.20 - Explicit keyed ownership
4
39
 
5
40
  Kudzu 0.8.20 completes the next Goal A source-analysis boundary: keyed collection sites now finalize into deterministic JSON-safe ownership records before the existing build-time renderer allocates DOM identity and lifecycle state.
@@ -6,7 +6,7 @@ The top-level [`GOAL_A.md`](../../GOAL_A.md) and [`GOAL_B.md`](../../GOAL_B.md)
6
6
 
7
7
  | Goal | Decision | Start condition |
8
8
  |---|---|---|
9
- | A: compiler foundation | `0.8.20` KeyedBlockIR ownership complete | Continue with `0.8.21` EffectIR ownership after the release boundary. |
9
+ | A: compiler foundation | `0.8.21` EffectIR source-analysis seam complete | Continue with `0.8.22` RouteIR, CapabilityIR, and the architecture/output audit after the release boundary. |
10
10
  | B: optimization benchmarks | Deferred | Goal A complete and its output baseline recorded |
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 |
@@ -1,6 +1,6 @@
1
1
  # Current Compiler Architecture
2
2
 
3
- This maps the completed `0.8.20` keyed ownership boundary. File and function names are the stable references; line numbers are intentionally omitted because Goal A moves code.
3
+ This maps the EffectIR boundary prepared for `0.8.21`. File and function names are the stable references; line numbers are intentionally omitted because Goal A moves code.
4
4
 
5
5
  ## Responsibility Map
6
6
 
@@ -13,14 +13,15 @@ This maps the completed `0.8.20` keyed ownership boundary. File and function nam
13
13
  | Focused normalization passes | [`framework/compiler/`](../../framework/compiler/) | React, Router, browser signals, animation-frame refs, custom-hook timers, Zustand, and render control each validate and lower a narrow source shape. |
14
14
  | Shared AST/scope helpers | [`framework/compiler/ast-helpers.mjs`](../../framework/compiler/ast-helpers.mjs) | Binding, scope, reference, effect-return, and source-location analysis. |
15
15
  | Pure collection language | [`framework/compiler/collection-analysis.mjs`](../../framework/compiler/collection-analysis.mjs) | Analyzes collection roots/selectors and serializes the allowed pure expression language used by lists and derived dependencies. |
16
- | Main semantic analysis | `framework/build.mjs`, `createKudzuTransformer()` | Produces transformed source plus explicit component, handler, binding, derived, and keyed ownership results while still owning effect-lifetime side tables. |
16
+ | Main semantic analysis | `framework/build.mjs`, `createKudzuTransformer()` | Produces transformed source plus explicit component, handler, binding, derived, keyed, and effect ownership results. |
17
17
  | Component ownership analysis | [`framework/compiler/analysis/component-analysis.mjs`](../../framework/compiler/analysis/component-analysis.mjs) | Retains ordered JSON-safe owner and specialization records for state, setters, props, refs, IDs, direct signal links, and source provenance; AST identity remains private to its source-local session. |
18
- | Per-source descriptor registration | [`framework/compiler/descriptor-session.mjs`](../../framework/compiler/descriptor-session.mjs), `createSemanticArtifact()`, `createDescriptorSession()` | Keeps AST descriptors private during analysis, then finalizes deterministic JSON-safe HandlerIR, BindingIR, DerivedIR, KeyedBlockIR, imports, and client roots into ModuleIR. |
18
+ | Per-source descriptor registration | [`framework/compiler/descriptor-session.mjs`](../../framework/compiler/descriptor-session.mjs), `createSemanticArtifact()`, `createDescriptorSession()` | Keeps AST descriptors private during analysis, then finalizes deterministic JSON-safe HandlerIR, BindingIR, DerivedIR, KeyedBlockIR, EffectIR, imports, and client roots into ModuleIR. |
19
19
  | Command IR and codegen | [`framework/compiler/optimize/command-specialization.mjs`](../../framework/compiler/optimize/command-specialization.mjs), [`framework/compiler/ir/module-ir.mjs`](../../framework/compiler/ir/module-ir.mjs), [`framework/compiler/codegen/command-codegen.mjs`](../../framework/compiler/codegen/command-codegen.mjs) | Supported command handlers specialize to JSON-safe ModuleIR, then emit the existing `__kBehavior` AST without changing route plans. |
20
20
  | Build module generation | `framework/build.mjs`, `compile()` | Runs TypeScript with the Kudzu transformer, writes build-executable modules to `.kudzu`, rejects surviving React/Router runtime references, and generates handler source when descriptors exist. |
21
21
  | Handler/evaluator lowering | [`framework/compiler/handler-lowering.mjs`](../../framework/compiler/handler-lowering.mjs) | Completes source-local callback/binding/list AST rewriting and diagnostics before the JSON-safe IR boundary. |
22
22
  | Handler module codegen | [`framework/compiler/handler-codegen.mjs`](../../framework/compiler/handler-codegen.mjs) | Renders finalized ordered imports and concatenates generated module-export source without TypeScript AST or semantic discovery. |
23
- | Worker graph | [`framework/compiler/worker-compiler.mjs`](../../framework/compiler/worker-compiler.mjs) | Validates the exact effect-owned Worker form, validates its relative graph, emits content-hashed ESM, and resolves placeholders only for rendered effects. |
23
+ | Effect analysis | [`framework/compiler/effect-analysis.mjs`](../../framework/compiler/effect-analysis.mjs) | Classifies ordered signal, derived, and keyed-item dependencies and validates cleanup-owned browser resources before EffectIR registration. |
24
+ | Worker graph | [`framework/compiler/worker-compiler.mjs`](../../framework/compiler/worker-compiler.mjs) | Returns functional Worker rewrite results and JSON-safe EffectIR edges, validates relative graphs, emits content-hashed ESM, and resolves placeholders only for rendered effects. |
24
25
  | Build-time JSX execution | [`framework/core.mjs`](../../framework/core.mjs), `renderPage()` | Executes compiled pages/layouts, allocates deterministic route/layout ownership IDs, emits complete HTML, and returns the serializable route plan and capability booleans. |
25
26
  | Route capability projection | [`framework/compiler/route-capability-planner.mjs`](../../framework/compiler/route-capability-planner.mjs), `planRouteCapabilities()` | Purely folds rendered route plans and route facts into aggregate runtime/artifact requirements. |
26
27
  | Effect entry generation | [`framework/compiler/effect-codegen.mjs`](../../framework/compiler/effect-codegen.mjs) | Generates ordinary, dependency, owned, and navigable effect entries from rendered descriptors. |
@@ -58,7 +59,7 @@ The browser consumes static HTML first. State seeds and descriptors in that HTML
58
59
  ## Current Coupling To Remove
59
60
 
60
61
  - `createKudzuTransformer()` combines discovery, validation, specialization, descriptor registration, and transformed-source emission.
61
- - Effect ownership relationships and transient component rewrite indexes remain source-local AST indexes; handler, binding, derived, keyed, and component ownership now have explicit JSON-safe source results.
62
+ - Transient component rewrite indexes remain source-local AST indexes; handler, binding, derived, keyed, effect, and component ownership now have explicit JSON-safe source results.
62
63
  - `build()` destructures a broad capability manifest into many booleans and performs artifact-specific source surgery.
63
64
  - Runtime specialization relies on exact source-string and regular-expression replacements in `framework/build.mjs`.
64
65
  - Route facts, rendered plans, artifact requirements, and emitted-file decisions are represented at adjacent but not fully explicit boundaries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Status
4
4
 
5
- The `0.8.20` KeyedBlockIR ownership result is complete. Goal A continues with the planned `0.8.21` EffectIR seam while preserving accepted React-shaped syntax, diagnostics, complete HTML, emitted capability behavior, and current public APIs.
5
+ The `0.8.21` EffectIR source-analysis seam is complete. Goal A continues with the planned `0.8.22` RouteIR, CapabilityIR, and architecture/output audit while preserving accepted React-shaped syntax, diagnostics, complete HTML, emitted capability behavior, and current public APIs.
6
6
 
7
7
  ## Target Boundaries
8
8
 
@@ -49,7 +49,7 @@ Existing representations are promoted rather than copied:
49
49
  | `HandlerIR` | Plain command data or a stable generated-module export reference. |
50
50
  | `BindingIR` | Promote existing binding descriptors. |
51
51
  | `DerivedIR` | Reuse the existing tagged Collection Expression IR. |
52
- | `EffectIR` | Replace effect AST side tables with one explicit result when that seam is ready. |
52
+ | `EffectIR` | Records setup HandlerIR, cleanup, ordered signal/DerivedIR dependencies and subscriptions, component/keyed ownership, source provenance, and Worker edges. |
53
53
  | `KeyedBlockIR` | Promote the existing list descriptor after ownership analysis is explicit. |
54
54
  | `CapabilityIR` | Reuse the current pure route capability manifest. |
55
55
 
@@ -77,7 +77,7 @@ Analysis produces plain data:
77
77
  }
78
78
  ```
79
79
 
80
- The `0.8.20` source result adds deterministic KeyedBlockIR parent/child ownership, collection and selector references, complete specialization membership, row state/ref provenance, and keyed HandlerIR/BindingIR links beside the existing command, component, handler, binding, and derived results. Source-local keyed AST validation is consumed during lowering and does not cross this boundary. Initial values, final state IDs, and effect lifetime ownership join their explicit results in later planned patches; `core.mjs` remains authoritative for route allocation today.
80
+ The `0.8.21` source result adds deterministic EffectIR setup HandlerIR links, cleanup, ordered dependencies and subscriptions, DerivedIR references, component/keyed ownership, source provenance, and Worker edges beside the existing command, component, handler, binding, derived, and KeyedBlockIR results. Source-local effect AST analysis is consumed during lowering and does not cross this boundary. Final route/layout/conditional/keyed effect IDs and lifetime allocation remain authoritative in `core.mjs`; the existing route plan is not duplicated in ModuleIR.
81
81
 
82
82
  Source codegen lowers that data through the existing build ABI:
83
83
 
@@ -89,7 +89,7 @@ __kBehavior([["add", count, 1]])
89
89
 
90
90
  ### Source Result
91
91
 
92
- One source-local result owns transformed source plus the sparse ModuleIR, generated handler/effect/binding/list evaluator source, client imports, Worker references, and explicit pass metadata. AST-bearing data is consumed inside analysis/codegen and does not become IR or cross-build state.
92
+ One source-local result owns transformed source plus the sparse ModuleIR, generated handler/effect/binding/list evaluator source, client imports, and explicit pass metadata. Worker ownership is recorded as EffectIR edges; AST-bearing data is consumed inside analysis/lowering and does not become IR or cross-build state.
93
93
 
94
94
  ### Route Result
95
95
 
@@ -117,7 +117,7 @@ No generator may analyze TSX, invent runtime component abstractions, or broaden
117
117
 
118
118
  - One patch, one boundary, with no migration feature mixed in.
119
119
  - Extract only code whose callers and outputs are understood.
120
- - Replace AST side tables only when an explicit keyed result can preserve ownership and source diagnostics.
120
+ - Replace AST side tables only when an explicit feature result can preserve ownership and source diagnostics.
121
121
  - Keep `build()` as orchestration; do not replace it with a service container or plugin framework.
122
122
  - Preserve current runtime files until their corresponding generator has artifact parity.
123
123
  - Treat deterministic byte changes as review items even when tests pass.
@@ -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.20` are completed scopes represented by package/release records; later rows remain planned until their package version and release record exist.
3
+ This is an execution sequence, not release history. `0.8.16` through `0.8.21` are completed scopes represented by package/release records; later rows remain planned until their package version and release record exist.
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
 
@@ -36,12 +36,13 @@ Reduced Zustand migration stores lower to one ordinary layout-lifetime state slo
36
36
  - `compiler/animation-frame-pass.mjs`: effect-owned animation-frame ref validation and plain mutable effect-scope lowering.
37
37
  - `compiler/browser-signal-passes.mjs`: static media-query external-store and navigator capability-condition normalization.
38
38
  - `compiler/collection-analysis.mjs`: pure collection expression IR, selector pipeline, alias, and imported-transform analysis.
39
- - `compiler/descriptor-session.mjs`: private source-local descriptor registration and deterministic JSON-safe HandlerIR, BindingIR, DerivedIR, KeyedBlockIR, import, and client-root finalization.
39
+ - `compiler/descriptor-session.mjs`: private source-local descriptor registration and deterministic JSON-safe HandlerIR, BindingIR, DerivedIR, KeyedBlockIR, EffectIR, import, and client-root finalization.
40
+ - `compiler/effect-analysis.mjs`: ordered effect dependency classification and cleanup-owned browser resource validation before EffectIR registration.
40
41
  - `compiler/normalization-pipeline.mjs`: ordered source normalization with parent-pointer repair after every compiler pass.
41
42
  - `compiler/react-migration-pass.mjs`: React import, memo, callback, `forwardRef`, and `useId` migration validation and lowering.
42
43
  - `compiler/custom-hook-timer-pass.mjs`: private custom-hook timeout-ref validation and compiler-owned timer-state lowering.
43
44
  - `compiler/effect-codegen.mjs`: route-specific ordinary, owned, and navigable effect entry generation.
44
- - `compiler/optimize/command-specialization.mjs`, `compiler/ir/module-ir.mjs`, and `compiler/codegen/command-codegen.mjs`: JSON-safe command and keyed ownership registration, sparse per-source ModuleIR, and source-analysis-free existing behavior-call generation.
45
+ - `compiler/optimize/command-specialization.mjs`, `compiler/ir/module-ir.mjs`, and `compiler/codegen/command-codegen.mjs`: JSON-safe command, keyed, and effect ownership registration, sparse per-source ModuleIR, and source-analysis-free existing behavior-call generation.
45
46
  - `compiler/handler-lowering.mjs`: source-local native/effect callback, reducer, Context, Zustand, reactive-binding, and list-evaluator AST lowering before IR finalization.
46
47
  - `compiler/handler-codegen.mjs`: AST-free ordered import rendering and finalized handler/binding export-source assembly.
47
48
  - `compiler/render-control-pass.mjs`: render-function early-return and exhaustive adjacent-assignment normalization.
@@ -64,7 +65,7 @@ Reduced Zustand migration stores lower to one ordinary layout-lifetime state slo
64
65
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
65
66
  - `*.d.ts`: public TypeScript and JSX declarations.
66
67
 
67
- Compiler ownership follows four explicit stages. `build.mjs` owns project discovery, route planning, and artifact emission. `compiler/normalization-pipeline.mjs` owns pass order and repairs parent pointers after every source transform; migration passes keep per-file state local and return additional metadata explicitly. The main transformer analyzes normalized source while `compiler/descriptor-session.mjs` owns one per-source semantic artifact containing handler, effect callback, binding, keyed block, and client-import descriptors. Keyed blocks link deterministic parent/child sites, component specializations, row states/refs, and generated handlers/bindings without replacing `core.mjs` final DOM ownership. `compiler/collection-analysis.mjs` owns the shared pure collection language used by React migration, reactive expressions, effects, and keyed lists. After build-time rendering, `compiler/route-capability-planner.mjs` projects serializable route plans into one capability manifest consumed by runtime specialization and artifact emission. Codegen modules turn descriptors into route-specific ESM without participating in source analysis. The versioned continuation plan lives in `docs/next-architecture`; it adds no runtime or accepted syntax by itself.
68
+ Compiler ownership follows four explicit stages. `build.mjs` owns project discovery, route planning, and artifact emission. `compiler/normalization-pipeline.mjs` owns pass order and repairs parent pointers after every source transform; migration passes keep per-file state local and return additional metadata explicitly. The main transformer analyzes normalized source while `compiler/descriptor-session.mjs` owns one per-source semantic artifact containing HandlerIR, BindingIR, DerivedIR, KeyedBlockIR, EffectIR, and client imports. Keyed blocks link deterministic parent/child sites, component specializations, row states/refs, and generated handlers/bindings without replacing `core.mjs` final DOM ownership. Effects link setup handlers, dependencies, cleanup, lexical/keyed ownership, provenance, and Worker edges without duplicating the rendered route plan. `compiler/collection-analysis.mjs` owns the shared pure collection language used by React migration, reactive expressions, effects, and keyed lists. After build-time rendering, `compiler/route-capability-planner.mjs` projects serializable route plans into one capability manifest consumed by runtime specialization and artifact emission. Codegen modules turn descriptors into route-specific ESM without participating in source analysis. The versioned continuation plan lives in `docs/next-architecture`; it adds no runtime or accepted syntax by itself.
68
69
 
69
70
  New syntax support belongs in an existing pass or a focused new pass only when a reduced migration fixture proves it. Passes must preserve source-located diagnostics, avoid module-global analysis state, and expose metadata through return values rather than AST-identity side channels. Build orchestration stays in `build.mjs`; feature-specific graph validation or code generation moves under `compiler/` when it has a stable input/output boundary.
70
71
 
@@ -11,6 +11,7 @@ import { normalizeMediaQueryExternalStores, normalizeNavigatorCapabilityConditio
11
11
  import { analyzeCollectionPipeline, collectionExpression, collectionParameters, isArrayFromCall, mutatingCollectionMethods as mutatingListMethods, pureCollectionMathMethods as pureMathMethods, pureCollectionMethods as pureListMethods } from "./compiler/collection-analysis.mjs"
12
12
  import { normalizeCustomHookTimerRefs } from "./compiler/custom-hook-timer-pass.mjs"
13
13
  import { captureNames, createDescriptorSession, createSemanticArtifact, nativeCaptureNames, referencedReducerDispatches, referencedStateNames } from "./compiler/descriptor-session.mjs"
14
+ import { analyzeEffectDependencies, validateEffectOwnedBrowserResources } from "./compiler/effect-analysis.mjs"
14
15
  import { createEffectCodegen } from "./compiler/effect-codegen.mjs"
15
16
  import { createHandlerCodegen } from "./compiler/handler-codegen.mjs"
16
17
  import { createHandlerLowering } from "./compiler/handler-lowering.mjs"
@@ -75,12 +76,16 @@ export async function build({ quiet = false, minify = true } = {}) {
75
76
  const { cssModules, cssOutputs } = await prepareSourceStyles(cssFiles, staticFiles, importedAssets, base)
76
77
 
77
78
  const sourceResults = []
78
- const workerReferences = []
79
79
  for (const file of sourceFiles) {
80
80
  if (file.endsWith(".worker.ts")) continue
81
- sourceResults.push(await compile(file, sourceFileSet, sourceIndex, staticFiles, importedAssets, cssModules, base, workerReferences))
81
+ sourceResults.push(await compile(file, sourceFileSet, sourceIndex, staticFiles, importedAssets, cssModules, base))
82
82
  }
83
83
  const handlerModules = sourceResults.flatMap(result => result.handlerModule ? [result.handlerModule] : [])
84
+ const workerReferences = sourceResults.flatMap(result => result.moduleIR.effects.flatMap(effect => {
85
+ const handler = result.moduleIR.handlers[effect.setup.handler]
86
+ if (!handler || handler.kind !== "module-export" || handler.role !== "effect") throw new Error(`EffectIR ${effect.slot} has no effect HandlerIR`)
87
+ return effect.workers.map(worker => ({ ...worker, module: assetPath(base, `assets/${result.handlerModule.path}`), handler: handler.exportName }))
88
+ }))
84
89
 
85
90
  const plans = []
86
91
  const routeCapabilities = new Map()
@@ -678,7 +683,7 @@ function escapeAttribute(value) {
678
683
  return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'")
679
684
  }
680
685
 
681
- async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base, workerReferences) {
686
+ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base) {
682
687
  const source = sourceIndex.get(file)
683
688
  const semantic = createSemanticArtifact(relative(root, file).replaceAll(sep, "/"))
684
689
  const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
@@ -690,7 +695,7 @@ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAsse
690
695
  jsx: ts.JsxEmit.ReactJSX,
691
696
  jsxImportSource: "@kudzujs/core"
692
697
  },
693
- transformers: { before: [createKudzuTransformer({ semantic, handlerUrl: assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base, workerReferences })] },
698
+ transformers: { before: [createKudzuTransformer({ semantic, handlerUrl: assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base })] },
694
699
  reportDiagnostics: true
695
700
  })
696
701
 
@@ -882,7 +887,7 @@ function normalizeCompilerSource(sourceFile, { base, context, file, importedColl
882
887
  return { sourceFile, customHookTimerStates }
883
888
  }
884
889
 
885
- function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base, workerReferences }) {
890
+ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base }) {
886
891
  const { moduleIR } = semantic
887
892
  return context => sourceFile => {
888
893
  const hasLinkElements = /<link/i.test(sourceFile.text)
@@ -954,7 +959,6 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
954
959
  const jsxLocalsByFunction = new Map()
955
960
  const listLocalDeclarations = []
956
961
  const listLocalUses = []
957
- const componentEffectEntries = new WeakMap()
958
962
  const analysisSource = node => {
959
963
  const original = ts.getOriginalNode(node)
960
964
  return original.pos >= 0 && original.end >= 0 ? { file: sourceName(original.getSourceFile()), start: original.getStart(), end: original.end } : undefined
@@ -1724,8 +1728,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1724
1728
  const effectStatements = specialization.effects.map(entry => {
1725
1729
  const effectCall = factory.updateCallExpression(entry.call, factory.createIdentifier("__kComponentUseEffect"), entry.call.typeArguments, entry.call.arguments)
1726
1730
  synthesizeTree(effectCall)
1727
- const effectSource = entry.source.getSourceFile()
1728
- componentEffectEntries.set(effectCall, { source: entry.source, sourceFile: effectSource, imports: clientImportBindings(effectSource, effectSource.fileName, sourceFiles) })
1731
+ ts.setOriginalNode(effectCall, entry.source)
1729
1732
  return factory.createExpressionStatement(effectCall)
1730
1733
  })
1731
1734
  const helper = factory.createFunctionDeclaration(
@@ -1956,15 +1959,14 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1956
1959
  return expanded
1957
1960
  }
1958
1961
  const preparedRenderedLists = []
1959
- const prepareListCallback = (callback, root, specialization, item, effectEntries) => {
1962
+ const prepareListCallback = (callback, root, specialization) => {
1960
1963
  const statements = [...specialization.hookDeclarations]
1961
1964
  if (specialization.effects.length) {
1962
1965
  usesListEffects = true
1963
1966
  statements.push(...specialization.effects.map(entry => {
1964
1967
  const call = factory.updateCallExpression(entry.call, factory.createIdentifier("__kListUseEffect"), entry.call.typeArguments, entry.call.arguments)
1965
1968
  synthesizeTree(call)
1966
- const effectSource = entry.source.getSourceFile()
1967
- effectEntries.push({ node: call, item, source: entry.source, sourceFile: effectSource, imports: clientImportBindings(effectSource, effectSource.fileName, sourceFiles) })
1969
+ ts.setOriginalNode(call, entry.source)
1968
1970
  return factory.createExpressionStatement(call)
1969
1971
  }))
1970
1972
  }
@@ -1995,13 +1997,11 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1995
1997
  ts.setParentRecursive(callback, false)
1996
1998
  callback.parent = originalParts.callback.parent
1997
1999
  }
1998
- const effectEntries = []
1999
- callback = prepareListCallback(callback, root, specialization, originalParts.item, effectEntries)
2000
+ callback = prepareListCallback(callback, root, specialization)
2000
2001
  const parts = {
2001
2002
  ...originalParts,
2002
2003
  root,
2003
2004
  callback,
2004
- effectEntries,
2005
2005
  specializations: [specialization.analysis?.slot, ...(specialization.specializations ?? [])].filter(slot => slot !== undefined),
2006
2006
  rowStates: [...specialization.rowStates, ...specialization.ordinaryStates],
2007
2007
  rowRefs: specialization.rowRefs,
@@ -2138,10 +2138,14 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2138
2138
  return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
2139
2139
  }
2140
2140
 
2141
- const listEffect = ts.isCallExpression(node) ? keyedEntry(activeKeyedBlock?.analysis.effects ?? [], node) : undefined
2142
- const componentEffect = ts.isCallExpression(node) ? componentEffectEntries.get(node) : undefined
2143
- const specializedEffect = listEffect ?? componentEffect
2144
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (hasUseEffectImport && node.expression.text === "useEffect" || specializedEffect)) {
2141
+ const effectAlias = ts.isCallExpression(node) && ts.isIdentifier(node.expression) ? node.expression.text : undefined
2142
+ const listEffect = effectAlias === "__kListUseEffect"
2143
+ const specializedEffect = listEffect || effectAlias === "__kComponentUseEffect" ? (() => {
2144
+ const source = ts.getOriginalNode(node)
2145
+ const sourceFile = source.getSourceFile()
2146
+ return { source, sourceFile, imports: clientImportBindings(sourceFile, sourceFile.fileName, sourceFiles) }
2147
+ })() : undefined
2148
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (hasUseEffectImport && effectAlias === "useEffect" || specializedEffect)) {
2145
2149
  const effectFail = (target, message) => {
2146
2150
  if (specializedEffect) throw sourceNodeError(specializedEffect.source, specializedEffect.sourceFile, message)
2147
2151
  fail(target, message)
@@ -2162,58 +2166,18 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2162
2166
  if (callback.asteriskToken) effectFail(callback, "useEffect() callback cannot be a generator")
2163
2167
  if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
2164
2168
  if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
2165
- const itemDependencies = []
2166
- const ordinaryDependencies = []
2167
2169
  const setters = settersForNode(node, settersByFunction)
2168
- let dependencyItem = listEffect?.item
2169
- for (const dependency of dependencies.elements) {
2170
- const value = unwrapExpression(dependency)
2171
- if (!dependencyItem && ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && isDestructuredParameter(value.expression, nearestFunction(node))) dependencyItem = value.expression.text
2172
- const field = dependencyItem && directProperty(dependency, dependencyItem)
2173
- if (field) {
2174
- if (["__proto__", "constructor", "prototype"].includes(field)) effectFail(dependency, `useEffect() keyed item property "${field}" is not supported`)
2175
- itemDependencies.push(field)
2176
- } else if (dependencyItem && referencesIdentifier(dependency, dependencyItem)) {
2177
- effectFail(dependency, "useEffect() keyed item dependencies must be direct item.<field> properties")
2178
- } else {
2179
- ordinaryDependencies.push(dependency)
2180
- }
2181
- }
2182
- const invalidDependency = ordinaryDependencies.find(dependency => !ts.isIdentifier(dependency))
2183
- if (invalidDependency) effectFail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
2184
- const dependencyDerived = []
2185
- const dependencyStates = new Map()
2186
- const dependencySubstitutions = new Map()
2187
- const subscriptionDependencies = []
2188
- let hasDerivedDependency = false
2189
- const stateNames = new Set(setters.values())
2190
- const localDeclarations = jsxLocalDeclarations.get(nearestFunction(node))
2191
- for (const dependency of ordinaryDependencies) {
2192
- const entries = localDeclarations?.get(dependency.text)
2193
- const initializer = entries?.length === 1 ? entries[0].initializer : undefined
2194
- const directAlias = initializer && ts.isIdentifier(unwrapExpression(initializer)) && stateNames.has(unwrapExpression(initializer).text)
2195
- const derivedStates = initializer && !directAlias ? referencedStateNames(initializer, setters) : new Set()
2196
- if (derivedStates.size) {
2197
- const usedStates = new Set()
2198
- const expression = collectionExpression(initializer, { fail: effectFail, stateNames, selectorStates: usedStates })
2199
- if (!usedStates.size) effectFail(dependency, `useEffect() derived dependency "${dependency.text}" must read direct primitive state`)
2200
- dependencyDerived.push({ expression, states: usedStates, source: initializer })
2201
- for (const name of usedStates) {
2202
- subscriptionDependencies.push(factory.createIdentifier(name))
2203
- dependencyStates.set(name, factory.createIdentifier(name))
2204
- }
2205
- dependencySubstitutions.set(dependency.text, initializer)
2206
- hasDerivedDependency = true
2207
- } else {
2208
- subscriptionDependencies.push(dependency)
2209
- dependencyDerived.push({ expression: ["state", dependency.text], states: [dependency.text], source: dependency })
2210
- dependencyStates.set(dependency.text, dependency)
2211
- }
2212
- }
2213
- if (!hasDerivedDependency) {
2214
- dependencyDerived.length = 0
2215
- dependencyStates.clear()
2216
- }
2170
+ const dependencyAnalysis = analyzeEffectDependencies({
2171
+ dependencies,
2172
+ node,
2173
+ listEffect,
2174
+ keyedItem: activeKeyedBlock?.parts.item,
2175
+ setters,
2176
+ localDeclarations: jsxLocalDeclarations.get(nearestFunction(node)),
2177
+ factory,
2178
+ fail: effectFail
2179
+ })
2180
+ const { dependencyItem, itemDependencies, ordinaryDependencies, entries: dependencyEntries, dependencyStates, substitutions: dependencySubstitutions, subscriptions: subscriptionDependencies, hasDerived: hasDerivedDependency } = dependencyAnalysis
2217
2181
  if (!effectOwner) fail(node, "useEffect() cannot be used outside a Kudzu component")
2218
2182
  if (!ts.isBlock(callback.body)) effectFail(callback, "useEffect() callback must use a block body")
2219
2183
  const cleanupSubstitutions = new Map()
@@ -2239,17 +2203,19 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2239
2203
  validateEffectOwnedBrowserResources(callback, returns, effectFail)
2240
2204
  const callbackSource = specializedEffect?.sourceFile ?? sourceFile
2241
2205
  const callbackFile = callbackSource.fileName
2242
- const workerStart = workerReferences.length
2243
2206
  let compiledCallback = dependencySubstitutions.size ? substituteClone(callback, dependencySubstitutions, factory, context) : callback
2244
2207
  if (compiledCallback !== callback) {
2245
2208
  ts.setParentRecursive(compiledCallback, false)
2246
2209
  compiledCallback.parent = callback.parent
2247
2210
  }
2211
+ let workers = []
2248
2212
  if (listEffect && callbackFile !== file) {
2249
- const originalCallback = listEffect.source.arguments[0]
2213
+ const originalCallback = specializedEffect.source.arguments[0]
2250
2214
  workerCompiler.rejectConstructions(originalCallback, callbackSource, "Relative TypeScript Worker construction in imported keyed-row effects is not supported; construct the Worker in a directly compiled page or local component effect")
2251
2215
  } else {
2252
- compiledCallback = workerCompiler.rewriteEffect(compiledCallback, callbackFile, callbackSource, sourceFiles, workerReferences, factory, context)
2216
+ const rewritten = workerCompiler.rewriteEffect(compiledCallback, callbackFile, callbackSource, sourceFiles, factory, context)
2217
+ compiledCallback = rewritten.callback
2218
+ workers = rewritten.workers
2253
2219
  }
2254
2220
  const descriptor = descriptors.compileEffectCallback(compiledCallback, {
2255
2221
  setters,
@@ -2261,22 +2227,38 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2261
2227
  snapshotNested: returns.cleanup,
2262
2228
  liveStates: customHookTimerStates
2263
2229
  })
2264
- for (const reference of workerReferences.slice(workerStart)) Object.assign(reference, { module: handlerUrl, handler: descriptor.exportName })
2265
2230
  usesListItem ||= Boolean(itemDependencies.length && !listEffect)
2266
2231
  usesBehavior = true
2267
- const derivedDependencies = hasDerivedDependency ? dependencyDerived.map(entry => descriptors.registerDerived("expression", entry.expression, entry.states, entry.source)) : []
2232
+ const derivedDependencies = hasDerivedDependency ? dependencyEntries.map(entry => entry.kind === "derived" ? descriptors.registerDerived("expression", entry.expression, entry.states, entry.source) : undefined) : []
2233
+ const effectSource = specializedEffect?.source ?? node
2234
+ const lexicalOwner = nearestFunction(effectSource)
2235
+ const effect = descriptors.registerEffect(descriptor, {
2236
+ cleanup: returns.cleanup,
2237
+ dependencies: hasDerivedDependency ? dependencyEntries.map((entry, index) => entry.kind === "derived" ? { kind: "derived", derived: derivedDependencies[index].slot, sources: [...entry.states] } : { kind: "signal", name: entry.name }) : ordinaryDependencies.map(dependency => ({ kind: "signal", name: dependency.text })),
2238
+ subscriptions: (hasDerivedDependency ? subscriptionDependencies : ordinaryDependencies).map(dependency => dependency.text),
2239
+ dependencyStates: [...dependencyStates.keys()],
2240
+ itemDependencies,
2241
+ ownership: {
2242
+ kind: activeKeyedBlock ? "keyed" : "component",
2243
+ ...(activeKeyedBlock ? { keyedBlock: activeKeyedBlock.block.slot } : {}),
2244
+ ...(lexicalOwner ? { component: { name: ownerName(lexicalOwner), ...(analysisSource(lexicalOwner) ? { source: analysisSource(lexicalOwner) } : {}) } } : {})
2245
+ },
2246
+ workers,
2247
+ ...(analysisSource(effectSource) ? { source: analysisSource(effectSource) } : {})
2248
+ })
2249
+ const dependencyExpressions = effect.dependencies.map(dependency => dependency.kind === "derived" ? moduleIR.derived[dependency.derived].expression : ["state", dependency.name])
2268
2250
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
2269
2251
  callback,
2270
- factory.createArrayLiteralExpression(hasDerivedDependency ? subscriptionDependencies : ordinaryDependencies),
2252
+ factory.createArrayLiteralExpression(effect.subscriptions.map(name => factory.createIdentifier(name))),
2271
2253
  factory.createStringLiteral(handlerUrl),
2272
- factory.createStringLiteral(descriptor.exportName),
2254
+ factory.createStringLiteral(effect.setup.exportName),
2273
2255
  descriptor.states,
2274
2256
  descriptor.scope,
2275
2257
  factory.createStringLiteral(specializedEffect ? sourceLocation(specializedEffect.source, specializedEffect.sourceFile) : sourceLocation(node, sourceFile)),
2276
- returns.cleanup ? factory.createTrue() : factory.createFalse(),
2277
- factory.createArrayLiteralExpression(itemDependencies.map(field => factory.createStringLiteral(field))),
2278
- hasDerivedDependency ? jsonExpression(derivedDependencies.map(entry => entry.expression), factory) : factory.createArrayLiteralExpression(),
2279
- factory.createArrayLiteralExpression([...dependencyStates].map(([name, state]) => factory.createArrayLiteralExpression([factory.createStringLiteral(name), state])))
2258
+ effect.cleanup ? factory.createTrue() : factory.createFalse(),
2259
+ factory.createArrayLiteralExpression(effect.itemDependencies.map(field => factory.createStringLiteral(field))),
2260
+ hasDerivedDependency ? jsonExpression(dependencyExpressions, factory) : factory.createArrayLiteralExpression(),
2261
+ factory.createArrayLiteralExpression(effect.dependencyStates.map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)])))
2280
2262
  ])
2281
2263
  }
2282
2264
 
@@ -2618,7 +2600,7 @@ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpeci
2618
2600
  const fail = (node, message) => {
2619
2601
  throw sourceNodeError(node, sourceFile, message)
2620
2602
  }
2621
- const analysis = { values: [], conditions: [], nested: [], effects: parts.effectEntries ?? [] }
2603
+ const analysis = { values: [], conditions: [], nested: [] }
2622
2604
  const root = parts.root
2623
2605
  const item = parts.item
2624
2606
  const nestedDiagnostic = "Nested keyed list collections must be a direct property of the parent item"
@@ -2657,8 +2639,7 @@ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpeci
2657
2639
  ts.setParentRecursive(callback, false)
2658
2640
  callback.parent = nested.callback.parent
2659
2641
  }
2660
- const effectEntries = []
2661
- callback = prepareListCallback(callback, root, specialization ?? { hookDeclarations: [], effects: [] }, nested.item, effectEntries)
2642
+ callback = prepareListCallback(callback, root, specialization ?? { hookDeclarations: [], effects: [] })
2662
2643
  const specializedStates = [...(specialization?.rowStates ?? []), ...(specialization?.ordinaryStates ?? [])]
2663
2644
  const nestedParts = {
2664
2645
  ...nested,
@@ -2666,7 +2647,6 @@ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpeci
2666
2647
  callback,
2667
2648
  state: parts.state,
2668
2649
  nested: true,
2669
- effectEntries,
2670
2650
  specializations: [specialization?.analysis?.slot, ...(specialization?.specializations ?? [])].filter(slot => slot !== undefined),
2671
2651
  rowStates: specializedStates,
2672
2652
  rowRefs: specialization?.rowRefs ?? [],
@@ -3429,30 +3409,6 @@ function localComponentDeclaration(sourceFile, name) {
3429
3409
  return undefined
3430
3410
  }
3431
3411
 
3432
- function validateEffectOwnedBrowserResources(callback, returns, fail) {
3433
- const observers = []
3434
- const frameAssignments = []
3435
- const cancellations = new Set()
3436
- const disconnected = new Set()
3437
- const insideCleanup = node => returns.cleanups.some(cleanup => {
3438
- for (let current = node; current; current = current.parent) if (current === cleanup) return true
3439
- return false
3440
- })
3441
- const visit = node => {
3442
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isNewExpression(unwrapExpression(node.initializer)) && ts.isIdentifier(unwrapExpression(node.initializer).expression) && unwrapExpression(node.initializer).expression.text === "IntersectionObserver") observers.push(node)
3443
- if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(unwrapExpression(node.left)) && ts.isCallExpression(unwrapExpression(node.right)) && ts.isIdentifier(unwrapExpression(node.right).expression) && unwrapExpression(node.right).expression.text === "requestAnimationFrame") frameAssignments.push(node)
3444
- if (insideCleanup(node) && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "cancelAnimationFrame" && node.arguments.length === 1 && ts.isIdentifier(unwrapExpression(node.arguments[0]))) cancellations.add(unwrapExpression(node.arguments[0]).text)
3445
- if (insideCleanup(node) && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.name.text === "disconnect" && node.arguments.length === 0) disconnected.add(node.expression.expression.text)
3446
- ts.forEachChild(node, visit)
3447
- }
3448
- visit(callback.body)
3449
- for (const observer of observers) if (!disconnected.has(observer.name.text)) fail(observer, `IntersectionObserver effects must disconnect ${JSON.stringify(observer.name.text)} in cleanup`)
3450
- for (const assignment of frameAssignments) {
3451
- const name = unwrapExpression(assignment.left).text
3452
- if (!cancellations.has(name)) fail(assignment, `Animation loop effects must cancel ${JSON.stringify(name)} in cleanup`)
3453
- }
3454
- }
3455
-
3456
3412
  async function collectClientModules(entries, sourceFiles) {
3457
3413
  const modules = new Set()
3458
3414
  const queue = [...new Set(entries)]
@@ -2,7 +2,7 @@ import ts from "typescript"
2
2
  import { createComponentAnalysis } from "./analysis/component-analysis.mjs"
3
3
  import { bindingNames, isFunctionLike, isReferenceIdentifier, isShadowedByParameter, isShadowedIdentifier, unwrapExpression } from "./ast-helpers.mjs"
4
4
  import { generateCommandBehavior } from "./codegen/command-codegen.mjs"
5
- import { createModuleIR, registerBinding, registerCommandHandler, registerDerived, registerKeyedBlock, registerModuleHandler } from "./ir/module-ir.mjs"
5
+ import { createModuleIR, registerBinding, registerCommandHandler, registerDerived, registerEffect, registerKeyedBlock, registerModuleHandler } from "./ir/module-ir.mjs"
6
6
 
7
7
  export function createSemanticArtifact(file) {
8
8
  return { componentAnalysis: createComponentAnalysis(file), moduleIR: createModuleIR(file) }
@@ -160,6 +160,10 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
160
160
  return registerKeyedBlock(moduleIR, descriptor)
161
161
  }
162
162
 
163
+ function registerEffectResult(handler, descriptor) {
164
+ return registerEffect(moduleIR, { ...descriptor, setup: { exportName: handler.exportName } })
165
+ }
166
+
163
167
  function finalize() {
164
168
  const callbacks = [...nativeHandlers, ...effectHandlers]
165
169
  for (const entry of callbacks) {
@@ -210,6 +214,11 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
210
214
  code: handlerLowering.lowerListExpression(entry),
211
215
  ...(source(entry.expression) ? { source: source(entry.expression) } : {})
212
216
  })
217
+ for (const effect of moduleIR.effects) {
218
+ const handler = moduleIR.handlers.find(candidate => candidate.kind === "module-export" && candidate.role === "effect" && candidate.exportName === effect.setup.exportName)
219
+ if (!handler) throw new Error(`Effect handler ${JSON.stringify(effect.setup.exportName)} was not finalized`)
220
+ effect.setup = { handler: handler.slot }
221
+ }
213
222
  const imports = [...callbacks, ...reactiveBindings].flatMap(entry => entry.imports ?? []).map(importRecord)
214
223
  moduleIR.imports = [...new Map(imports.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry])).values()]
215
224
  moduleIR.clientModules = [...clientModules]
@@ -223,7 +232,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
223
232
 
224
233
  const importRecord = entry => ({ target: entry.target, kind: entry.kind, local: entry.local, ...(entry.imported ? { imported: entry.imported } : {}), package: Boolean(entry.package) })
225
234
 
226
- return { compileConditional, compileEffectCallback, compileEvent, compileListConditional, compileListValue, compileReactiveBinding, finalize, registerDerived: registerDerivedResult, registerKeyedBlock: registerKeyedBlockResult }
235
+ return { compileConditional, compileEffectCallback, compileEvent, compileListConditional, compileListValue, compileReactiveBinding, finalize, registerDerived: registerDerivedResult, registerEffect: registerEffectResult, registerKeyedBlock: registerKeyedBlockResult }
227
236
  }
228
237
 
229
238
  function directStateIdentifier(expression, setters) {
@@ -0,0 +1,89 @@
1
+ import ts from "typescript"
2
+ import { nearestFunction, referencesIdentifier, unwrapExpression } from "./ast-helpers.mjs"
3
+ import { collectionExpression } from "./collection-analysis.mjs"
4
+ import { referencedStateNames } from "./descriptor-session.mjs"
5
+
6
+ export function analyzeEffectDependencies({ dependencies, node, listEffect, keyedItem, setters, localDeclarations, factory, fail }) {
7
+ const itemDependencies = []
8
+ const ordinaryDependencies = []
9
+ let dependencyItem = listEffect ? keyedItem : undefined
10
+ for (const dependency of dependencies.elements) {
11
+ const value = unwrapExpression(dependency)
12
+ if (!dependencyItem && ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && isDestructuredParameter(value.expression, nearestFunction(node))) dependencyItem = value.expression.text
13
+ const field = dependencyItem && directProperty(dependency, dependencyItem)
14
+ if (field) {
15
+ if (["__proto__", "constructor", "prototype"].includes(field)) fail(dependency, `useEffect() keyed item property "${field}" is not supported`)
16
+ itemDependencies.push(field)
17
+ } else if (dependencyItem && referencesIdentifier(dependency, dependencyItem)) {
18
+ fail(dependency, "useEffect() keyed item dependencies must be direct item.<field> properties")
19
+ } else {
20
+ ordinaryDependencies.push(dependency)
21
+ }
22
+ }
23
+ const invalidDependency = ordinaryDependencies.find(dependency => !ts.isIdentifier(dependency))
24
+ if (invalidDependency) fail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
25
+
26
+ const entries = []
27
+ const dependencyStates = new Map()
28
+ const substitutions = new Map()
29
+ const subscriptions = []
30
+ let hasDerived = false
31
+ const stateNames = new Set(setters.values())
32
+ for (const dependency of ordinaryDependencies) {
33
+ const declarations = localDeclarations?.get(dependency.text)
34
+ const initializer = declarations?.length === 1 ? declarations[0].initializer : undefined
35
+ const directAlias = initializer && ts.isIdentifier(unwrapExpression(initializer)) && stateNames.has(unwrapExpression(initializer).text)
36
+ const derivedStates = initializer && !directAlias ? referencedStateNames(initializer, setters) : new Set()
37
+ if (derivedStates.size) {
38
+ const usedStates = new Set()
39
+ const expression = collectionExpression(initializer, { fail, stateNames, selectorStates: usedStates })
40
+ if (!usedStates.size) fail(dependency, `useEffect() derived dependency "${dependency.text}" must read direct primitive state`)
41
+ entries.push({ kind: "derived", name: dependency.text, expression, states: usedStates, source: initializer })
42
+ for (const name of usedStates) {
43
+ subscriptions.push(factory.createIdentifier(name))
44
+ dependencyStates.set(name, factory.createIdentifier(name))
45
+ }
46
+ substitutions.set(dependency.text, initializer)
47
+ hasDerived = true
48
+ } else {
49
+ subscriptions.push(dependency)
50
+ entries.push({ kind: "signal", name: dependency.text })
51
+ dependencyStates.set(dependency.text, dependency)
52
+ }
53
+ }
54
+ if (!hasDerived) dependencyStates.clear()
55
+ return { dependencyItem, itemDependencies, ordinaryDependencies, entries, dependencyStates, substitutions, subscriptions, hasDerived }
56
+ }
57
+
58
+ export function validateEffectOwnedBrowserResources(callback, returns, fail) {
59
+ const observers = []
60
+ const frameAssignments = []
61
+ const cancellations = new Set()
62
+ const disconnected = new Set()
63
+ const insideCleanup = node => returns.cleanups.some(cleanup => {
64
+ for (let current = node; current; current = current.parent) if (current === cleanup) return true
65
+ return false
66
+ })
67
+ const visit = node => {
68
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isNewExpression(unwrapExpression(node.initializer)) && ts.isIdentifier(unwrapExpression(node.initializer).expression) && unwrapExpression(node.initializer).expression.text === "IntersectionObserver") observers.push(node)
69
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(unwrapExpression(node.left)) && ts.isCallExpression(unwrapExpression(node.right)) && ts.isIdentifier(unwrapExpression(node.right).expression) && unwrapExpression(node.right).expression.text === "requestAnimationFrame") frameAssignments.push(node)
70
+ if (insideCleanup(node) && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "cancelAnimationFrame" && node.arguments.length === 1 && ts.isIdentifier(unwrapExpression(node.arguments[0]))) cancellations.add(unwrapExpression(node.arguments[0]).text)
71
+ if (insideCleanup(node) && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.name.text === "disconnect" && node.arguments.length === 0) disconnected.add(node.expression.expression.text)
72
+ ts.forEachChild(node, visit)
73
+ }
74
+ visit(callback.body)
75
+ for (const observer of observers) if (!disconnected.has(observer.name.text)) fail(observer, `IntersectionObserver effects must disconnect ${JSON.stringify(observer.name.text)} in cleanup`)
76
+ for (const assignment of frameAssignments) {
77
+ const name = unwrapExpression(assignment.left).text
78
+ if (!cancellations.has(name)) fail(assignment, `Animation loop effects must cancel ${JSON.stringify(name)} in cleanup`)
79
+ }
80
+ }
81
+
82
+ function isDestructuredParameter(identifier, fn) {
83
+ return fn?.parameters.some(parameter => ts.isObjectBindingPattern(parameter.name) && parameter.name.elements.some(element => ts.isIdentifier(element.name) && element.name.text === identifier.text)) ?? false
84
+ }
85
+
86
+ function directProperty(expression, objectName) {
87
+ const value = unwrapExpression(expression)
88
+ return ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && value.expression.text === objectName ? value.name.text : undefined
89
+ }
@@ -1,5 +1,5 @@
1
1
  export function createModuleIR(file) {
2
- return { version: 1, file, signals: [], handlers: [], bindings: [], derived: [], keyedBlocks: [], imports: [], clientModules: [] }
2
+ return { version: 1, file, signals: [], handlers: [], bindings: [], derived: [], effects: [], keyedBlocks: [], imports: [], clientModules: [] }
3
3
  }
4
4
 
5
5
  export function registerCommandHandler(moduleIR, commands, source, scope = "module") {
@@ -40,6 +40,12 @@ export function registerDerived(moduleIR, descriptor) {
40
40
  return derived
41
41
  }
42
42
 
43
+ export function registerEffect(moduleIR, descriptor) {
44
+ const effect = { slot: moduleIR.effects.length, ...descriptor }
45
+ moduleIR.effects.push(effect)
46
+ return effect
47
+ }
48
+
43
49
  export function registerKeyedBlock(moduleIR, descriptor) {
44
50
  const block = { slot: moduleIR.keyedBlocks.length, ...descriptor }
45
51
  moduleIR.keyedBlocks.push(block)
@@ -44,7 +44,8 @@ export function createWorkerCompiler({
44
44
  return { worker, url, specifier: specifierNode.text, options }
45
45
  }
46
46
 
47
- const rewriteEffect = (callback, file, sourceFile, sourceFiles, workerReferences, factory, context) => {
47
+ const rewriteEffect = (callback, file, sourceFile, sourceFiles, factory, context) => {
48
+ const workers = []
48
49
  const visit = node => {
49
50
  const value = candidate(node, sourceFile)
50
51
  if (value) {
@@ -56,12 +57,17 @@ export function createWorkerCompiler({
56
57
  if (!sourceFiles.has(target)) throw sourceNodeError(url.arguments[0], sourceFile, `Relative TypeScript Worker ${JSON.stringify(specifier)} must resolve to an existing .worker.ts file under src/`)
57
58
  const identity = `${sourceRelative.replaceAll(sep, "/")}:${ts.getOriginalNode(node).getStart(sourceFile)}`
58
59
  const placeholder = `/__kudzu_worker_${createHash("sha256").update(identity).digest("hex").slice(0, 16)}__.js`
59
- workerReferences.push({ root: target, placeholder })
60
+ const original = ts.getOriginalNode(node)
61
+ workers.push({
62
+ root: sourceRelative.replaceAll(sep, "/"),
63
+ placeholder,
64
+ source: { file: relative(root, sourceFile.fileName).replaceAll(sep, "/"), start: original.getStart(sourceFile), end: original.end }
65
+ })
60
66
  return factory.updateNewExpression(worker, worker.expression, worker.typeArguments, [factory.createStringLiteral(placeholder), options])
61
67
  }
62
68
  return ts.visitEachChild(node, visit, context)
63
69
  }
64
- return ts.visitEachChild(callback, visit, context)
70
+ return { callback: ts.visitEachChild(callback, visit, context), workers }
65
71
  }
66
72
 
67
73
  const rejectConstructions = (expression, sourceFile, message) => {
@@ -124,7 +130,7 @@ export function createWorkerCompiler({
124
130
  }
125
131
 
126
132
  const emit = async (references, sourceFiles, assetsDirectory, base, minify) => {
127
- const roots = [...new Set(references.map(reference => reference.root))].sort()
133
+ const roots = [...new Set(references.map(reference => resolve(sourceDirectory, reference.root)))].sort()
128
134
  if (!roots.length) return new Map()
129
135
  await validateGraphs(roots, sourceFiles)
130
136
  const workerDirectory = resolve(assetsDirectory, "workers")
@@ -150,12 +156,12 @@ export function createWorkerCompiler({
150
156
  for (const [output, metadata] of Object.entries(result.metafile.outputs)) {
151
157
  if (!metadata.entryPoint) continue
152
158
  const entry = resolve(root, metadata.entryPoint)
153
- const rootReferences = references.filter(reference => reference.root === entry)
159
+ const rootReferences = references.filter(reference => resolve(sourceDirectory, reference.root) === entry)
154
160
  const outputFile = resolve(root, output)
155
161
  const url = assetPath(base, relative(outputDirectory, outputFile).replaceAll(sep, "/"))
156
162
  for (const reference of rootReferences) emitted.set(reference.placeholder, url)
157
163
  }
158
- for (const reference of references) if (!emitted.has(reference.placeholder)) throw new Error(`Worker entry was not emitted: ${relative(root, reference.root)}`)
164
+ for (const reference of references) if (!emitted.has(reference.placeholder)) throw new Error(`Worker entry was not emitted: ${reference.root}`)
159
165
  return emitted
160
166
  }
161
167
 
@@ -156,7 +156,21 @@ export function renderPage<Props = Record<string, never>>(
156
156
  commands?: Array<[string, string, unknown]>
157
157
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
158
158
  }>
159
- effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; lifetime?: "layout" | "route"; dependencies?: string[]; itemDependencies?: string[]; listState?: string; cleanup?: true; owner?: string; list?: true }>
159
+ effects: Array<{
160
+ module: string
161
+ handler: string
162
+ states: Record<string, string>
163
+ scope: Record<string, unknown>
164
+ lifetime?: "layout" | "route"
165
+ dependencies?: string[]
166
+ dependencyExpressions?: unknown[]
167
+ dependencyStates?: Record<string, string>
168
+ itemDependencies?: string[]
169
+ listState?: string
170
+ cleanup?: true
171
+ owner?: string
172
+ list?: true
173
+ }>
160
174
  bindings: Array<{
161
175
  target: string
162
176
  state?: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.20",
3
+ "version": "0.8.21",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",