@kudzujs/core 0.8.62 → 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 (47) hide show
  1. package/MIGRATION_ROADMAP.md +36 -1
  2. package/PERFORMANCE.md +79 -1
  3. package/README.md +2 -2
  4. package/RELEASES.md +29 -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 +5 -3
  17. package/docs/next-architecture/versioning.md +1 -1
  18. package/framework/README.md +2 -0
  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/route-artifact-report.mjs +4 -3
  33. package/framework/compiler/route-build-record.mjs +12 -0
  34. package/framework/compiler/route-capability-planner.mjs +3 -3
  35. package/framework/compiler/route-ir.mjs +27 -11
  36. package/framework/compiler/runtime-codegen.mjs +2 -2
  37. package/framework/compiler/source-compiler.mjs +359 -78
  38. package/framework/core.d.ts +1 -0
  39. package/framework/core.mjs +18 -5
  40. package/framework/dependency-runtime.js +1 -1
  41. package/framework/effect-runtime.js +2 -2
  42. package/framework/list-runtime.js +67 -24
  43. package/framework/native-runtime.js +12 -9
  44. package/framework/runtime.js +1 -1
  45. package/framework/serialization.js +13 -6
  46. package/framework/shared-runtime.js +14 -12
  47. 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
 
@@ -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, `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 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.
@@ -65,7 +65,7 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
65
65
 
66
66
  ## Generator Versions
67
67
 
68
- `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`.
69
69
 
70
70
  ## Release Boundary
71
71
 
@@ -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) {