@kudzujs/core 0.8.55 → 0.8.56

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  This document is the source of truth for Kudzu's product direction, architecture invariants, and future development order. Read it before extending React-shaped syntax or browser capabilities.
4
4
 
5
- The executable post-`0.8.55` compiler and large-application sequence is maintained in [`docs/next-architecture/large-application-ai-native-roadmap.md`](./docs/next-architecture/large-application-ai-native-roadmap.md). Follow its PR dependencies for implementation work; this document remains authoritative when selecting or accepting a migration capability.
5
+ The executable post-`0.8.56` compiler and large-application sequence is maintained in [`docs/next-architecture/large-application-ai-native-roadmap.md`](./docs/next-architecture/large-application-ai-native-roadmap.md). Follow its PR dependencies for implementation work; this document remains authoritative when selecting or accepting a migration capability.
6
6
 
7
7
  [`GOAL_A.md`](./GOAL_A.md) and [`GOAL_B.md`](./GOAL_B.md) are completed capability-validation records. Their commerce and realtime dashboard fixtures prove general lifecycle, navigation, async-workflow, and Worker capabilities; they are not separate product verticals or future priority lists.
8
8
 
package/PERFORMANCE.md CHANGED
@@ -6,7 +6,11 @@ Reproducibility classes: `npm run benchmark`, `npm run benchmark:keyed`, `npm ru
6
6
 
7
7
  The maintained 2026-08-13 comparison used Node 24.14.0 and an Intel Core i5-9500 Linux x64 host, one warm-up, and seven alternating fresh-process samples against clean `v0.8.44`. A narrow fast path skips Kudzu semantic transformation for 450 plain `.ts` modules whose runtime edges are exclusively resolvable relative TypeScript imports or exports; all other modules retain the existing transformer. Compile median fell from 2,323.9 ms to 1,413.2 ms (39.2%) and clean-build median from 3,325.3 ms to 2,382.4 ms (28.4%); every paired sample improved. Compile peak-RSS median fell from 571.2 MiB to 552.6 MiB, while build peak RSS was 570.9 MiB versus 568.8 MiB. Compiler scratch fell from 7,328,390 to 1,971,061 bytes. Both targets emitted the same 50 static HTML files, 10,980 bytes, and deploy SHA-256 `e107d78a7f55bc8a1af0ea6e53efeffa19b3d44d21c892484d103fa346e7ba7b`. This is a source-scale compiler comparison, not a cross-framework result.
8
8
 
9
- ## Current 0.8.55 Release Snapshot
9
+ ## Current 0.8.56 Release Snapshot
10
+
11
+ Kudzu 0.8.56 avoids unaffected source compilation and build-time JSX execution during development. The focused two-route correctness fixture recompiles two of four modules and rerenders one of two pages after a route-owned helper edit; this is a work-reduction assertion, not a timing claim.
12
+
13
+ ## Maintained 0.8.55 Release Snapshot
10
14
 
11
15
  Kudzu 0.8.55 replaces site-wide runtime specialization with deterministic route or navigation-group capability families. Unrelated standalone capabilities no longer change another route's loaded runtime bytes or cache URL; no new timing claim is made.
12
16
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
14
14
 
15
15
  > Experimental `0.8.x`: the compiler API and supported TSX surface may change.
16
16
 
17
- **Latest release: 0.8.55 - Signature-keyed runtime families.** Equal standalone capability signatures reuse one runtime family, different signatures stay isolated, and enhanced-navigation groups retain one union ESM singleton for layout state and lifecycle ownership. Read the [release notes](./RELEASES.md#0855---signature-keyed-runtime-families), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.8.55), or follow the [architecture packet](./docs/next-architecture/README.md).
17
+ **Latest release: 0.8.56 - Incremental affected-route development builds.** Source edits now recompile and rerender only intersecting page graphs while preserving full-build output, navigation-group identity, and rollback-safe staging. Read the [release notes](./RELEASES.md#0856---incremental-affected-route-development-builds), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.8.56), or follow the [architecture packet](./docs/next-architecture/README.md).
18
18
 
19
19
  - [Documentation](https://kudzujs.cloud/docs)
20
20
  - [Installation guide](https://kudzujs.cloud/docs#install)
package/RELEASES.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.56 - Incremental affected-route development builds
4
+
5
+ Kudzu 0.8.56 keeps one project session alive during development so source edits recompile and rerender only the routes whose runtime graphs changed.
6
+
7
+ ### Changed in 0.8.56
8
+
9
+ - The development server batches changed source paths and intersects them with current and prior per-page runtime graphs.
10
+ - Unaffected compiled `SourceResult` and pre-family route render records are reused across successful rebuilds.
11
+ - Every affected page recompiles its complete reachable graph so imported component specialization remains correct.
12
+ - Enhanced-navigation groups invalidate together to preserve shared layout function and ESM singleton identity.
13
+ - Generation-specific build-time module paths prevent stale Node ESM imports after both successful and failed builds.
14
+
15
+ ### Safety
16
+
17
+ - Failed rebuilds retain the previous deployed output and carry every pending changed path into the next attempt.
18
+ - Added and removed source files refresh the session index and reachable source set.
19
+ - CSS, assets, and unknown source events conservatively invalidate every page.
20
+ - Production builds retain the existing complete staging, collision checks, `afterBuild()`, and rollback-safe promotion.
21
+ - No browser runtime, hydration layer, VDOM, retained component tree, or SPA router is added.
22
+
23
+ ### Validation
24
+
25
+ - A focused independent-route fixture changes one route-owned helper and recompiles two of four modules while rerendering one of two pages.
26
+ - Its unaffected interactive route is reused, its static sibling remains zero JavaScript, and incremental deploy output is byte-identical to a fresh-process full build.
27
+ - Existing development error recovery, navigation groups, Workers, handlers, route families, and source-scale checks pass.
28
+ - `npm run check`, `npm test`, and `npm run test:package` pass with all 222 tests and 171 generated pages.
29
+
30
+ ### Upgrade
31
+
32
+ ```sh
33
+ npm install @kudzujs/core@^0.8.56
34
+ ```
35
+
3
36
  ## 0.8.55 - Signature-keyed runtime families
4
37
 
5
38
  Kudzu 0.8.55 replaces site-wide runtime specialization with deterministic capability families loaded only by the routes that require them.
@@ -11,7 +11,7 @@ The top-level [`GOAL_A.md`](../../GOAL_A.md) and [`GOAL_B.md`](../../GOAL_B.md)
11
11
  | C: state/resource model | Research only | Reduced fixtures expose a limitation |
12
12
  | D: routing compatibility | Current behavior preserved | Revisit only with migration evidence and invariant review |
13
13
 
14
- The active post-`0.8.55` implementation sequence is [`large-application-ai-native-roadmap.md`](./large-application-ai-native-roadmap.md). P0.12 validation, property-level object-state dependencies, direct three-boundary callback/ref ownership, direct primitive prop state initialization, repeated direct leaf-handler callback use, direct child callback fan-out, collision-free and private Context action setters, package-neutral shared-state/action IR, browser-only package imports and private mutable refs in owned effects, route/layout CSS closure, structural per-route capability/chunk reporting, signature-keyed runtime families, measured route-entry output optimization, and the plain TypeScript source-scale fast path are complete. Incremental source and affected-route builds are next; ResourceIR remains research for cross-owner transports and subscriptions. The plan orders compiler semantic generalization, large-application foundations, compatibility boundaries, AI tooling, and production validation without changing the invariants below.
14
+ The active post-`0.8.56` implementation sequence is [`large-application-ai-native-roadmap.md`](./large-application-ai-native-roadmap.md). P0.12 validation, property-level object-state dependencies, direct three-boundary callback/ref ownership, direct primitive prop state initialization, repeated direct leaf-handler callback use, direct child callback fan-out, collision-free and private Context action setters, package-neutral shared-state/action IR, browser-only package imports and private mutable refs in owned effects, route/layout CSS closure, structural per-route capability/chunk reporting, signature-keyed runtime families, incremental source and affected-route development builds, measured route-entry output optimization, and the plain TypeScript source-scale fast path are complete. ResourceIR remains research for cross-owner transports and subscriptions; range ownership, virtualization, and optimistic transactions still require independent fixtures. The plan orders compiler semantic generalization, large-application foundations, compatibility boundaries, AI tooling, and production validation without changing the invariants below.
15
15
 
16
16
  ## Required Invariants
17
17
 
@@ -1,15 +1,15 @@
1
1
  # Current Compiler Architecture
2
2
 
3
- This maps the current `0.8.55` architecture, built on the completed `0.8.23` Goal A compiler foundation. File and function names are the stable references; line numbers are intentionally omitted because later work may still move code.
3
+ This maps the current `0.8.56` architecture, built on the completed `0.8.23` Goal A compiler foundation. File and function names are the stable references; line numbers are intentionally omitted because later work may still move code.
4
4
 
5
5
  ## Responsibility Map
6
6
 
7
7
  | Responsibility | Current owner | Current contract |
8
8
  |---|---|---|
9
9
  | CLI entry | [`bin/kudzu.mjs`](../../bin/kudzu.mjs) | Dispatches build and development commands. |
10
- | Project session | [`framework/compiler/project-session.mjs`](../../framework/compiler/project-session.mjs), `createProjectSession()` | Owns one absolute root, standard project paths, source records, bound graph operations, and Worker compiler for a build. Omitted roots resolve from call-time CWD. |
10
+ | Project session | [`framework/compiler/project-session.mjs`](../../framework/compiler/project-session.mjs), `createProjectSession()` | Owns one absolute root, standard project paths, source records, bound graph operations, and Worker compiler. Production builds use one session; development retains it across rebuilds. Omitted roots resolve from call-time CWD. |
11
11
  | Parsed module cache and symbols | [`framework/compiler/project-session.mjs`](../../framework/compiler/project-session.mjs) | Parses each unchanged source module once per ProjectSession and records source-local declaration/import/re-export sites. Stable ModuleSymbol records resolve direct, aliased, barrel, and `export *` exports with cycle and ambiguity checks; repeated resolutions are cached against their source dependencies, and normalization consumers locate the resolved SiteId in a fresh clone with independent parent links. |
12
- | Build orchestration | [`framework/build.mjs`](../../framework/build.mjs), `build()` | Coordinates config, discovery, source compilation, RouteBuildRecord collection, CapabilityIR projection, generator invocation, artifact emission, and `afterBuild`. |
12
+ | Build orchestration | [`framework/build.mjs`](../../framework/build.mjs), `build()`, `buildWithSession()` | Coordinates config, discovery, source compilation, RouteBuildRecord collection, CapabilityIR projection, generator invocation, artifact emission, and `afterBuild`. A retained session caches source results and pre-family route renders by page graph; successful builds alone replace that cache. |
13
13
  | Reachability/import resolution | [`framework/compiler/source-compiler.mjs`](../../framework/compiler/source-compiler.mjs), `reachableSourceFiles()`; [`framework/compiler/source-graph.mjs`](../../framework/compiler/source-graph.mjs), `ordinaryRuntimeDependencies()`, `resolveSourceImport()` | Starts from page entries, follows relative runtime imports/re-exports and validated Worker references, excludes unreachable migration source, and fails unresolved ordinary edges or dynamic imports at the importer source location before code generation. |
14
14
  | Ordered normalization | [`framework/compiler/normalization-pipeline.mjs`](../../framework/compiler/normalization-pipeline.mjs), `applyNormalizationPasses()`; [`framework/compiler/source-compiler.mjs`](../../framework/compiler/source-compiler.mjs), `normalizeCompilerSource()` | Applies migration/resource passes in order and repairs TypeScript parent pointers after every structural change. Imported source uses the same pipeline. |
15
15
  | Focused normalization passes | [`framework/compiler/`](../../framework/compiler/) | React, Router, browser signals, animation-frame refs, custom-hook timers, Zustand, and render control each validate and lower a narrow source shape. |
@@ -35,7 +35,7 @@ This maps the current `0.8.55` architecture, built on the completed `0.8.23` Goa
35
35
  | Artifact emission | `framework/build.mjs` | Selects route artifacts from RouteBuildRecord edges, emits each distinct runtime family under `assets/runtime/<family>/`, writes route HTML in bounded batches, bundles in a project-local staging sibling, copies public subtrees without replacing generated paths, runs `afterBuild`, then promotes with rollback. Byte-identical native, parameter, and effect entries share one file only when their family imports also match. |
36
36
  | Browser capabilities | [`framework/*.js`](../../framework/) | Small optional modules for commands, bindings, lists, effects, native handlers, serialization, parameters, and navigation; native contexts invalidate writes and refs at DOM ownership release, with no component runtime. |
37
37
  | Opt-in navigation | [`framework/navigation-runtime.js`](../../framework/navigation-runtime.js) plus `framework/build.mjs` navigation configuration/emission | Fetches and validates complete same-origin documents, replaces only the marked route range, manages route/layout disposal, history, focus, finite prefetch retention, and native fallback. |
38
- | Development serving | [`framework/dev-server.mjs`](../../framework/dev-server.mjs) and [`framework/dev-state.js`](../../framework/dev-state.js) | Rebuild/watch/SSE and response-only short-lived state restoration; failed rebuilds show the existing error overlay while preserving the previous on-disk output. |
38
+ | Development serving | [`framework/dev-server.mjs`](../../framework/dev-server.mjs) and [`framework/dev-state.js`](../../framework/dev-state.js) | Watches and batches changed source paths, recompiles and rerenders intersecting page graphs, then performs the existing complete staged artifact emission and SSE reload. Navigation groups invalidate together to retain one layout-function identity. Failed rebuilds preserve both prior output and pending invalidations while showing the existing error overlay. |
39
39
 
40
40
  ## Current Data Flow
41
41
 
@@ -43,6 +43,8 @@ This maps the current `0.8.55` architecture, built on the completed `0.8.23` Goa
43
43
  src/pages entries + config
44
44
  -> ProjectSession(root) with project paths, source records, parsed/symbol caches, graph, and Worker compiler
45
45
  -> project discovery and reachable relative graph
46
+ -> in development, intersect changed paths with current and prior per-page graphs
47
+ -> reuse unaffected SourceResult and pre-family route render records
46
48
  -> compileSource()
47
49
  -> ordered normalization and parent repair
48
50
  -> source-local binding index
@@ -75,6 +77,7 @@ The browser consumes static HTML first. State seeds and descriptors in that HTML
75
77
  - `build()` still owns filesystem writes after structural route artifact selection and generator results are produced.
76
78
  - Runtime generators intentionally specialize readable authored sources through exact anchors; every required anchor fails closed, but a future generator format may remove this transitional dependency.
77
79
  - Source reachability and source compilation remain in one session-bound compiler factory because both consume the same normalization and import graph contracts. ModuleSymbol resolution is stable across canonical and cloned trees; unsupported export syntax remains intentionally narrow.
80
+ - Incremental development still reads the source tree, rebuilds graph closure, and emits a complete rollback-safe staging directory. It avoids unaffected compilation and build-time JSX execution; finer filesystem and artifact-write invalidation is not yet justified.
78
81
  - Imported declarations resolve by ModuleSymbol and source-local SiteId. Specialized and compiler-synthesized trees still use conservative name/scope fallback where the source-local binding index does not own the complete AST.
79
82
 
80
83
  These are future simplification opportunities, not incomplete Goal A contracts. Goal A changed no source support, browser output semantics, or browser architecture.
@@ -34,7 +34,7 @@ The current limiting architecture is observable in these files:
34
34
 
35
35
  | Concern | Current owner | Limitation to remove |
36
36
  |---|---|---|
37
- | Project graph | `framework/compiler/project-session.mjs`, `source-graph.mjs`, `build.mjs` | Explicit build-scoped root, parsed-module/export-summary cache, and source ownership; narrow export resolution and no incremental invalidation remain |
37
+ | Project graph | `framework/compiler/project-session.mjs`, `source-graph.mjs`, `build.mjs` | Explicit project root, parsed-module/export-summary cache, source ownership, and development-session source/result/route-render invalidation; export resolution remains intentionally narrow |
38
38
  | Normalization | `framework/compiler/source-compiler.mjs`, focused passes | Pass order and package ownership are implicit; semantically equal source often follows different shape-specific paths |
39
39
  | Component/state analysis | `framework/compiler/source-compiler.mjs`, `analysis/component-analysis.mjs` | AST identity, identifier text, source offsets, and caller-side AST specialization remain central |
40
40
  | Handler/binding analysis | `framework/compiler/descriptor-session.mjs`, `handler-lowering.mjs` | Capture/import/state discovery is name-based and arbitrary handlers become code before IR finalization |
@@ -384,7 +384,7 @@ After the relevant P0 foundations, investigate capabilities in this order:
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.
386
386
  6. Route/layout capability and CSS chunk closure. **Route/layout CSS closure completed in `0.8.53`; structural reporting completed in `0.8.54`; signature-keyed runtime emission completed in `0.8.55`:** each page's reachable TypeScript graph supplies exact stylesheet edges, and `.kudzu/kudzu-artifacts.json` follows handler and Worker metafiles transitively. Equal standalone CapabilityIR signatures now share one co-located runtime family, different signatures emit isolated files, and every enhanced-navigation group uses one union family so persistent layout state and lifecycle hooks retain a single ESM identity. Static routes emit no family, route entries import their assigned family, and unrelated route capabilities no longer change loaded runtime bytes or URLs.
387
- 7. Incremental source and affected-route builds.
387
+ 7. Incremental source and affected-route builds. **Completed in `0.8.56`:** development retains one ProjectSession, intersects batched changed paths with current and prior per-page runtime graphs, recompiles the complete graph of only affected pages, and reuses unaffected SourceResult and pre-family route render records. Enhanced-navigation groups invalidate together, failed builds retain pending changes and prior output, generation-specific build modules prevent stale Node ESM imports, and a focused independent-route fixture proves one helper change recompiles two of four modules, rerenders one of two pages, preserves a static zero-JavaScript sibling, and emits byte-identical deploy output to a fresh-process full build.
388
388
  8. Range ownership and virtualization only after a real data-heavy fixture establishes direct DOM limits.
389
389
  9. Optimistic shared transactions only after independent mutation fixtures establish commit/rollback semantics.
390
390
 
@@ -481,4 +481,4 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
481
481
 
482
482
  ## Immediate Decision
483
483
 
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 prop initializer slice, 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, and the `0.8.42` measured route-output optimization are complete. Continue with item 7 incremental source and affected-route builds; keep ResourceIR limited to cross-owner fixtures and do not skip directly to a public adapter/store API, router, virtualization, or unsupported ecosystem package feature.
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 prop initializer slice, 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 cross-owner fixtures and do not add range ownership, virtualization, optimistic transactions, a public adapter/store API, or a router before independent fixtures justify 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.55` are completed scopes represented by package/release records.
3
+ This is an execution sequence, not release history. `0.8.16` through `0.8.56` 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
 
@@ -46,6 +46,7 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
46
46
  | `0.8.53` | Close source CSS over each route's reachable TypeScript graph and reconcile managed styles during navigation. | Routes exclude unrelated CSS; destination styles load before replacement, shared layout links retain identity, and cancelled loads roll back safely. |
47
47
  | `0.8.54` | Project per-route capability requirements and post-bundle handler/Worker chunk closure into an inspectable artifact contract. | Deterministic signatures, exact retained entries, transitive chunks, reverse shared ownership, static-route exclusion, and `afterBuild()` access pass. |
48
48
  | `0.8.55` | Emit deduplicated signature-keyed runtime families while preserving navigation-group singleton ownership. | Equal standalone signatures share files, different signatures isolate output, grouped routes share one union family, and static routes emit no runtime. |
49
+ | `0.8.56` | Recompile and rerender only source-affected routes during development. | A route-owned helper edit recompiles two of four modules, rerenders one of two pages, and emits byte-identical deploy output to a fresh full build. |
49
50
 
50
51
  ## Sequence Rules
51
52
 
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto"
2
2
  import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
3
- import { dirname, join, relative, resolve, sep } from "node:path"
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
4
4
  import { pathToFileURL } from "node:url"
5
5
  import { build as bundle, transform } from "esbuild"
6
6
  import { createEffectCodegen } from "./compiler/effect-codegen.mjs"
@@ -33,6 +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 })
37
+ }
38
+
39
+ export async function buildWithSession(project, { changedFiles, quiet = false, minify = true } = {}) {
36
40
  const { root, outputDirectory } = project
37
41
  const stagedOutput = join(root, ".kudzu-dist-staging")
38
42
  const backupOutput = join(root, ".kudzu-dist-backup")
@@ -41,8 +45,9 @@ export async function build({ quiet = false, minify = true, root: projectRoot =
41
45
  try {
42
46
  await recoverOutput(outputDirectory, backupOutput)
43
47
  await rm(stagedOutput, { recursive: true, force: true })
44
- const { result, pageCount, behaviorCount } = await buildInto(project, stagedOutput, { minify })
48
+ const { result, pageCount, behaviorCount, cache } = await buildInto(project, stagedOutput, { changedFiles, minify })
45
49
  await promoteOutput(stagedOutput, outputDirectory, backupOutput)
50
+ project.buildCache = cache
46
51
  if (!quiet) console.log(`Built ${pageCount} page(s), ${behaviorCount} interactive page(s) into dist/`)
47
52
  return result
48
53
  } finally {
@@ -58,8 +63,11 @@ export async function build({ quiet = false, minify = true, root: projectRoot =
58
63
  }
59
64
  }
60
65
 
61
- async function buildInto(project, outputDirectory, { minify }) {
66
+ async function buildInto(project, outputDirectory, { changedFiles, minify }) {
62
67
  const { root, sourceDirectory, pagesDirectory, workDirectory } = project
68
+ const previous = project.buildCache
69
+ project.buildGeneration = (project.buildGeneration ?? 0) + 1
70
+ project.buildDirectory = project.buildGeneration > 1 ? join(workDirectory, "build", String(project.buildGeneration)) : workDirectory
63
71
  const { collectClientModules, compileClientModule, compiledPath, compileSource, layoutExportError, orderSourceStyles, reachableSourceFiles, safeStaticFiles } = createSourceCompiler(project)
64
72
  const config = await loadConfig(root)
65
73
  const base = normalizeBase(config.base)
@@ -89,11 +97,14 @@ async function buildInto(project, outputDirectory, { minify }) {
89
97
  if (!allSourceFiles.length) throw new Error("No TypeScript files found in src/")
90
98
  const allSourceFileSet = new Set(allSourceFiles)
91
99
  const sourceIndex = project.sourceIndex
100
+ for (const file of sourceIndex.keys()) if (file.startsWith(`${sourceDirectory}${sep}`) && !allSourceFileSet.has(file)) sourceIndex.delete(file)
92
101
  for (const [file, source] of await Promise.all(allSourceFiles.map(async file => [file, await readFile(file, "utf8")]))) sourceIndex.set(file, source)
93
102
  const pageFiles = allSourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))
94
103
  if (!pageFiles.length) throw new Error("No pages found in src/pages/")
95
- const sourceFiles = reachableSourceFiles(pageFiles, allSourceFileSet, sourceIndex)
104
+ const pageSources = new Map(pageFiles.map(file => [file, new Set(reachableSourceFiles([file], allSourceFileSet, sourceIndex))]))
105
+ const sourceFiles = [...new Set([...pageSources.values()].flatMap(files => [...files]))].sort()
96
106
  const sourceFileSet = project.sourceFiles
107
+ sourceFileSet.clear()
97
108
  for (const file of sourceFiles) sourceFileSet.add(file)
98
109
  const staticFiles = await safeStaticFiles(projectFiles)
99
110
  const stylesByPage = new Map(pageFiles.map(file => [file, orderSourceStyles([file], sourceFiles, sourceIndex, staticFiles).filter(style => !configuredStyleSources.has(style))]))
@@ -101,15 +112,26 @@ async function buildInto(project, outputDirectory, { minify }) {
101
112
  const importedAssets = new Set()
102
113
  const { cssModules, cssOutputs } = await prepareSourceStyles(cssFiles, staticFiles, importedAssets, base, project)
103
114
 
115
+ const affectedPages = affectedPageFiles({ changedFiles, pageFiles, pageSources, previous, sourceDirectory })
116
+ expandAffectedNavigationGroups(affectedPages, previous?.pageRenders, navigationGroups)
117
+ const affectedSources = new Set([...affectedPages].flatMap(file => [...pageSources.get(file)]))
104
118
  const sourceResults = []
119
+ const sourceResultsByFile = new Map()
120
+ let compiledModules = 0
105
121
  for (const file of sourceFiles) {
106
122
  if (file.endsWith(".worker.ts")) continue
107
- const result = compileSource(file, sourceFileSet, sourceIndex, staticFiles, cssModules, base)
123
+ let result = !affectedSources.has(file) ? previous?.sourceResults.get(file) : undefined
124
+ if (!result) {
125
+ result = compileSource(file, sourceFileSet, sourceIndex, staticFiles, cssModules, base)
126
+ compiledModules++
127
+ }
128
+ result = { ...result, buildModule: { ...result.buildModule, path: relative(root, compiledPath(file)).replaceAll(sep, "/") } }
108
129
  for (const asset of result.importedAssets) importedAssets.add(resolve(root, asset))
109
130
  const output = resolve(root, result.buildModule.path)
110
131
  await mkdir(dirname(output), { recursive: true })
111
132
  await writeFile(output, result.buildModule.code)
112
133
  sourceResults.push(result)
134
+ sourceResultsByFile.set(file, result)
113
135
  }
114
136
  const handlerModules = sourceResults.flatMap(result => result.handlerModule ? [result.handlerModule] : [])
115
137
  const workerReferences = sourceResults.flatMap(result => result.moduleIR.effects.flatMap(effect => {
@@ -128,11 +150,37 @@ async function buildInto(project, outputDirectory, { minify }) {
128
150
  const emittedApplicationRoutes = new Set()
129
151
  const emittedNavigationRecords = []
130
152
  const navigationAssets = new Map()
131
- const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
132
- const bindingPlaceholder = `/__kudzu_binding_${randomUUID()}.js`
133
- const listPlaceholder = `/__kudzu_list_${randomUUID()}.js`
153
+ const placeholders = previous?.placeholders ?? {
154
+ runtime: `/__kudzu_runtime_${randomUUID()}.js`,
155
+ binding: `/__kudzu_binding_${randomUUID()}.js`,
156
+ list: `/__kudzu_list_${randomUUID()}.js`
157
+ }
158
+ const runtimePlaceholder = placeholders.runtime
159
+ const bindingPlaceholder = placeholders.binding
160
+ const listPlaceholder = placeholders.list
161
+ const pageRenders = new Map()
162
+ let renderedPages = 0
134
163
 
135
164
  for (const pageFile of pageFiles) {
165
+ const cached = !affectedPages.has(pageFile) ? previous?.pageRenders.get(pageFile) : undefined
166
+ if (cached) {
167
+ replayPageRender(cached, {
168
+ emittedApplicationRoutes,
169
+ emittedNavigationRecords,
170
+ emittedRoutes,
171
+ navigationAssets,
172
+ navigationByRoute,
173
+ routeDrafts,
174
+ routeRecords,
175
+ rewrites
176
+ })
177
+ pageRenders.set(pageFile, cached)
178
+ continue
179
+ }
180
+ renderedPages++
181
+ const draftOffset = routeDrafts.length
182
+ const navigationOffset = emittedNavigationRecords.length
183
+ const rewriteOffset = rewrites.length
136
184
  const sourceStyleUrls = stylesByPage.get(pageFile).map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)).filter(url => !globalStyleUrlSet.has(url))
137
185
  const styleUrls = [...sourceStyleUrls, ...globalStyleUrls]
138
186
  const compiledFile = compiledPath(pageFile)
@@ -231,8 +279,14 @@ async function buildInto(project, outputDirectory, { minify }) {
231
279
  })
232
280
  routeRecords.push(record)
233
281
  if (navigationGroup) navigationGroup.buildRecords.push(record)
234
- routeDrafts.push({ record, result, runtimeSchema, navigationGroup, effectPath, nativePath, paramPath })
282
+ routeDrafts.push({ record, result, runtimeSchema, navigationGroup, applicationRoute, effectPath, nativePath, paramPath })
235
283
  }
284
+ pageRenders.set(pageFile, {
285
+ drafts: routeDrafts.slice(draftOffset).map(({ navigationGroup: _, ...draft }) => draft),
286
+ layout: module.layout,
287
+ navigationRecords: emittedNavigationRecords.slice(navigationOffset).map(({ group: _, ...record }) => record),
288
+ rewrites: rewrites.slice(rewriteOffset)
289
+ })
236
290
  }
237
291
 
238
292
  for (const group of navigationGroups) for (const route of group.routes) if (!emittedApplicationRoutes.has(route)) throw new Error(`${group.label} route ${JSON.stringify(route)} is not an emitted route`)
@@ -435,7 +489,78 @@ async function buildInto(project, outputDirectory, { minify }) {
435
489
  await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans, rewrites: sortedRewrites, artifacts })
436
490
  }
437
491
 
438
- return { result: { sourceResults }, pageCount: plans.length, behaviorCount }
492
+ const incremental = { compiledModules, renderedPages }
493
+ return {
494
+ result: { sourceResults, incremental },
495
+ pageCount: plans.length,
496
+ behaviorCount,
497
+ cache: { pageRenders, pageSources, placeholders, sourceResults: sourceResultsByFile }
498
+ }
499
+ }
500
+
501
+ function affectedPageFiles({ changedFiles, pageFiles, pageSources, previous, sourceDirectory }) {
502
+ if (!previous || changedFiles === undefined) return new Set(pageFiles)
503
+ if (changedFiles.some(file => typeof file !== "string")) return new Set(pageFiles)
504
+ const changes = new Set(changedFiles.map(file => isAbsolute(file) ? file : resolve(sourceDirectory, file)))
505
+ if ([...changes].some(file => !/\.(?:ts|tsx)$/.test(file))) return new Set(pageFiles)
506
+ const affected = new Set()
507
+ for (const page of pageFiles) {
508
+ const current = pageSources.get(page)
509
+ const prior = previous.pageSources.get(page)
510
+ if (!prior || [...changes].some(file => current.has(file) || prior.has(file))) affected.add(page)
511
+ }
512
+ return affected
513
+ }
514
+
515
+ function expandAffectedNavigationGroups(affected, pageRenders, groups) {
516
+ if (!affected.size || !pageRenders || !groups.length) return
517
+ const groupRoutes = groups.map(group => new Set(group.routes))
518
+ const groupIndexes = new Set()
519
+ for (const page of affected) {
520
+ const render = pageRenders.get(page)
521
+ if (!render) {
522
+ for (let index = 0; index < groups.length; index++) groupIndexes.add(index)
523
+ continue
524
+ }
525
+ for (const draft of render.drafts) for (let index = 0; index < groups.length; index++) if (groupRoutes[index].has(draft.applicationRoute)) groupIndexes.add(index)
526
+ }
527
+ for (const [page, render] of pageRenders) {
528
+ if (render.drafts.some(draft => [...groupIndexes].some(index => groupRoutes[index].has(draft.applicationRoute)))) affected.add(page)
529
+ }
530
+ }
531
+
532
+ function replayPageRender(cached, state) {
533
+ for (const rewrite of cached.rewrites) {
534
+ const conflicting = state.rewrites.find(entry => sameRuntimePrecedence(entry, rewrite))
535
+ if (conflicting) throw new Error(`Ambiguous runtime routes: ${conflicting.route} and ${rewrite.route}`)
536
+ state.rewrites.push(rewrite)
537
+ }
538
+ for (const entry of cached.navigationRecords) {
539
+ const group = state.navigationByRoute.get(entry.route)
540
+ const routeRecord = { ...entry, group }
541
+ state.emittedNavigationRecords.push(routeRecord)
542
+ state.emittedApplicationRoutes.add(entry.route)
543
+ if (!group) continue
544
+ if (typeof cached.layout !== "function") throw new Error(`${group.label} emitted route ${JSON.stringify(entry.record.path ?? entry.record.id)} must export a layout function so Kudzu can emit route markers`)
545
+ if (group.layoutIdentity && group.layoutIdentity !== cached.layout) throw new Error(`${group.label} routes ${JSON.stringify(group.layoutRoute)} and ${JSON.stringify(entry.route)} must export the same layout function identity`)
546
+ group.layoutIdentity = cached.layout
547
+ group.layoutRoute ??= entry.route
548
+ group.records.push(entry.record)
549
+ group.routeRecords.push(routeRecord)
550
+ }
551
+ for (const draft of cached.drafts) {
552
+ const navigationGroup = state.navigationByRoute.get(draft.applicationRoute)
553
+ if (state.emittedRoutes.has(draft.record.route)) throw new Error(`Duplicate route: ${draft.record.route}`)
554
+ state.emittedRoutes.add(draft.record.route)
555
+ if (navigationGroup) {
556
+ state.navigationAssets.set(draft.record.route, navigationGroup.assetPath)
557
+ navigationGroup.buildRecords.push(draft.record)
558
+ navigationGroup.hasEffects ||= draft.result.hasEffects
559
+ navigationGroup.hasParams ||= draft.result.hasParams
560
+ }
561
+ state.routeRecords.push(draft.record)
562
+ state.routeDrafts.push({ ...draft, navigationGroup })
563
+ }
439
564
  }
440
565
 
441
566
  async function acquireBuildLock(lockPath, root = dirname(lockPath)) {
@@ -609,7 +734,7 @@ export async function dev({ port = parseDevPort(process.env.PORT), host = parseD
609
734
  const project = createProjectSession(projectRoot)
610
735
  const { root, sourceDirectory, workDirectory, outputDirectory } = project
611
736
  const base = normalizeBase((await loadConfig(root)).base)
612
- return startDevServer({ build: options => build({ ...options, root }), port, host, base, sourceDirectory, workDirectory, outputDirectory })
737
+ return startDevServer({ build: options => buildWithSession(project, options), port, host, base, sourceDirectory, workDirectory, outputDirectory })
613
738
  }
614
739
 
615
740
  function inlineJson(value) {
@@ -24,6 +24,7 @@ import { createZustandPass } from "./zustand-pass.mjs"
24
24
 
25
25
  export function createSourceCompiler(project) {
26
26
  const { root, sourceDirectory, pagesDirectory, workDirectory, workerCompiler, modules, counters } = project
27
+ const buildDirectory = project.buildDirectory ?? workDirectory
27
28
  const { ordinaryRuntimeDependencies, resolveSourceImport, runtimeModuleReference } = project.graph
28
29
  const parseSourceFile = (file, source) => modules.read(file, source).sourceFile
29
30
  const staticAssetExtensions = new Set([".avif", ".gif", ".ico", ".jpeg", ".jpg", ".otf", ".png", ".svg", ".ttf", ".webp", ".woff", ".woff2"])
@@ -3166,7 +3167,7 @@ function clientModulePath(file) {
3166
3167
  }
3167
3168
 
3168
3169
  function compiledPath(file) {
3169
- return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
3170
+ return join(buildDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
3170
3171
  }
3171
3172
 
3172
3173
  const compileEventCommand = createCommandSpecializer({ isPrimitiveLiteral: isPrimitiveDefaultLiteral })
@@ -85,6 +85,7 @@ export async function startDevServer({ build, port, host, base, sourceDirectory,
85
85
  let rebuilding = false
86
86
  let pending = false
87
87
  let changedFile
88
+ const changedFiles = new Set()
88
89
  const rebuild = async () => {
89
90
  if (rebuilding) {
90
91
  pending = true
@@ -93,13 +94,16 @@ export async function startDevServer({ build, port, host, base, sourceDirectory,
93
94
  rebuilding = true
94
95
  do {
95
96
  pending = false
97
+ const changes = [...changedFiles]
98
+ changedFiles.clear()
96
99
  try {
97
- await build({ quiet: true, minify: false })
100
+ await build({ changedFiles: changes, quiet: true, minify: false })
98
101
  buildError = undefined
99
102
  revision++
100
103
  console.log(`Rebuilt after ${changedFile ?? "source change"}`)
101
104
  for (const client of clients) sendEvent(client, "reload")
102
105
  } catch (error) {
106
+ for (const file of changes) changedFiles.add(file)
103
107
  buildError = errorText(error)
104
108
  console.error(error)
105
109
  for (const client of clients) sendEvent(client, "build-error", buildError)
@@ -110,6 +114,7 @@ export async function startDevServer({ build, port, host, base, sourceDirectory,
110
114
  const watcher = watch(sourceDirectory, { recursive: true })
111
115
  for await (const event of watcher) {
112
116
  changedFile = event.filename
117
+ changedFiles.add(event.filename)
113
118
  clearTimeout(timer)
114
119
  timer = setTimeout(rebuild, 80)
115
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.55",
3
+ "version": "0.8.56",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",