@kudzujs/core 0.8.61 → 0.9.0

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.
Files changed (49) hide show
  1. package/MIGRATION_ROADMAP.md +37 -1
  2. package/PERFORMANCE.md +79 -1
  3. package/README.md +2 -2
  4. package/RELEASES.md +58 -0
  5. package/bin/kudzu.mjs +10 -1
  6. package/docs/next-architecture/0.9-baseline.md +1199 -0
  7. package/docs/next-architecture/0.9-benchmark-contracts.md +507 -0
  8. package/docs/next-architecture/0.9-component-property-contract.md +89 -0
  9. package/docs/next-architecture/0.9-compression-ledger.md +227 -0
  10. package/docs/next-architecture/0.9-final-proof-audit.md +176 -0
  11. package/docs/next-architecture/0.9-implementation-plan.md +1819 -0
  12. package/docs/next-architecture/0.9-resource-lifecycle.md +118 -0
  13. package/docs/next-architecture/0.9-semantic-compression.md +384 -0
  14. package/docs/next-architecture/README.md +16 -12
  15. package/docs/next-architecture/compiler-current-architecture.md +7 -7
  16. package/docs/next-architecture/large-application-ai-native-roadmap.md +6 -4
  17. package/docs/next-architecture/versioning.md +3 -2
  18. package/framework/README.md +3 -1
  19. package/framework/binding-runtime.js +4 -4
  20. package/framework/build.mjs +135 -30
  21. package/framework/compiler/ast-helpers.mjs +5 -0
  22. package/framework/compiler/browser-signal-passes.mjs +2 -7
  23. package/framework/compiler/collection-analysis.mjs +4 -0
  24. package/framework/compiler/descriptor-session.mjs +36 -12
  25. package/framework/compiler/effect-analysis.mjs +28 -8
  26. package/framework/compiler/effect-codegen.mjs +79 -36
  27. package/framework/compiler/effect-private-ref-pass.mjs +4 -8
  28. package/framework/compiler/handler-lowering.mjs +12 -7
  29. package/framework/compiler/ir/module-ir.mjs +26 -4
  30. package/framework/compiler/list-runtime-codegen.mjs +4 -2
  31. package/framework/compiler/optimize/command-specialization.mjs +4 -7
  32. package/framework/compiler/outside-click-pass.mjs +79 -0
  33. package/framework/compiler/react-migration-pass.mjs +27 -1
  34. package/framework/compiler/route-artifact-report.mjs +4 -3
  35. package/framework/compiler/route-build-record.mjs +12 -0
  36. package/framework/compiler/route-capability-planner.mjs +3 -3
  37. package/framework/compiler/route-ir.mjs +27 -11
  38. package/framework/compiler/runtime-codegen.mjs +2 -2
  39. package/framework/compiler/source-compiler.mjs +407 -74
  40. package/framework/core.d.ts +1 -0
  41. package/framework/core.mjs +18 -5
  42. package/framework/dependency-runtime.js +1 -1
  43. package/framework/effect-runtime.js +2 -2
  44. package/framework/list-runtime.js +67 -24
  45. package/framework/native-runtime.js +12 -9
  46. package/framework/runtime.js +1 -1
  47. package/framework/serialization.js +13 -6
  48. package/framework/shared-runtime.js +14 -12
  49. package/package.json +1 -1
@@ -2,9 +2,9 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Active execution plan after `0.8.35`. This document turns the current compiler audit into an ordered implementation program. It does not mark any planned capability as supported and does not authorize a React runtime, VDOM, hydration, retained browser component tree, generic rerenderer, public store/query/resource API, SPA router, or islands.
5
+ Completed compiler-foundation record and longer-term plan after `0.8.35`. The active post-`0.8.62` execution queue is [`0.9-semantic-compression.md`](./0.9-semantic-compression.md). This document does not mark any remaining planned capability as supported and does not authorize a React runtime, VDOM, hydration, retained browser component tree, generic rerenderer, public store/query/resource API, SPA router, or islands.
6
6
 
7
- [`MIGRATION_ROADMAP.md`](../../MIGRATION_ROADMAP.md) remains authoritative for product invariants and fixture-driven feature selection. This plan is authoritative for the order and completion evidence of compiler generalization, large-application foundations, compatibility boundaries, AI tooling, and scale validation. If implementation evidence changes a boundary, update this document before broadening a patch.
7
+ [`MIGRATION_ROADMAP.md`](../../MIGRATION_ROADMAP.md) remains authoritative for product invariants and fixture-driven feature selection. [`0.9-semantic-compression.md`](./0.9-semantic-compression.md) is authoritative for current work order and evidence; this plan retains the completed foundation, deferred program, and long-term production gates. If implementation evidence changes either boundary, update the relevant document before broadening a patch.
8
8
 
9
9
  ## Product Outcome
10
10
 
@@ -379,7 +379,7 @@ function increment(value) { setCount(value + 1) }; increment(count)
379
379
  After the relevant P0 foundations, investigate capabilities in this order:
380
380
 
381
381
  1. Property-level derived dependencies over ordinary object state. **Completed in `0.8.40`:** direct property paths and top-level immutable primitive locals over object state reuse tagged DerivedIR, subscribe to the source signal, and compare selected values with `Object.is`; whole-object and dynamic dependencies remain rejected.
382
- 2. Multi-boundary component/prop/callback/ref/context dataflow. **Three-boundary callback/ref ownership completed in `0.8.43`; collision-free Context action-private state completed in `0.8.44`; action-only Provider setter exposure removed in `0.8.46`; direct primitive prop state initialization completed in `0.8.47`; repeated direct leaf-handler callback use completed in `0.8.48`; direct child callback fan-out completed in `0.8.49`; direct plain-object prop state initialization completed in `0.8.57`; direct keyed item draft initialization completed in `0.8.58`; direct array prop draft initialization completed in `0.8.59`; matching array-draft setter effects completed in `0.8.60`; parameterized primitive debounce hooks completed in `0.8.61`:** forwarding preserves parent SignalIR, Context action lowering uses compiler-owned aliases when consumer locals reuse Provider state/setter names, action-required setters may remain compiler-only when their state is publicly exposed, specialized children may seed local state from a direct parent signal authored with a serializable primitive, plain-object, or array literal, keyed rows may seed object draft state from their direct item prop, and one callback may branch through multiple component `on*` props and intrinsic handlers. A direct first-boundary parent setter may also retain its authored matching `set*` prop name; additional forwarding remains restricted to `on*` props. The ClimateCompatibleGrowth-derived dropdown proves independent array drafts and exact direct setter-effect synchronization, while its relative debounce hook specializes one direct primitive source and literal delay through existing timeout cleanup ownership. A fourth callback boundary, callback aliases/non-handler uses, dynamic debounce delays, non-primitive debounce inputs, mismatched state/setter effect pairs, fully hidden Context state, additional `set*` forwarding, keyed item aliases, property paths, and composed expressions remain fail-closed. Broader prop, callback, ref, and Context graphs remain migration-led work.
382
+ 2. Multi-boundary component/prop/callback/ref/context dataflow. **Three-boundary callback/ref ownership completed in `0.8.43`; collision-free Context action-private state completed in `0.8.44`; action-only Provider setter exposure removed in `0.8.46`; direct primitive prop state initialization completed in `0.8.47`; repeated direct leaf-handler callback use completed in `0.8.48`; direct child callback fan-out completed in `0.8.49`; direct plain-object prop state initialization completed in `0.8.57`; direct keyed item draft initialization completed in `0.8.58`; direct array prop draft initialization completed in `0.8.59`; matching array-draft setter effects completed in `0.8.60`; parameterized primitive debounce hooks completed in `0.8.61`; direct `createRef()` outside-click hooks completed in `0.8.62`:** forwarding preserves parent SignalIR, Context action lowering uses compiler-owned aliases when consumer locals reuse Provider state/setter names, action-required setters may remain compiler-only when their state is publicly exposed, specialized children may seed local state from a direct parent signal authored with a serializable primitive, plain-object, or array literal, keyed rows may seed object draft state from their direct item prop, and one callback may branch through multiple component `on*` props and intrinsic handlers. A direct first-boundary parent setter may also retain its authored matching `set*` prop name; additional forwarding remains restricted to `on*` props. The ClimateCompatibleGrowth-derived dropdown proves independent array drafts, exact direct setter-effect synchronization, parameterized debounce ownership, and one exact outside-click listener over an intrinsic DOM ref. A fourth callback boundary, callback aliases/non-handler uses, dynamic debounce delays, non-primitive debounce inputs, dynamic outside-click events, mismatched cleanup/state-setter pairs, fully hidden Context state, additional `set*` forwarding, keyed item aliases, property paths, and composed expressions remain fail-closed. Broader prop, callback, ref, and Context graphs remain migration-led work.
383
383
  3. Package-neutral shared state/actions and migration of current Zustand internals. **Completed in `0.8.50`:** Zustand source normalization produces one generic shared-state adapter descriptor; selectors and handlers register JSON-safe SharedStateIR/SharedActionIR records, handler lowering consumes package-neutral actions, and existing RouteIR, layout ownership, same-turn updates, navigation persistence, and browser output remain unchanged. Redux/RTK and public adapter APIs remain unsupported.
384
384
  4. Browser-only package imports in owned effect/resource modules. **Completed in `0.8.51` for effects:** direct package references in inline effect setup/cleanup callbacks use existing package import records and route-owned effect ESM bundling; build-time component modules and static siblings omit the package. Helper-indirect, render-time, dynamic-import, and ResourceIR package graphs remain unsupported.
385
385
  5. ResourceIR from at least two independent WebSocket/SSE/SDK fixtures with the same semantics. **Completed in `0.8.52` without ResourceIR for private ownership:** the E2B terminal and route-owned WebSocket fixtures lower refs used exclusively by one inline effect to invocation-private closure objects, while existing effect ownership supplies replacement, cleanup, stale setter invalidation, navigation, and BFCache disposal. ResourceIR remains unapproved and now requires independent cross-owner transport/subscription fixtures that cannot fit this narrower model.
@@ -453,6 +453,8 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
453
453
 
454
454
  ## Production Gates Before 1.0
455
455
 
456
+ - The active `0.9.0` cross-framework gate proves lower browser cost and matched user-facing performance against React + Vite, Vue, Svelte, and Astro before AI productivity claims are considered.
457
+ - The maintained AI delivery suite uses the same model, tools, requirements, budgets, and acceptance checks across frameworks, includes failed attempts, and establishes the highest success rate plus lowest median cost per successful task before `1.0.0`.
456
458
  - Async native and effect work cannot write after ownership release.
457
459
  - Build output is staged, collision-safe, and rollback/recovery guarded.
458
460
  - Source maps connect generated route code to TS/TSX diagnostics.
@@ -481,4 +483,4 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
481
483
 
482
484
  ## Immediate Decision
483
485
 
484
- PR 1 through PR 12, the `0.8.40` property-dependency slice, the `0.8.41` and `0.8.43` direct multi-boundary callback/ref slices, the `0.8.44` Context alias slice, the `0.8.46` action-only Provider setter slice, the `0.8.47` direct primitive, `0.8.57` plain-object prop, `0.8.58` direct keyed item, `0.8.59` direct array prop initializer, `0.8.60` matching array-draft setter-effect, and `0.8.61` parameterized primitive debounce-hook slices, the `0.8.48` repeated direct leaf-handler callback slice, the `0.8.49` direct child callback fan-out slice, the `0.8.50` package-neutral shared-state/action slice, the `0.8.51` owned-effect package import slice, the `0.8.52` effect-private mutable-ref slice, the `0.8.53` route/layout CSS closure slice, the `0.8.54` structural per-route capability/chunk report, the `0.8.55` signature-keyed runtime families, item 7 incremental source and affected-route builds, and the `0.8.42` measured route-output optimization are complete. Continue migration-led semantic generalization; keep ResourceIR limited to independent fixtures, and do not add range ownership, virtualization, optimistic transactions, a public adapter/store API, or a router before evidence justifies them.
486
+ PR 1 through PR 12, the `0.8.40` property-dependency slice, the `0.8.41` and `0.8.43` direct multi-boundary callback/ref slices, the `0.8.44` Context alias slice, the `0.8.46` action-only Provider setter slice, the `0.8.47` direct primitive, `0.8.57` plain-object prop, `0.8.58` direct keyed item, `0.8.59` direct array prop initializer, `0.8.60` matching array-draft setter-effect, `0.8.61` parameterized primitive debounce-hook, and `0.8.62` direct-ref outside-click-hook slices, the `0.8.48` repeated direct leaf-handler callback slice, the `0.8.49` direct child callback fan-out slice, the `0.8.50` package-neutral shared-state/action slice, the `0.8.51` owned-effect package import slice, the `0.8.52` effect-private mutable-ref slice, the `0.8.53` route/layout CSS closure slice, the `0.8.54` structural per-route capability/chunk report, the `0.8.55` signature-keyed runtime families, item 7 incremental source and affected-route builds, and the `0.8.42` measured route-output optimization are complete. Continue with `0.9.0-01` in the active Semantic Compression plan; keep ResourceIR limited to qualifying independent fixtures, and do not add range ownership, virtualization, optimistic transactions, a public adapter/store API, or a router before evidence justifies them.
@@ -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.61` are completed scopes represented by package/release records.
3
+ This is an execution sequence, not release history. `0.8.16` through `0.8.62` are completed scopes represented by package/release records.
4
4
 
5
5
  Keep each patch behavior-preserving and independently reviewable. If a boundary proves inseparable, revise this plan before combining releases; do not silently broaden a patch.
6
6
 
@@ -52,6 +52,7 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
52
52
  | `0.8.59` | Initialize specialized child draft state from one direct parent array-state prop and accept a direct matching `set*` setter prop. | A ClimateCompatibleGrowth-derived dropdown preserves independent array drafts, explicit parent commit, source naming, nearby diagnostics, and a static zero-JavaScript sibling without runtime changes. |
53
53
  | `0.8.60` | Synchronize direct prop-derived array draft state through its matching parent setter in a dependency effect. | The ClimateCompatibleGrowth-derived dropdown preserves its exact direct effect/dependency shape, stable setter erasure, array identity comparison, independent parent replacement, nearby diagnostics, and a static zero-JavaScript sibling without runtime changes. |
54
54
  | `0.8.61` | Specialize one parameterized relative primitive debounce hook through existing state and dependency-effect ownership. | The ClimateCompatibleGrowth-derived hook preserves direct state initialization, literal delay scope, timeout replacement/cleanup, conditional release, latest-value commit, nearby diagnostics, and a static zero-JavaScript sibling without runtime changes. |
55
+ | `0.8.62` | Lower direct React `createRef()` and one parameterized outside-click hook through existing DOM-ref and effect ownership. | The ClimateCompatibleGrowth-derived hook preserves inside/outside behavior, serializable setter scope, exact listener cleanup, conditional release/remount, nearby diagnostics, and a static zero-JavaScript sibling without runtime changes. |
55
56
 
56
57
  ## Sequence Rules
57
58
 
@@ -64,7 +65,7 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
64
65
 
65
66
  ## Generator Versions
66
67
 
67
- `create-kudzu@0.1.102` adds an explicit install step to the generated README and clearer completion output. Its `@kudzujs/core` range `^0.8.15` accepts every current `0.8.x` patch; a future `0.9.0` template must use `^0.9.0`.
68
+ `create-kudzu@0.1.103` retains the explicit install instructions and generates projects with `@kudzujs/core@^0.9.0`.
68
69
 
69
70
  ## Release Boundary
70
71
 
@@ -6,7 +6,7 @@ Migration source may retain conventional `react` imports for supported named or
6
6
 
7
7
  Compilation begins from page entries and follows relative runtime imports, re-exports, and validated Worker references; unreachable TypeScript migration files are not transformed. Direct maps over imported immutable JSON-safe arrays fold to literals for zero-JavaScript static rows. Synchronous relative calculation functions may return objects whose direct static fields feed reactive JSX bindings; build rendering uses current signal values and route-specific binding ESM reevaluates the same helper after state commits. One direct array field may instead feed a keyed intrinsic map: its evaluator refreshes a compiler-owned array anchor before the existing list reconciler runs, preserving keyed DOM and SVG identity without a calculation runtime. That field must remain a JSON-safe array after every source-state commit. Package imports have a separate narrow boundary: direct references inside intrinsic JSX event callbacks are erased from build modules and bundled into route handler ESM, while render-time, effect, helper-indirect, and mixed package use fails.
8
8
 
9
- Native platform work remains ordinary source. A direct async handler or directly returned relative custom-hook callback may call `navigator.clipboard.writeText()` and update application-owned success/failure state; Kudzu emits only its existing route handler ESM. Debounced synchronization uses a dependency effect that creates `setTimeout()` work and directly returns `clearTimeout()` cleanup, reusing dependency, conditional, keyed, and route ownership. A relative `useDebounce(value, delay)` hook may accept one direct primitive state and numeric literal delay, initialize and return one debounced state, and reuse that same effect ownership. One directly returned relative custom-hook callback may own one `null`-initialized private timeout ref when it directly clears the previous value, assigns a numeric-literal-delay `setTimeout()`, and an empty-dependency effect directly clears the timer on cleanup. Kudzu lowers that ref to compiler-owned state shared by existing handler and effect contexts. An ordinary inline effect may exclusively own top-level `useRef(null)` and `useRef(0)` values through direct `.current` references; Kudzu moves them into the setup invocation's closure for browser SDK handles, WebSockets, generation tokens, and animation frames without serialized captures or a resource runtime. Multiple timers, dynamic delays, intervals, ref aliases, cross-effect/event mutable refs, unowned delayed writes, and arbitrary timed callback graphs remain unsupported.
9
+ Native platform work remains ordinary source. A direct async handler or directly returned relative custom-hook callback may call `navigator.clipboard.writeText()` and update application-owned success/failure state; Kudzu emits only its existing route handler ESM. Debounced synchronization uses a dependency effect that creates `setTimeout()` work and directly returns `clearTimeout()` cleanup, reusing dependency, conditional, keyed, and route ownership. A relative `useDebounce(value, delay)` hook may accept one direct primitive state and numeric literal delay, initialize and return one debounced state, and reuse that same effect ownership. Direct React `createRef()` input may lower to an intrinsic DOM ref passed with one direct literal setter callback into an exact outside-click listener effect; ref and setter serialization reuse existing effect scope and cleanup. One directly returned relative custom-hook callback may own one `null`-initialized private timeout ref when it directly clears the previous value, assigns a numeric-literal-delay `setTimeout()`, and an empty-dependency effect directly clears the timer on cleanup. Kudzu lowers that ref to compiler-owned state shared by existing handler and effect contexts. An ordinary inline effect may exclusively own top-level `useRef(null)` and `useRef(0)` values through direct `.current` references; Kudzu moves them into the setup invocation's closure for browser SDK handles, WebSockets, generation tokens, and animation frames without serialized captures or a resource runtime. Multiple timers, dynamic delays, intervals, ref aliases, cross-effect/event mutable refs, unowned delayed writes, and arbitrary timed callback graphs remain unsupported.
10
10
 
11
11
  Imperative canvas migrations use the same effect ownership rather than a component or canvas runtime. One `null`-initialized canvas DOM ref may feed an inline effect whose local variables persist across a recursive animation-frame callback, an `IntersectionObserver`, and native canvas/window listeners; the returned cleanup must cancel the latest frame, disconnect the observer, and remove every listener. Bare `IntersectionObserver` and `performance` identifiers remain browser globals in emitted effect ESM. Component-level mutable value refs and callbacks shared across effects or JSX handlers remain unsupported; move resource-private state and listeners into the owning effect.
12
12
 
@@ -94,4 +94,6 @@ A direct setter or inline/simple `const` setter callback may cross one same-file
94
94
 
95
95
  `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
96
96
 
97
+ A direct object-state prop may expose one-segment static fields when its same-file or relative-imported child directly maps an array field. Scalar bindings and that selected array effect dependency reuse the parent signal, existing `Object.is` comparison, and binding-backed keyed ownership without field state or a browser component. Dynamic paths, aliases, mutation, child state/ref/ID hooks, and opaque call-site values remain unsupported.
98
+
97
99
  Cross-framework performance tables are historical snapshots from excluded workspaces and are not current rankings. `npm run benchmark` tracks the Worker build/graph fixture; `RUNS=21 npm run benchmark:keyed` tracks large keyed restoration; `BASELINE_ROOT=... npm run benchmark:native` tracks native dispatch and exact artifact changes. Current methodology, raw arrays, external-fixture limits, and artifact deltas live in `PERFORMANCE.md` and the web docs.
@@ -56,10 +56,10 @@ function commitBindings(id) {
56
56
  }
57
57
 
58
58
  registerCommitter(commitBindings)
59
- registerMountHook(mountBindings)
60
- registerMountHook(mountConditions)
61
- registerUnmountHook(unmountBindings)
62
- registerUnmountHook(unmountConditions)
59
+ registerMountHook(mountBindings, "bindings")
60
+ registerMountHook(mountConditions, "conditions")
61
+ registerUnmountHook(unmountBindings, "bindings")
62
+ registerUnmountHook(unmountConditions, "conditions")
63
63
  registerStateReleaseHook(releaseBindings)
64
64
 
65
65
  if (typeof document !== "undefined") mountDom(document)
@@ -7,7 +7,7 @@ import { createEffectCodegen } from "./compiler/effect-codegen.mjs"
7
7
  import { generateListRuntime } from "./compiler/list-runtime-codegen.mjs"
8
8
  import { assetPath, browserPath, relativeModulePath, withBase } from "./compiler/path-helpers.mjs"
9
9
  import { createProjectSession } from "./compiler/project-session.mjs"
10
- import { createRouteBuildRecord, planRouteArtifacts } from "./compiler/route-build-record.mjs"
10
+ import { createRouteBuildRecord, planRouteArtifacts, releaseRouteBuildRecordPlan } from "./compiler/route-build-record.mjs"
11
11
  import { createRouteArtifactReport } from "./compiler/route-artifact-report.mjs"
12
12
  import { planRuntimeFamilies } from "./compiler/runtime-family-planner.mjs"
13
13
  import { createSourceCompiler } from "./compiler/source-compiler.mjs"
@@ -33,10 +33,10 @@ async function loadConfig(root) {
33
33
 
34
34
  export async function build({ quiet = false, minify = true, root: projectRoot = process.cwd() } = {}) {
35
35
  const project = createProjectSession(projectRoot)
36
- return buildWithSession(project, { quiet, minify })
36
+ return buildWithSession(project, { quiet, minify, retainCache: false })
37
37
  }
38
38
 
39
- export async function buildWithSession(project, { changedFiles, quiet = false, minify = true } = {}) {
39
+ export async function buildWithSession(project, { changedFiles, quiet = false, minify = true, retainCache = true } = {}) {
40
40
  const { root, outputDirectory } = project
41
41
  const stagedOutput = join(root, ".kudzu-dist-staging")
42
42
  const backupOutput = join(root, ".kudzu-dist-backup")
@@ -45,9 +45,9 @@ export async function buildWithSession(project, { changedFiles, quiet = false, m
45
45
  try {
46
46
  await recoverOutput(outputDirectory, backupOutput)
47
47
  await rm(stagedOutput, { recursive: true, force: true })
48
- const { result, pageCount, behaviorCount, cache } = await buildInto(project, stagedOutput, { changedFiles, minify })
48
+ const { result, pageCount, behaviorCount, cache } = await buildInto(project, stagedOutput, { changedFiles, minify, retainCache })
49
49
  await promoteOutput(stagedOutput, outputDirectory, backupOutput)
50
- project.buildCache = cache
50
+ project.buildCache = retainCache ? cache : undefined
51
51
  if (!quiet) console.log(`Built ${pageCount} page(s), ${behaviorCount} interactive page(s) into dist/`)
52
52
  return result
53
53
  } finally {
@@ -63,12 +63,12 @@ export async function buildWithSession(project, { changedFiles, quiet = false, m
63
63
  }
64
64
  }
65
65
 
66
- async function buildInto(project, outputDirectory, { changedFiles, minify }) {
66
+ async function buildInto(project, outputDirectory, { changedFiles, minify, retainCache }) {
67
67
  const { root, sourceDirectory, pagesDirectory, workDirectory } = project
68
- const previous = project.buildCache
68
+ const previous = retainCache ? project.buildCache : undefined
69
69
  project.buildGeneration = (project.buildGeneration ?? 0) + 1
70
70
  project.buildDirectory = project.buildGeneration > 1 ? join(workDirectory, "build", String(project.buildGeneration)) : workDirectory
71
- const { collectClientModules, compileClientModule, compiledPath, compileSource, layoutExportError, orderSourceStyles, reachableSourceFiles, safeStaticFiles } = createSourceCompiler(project)
71
+ const { collectClientModules, compileClientModule, compiledPath, compileSourceAsync, layoutExportError, orderSourceStyles, reachableSourceFiles, safeStaticFiles } = createSourceCompiler(project)
72
72
  const config = await loadConfig(root)
73
73
  const base = normalizeBase(config.base)
74
74
  const configuredStyles = normalizeStyles(config.styles, base, project)
@@ -122,7 +122,7 @@ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
122
122
  if (file.endsWith(".worker.ts")) continue
123
123
  let result = !affectedSources.has(file) ? previous?.sourceResults.get(file) : undefined
124
124
  if (!result) {
125
- result = compileSource(file, sourceFileSet, sourceIndex, staticFiles, cssModules, base)
125
+ result = await compileSourceAsync(file, sourceFileSet, sourceIndex, staticFiles, cssModules, base)
126
126
  compiledModules++
127
127
  }
128
128
  result = { ...result, buildModule: { ...result.buildModule, path: relative(root, compiledPath(file)).replaceAll(sep, "/") } }
@@ -139,12 +139,14 @@ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
139
139
  if (!handler || handler.kind !== "module-export" || handler.role !== "effect") throw new Error(`EffectIR ${effect.slot} has no effect HandlerIR`)
140
140
  return effect.workers.map(worker => ({ ...worker, module: assetPath(base, `assets/${result.handlerModule.path}`), handler: handler.exportName }))
141
141
  }))
142
+ globalThis.gc?.()
142
143
 
143
144
  let routeRecords = []
144
145
  const routeDrafts = []
145
146
  const routeEntryTransforms = new Map()
146
147
  const routeEntrySources = new Map()
147
148
  const routeEntryPaths = new Map()
149
+ const routePlanPools = Object.fromEntries(["states", "params", "searchParams", "effects", "conditions", "lists", "commands", "nativeStates", "nativeScope", "bindingStates", "bindingScope", "scopeStates", "scopeBindings"].map(name => [name, new Map()]))
148
150
  const rewrites = []
149
151
  const emittedRoutes = new Set()
150
152
  const emittedApplicationRoutes = new Set()
@@ -249,8 +251,8 @@ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
249
251
  navigationGroup.hasEffects ||= result.hasEffects
250
252
  navigationGroup.hasParams ||= result.hasParams
251
253
  }
252
- const usesDependencyRuntime = usesRouteDependencyRuntime({ plan: result.plan, navigable, hasBindings: result.hasBindings, hasLists: result.hasLists })
253
- const plan = { route: routePath, ...result.plan }
254
+ const plan = internRoutePlan({ route: routePath, ...result.plan }, routePlanPools)
255
+ const usesDependencyRuntime = usesRouteDependencyRuntime({ plan, navigable, hasBindings: result.hasBindings, hasLists: result.hasLists }, false)
254
256
  const entries = {
255
257
  ...(result.hasParams ? { param: paramPath } : {}),
256
258
  ...(result.hasEffects ? { effect: effectPath } : {}),
@@ -279,7 +281,13 @@ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
279
281
  })
280
282
  routeRecords.push(record)
281
283
  if (navigationGroup) navigationGroup.buildRecords.push(record)
282
- routeDrafts.push({ record, result, runtimeSchema, navigationGroup, applicationRoute, effectPath, nativePath, paramPath })
284
+ routeDrafts.push({ record, runtimeSchema, navigationGroup, applicationRoute, effectPath, nativePath, paramPath })
285
+ if (!retainCache && config.afterBuild === undefined) {
286
+ const routeDirectory = join(outputDirectory, record.output)
287
+ await mkdir(routeDirectory, { recursive: true })
288
+ await writeFile(join(routeDirectory, "index.html"), record.html)
289
+ record.html = ""
290
+ }
283
291
  }
284
292
  pageRenders.set(pageFile, {
285
293
  drafts: routeDrafts.slice(draftOffset).map(({ navigationGroup: _, ...draft }) => draft),
@@ -303,14 +311,16 @@ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
303
311
  group.assetPath = assetPath(base, `assets/runtime/${group.runtimeFamily.id}/${group.assetName}`)
304
312
  }
305
313
  const runtimeFamilyByRecord = new Map()
306
- routeRecords = routeDrafts.map(draft => {
307
- const { record, result, runtimeSchema, navigationGroup, effectPath, nativePath, paramPath } = draft
314
+ const finalizedRecords = []
315
+ for (const draft of routeDrafts) {
316
+ const { record, runtimeSchema, navigationGroup, effectPath, nativePath, paramPath } = draft
308
317
  const family = runtimePlan.familyByRecord.get(record)
309
318
  if (record.capabilities.hasBehaviors && !family) throw new Error(`Interactive route has no runtime family: ${record.route}`)
310
319
  const runtimeDirectory = family ? join(outputDirectory, "assets", "runtime", family.id) : undefined
311
320
  const routeRuntimeName = record.capabilities.usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
312
321
  const entries = {}
313
- let html = record.html
322
+ const routeFile = join(outputDirectory, record.output, "index.html")
323
+ let html = retainCache || config.afterBuild !== undefined ? record.html : await readFile(routeFile, "utf8")
314
324
  if (record.capabilities.hasParams) {
315
325
  const entry = retainRouteEntry(paramPath, output => printParamEntry(runtimeSchema, record.plan.params, record.plan.searchParams, record.plan.searchParamsWritable, output, runtimeDirectory, base, routeRuntimeName, record.capabilities.navigable), routeEntrySources, routeEntryPaths, outputDirectory)
316
326
  entries.param = entry.path
@@ -330,25 +340,31 @@ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
330
340
  }
331
341
  if (family) {
332
342
  html = html.replaceAll(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/runtime/${family.id}/${routeRuntimeName}`)))
333
- html = html.replaceAll(bindingPlaceholder, escapeAttribute(assetPath(base, `assets/runtime/${family.id}/kudzu-binding.js`)))
334
- html = html.replaceAll(listPlaceholder, escapeAttribute(assetPath(base, `assets/runtime/${family.id}/kudzu-list.js`)))
343
+ if (record.capabilities.hasBindings) html = html.replaceAll(bindingPlaceholder, escapeAttribute(assetPath(base, `assets/runtime/${family.id}/kudzu-binding.js`)))
344
+ if (record.capabilities.hasLists) html = html.replaceAll(listPlaceholder, escapeAttribute(assetPath(base, `assets/runtime/${family.id}/kudzu-list.js`)))
335
345
  }
336
346
  if (navigationGroup) html = html.replaceAll(escapeAttribute(navigationAssets.get(record.route)), escapeAttribute(navigationGroup.assetPath))
337
- const finalRecord = createRouteBuildRecord({
347
+ const finalRecord = retainCache ? createRouteBuildRecord({
338
348
  route: record.route,
339
349
  output: record.output,
340
350
  html,
341
351
  plan: record.plan,
342
- handlerReferences: result.handlerReferences,
352
+ handlerReferences: record.artifacts.handlers,
343
353
  styles: record.artifacts.styles,
344
354
  capabilities: record.capabilities,
345
355
  entries,
346
356
  runtimeSchema
347
- })
357
+ }) : Object.assign(record, { html: "", entries })
358
+ if (!retainCache) {
359
+ if ([runtimePlaceholder, bindingPlaceholder, listPlaceholder].some(placeholder => html.includes(placeholder))) throw new Error(`Runtime family placeholder survived in ${record.route}`)
360
+ await mkdir(dirname(routeFile), { recursive: true })
361
+ await writeFile(routeFile, preloadModules(html))
362
+ }
348
363
  if (family) runtimeFamilyByRecord.set(finalRecord, family)
349
364
  if (navigationGroup) navigationAssets.set(finalRecord.route, navigationGroup.assetPath)
350
- return finalRecord
351
- })
365
+ finalizedRecords.push(finalRecord)
366
+ }
367
+ routeRecords = finalizedRecords
352
368
 
353
369
  const assetsDirectory = join(outputDirectory, "assets")
354
370
  await mkdir(assetsDirectory, { recursive: true })
@@ -364,9 +380,15 @@ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
364
380
  }
365
381
  if (module.code.includes("/__kudzu_worker_")) throw new Error(`Worker URL placeholder survived in ${module.path}`)
366
382
  }
367
- const plans = routeRecords.map(record => record.plan)
383
+ const sortedRewrites = rewrites.sort((left, right) => runtimeSpecificity(right) - runtimeSpecificity(left) || left.pattern.localeCompare(right.pattern))
384
+ const releaseRoutePlans = !retainCache && config.afterBuild === undefined
385
+ const plans = releaseRoutePlans ? undefined : routeRecords.map(record => record.plan)
386
+ if (releaseRoutePlans) {
387
+ await writeRoutePlans(join(workDirectory, "kudzu-plan.json"), routeRecords, sortedRewrites, runtimeFamilyByRecord, routeDrafts)
388
+ globalThis.gc?.()
389
+ }
368
390
  const behaviorCount = routeRecords.filter(record => record.capabilities.hasBehaviors).length
369
- for (let offset = 0; offset < routeRecords.length; offset += 64) {
391
+ for (let offset = 0; retainCache && offset < routeRecords.length; offset += 64) {
370
392
  await Promise.all(routeRecords.slice(offset, offset + 64).map(async record => {
371
393
  const routeDirectory = join(outputDirectory, record.output)
372
394
  await mkdir(routeDirectory, { recursive: true })
@@ -451,10 +473,9 @@ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
451
473
  handlerMetafile = result.metafile
452
474
  await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
453
475
  }
454
- const sortedRewrites = rewrites.sort((left, right) => runtimeSpecificity(right) - runtimeSpecificity(left) || left.pattern.localeCompare(right.pattern))
455
- await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans, rewrites: sortedRewrites }, null, 2))
476
+ if (!releaseRoutePlans) await writePrettyJson(join(workDirectory, "kudzu-plan.json"), { routes: plans, rewrites: sortedRewrites })
456
477
  const artifacts = createRouteArtifactReport(routeRecords, { base, handlerMetafile, outputDirectory, navigationAssets, runtimeFamilies: runtimePlan.families, runtimeFamilyByRecord, workerReferences: renderedWorkerReferences, workerOutputs })
457
- await writeFile(join(workDirectory, "kudzu-artifacts.json"), JSON.stringify(artifacts, null, 2))
478
+ await writePrettyJson(join(workDirectory, "kudzu-artifacts.json"), artifacts)
458
479
  const emittedCssFiles = new Set()
459
480
  for (const file of cssFiles.filter(file => renderedStyleUrls.has(assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)))) {
460
481
  const output = join(assetsDirectory, relative(sourceDirectory, file))
@@ -492,7 +513,7 @@ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
492
513
  const incremental = { compiledModules, renderedPages }
493
514
  return {
494
515
  result: { sourceResults, incremental },
495
- pageCount: plans.length,
516
+ pageCount: routeRecords.length,
496
517
  behaviorCount,
497
518
  cache: { pageRenders, pageSources, placeholders, sourceResults: sourceResultsByFile }
498
519
  }
@@ -555,8 +576,8 @@ function replayPageRender(cached, state) {
555
576
  if (navigationGroup) {
556
577
  state.navigationAssets.set(draft.record.route, navigationGroup.assetPath)
557
578
  navigationGroup.buildRecords.push(draft.record)
558
- navigationGroup.hasEffects ||= draft.result.hasEffects
559
- navigationGroup.hasParams ||= draft.result.hasParams
579
+ navigationGroup.hasEffects ||= draft.record.capabilities.hasEffects
580
+ navigationGroup.hasParams ||= draft.record.capabilities.hasParams
560
581
  }
561
582
  state.routeRecords.push(draft.record)
562
583
  state.routeDrafts.push({ ...draft, navigationGroup })
@@ -688,6 +709,7 @@ function runtimeEffects(effects, lifetimes = false) {
688
709
  handler: effect.handler,
689
710
  ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
690
711
  ...(effect.dependencyExpressions ? { dependencyExpressions: effect.dependencyExpressions, dependencyStates: effect.dependencyStates } : {}),
712
+ ...(effect.dependencyEvaluators ? { dependencyEvaluators: effect.dependencyEvaluators } : {}),
691
713
  ...(effect.itemDependencies ? { itemDependencies: effect.itemDependencies, listState: effect.listState } : {}),
692
714
  ...(effect.cleanup ? { cleanup: true } : {}),
693
715
  ...(effect.owner ? { owner: effect.owner } : {}),
@@ -698,11 +720,94 @@ function runtimeEffects(effects, lifetimes = false) {
698
720
  }))
699
721
  }
700
722
 
723
+ function internRoutePlan(plan, pools) {
724
+ for (const field of ["states", "params", "searchParams", "effects", "conditions", "lists"]) plan[field] = internJson(plan[field], pools[field])
725
+ for (const event of plan.events) {
726
+ if (event.commands) event.commands = internJson(event.commands, pools.commands)
727
+ if (event.native) {
728
+ event.native.states = internJson(event.native.states, pools.nativeStates)
729
+ event.native.scope = internJson(event.native.scope, pools.nativeScope)
730
+ }
731
+ }
732
+ for (const binding of plan.bindings) {
733
+ if (binding.states) binding.states = internJson(binding.states, pools.bindingStates)
734
+ if (binding.scope) binding.scope = internJson(binding.scope, pools.bindingScope)
735
+ if (binding.scopeStates) binding.scopeStates = internJson(binding.scopeStates, pools.scopeStates)
736
+ if (binding.scopeBindings) binding.scopeBindings = internJson(binding.scopeBindings, pools.scopeBindings)
737
+ }
738
+ return plan
739
+ }
740
+
741
+ function internJson(value, pool) {
742
+ const encoded = JSON.stringify(value)
743
+ const existing = pool.get(encoded)
744
+ if (existing !== undefined) return existing
745
+ pool.set(encoded, value)
746
+ return value
747
+ }
748
+
701
749
  async function writeJavaScript(file, source, minify, define) {
702
750
  const code = minify || define ? (await transform(source, { define, format: "esm", legalComments: "none", minify, target: "es2022" })).code : source
703
751
  await writeFile(file, code)
704
752
  }
705
753
 
754
+ async function writePrettyJson(file, value) {
755
+ const entries = Object.entries(value)
756
+ if (!entries.some(([, entry]) => Array.isArray(entry) && entry.length > 2048)) {
757
+ await writeFile(file, JSON.stringify(value, null, 2))
758
+ return
759
+ }
760
+ const output = await open(file, "w")
761
+ try {
762
+ await output.write("{\n")
763
+ for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
764
+ const [key, entry] = entries[entryIndex]
765
+ const comma = entryIndex === entries.length - 1 ? "" : ","
766
+ if (!Array.isArray(entry) || entry.length <= 2048) {
767
+ await output.write(` ${JSON.stringify(key)}: ${JSON.stringify(entry, null, 2).replaceAll("\n", "\n ")}${comma}\n`)
768
+ continue
769
+ }
770
+ await output.write(` ${JSON.stringify(key)}: [\n`)
771
+ for (let offset = 0; offset < entry.length; offset += 64) {
772
+ const batch = entry.slice(offset, offset + 64).map((item, index) => {
773
+ const separator = offset + index === entry.length - 1 ? "" : ","
774
+ return ` ${JSON.stringify(item, null, 2).replaceAll("\n", "\n ")}${separator}\n`
775
+ }).join("")
776
+ await output.write(batch)
777
+ }
778
+ await output.write(` ]${comma}\n`)
779
+ }
780
+ await output.write("}")
781
+ } finally {
782
+ await output.close()
783
+ }
784
+ }
785
+
786
+ async function writeRoutePlans(file, records, rewrites, familyByRecord, drafts) {
787
+ const output = await open(file, "w")
788
+ try {
789
+ await output.write(records.length ? '{\n "routes": [\n' : '{\n "routes": []')
790
+ for (let offset = 0; offset < records.length; offset += 64) {
791
+ const batch = records.slice(offset, offset + 64).map((record, index) => {
792
+ const recordIndex = offset + index
793
+ const separator = recordIndex === records.length - 1 ? "" : ","
794
+ const encoded = ` ${JSON.stringify(record.plan, null, 2).replaceAll("\n", "\n ")}${separator}\n`
795
+ const family = familyByRecord.get(record)
796
+ if (family && !family.navigation) {
797
+ releaseRouteBuildRecordPlan(record)
798
+ if (drafts[recordIndex].record !== record) releaseRouteBuildRecordPlan(drafts[recordIndex].record)
799
+ }
800
+ return encoded
801
+ }).join("")
802
+ await output.write(batch)
803
+ if ((offset + 64) % 512 === 0) globalThis.gc?.()
804
+ }
805
+ await output.write(`${records.length ? " ]" : ""},\n "rewrites": ${JSON.stringify(rewrites, null, 2).replaceAll("\n", "\n ")}\n}`)
806
+ } finally {
807
+ await output.close()
808
+ }
809
+ }
810
+
706
811
  export async function writeRouteEntry(file, source, minify, transforms, transformSource = transform, write = writeFile) {
707
812
  let code = transforms.get(source)
708
813
  if (code === undefined) {
@@ -160,6 +160,11 @@ export function sourceLocation(node, fallbackSource) {
160
160
  return `${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
161
161
  }
162
162
 
163
+ export function isNodeWithin(node, root) {
164
+ for (let current = node; current; current = current.parent) if (current === root) return true
165
+ return false
166
+ }
167
+
163
168
  export function effectReturns(callback) {
164
169
  let cleanup = false
165
170
  let invalid
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript"
2
- import { bindingNames, containsJsx, functionVarDeclaresName, importDeclarationNames, isShadowedIdentifier, isUnshadowedGlobal, nearestFunction, referenceIdentifiers, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
2
+ import { bindingNames, containsJsx, functionVarDeclaresName, importDeclarationNames, isNodeWithin, isShadowedIdentifier, isUnshadowedGlobal, nearestFunction, referenceIdentifiers, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
3
3
 
4
4
  export function normalizeMediaQueryExternalStores(sourceFile, factory, context) {
5
5
  const imports = sourceFile.statements.filter(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings))
@@ -58,7 +58,7 @@ export function normalizeMediaQueryExternalStores(sourceFile, factory, context)
58
58
  }
59
59
  inspect(sourceFile)
60
60
  const references = referenceIdentifiers(sourceFile, "useSyncExternalStore")
61
- if (references.length !== candidates.size) throw sourceNodeError(references.find(reference => ![...candidates.values()].some(candidate => insideNode(reference, candidate.declaration.initializer))) ?? externalStoreImport.entry, sourceFile, "useSyncExternalStore is supported only for direct static media query declarations")
61
+ if (references.length !== candidates.size) throw sourceNodeError(references.find(reference => ![...candidates.values()].some(candidate => isNodeWithin(reference, candidate.declaration.initializer))) ?? externalStoreImport.entry, sourceFile, "useSyncExternalStore is supported only for direct static media query declarations")
62
62
  const directHooks = new Set(imports.flatMap(statement => statement.importClause.namedBindings.elements.filter(entry => !entry.propertyName).map(entry => entry.name.text)))
63
63
  const missingHooks = ["useEffect", "useState"].filter(name => !directHooks.has(name))
64
64
  for (const name of missingHooks) {
@@ -105,11 +105,6 @@ export function normalizeMediaQueryExternalStores(sourceFile, factory, context)
105
105
  return ts.visitNode(sourceFile, visitor)
106
106
  }
107
107
 
108
- function insideNode(node, root) {
109
- for (let current = node; current; current = current.parent) if (current === root) return true
110
- return false
111
- }
112
-
113
108
  export function normalizeNavigatorCapabilityConditions(sourceFile, factory, context) {
114
109
  const candidates = new Map()
115
110
  let index = 0
@@ -45,6 +45,10 @@ export function analyzeCollectionPipeline(expression, options) {
45
45
  if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression)) {
46
46
  const calculation = calculatedCollection?.(value)
47
47
  if (calculation) return { calculation, selector: [], selectorStates: new Set() }
48
+ if (stateNames.has(value.expression.text)) {
49
+ if (["__proto__", "constructor", "prototype"].includes(value.name.text)) fail(value, `Rendered collection property "${value.name.text}" is not supported`)
50
+ return { state: value.expression, calculation: value, selector: [], selectorStates: new Set() }
51
+ }
48
52
  return { state: undefined, ownerField: value.name.text, selector: [], parentItem: value.expression.text }
49
53
  }
50
54
  if (ts.isCallExpression(value) && ts.isIdentifier(value.expression) && importedCollectionTransforms.has(value.expression.text)) {
@@ -75,14 +75,30 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
75
75
  : compileListExpression(read, expression, entry.item, entry.index, entry.states, entry.keyedBlock, indexed ? bindingIndex : undefined)
76
76
  }
77
77
 
78
- function compileReactiveBinding(expression, { setters, importBindings = new Map(), keyedBlock }) {
78
+ function compileReactiveBinding(expression, { setters, importBindings = new Map(), keyedBlock, derived }) {
79
79
  const parts = conditionalParts(expression)
80
80
  const state = parts && directStateIdentifier(parts.condition, setters, bindingIndex)
81
81
  if (state && isPrimitiveLiteral(parts.truthy) && isPrimitiveLiteral(parts.falsy)) {
82
82
  return { node: factory.createCallExpression(factory.createIdentifier("__kSelect"), undefined, [state, parts.truthy, parts.falsy]) }
83
83
  }
84
84
  const binding = reactiveBindings.length
85
- return { node: factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, importBindings, keyedBlock)), binding }
85
+ const node = factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, importBindings, keyedBlock))
86
+ if (derived) reactiveBindings[binding].derived = derived
87
+ return { node, binding }
88
+ }
89
+
90
+ function compileDerivedEvaluator(expression, { setters, importBindings = new Map() }) {
91
+ const binding = reactiveBindings.length
92
+ const [, module, handler, states, scope] = compileReactiveExpression(expression, setters, importBindings)
93
+ return {
94
+ binding,
95
+ descriptor: factory.createObjectLiteralExpression([
96
+ factory.createPropertyAssignment("module", module),
97
+ factory.createPropertyAssignment("handler", handler),
98
+ factory.createPropertyAssignment("states", states),
99
+ factory.createPropertyAssignment("scope", scope)
100
+ ])
101
+ }
86
102
  }
87
103
 
88
104
  function compileConditional(kind, expression, truthy, falsy, setters) {
@@ -125,12 +141,18 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
125
141
  }
126
142
 
127
143
  function compileEvent(expression, { owner = "module", stateOwners = new Map(), setters, reducers, functions, listItem, keyedBlock, importBindings }) {
128
- if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
144
+ const directFunction = ts.isIdentifier(expression) ? functions.get(expression.text) : undefined
145
+ const directReducer = ts.isIdentifier(expression) ? reducers.get(expression.text) : undefined
146
+ const directImplementation = directReducer?.sharedAction?.directImplementation
147
+ const directSource = directFunction && ts.getOriginalNode(directFunction)
148
+ const actionSource = directImplementation && ts.getOriginalNode(directImplementation)
149
+ const directAction = directSource?.pos >= 0 && directSource.pos === actionSource?.pos && directSource.end === actionSource.end && directSource.getSourceFile().fileName === actionSource.getSourceFile().fileName ? directReducer.sharedAction : undefined
150
+ if (ts.isIdentifier(expression)) expression = directFunction
129
151
  if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
130
- const optimized = referencedReducerDispatches(expression.body, reducers, expression).size ? undefined : compileOptimizedEvent(expression, setters, stateOwners, owner, keyedBlock)
152
+ const optimized = referencedReducerDispatches(expression.body, reducers, expression).size ? undefined : compileOptimizedEvent(expression, setters, stateOwners, owner, keyedBlock, directAction ? [directAction.slot] : [])
131
153
  if (optimized) return optimized
132
154
  rejectWorkerConstructions(expression)
133
- const descriptor = compileNativeCallback(expression, { setters, reducers, entries: nativeHandlers, importBindings, prefix: "handler", role: "native", listItem, keyedBlock, stateOwners })
155
+ const descriptor = compileNativeCallback(expression, { setters, reducers, entries: nativeHandlers, importBindings, prefix: "handler", role: "native", listItem, keyedBlock, stateOwners, actionSlots: directAction ? [directAction.slot] : [] })
134
156
  return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
135
157
  factory.createStringLiteral(handlerUrl), factory.createStringLiteral(descriptor.exportName), descriptor.states, descriptor.scope
136
158
  ])
@@ -140,7 +162,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
140
162
  return compileNativeCallback(expression, { ...options, entries: effectHandlers, prefix: "effect", role: "effect" })
141
163
  }
142
164
 
143
- function compileNativeCallback(expression, { setters, reducers, entries, importBindings, prefix, role, listItem, keyedBlock, stateOwners, deferValues = false, snapshotNested = false, liveStates = new Set() }) {
165
+ function compileNativeCallback(expression, { setters, reducers, entries, importBindings, prefix, role, listItem, keyedBlock, stateOwners, actionSlots = [], deferValues = false, snapshotNested = false, liveStates = new Set() }) {
144
166
  const indexedBindingIndex = indexedReferences(bindingIndex, expression, expression) ? bindingIndex : undefined
145
167
  const allCaptures = nativeCaptureNames(expression, setters, indexedBindingIndex)
146
168
  const usedReducers = referencedReducerDispatches(expression.body, reducers, expression, indexedBindingIndex)
@@ -151,10 +173,11 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
151
173
  const usedStates = referencedStateNames(expression.body, setters, expression, indexedBindingIndex)
152
174
  for (const name of usedReducers) {
153
175
  const reducer = reducers.get(name)
154
- if (reducer.contextAction) for (const state of referencedStateNames(reducer.contextAction.body, reducer.states, reducer.contextAction, bindingIndex)) usedStates.add(state)
176
+ const direct = reducer.directImplementation ?? reducer.sharedAction?.directImplementation
177
+ if (direct) for (const state of referencedStateNames(direct.body, reducer.states ?? reducer.sharedAction.states, direct, bindingIndex)) usedStates.add(state)
155
178
  }
156
179
  const exportName = `${prefix}${entries.length}`
157
- const entry = { exportName, expression, captures, deferValues, imports, listItem, keyedBlock, liveStates, role, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), reducers: new Map([...reducers].filter(([name]) => usedReducers.has(name))), snapshotNested, usedStates, signalRefs: new Map([...usedStates].map(name => [name, signal(name, expression, stateOwners, [...setters].filter(([, state]) => state === name).map(([setter]) => setter))])), bindingIndex: indexedBindingIndex }
180
+ const entry = { actionSlots, exportName, expression, captures, deferValues, imports, listItem, keyedBlock, liveStates, role, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), reducers: new Map([...reducers].filter(([name]) => usedReducers.has(name))), snapshotNested, usedStates, signalRefs: new Map([...usedStates].map(name => [name, signal(name, expression, stateOwners, [...setters].filter(([, state]) => state === name).map(([setter]) => setter))])), bindingIndex: indexedBindingIndex }
158
181
  entries.push(entry)
159
182
  const value = name => deferValues
160
183
  ? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createIdentifier(name))
@@ -172,14 +195,14 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
172
195
  }
173
196
  }
174
197
 
175
- function compileOptimizedEvent(expression, setters, stateOwners, owner, keyedBlock) {
198
+ function compileOptimizedEvent(expression, setters, stateOwners, owner, keyedBlock, actions = []) {
176
199
  const statements = ts.isBlock(expression.body) ? expression.body.statements : [factory.createExpressionStatement(expression.body)]
177
200
  let commands = statements.map(statement => ts.isExpressionStatement(statement) && canSpecializeCommand(statement.expression, expression, setters) ? compileEventCommand(statement.expression, setters) : undefined)
178
201
  if ((!commands.length || commands.some(command => !command)) && compileEventCommand.handler) commands = compileEventCommand.handler(expression, setters, bindingIndex)
179
202
  if (!commands?.length || commands.some(command => !command)) return undefined
180
203
  const original = ts.getOriginalNode(expression)
181
204
  const source = original.pos >= 0 && original.end >= 0 ? { file: sourceName(original.getSourceFile()), start: original.getStart(), end: original.end } : undefined
182
- const handler = registerCommandHandler(moduleIR, commands.map(command => ({ ...command, reference: stateOwners.get(command.state) ?? stateReferences(expression).get(command.state) })), source)
205
+ const handler = registerCommandHandler(moduleIR, commands.map(command => ({ ...command, reference: stateOwners.get(command.state) ?? stateReferences(expression).get(command.state) })), source, actions)
183
206
  if (keyedBlock !== undefined) handler.keyedBlock = keyedBlock
184
207
  return generateCommandBehavior(moduleIR, handler, factory)
185
208
  }
@@ -244,7 +267,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
244
267
  snapshot: lowered.captureSnapshots.includes(name)
245
268
  })),
246
269
  imports: entry.imports.map(entry => importSlot(importRecord(entry))),
247
- actions: [...entry.reducers.values()].flatMap(reducer => reducer.sharedAction ? [reducer.sharedAction.slot] : []),
270
+ actions: [...new Set([...entry.actionSlots, ...[...entry.reducers.values()].flatMap(reducer => reducer.sharedAction ? [reducer.sharedAction.slot] : [])])],
248
271
  code: lowered.code,
249
272
  ...(source(entry.expression) ? { source: source(entry.expression) } : {})
250
273
  })
@@ -258,6 +281,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
258
281
  signals: [...entry.states].map(name => entry.signalRefs.get(name)),
259
282
  captures: [...entry.captures].map(name => ({ ...(symbolSlot(entry, name) !== undefined ? { symbol: symbolSlot(entry, name) } : {}), name, source: "scope" })),
260
283
  imports: entry.imports.map(entry => importSlot(importRecord(entry))),
284
+ ...(entry.derived ? { derived: entry.derived } : {}),
261
285
  code: handlerLowering.lowerReactiveBinding(entry),
262
286
  ...(source(entry.expression) ? { source: source(entry.expression) } : {})
263
287
  })
@@ -293,7 +317,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
293
317
 
294
318
  const importRecord = entry => ({ target: entry.target, kind: entry.kind, local: entry.local, ...(entry.imported ? { imported: entry.imported } : {}), package: Boolean(entry.package) })
295
319
 
296
- return { compileConditional, compileEffectCallback, compileEvent, compileListConditional, compileListValue, compileReactiveBinding, finalize, registerDerived: registerDerivedResult, registerEffect: registerEffectResult, registerKeyedBlock: registerKeyedBlockResult, signal }
320
+ return { compileConditional, compileDerivedEvaluator, compileEffectCallback, compileEvent, compileListConditional, compileListValue, compileReactiveBinding, finalize, registerDerived: registerDerivedResult, registerEffect: registerEffectResult, registerKeyedBlock: registerKeyedBlockResult, signal }
297
321
  }
298
322
 
299
323
  function bindingIdentifier(name, names) {