@bamboocss/vite 1.46.0 → 1.46.2

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.
@@ -1,5 +1,6 @@
1
1
  const require_chunk = require("./chunk.cjs");
2
2
  let _bamboocss_shared = require("@bamboocss/shared");
3
+ let node_crypto = require("node:crypto");
3
4
  let node_path = require("node:path");
4
5
  let magic_string = require("magic-string");
5
6
  magic_string = require_chunk.__toESM(magic_string);
@@ -1007,6 +1008,97 @@ const createRuntimeToken = (ctx) => (path) => tokenValuesFor(ctx).get(path)?.var
1007
1008
  //#endregion
1008
1009
  //#region src/fold.ts
1009
1010
  /**
1011
+ * Decide whether an edit to `changedFile` moved anything a dependent's fold actually read.
1012
+ *
1013
+ * `'unchanged'` means every recorded read of the edited file re-digests to the same value —
1014
+ * the dependent's fold inputs did not move, so its output cannot have, and the re-fold can be
1015
+ * skipped outright. `'changed'` means a read definitely differs, which is the same verdict
1016
+ * the re-fold would reach after doing all the work. `'unknown'` is every other situation —
1017
+ * no read names the edited file (the relationship runs through a channel these records do
1018
+ * not witness, a barrel hop in a recipe binding walk, say), a digest could not be pinned
1019
+ * down on either side — and sends the caller to the full re-fold this replaced.
1020
+ *
1021
+ * `digestMemo` is the per-event cache: many dependents verify against the same edited file,
1022
+ * and each distinct `(kind, file, name)` needs digesting once, not once per dependent.
1023
+ */
1024
+ const verifyExportReads = (ctx, parseModule, reads, changedFile, digestMemo) => {
1025
+ const relevant = reads.filter((read) => read.file === changedFile);
1026
+ if (!relevant.length) return {
1027
+ verdict: "unknown",
1028
+ crossings: []
1029
+ };
1030
+ const crossings = /* @__PURE__ */ new Set();
1031
+ for (const read of relevant) {
1032
+ if (read.digest === void 0) return {
1033
+ verdict: "unknown",
1034
+ crossings: []
1035
+ };
1036
+ const key = `${read.kind}\u0000${read.file}\u0000${read.name}`;
1037
+ if (!digestMemo.has(key)) if (read.kind === "value") {
1038
+ const crossed = [];
1039
+ const digest = ctx.project?.digestExportRead?.(read.file, read.name, (path) => crossed.push(path));
1040
+ digestMemo.set(key, {
1041
+ digest,
1042
+ crossings: crossed
1043
+ });
1044
+ } else {
1045
+ const batched = `recipe-file\u0000${read.file}`;
1046
+ if (!digestMemo.has(batched)) {
1047
+ try {
1048
+ const result = parseModule(read.file);
1049
+ if (result) for (const [name, entry] of collectRecipeConfigs(result)) digestMemo.set(`recipe\u0000${read.file}\u0000${name}`, {
1050
+ digest: entry === AMBIGUOUS ? "bamboo:export-missing" : digestRecipeConfig(entry),
1051
+ crossings: []
1052
+ });
1053
+ } catch {}
1054
+ digestMemo.set(batched, {
1055
+ digest: "bamboo:batched",
1056
+ crossings: []
1057
+ });
1058
+ }
1059
+ if (!digestMemo.has(key)) digestMemo.set(key, {
1060
+ digest: digestRecipeReadNow(parseModule, read.file, read.name),
1061
+ crossings: []
1062
+ });
1063
+ }
1064
+ const entry = digestMemo.get(key);
1065
+ if (entry.digest === void 0) return {
1066
+ verdict: "unknown",
1067
+ crossings: []
1068
+ };
1069
+ if (entry.digest !== read.digest) return {
1070
+ verdict: "changed",
1071
+ crossings: []
1072
+ };
1073
+ for (const path of entry.crossings) crossings.add(path);
1074
+ }
1075
+ return {
1076
+ verdict: "unchanged",
1077
+ crossings: [...crossings]
1078
+ };
1079
+ };
1080
+ const digestRecipeReadNow = (parseModule, file, name) => {
1081
+ try {
1082
+ const result = parseModule(file);
1083
+ if (!result) return "bamboo:module-missing";
1084
+ const entry = collectRecipeConfigs(result).get(name);
1085
+ if (!entry || entry === AMBIGUOUS) return "bamboo:export-missing";
1086
+ return digestRecipeConfig(entry);
1087
+ } catch {
1088
+ return;
1089
+ }
1090
+ };
1091
+ /** The verification witness for a foreign recipe read: the config bytes, order preserved. */
1092
+ const digestRecipeConfig = (entry) => {
1093
+ try {
1094
+ const json = JSON.stringify(entry.config, (_key, value) => value === void 0 ? "bamboo:undefined" : value);
1095
+ if (json === void 0) return void 0;
1096
+ return (0, node_crypto.createHash)("sha256").update(json).digest("base64");
1097
+ } catch {
1098
+ return;
1099
+ }
1100
+ };
1101
+ /**
1010
1102
  * `cva`/`sva` return a function, so their definitions are compile-time declarations rather
1011
1103
  * than class-producing calls; once their uses are lowered, the factory calls are erased.
1012
1104
  * `token` also resolves to no class, but it does resolve to a literal, so it compiles through
@@ -1480,6 +1572,8 @@ const foldSource = (options) => {
1480
1572
  const helperModules = /* @__PURE__ */ new Map();
1481
1573
  /** Declaring modules a fold read, recorded as paths because their nodes do not persist. */
1482
1574
  const foreignDependencies = /* @__PURE__ */ new Set();
1575
+ /** Foreign recipe configs consumed, digested at read time for later verification. */
1576
+ const exportReads = [];
1483
1577
  /** Resolutions for this module's own call sites, keyed by the name the call site writes. */
1484
1578
  const importedRecipes = /* @__PURE__ */ new Map();
1485
1579
  /**
@@ -1520,6 +1614,12 @@ const foldSource = (options) => {
1520
1614
  const entry = foreign.configs.get(origin.name);
1521
1615
  if (!entry || entry === AMBIGUOUS) return void 0;
1522
1616
  foreignDependencies.add(origin.filePath);
1617
+ exportReads.push({
1618
+ kind: "recipe",
1619
+ file: origin.filePath,
1620
+ name: origin.name,
1621
+ digest: digestRecipeConfig(entry)
1622
+ });
1523
1623
  helperModules.set(name, foreign.cssSpecifier ? rebaseSpecifier(foreign.cssSpecifier, origin.filePath, consuming.getFilePath()) : void 0);
1524
1624
  return entry;
1525
1625
  };
@@ -2092,7 +2192,8 @@ const foldSource = (options) => {
2092
2192
  map: null,
2093
2193
  folded,
2094
2194
  skipped,
2095
- dependencies: []
2195
+ dependencies: [],
2196
+ exportReads: []
2096
2197
  };
2097
2198
  }
2098
2199
  const rewriteSourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile() ?? recipeDefinitions[0]?.call.getSourceFile();
@@ -2101,7 +2202,8 @@ const foldSource = (options) => {
2101
2202
  map: null,
2102
2203
  folded,
2103
2204
  skipped,
2104
- dependencies: []
2205
+ dependencies: [],
2206
+ exportReads: []
2105
2207
  };
2106
2208
  const dependencyScan = createDependencyScan(rewriteSourceFile);
2107
2209
  candidates.sort((a, b) => a.start - b.start || b.end - a.end);
@@ -2408,7 +2510,8 @@ const foldSource = (options) => {
2408
2510
  map: null,
2409
2511
  folded,
2410
2512
  skipped,
2411
- dependencies: []
2513
+ dependencies: [],
2514
+ exportReads: []
2412
2515
  };
2413
2516
  return {
2414
2517
  code: magic.toString(),
@@ -2419,7 +2522,8 @@ const foldSource = (options) => {
2419
2522
  }),
2420
2523
  folded,
2421
2524
  skipped,
2422
- dependencies: [...dependencyScan.results, ...foreignDependencies]
2525
+ dependencies: [...dependencyScan.results, ...foreignDependencies],
2526
+ exportReads
2423
2527
  };
2424
2528
  };
2425
2529
  //#endregion
@@ -2485,3 +2589,4 @@ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (cl
2485
2589
  exports.createRuntimeCss = createRuntimeCss;
2486
2590
  exports.createStaticStyleSetCompiler = createStaticStyleSetCompiler;
2487
2591
  exports.foldSource = foldSource;
2592
+ exports.verifyExportReads = verifyExportReads;
@@ -1,4 +1,5 @@
1
1
  import { compact, createCssUncached, createMergeCss, memo, viewTransitionClassName } from "@bamboocss/shared";
2
+ import { createHash } from "node:crypto";
2
3
  import { dirname, relative, resolve } from "node:path";
3
4
  import MagicString from "magic-string";
4
5
  import { resolveTsPathPattern } from "@bamboocss/config/ts-path";
@@ -1005,6 +1006,97 @@ const createRuntimeToken = (ctx) => (path) => tokenValuesFor(ctx).get(path)?.var
1005
1006
  //#endregion
1006
1007
  //#region src/fold.ts
1007
1008
  /**
1009
+ * Decide whether an edit to `changedFile` moved anything a dependent's fold actually read.
1010
+ *
1011
+ * `'unchanged'` means every recorded read of the edited file re-digests to the same value —
1012
+ * the dependent's fold inputs did not move, so its output cannot have, and the re-fold can be
1013
+ * skipped outright. `'changed'` means a read definitely differs, which is the same verdict
1014
+ * the re-fold would reach after doing all the work. `'unknown'` is every other situation —
1015
+ * no read names the edited file (the relationship runs through a channel these records do
1016
+ * not witness, a barrel hop in a recipe binding walk, say), a digest could not be pinned
1017
+ * down on either side — and sends the caller to the full re-fold this replaced.
1018
+ *
1019
+ * `digestMemo` is the per-event cache: many dependents verify against the same edited file,
1020
+ * and each distinct `(kind, file, name)` needs digesting once, not once per dependent.
1021
+ */
1022
+ const verifyExportReads = (ctx, parseModule, reads, changedFile, digestMemo) => {
1023
+ const relevant = reads.filter((read) => read.file === changedFile);
1024
+ if (!relevant.length) return {
1025
+ verdict: "unknown",
1026
+ crossings: []
1027
+ };
1028
+ const crossings = /* @__PURE__ */ new Set();
1029
+ for (const read of relevant) {
1030
+ if (read.digest === void 0) return {
1031
+ verdict: "unknown",
1032
+ crossings: []
1033
+ };
1034
+ const key = `${read.kind}\u0000${read.file}\u0000${read.name}`;
1035
+ if (!digestMemo.has(key)) if (read.kind === "value") {
1036
+ const crossed = [];
1037
+ const digest = ctx.project?.digestExportRead?.(read.file, read.name, (path) => crossed.push(path));
1038
+ digestMemo.set(key, {
1039
+ digest,
1040
+ crossings: crossed
1041
+ });
1042
+ } else {
1043
+ const batched = `recipe-file\u0000${read.file}`;
1044
+ if (!digestMemo.has(batched)) {
1045
+ try {
1046
+ const result = parseModule(read.file);
1047
+ if (result) for (const [name, entry] of collectRecipeConfigs(result)) digestMemo.set(`recipe\u0000${read.file}\u0000${name}`, {
1048
+ digest: entry === AMBIGUOUS ? "bamboo:export-missing" : digestRecipeConfig(entry),
1049
+ crossings: []
1050
+ });
1051
+ } catch {}
1052
+ digestMemo.set(batched, {
1053
+ digest: "bamboo:batched",
1054
+ crossings: []
1055
+ });
1056
+ }
1057
+ if (!digestMemo.has(key)) digestMemo.set(key, {
1058
+ digest: digestRecipeReadNow(parseModule, read.file, read.name),
1059
+ crossings: []
1060
+ });
1061
+ }
1062
+ const entry = digestMemo.get(key);
1063
+ if (entry.digest === void 0) return {
1064
+ verdict: "unknown",
1065
+ crossings: []
1066
+ };
1067
+ if (entry.digest !== read.digest) return {
1068
+ verdict: "changed",
1069
+ crossings: []
1070
+ };
1071
+ for (const path of entry.crossings) crossings.add(path);
1072
+ }
1073
+ return {
1074
+ verdict: "unchanged",
1075
+ crossings: [...crossings]
1076
+ };
1077
+ };
1078
+ const digestRecipeReadNow = (parseModule, file, name) => {
1079
+ try {
1080
+ const result = parseModule(file);
1081
+ if (!result) return "bamboo:module-missing";
1082
+ const entry = collectRecipeConfigs(result).get(name);
1083
+ if (!entry || entry === AMBIGUOUS) return "bamboo:export-missing";
1084
+ return digestRecipeConfig(entry);
1085
+ } catch {
1086
+ return;
1087
+ }
1088
+ };
1089
+ /** The verification witness for a foreign recipe read: the config bytes, order preserved. */
1090
+ const digestRecipeConfig = (entry) => {
1091
+ try {
1092
+ const json = JSON.stringify(entry.config, (_key, value) => value === void 0 ? "bamboo:undefined" : value);
1093
+ if (json === void 0) return void 0;
1094
+ return createHash("sha256").update(json).digest("base64");
1095
+ } catch {
1096
+ return;
1097
+ }
1098
+ };
1099
+ /**
1008
1100
  * `cva`/`sva` return a function, so their definitions are compile-time declarations rather
1009
1101
  * than class-producing calls; once their uses are lowered, the factory calls are erased.
1010
1102
  * `token` also resolves to no class, but it does resolve to a literal, so it compiles through
@@ -1478,6 +1570,8 @@ const foldSource = (options) => {
1478
1570
  const helperModules = /* @__PURE__ */ new Map();
1479
1571
  /** Declaring modules a fold read, recorded as paths because their nodes do not persist. */
1480
1572
  const foreignDependencies = /* @__PURE__ */ new Set();
1573
+ /** Foreign recipe configs consumed, digested at read time for later verification. */
1574
+ const exportReads = [];
1481
1575
  /** Resolutions for this module's own call sites, keyed by the name the call site writes. */
1482
1576
  const importedRecipes = /* @__PURE__ */ new Map();
1483
1577
  /**
@@ -1518,6 +1612,12 @@ const foldSource = (options) => {
1518
1612
  const entry = foreign.configs.get(origin.name);
1519
1613
  if (!entry || entry === AMBIGUOUS) return void 0;
1520
1614
  foreignDependencies.add(origin.filePath);
1615
+ exportReads.push({
1616
+ kind: "recipe",
1617
+ file: origin.filePath,
1618
+ name: origin.name,
1619
+ digest: digestRecipeConfig(entry)
1620
+ });
1521
1621
  helperModules.set(name, foreign.cssSpecifier ? rebaseSpecifier(foreign.cssSpecifier, origin.filePath, consuming.getFilePath()) : void 0);
1522
1622
  return entry;
1523
1623
  };
@@ -2090,7 +2190,8 @@ const foldSource = (options) => {
2090
2190
  map: null,
2091
2191
  folded,
2092
2192
  skipped,
2093
- dependencies: []
2193
+ dependencies: [],
2194
+ exportReads: []
2094
2195
  };
2095
2196
  }
2096
2197
  const rewriteSourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile() ?? recipeDefinitions[0]?.call.getSourceFile();
@@ -2099,7 +2200,8 @@ const foldSource = (options) => {
2099
2200
  map: null,
2100
2201
  folded,
2101
2202
  skipped,
2102
- dependencies: []
2203
+ dependencies: [],
2204
+ exportReads: []
2103
2205
  };
2104
2206
  const dependencyScan = createDependencyScan(rewriteSourceFile);
2105
2207
  candidates.sort((a, b) => a.start - b.start || b.end - a.end);
@@ -2406,7 +2508,8 @@ const foldSource = (options) => {
2406
2508
  map: null,
2407
2509
  folded,
2408
2510
  skipped,
2409
- dependencies: []
2511
+ dependencies: [],
2512
+ exportReads: []
2410
2513
  };
2411
2514
  return {
2412
2515
  code: magic.toString(),
@@ -2417,7 +2520,8 @@ const foldSource = (options) => {
2417
2520
  }),
2418
2521
  folded,
2419
2522
  skipped,
2420
- dependencies: [...dependencyScan.results, ...foreignDependencies]
2523
+ dependencies: [...dependencyScan.results, ...foreignDependencies],
2524
+ exportReads
2421
2525
  };
2422
2526
  };
2423
2527
  //#endregion
@@ -2480,4 +2584,4 @@ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (cl
2480
2584
  };
2481
2585
  };
2482
2586
  //#endregion
2483
- export { createRuntimeCss, createStaticStyleSetCompiler, foldSource };
2587
+ export { createRuntimeCss, createStaticStyleSetCompiler, foldSource, verifyExportReads };
package/dist/index.cjs CHANGED
@@ -61,6 +61,7 @@ const createLazyCompilerState = (loadContext, loadFold) => createRetryableLazy(a
61
61
  return {
62
62
  context,
63
63
  foldSource: fold.foldSource,
64
+ verifyExportReads: fold.verifyExportReads,
64
65
  runtimeCss,
65
66
  styleCompiler
66
67
  };
@@ -208,6 +209,23 @@ const bamboocssCss = (options) => {
208
209
  * stylesheet twice over.
209
210
  */
210
211
  let pending;
212
+ /**
213
+ * Which change the current `pending` was generated for, and the validated sheet it produced.
214
+ *
215
+ * Every environment loads the virtual stylesheet — a react-router dev server loads it once
216
+ * for the client graph and once for SSR — and each load used to run a complete extraction
217
+ * and optimization pass to produce byte-identical CSS. The sheet is a function of the source
218
+ * files alone, and the watcher below is the single point every event that can reach it
219
+ * passes through — Vite's own propagation only arrives via the watch edges `load` registers,
220
+ * over the same extracted files the watcher checks. A monotonic counter bumped there is
221
+ * therefore enough to know whether a build already reflects the world a load is asking about.
222
+ *
223
+ * Dev only. A production build has no dev watcher to advance the counter, so serving the
224
+ * memo there would hand `vite build --watch` a stale sheet; builds regenerate per load.
225
+ */
226
+ let changeGeneration = 0;
227
+ let pendingGeneration = -1;
228
+ let servedCss;
211
229
  const build = async () => {
212
230
  const builder = await ensureBuilder();
213
231
  await builder.setup({
@@ -217,6 +235,7 @@ const bamboocssCss = (options) => {
217
235
  });
218
236
  await builder.emit();
219
237
  builder.extract();
238
+ await new Promise((settle) => setImmediate(settle));
220
239
  if (builder.context?.config.polyfill) throw new Error("bamboocss: the cascade-layer polyfill is incompatible with compiled atomic styles. The polyfill removes the utility-layer boundary required for safe atom reachability and renaming.");
221
240
  if (builder.context) {
222
241
  session.utilityLayer = builder.context.config.layers?.utilities ?? "utilities";
@@ -245,6 +264,8 @@ const bamboocssCss = (options) => {
245
264
  return css;
246
265
  };
247
266
  const generate = () => {
267
+ if (command === "serve" && pending && pendingGeneration === changeGeneration) return pending;
268
+ pendingGeneration = changeGeneration;
248
269
  pending = Promise.resolve(pending).catch(() => void 0).then(build);
249
270
  return pending;
250
271
  };
@@ -393,6 +414,11 @@ const bamboocssCss = (options) => {
393
414
  const query = queryOf(id);
394
415
  if (id.slice(0, id.length - query.length) !== RESOLVED_ID) return null;
395
416
  session.cssLoaded = true;
417
+ const generationAtStart = changeGeneration;
418
+ if (command === "serve" && servedCss?.generation === generationAtStart) {
419
+ if (this.addWatchFile) for (const file of session.extractedFiles) this.addWatchFile(file);
420
+ return servedCss.css;
421
+ }
396
422
  let css;
397
423
  try {
398
424
  const validateDevCss = command === "serve" ? (await loadCssOutput()).pruneStaticCss : void 0;
@@ -403,7 +429,11 @@ const bamboocssCss = (options) => {
403
429
  } catch (error) {
404
430
  throw asError(error, `failed to generate ${VIRTUAL_CSS_ID}`);
405
431
  }
406
- if (this.addWatchFile) for (const file of extractedSourceFiles()) this.addWatchFile(file);
432
+ if (command === "serve" && generationAtStart === changeGeneration) servedCss = {
433
+ generation: generationAtStart,
434
+ css
435
+ };
436
+ if (this.addWatchFile) for (const file of session.extractedFiles) this.addWatchFile(file);
407
437
  return css;
408
438
  },
409
439
  configureServer(devServer) {
@@ -427,6 +457,7 @@ const bamboocssCss = (options) => {
427
457
  if (!ctx) return;
428
458
  const absoluteFile = ctx.runtime.path.abs(ctx.config.cwd, file);
429
459
  if (!session.extractedFiles.has(absoluteFile)) return;
460
+ changeGeneration++;
430
461
  prebuilt = void 0;
431
462
  const mod = server?.moduleGraph.getModuleById(RESOLVED_ID);
432
463
  if (!mod) return;
@@ -694,6 +725,45 @@ const bamboocss = (options = {}) => {
694
725
  return /* @__PURE__ */ new Error(`bamboocss: cached transform metadata for ${JSON.stringify(id)} in the ${JSON.stringify(environment)} environment ${problem}.\n\nBamboo cannot safely rebuild from this entry because cached JavaScript may still name CSS classes whose rules would be dropped. Restart Vite to invalidate its in-memory transform cache. If this persists, clear Vite's cache directory and rebuild.`);
695
726
  };
696
727
  const transformStateByEnvironment = /* @__PURE__ */ new Map();
728
+ /**
729
+ * One fold per file content per change event, shared across environments and hooks.
730
+ *
731
+ * A single edit folds the same bytes repeatedly: `hotUpdate` provisionally re-folds every
732
+ * dependent once per environment to decide what to invalidate, then `transform` folds the
733
+ * edited module for the client graph, again for SSR, and once more for each update a
734
+ * framework re-drives — react-router's server-change trigger calls `reloadModule` per pass.
735
+ * All of them read the same shared ts-morph project under the same config, so the result is
736
+ * a function of the bytes alone and the repeats were pure cost: on the app this was measured
737
+ * on, four transforms of a 46 kB route module per edit, ~10 ms each.
738
+ *
739
+ * `watchChange` clears it, which is the exact validity window: entries are correct until a
740
+ * file event changes what a fold could resolve, and `watchChange` is the one hook Vite calls
741
+ * for every such event before any update work begins. The per-environment resolution closure
742
+ * is deliberately not memoized — `withResolutionClosure` is recomputed per consumer against
743
+ * that environment's own recorded dependencies.
744
+ *
745
+ * Keyed by path *and* content digest, not path alone, because one physical file is served
746
+ * as more than one module shape in the same event — react-router clips a route module down
747
+ * to its route exports for the client graph while SSR gets the full file — and a last-write
748
+ * key would make the two shapes evict each other on every pass.
749
+ *
750
+ * Dev only, like the verdict memo it sits beside: a build transforms each module once per
751
+ * environment with no bracketing events, and holding every module's fold for the length of a
752
+ * build is memory a one-shot pass has no reason to spend.
753
+ */
754
+ const foldMemoByContent = /* @__PURE__ */ new Map();
755
+ /** Per-event digests of the edited file's read values, shared across every dependent. */
756
+ const verifyDigestMemo = /* @__PURE__ */ new Map();
757
+ const foldMemoKey = (filePath, inputDigest) => `${filePath}\0${inputDigest}`;
758
+ /**
759
+ * The Project resolution walk `withResolutionClosure` runs, memoized per change event.
760
+ *
761
+ * The walk is the dominant per-dependent cost once the fold itself is memoized — it runs
762
+ * once per dependent per environment with identical inputs, since both environments record
763
+ * the same fold dependencies for byte-identical source. Same bracketing as the fold memo:
764
+ * `watchChange` clears it, so no entry outlives the project state it was computed against.
765
+ */
766
+ const resolutionClosureMemo = /* @__PURE__ */ new Map();
697
767
  /** The immutable generation currently occupying each configured output on disk. */
698
768
  const liveOutputSlotsByEnvironment = /* @__PURE__ */ new Map();
699
769
  /** Graphs which passed `buildEnd`, but whose output has not succeeded yet. */
@@ -728,7 +798,8 @@ const bamboocss = (options = {}) => {
728
798
  transformedModulesThisRun: /* @__PURE__ */ new Set(),
729
799
  unchangedFolds: /* @__PURE__ */ new Map(),
730
800
  changedRun: 0,
731
- cssLoaded: false
801
+ cssLoaded: false,
802
+ exportReadsByModule: /* @__PURE__ */ new Map()
732
803
  });
733
804
  const cloneEnvironmentState = (state) => ({
734
805
  transformArtifactsByModule: new Map(state.transformArtifactsByModule),
@@ -740,7 +811,8 @@ const bamboocss = (options = {}) => {
740
811
  transformedModulesThisRun: new Set(state.transformedModulesThisRun),
741
812
  unchangedFolds: new Map(state.unchangedFolds),
742
813
  changedRun: state.changedRun,
743
- cssLoaded: state.cssLoaded
814
+ cssLoaded: state.cssLoaded,
815
+ exportReadsByModule: new Map(state.exportReadsByModule)
744
816
  });
745
817
  const environmentState = (context) => {
746
818
  const identity = environmentName(context);
@@ -1218,36 +1290,75 @@ const bamboocss = (options = {}) => {
1218
1290
  * that returns nothing, a fold that throws — because "changed" is what this path did before,
1219
1291
  * and a wrong "unchanged" is a stale class string in the browser.
1220
1292
  */
1221
- const foldOutputUnchanged = (state, dependent) => {
1293
+ const foldOutputUnchanged = (state, dependent, changedFile) => {
1222
1294
  const memoized = state.unchangedFolds.get(dependent);
1223
1295
  if (memoized !== void 0) return memoized;
1224
- const unchanged = state.changedRun < CHANGED_RUN_LIMIT && refoldMatchesSignature(state, dependent);
1296
+ const unchanged = state.changedRun < CHANGED_RUN_LIMIT && refoldMatchesSignature(state, dependent, changedFile);
1225
1297
  state.changedRun = unchanged ? 0 : state.changedRun + 1;
1226
1298
  state.unchangedFolds.set(dependent, unchanged);
1227
1299
  return unchanged;
1228
1300
  };
1229
- const refoldMatchesSignature = (state, dependent) => {
1301
+ const refoldMatchesSignature = (state, dependent, changedFile) => {
1230
1302
  const signature = state.foldSignatures.get(dependent);
1231
1303
  if (!signature || !ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return false;
1232
1304
  try {
1233
1305
  const code = (0, node_fs.readFileSync)(signature.path, "utf8");
1234
- if (digest(code) !== signature.input) return false;
1235
- const sourceFile = ctx.project.addSourceFile(signature.path, code);
1236
- const parserResult = ctx.project.parseSourceFile(signature.path);
1237
- if (!parserResult) return false;
1238
- const result = withResolutionClosure(signature.path, foldSourceImpl({
1239
- ctx,
1240
- code,
1241
- parserResult,
1242
- filePath: signature.path,
1243
- runtimeCss,
1244
- styleCompiler,
1245
- maxRecipeStates,
1246
- parseModule: (path) => ctx?.project.parseSourceFile(path),
1247
- recipeConfigCache: state.recipeConfigCache,
1248
- reportSurvivors: false,
1249
- sourceFile
1250
- }), parserResult.getDependencies(), state.dependenciesByModule.get(dependent));
1306
+ const inputDigest = digest(code);
1307
+ if (inputDigest !== signature.input) return false;
1308
+ /**
1309
+ * Try to answer from what the fold *read* before re-running it.
1310
+ *
1311
+ * The recorded reads carry the digest of every cross-file value and recipe config this
1312
+ * dependent's fold consumed. When each read of the edited file re-digests identically,
1313
+ * the fold's inputs did not move and its output cannot have — the whole re-fold below
1314
+ * is skipped, which is most of what an edit to a shared module used to cost. Any gap —
1315
+ * no read naming the edited file, an unverifiable digest, the verifier chunk not loaded
1316
+ * — falls through to the full re-fold, which is exactly the previous behavior. A
1317
+ * definite mismatch is equally final in the other direction: the re-fold would only
1318
+ * rediscover the change.
1319
+ */
1320
+ const reads = state.exportReadsByModule.get(dependent);
1321
+ if (reads?.length && verifyExportReadsImpl) {
1322
+ const { verdict, crossings } = verifyExportReadsImpl(ctx, (path) => ctx?.project.parseSourceFile(path), reads, normalizeFsPath(changedFile), verifyDigestMemo);
1323
+ if (verdict === "unchanged") {
1324
+ recordFoldDependencies(state, dependent, signature.path, [...state.dependenciesByModule.get(dependent) ?? [], ...crossings]);
1325
+ return true;
1326
+ }
1327
+ if (verdict === "changed") return false;
1328
+ }
1329
+ let raw;
1330
+ let parserDependencies;
1331
+ const memoKey = foldMemoKey(signature.path, inputDigest);
1332
+ const memoized = foldMemoByContent.get(memoKey);
1333
+ if (memoized) {
1334
+ raw = memoized.result;
1335
+ parserDependencies = memoized.parserDependencies;
1336
+ } else {
1337
+ const sourceFile = ctx.project.addSourceFile(signature.path, code);
1338
+ const parserResult = ctx.project.parseSourceFile(signature.path);
1339
+ if (!parserResult) return false;
1340
+ raw = foldSourceImpl({
1341
+ ctx,
1342
+ code,
1343
+ parserResult,
1344
+ filePath: signature.path,
1345
+ runtimeCss,
1346
+ styleCompiler,
1347
+ maxRecipeStates,
1348
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
1349
+ recipeConfigCache: state.recipeConfigCache,
1350
+ reportSurvivors: false,
1351
+ sourceFile
1352
+ });
1353
+ parserDependencies = parserResult.getDependencies();
1354
+ foldMemoByContent.set(memoKey, {
1355
+ result: raw,
1356
+ parserDependencies,
1357
+ valueReads: parserResult.getExportReads?.() ?? [],
1358
+ reportedSurvivors: false
1359
+ });
1360
+ }
1361
+ const result = withResolutionClosure(signature.path, raw, parserDependencies, state.dependenciesByModule.get(dependent));
1251
1362
  const unchanged = digest(result.code) === signature.output;
1252
1363
  /**
1253
1364
  * Edges re-recorded exactly on the way to suppressing a module. A changed provisional
@@ -1303,7 +1414,7 @@ const bamboocss = (options = {}) => {
1303
1414
  * one can end in a page reload, which is the honest outcome: its compiled classes really did
1304
1415
  * change, and a reload is what Vite does with any update nothing accepts.
1305
1416
  */
1306
- const foldDependentModules = (state, file, modules, graph) => {
1417
+ const foldDependentModules = (state, file, modules, graph, verify = true) => {
1307
1418
  const dependents = state.dependentsByDependency.get(normalizeFsPath(file));
1308
1419
  if (!dependents?.size) return;
1309
1420
  const added = [];
@@ -1341,7 +1452,7 @@ const bamboocss = (options = {}) => {
1341
1452
  * the edited module for a runtime value is still reached by `propagateUpdate` exactly as
1342
1453
  * it would be with no plugin here at all — that direction was never this list's to decide.
1343
1454
  */
1344
- if (foldOutputUnchanged(state, dependent)) continue;
1455
+ if (verify && foldOutputUnchanged(state, dependent, file)) continue;
1345
1456
  const exact = graph.getModuleById?.(dependent);
1346
1457
  const dependentFile = state.filesByModule.get(dependent) ?? dependent;
1347
1458
  const candidates = exact ? [exact] : graph.getModulesByFile(normalizeFsPath(dependentFile)) ?? [];
@@ -1375,6 +1486,7 @@ const bamboocss = (options = {}) => {
1375
1486
  };
1376
1487
  let ctx;
1377
1488
  let foldSourceImpl;
1489
+ let verifyExportReadsImpl;
1378
1490
  let runtimeCss;
1379
1491
  let styleCompiler;
1380
1492
  let command = "build";
@@ -1397,7 +1509,14 @@ const bamboocss = (options = {}) => {
1397
1509
  const targets = new Set(dependencies);
1398
1510
  for (const dependency of previousDependencies ?? []) targets.add(dependency);
1399
1511
  if (!targets.size) return result;
1400
- for (const dependency of ctx.project.getDependencies(filePath, [...targets])) if (!isGeneratedOutput(dependency, ctx)) dependencies.add(dependency);
1512
+ const targetList = [...targets];
1513
+ const closureKey = command === "serve" ? `${filePath}\0${targetList.slice().sort().join("|")}` : void 0;
1514
+ let reachable = closureKey ? resolutionClosureMemo.get(closureKey) : void 0;
1515
+ if (!reachable) {
1516
+ reachable = ctx.project.getDependencies(filePath, targetList);
1517
+ if (closureKey) resolutionClosureMemo.set(closureKey, reachable);
1518
+ }
1519
+ for (const dependency of reachable) if (!isGeneratedOutput(dependency, ctx)) dependencies.add(dependency);
1401
1520
  const expanded = [...dependencies];
1402
1521
  if (expanded.length === result.dependencies.length && expanded.every((dependency, index) => dependency === result.dependencies[index])) return result;
1403
1522
  return {
@@ -1421,6 +1540,7 @@ const bamboocss = (options = {}) => {
1421
1540
  const loaded = await loadCompilerState();
1422
1541
  ctx = loaded.context;
1423
1542
  foldSourceImpl = loaded.foldSource;
1543
+ verifyExportReadsImpl = loaded.verifyExportReads;
1424
1544
  runtimeCss = loaded.runtimeCss;
1425
1545
  styleCompiler = loaded.styleCompiler;
1426
1546
  };
@@ -1581,6 +1701,9 @@ const bamboocss = (options = {}) => {
1581
1701
  * the parser still holds the file.
1582
1702
  */
1583
1703
  watchChange(id, change) {
1704
+ foldMemoByContent.clear();
1705
+ resolutionClosureMemo.clear();
1706
+ verifyDigestMemo.clear();
1584
1707
  for (const state of transformStateByEnvironment.values()) {
1585
1708
  state.unchangedFolds.clear();
1586
1709
  state.changedRun = 0;
@@ -1603,6 +1726,52 @@ const bamboocss = (options = {}) => {
1603
1726
  return;
1604
1727
  }
1605
1728
  ctx.project.reloadSourceFile(filePath);
1729
+ /**
1730
+ * Fold the edited file before the browser asks for it.
1731
+ *
1732
+ * The first transform after an edit is the one fold the memo cannot already hold — the
1733
+ * bytes are new — and it sits on the repaint path: the websocket round trip plus the
1734
+ * module refetch land ~15-30ms after this hook, and the fold costs ~5-13ms of that
1735
+ * budget on a route-sized module. Folding one macrotask later, after the update hooks
1736
+ * have run and the broadcast is out, has the memo hot before the request arrives.
1737
+ *
1738
+ * `setImmediate` is the load-bearing part: this hook is awaited before Vite announces
1739
+ * anything, so the work must not run inline. Content-keyed like every memo entry, so a
1740
+ * racing save cannot poison anything — the entry states what these exact bytes fold to,
1741
+ * and a later event's `watchChange` clears the memo before that event's transforms run.
1742
+ * Failures are swallowed here; the real transform runs the same fold and owns the
1743
+ * diagnostics.
1744
+ */
1745
+ if (command === "serve") setImmediate(() => {
1746
+ if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return;
1747
+ try {
1748
+ const code = (0, node_fs.readFileSync)(filePath, "utf8");
1749
+ const memoKey = foldMemoKey(filePath, digest(code));
1750
+ if (foldMemoByContent.has(memoKey)) return;
1751
+ const sourceFile = ctx.project.addSourceFile(filePath, code);
1752
+ const parserResult = ctx.project.parseSourceFile(filePath);
1753
+ if (!parserResult) return;
1754
+ const folded = foldSourceImpl({
1755
+ ctx,
1756
+ code,
1757
+ parserResult,
1758
+ filePath,
1759
+ runtimeCss,
1760
+ styleCompiler,
1761
+ maxRecipeStates,
1762
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
1763
+ recipeConfigCache: transformStateByEnvironment.get("client")?.recipeConfigCache ?? /* @__PURE__ */ new Map(),
1764
+ reportSurvivors: true,
1765
+ sourceFile
1766
+ });
1767
+ foldMemoByContent.set(memoKey, {
1768
+ result: folded,
1769
+ parserDependencies: parserResult.getDependencies(),
1770
+ valueReads: parserResult.getExportReads?.() ?? [],
1771
+ reportedSurvivors: true
1772
+ });
1773
+ } catch {}
1774
+ });
1606
1775
  },
1607
1776
  /**
1608
1777
  * Re-transform whatever folded a value out of the file that just changed.
@@ -1626,7 +1795,22 @@ const bamboocss = (options = {}) => {
1626
1795
  hotUpdate({ file, modules }) {
1627
1796
  const graph = this.environment?.moduleGraph;
1628
1797
  if (!graph) return;
1629
- return foldDependentModules(environmentState(this), file, modules, graph);
1798
+ /**
1799
+ * The provisional re-folds exist to spare the *browser*: an announced client module is a
1800
+ * refetch round trip, and behind a framework that re-drives HMR per entry, a router
1801
+ * revalidation — that is what deciding "unchanged" before Vite is told anything buys.
1802
+ *
1803
+ * A server graph has none of that economy. Its modules are re-transformed by this same
1804
+ * process the next time something renders, nothing is announced by invalidating quietly,
1805
+ * and the verification runs on the awaited path *before* the client's update can be
1806
+ * broadcast — on a react-router app, re-folding every SSR consumer of a shared style
1807
+ * module added ~15ms to each edit's repaint for work whose only reader was the next
1808
+ * `.data` revalidation. Invalidate outright there and let the next render pay lazily,
1809
+ * off the repaint path. `verify` stays on when the consumer kind is unknown — a harness
1810
+ * without environment config keeps the conservative shape.
1811
+ */
1812
+ const consumer = this.environment?.config?.consumer;
1813
+ return foldDependentModules(environmentState(this), file, modules, graph, consumer !== "server");
1630
1814
  },
1631
1815
  handleHotUpdate({ file, modules, server }) {
1632
1816
  const legacy = server;
@@ -1650,27 +1834,48 @@ const bamboocss = (options = {}) => {
1650
1834
  const previousDependencies = previousSignature && previousSignature.input === (inputDigest ??= digest(code)) ? state.dependenciesByModule.get(id) : void 0;
1651
1835
  let result;
1652
1836
  try {
1653
- const sourceFile = ctx.project.addSourceFile(filePath, code);
1654
- const parserResult = ctx.project.parseSourceFile(filePath);
1655
- if (!parserResult) {
1656
- state.transformArtifactsByModule.delete(id);
1657
- recordFoldDependencies(state, id, filePath, []);
1658
- state.foldSignatures.delete(id);
1659
- return null;
1837
+ const memoKey = command === "serve" ? foldMemoKey(filePath, inputDigest ??= digest(code)) : void 0;
1838
+ const memoized = memoKey ? foldMemoByContent.get(memoKey) : void 0;
1839
+ let valueReads = [];
1840
+ if (memoized?.reportedSurvivors) {
1841
+ valueReads = memoized.valueReads;
1842
+ result = withResolutionClosure(filePath, memoized.result, memoized.parserDependencies, previousDependencies);
1843
+ } else {
1844
+ const sourceFile = ctx.project.addSourceFile(filePath, code);
1845
+ const parserResult = ctx.project.parseSourceFile(filePath);
1846
+ if (!parserResult) {
1847
+ state.transformArtifactsByModule.delete(id);
1848
+ recordFoldDependencies(state, id, filePath, []);
1849
+ state.foldSignatures.delete(id);
1850
+ return null;
1851
+ }
1852
+ const folded = foldSourceImpl({
1853
+ ctx,
1854
+ code,
1855
+ parserResult,
1856
+ filePath,
1857
+ runtimeCss,
1858
+ styleCompiler,
1859
+ maxRecipeStates,
1860
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
1861
+ recipeConfigCache: state.recipeConfigCache,
1862
+ reportSurvivors: true,
1863
+ sourceFile
1864
+ });
1865
+ const parserDependencies = parserResult.getDependencies();
1866
+ valueReads = parserResult.getExportReads?.() ?? [];
1867
+ if (memoKey) foldMemoByContent.set(memoKey, {
1868
+ result: folded,
1869
+ parserDependencies,
1870
+ valueReads,
1871
+ reportedSurvivors: true
1872
+ });
1873
+ result = withResolutionClosure(filePath, folded, parserDependencies, previousDependencies);
1660
1874
  }
1661
- result = withResolutionClosure(filePath, foldSourceImpl({
1662
- ctx,
1663
- code,
1664
- parserResult,
1665
- filePath,
1666
- runtimeCss,
1667
- styleCompiler,
1668
- maxRecipeStates,
1669
- parseModule: (path) => ctx?.project.parseSourceFile(path),
1670
- recipeConfigCache: state.recipeConfigCache,
1671
- reportSurvivors: true,
1672
- sourceFile
1673
- }), parserResult.getDependencies(), previousDependencies);
1875
+ state.exportReadsByModule.set(id, [...valueReads.map((read) => ({
1876
+ kind: "value",
1877
+ ...read
1878
+ })), ...result.exportReads]);
1674
1879
  } catch (error) {
1675
1880
  _bamboocss_logger.logger.caughtError("vite:transform", `Failed to compile ${filePath}`, error);
1676
1881
  const previousDependencies = [...state.dependenciesByModule.get(id) ?? []];
package/dist/index.mjs CHANGED
@@ -56,6 +56,7 @@ const createLazyCompilerState = (loadContext, loadFold) => createRetryableLazy(a
56
56
  return {
57
57
  context,
58
58
  foldSource: fold.foldSource,
59
+ verifyExportReads: fold.verifyExportReads,
59
60
  runtimeCss,
60
61
  styleCompiler
61
62
  };
@@ -203,6 +204,23 @@ const bamboocssCss = (options) => {
203
204
  * stylesheet twice over.
204
205
  */
205
206
  let pending;
207
+ /**
208
+ * Which change the current `pending` was generated for, and the validated sheet it produced.
209
+ *
210
+ * Every environment loads the virtual stylesheet — a react-router dev server loads it once
211
+ * for the client graph and once for SSR — and each load used to run a complete extraction
212
+ * and optimization pass to produce byte-identical CSS. The sheet is a function of the source
213
+ * files alone, and the watcher below is the single point every event that can reach it
214
+ * passes through — Vite's own propagation only arrives via the watch edges `load` registers,
215
+ * over the same extracted files the watcher checks. A monotonic counter bumped there is
216
+ * therefore enough to know whether a build already reflects the world a load is asking about.
217
+ *
218
+ * Dev only. A production build has no dev watcher to advance the counter, so serving the
219
+ * memo there would hand `vite build --watch` a stale sheet; builds regenerate per load.
220
+ */
221
+ let changeGeneration = 0;
222
+ let pendingGeneration = -1;
223
+ let servedCss;
206
224
  const build = async () => {
207
225
  const builder = await ensureBuilder();
208
226
  await builder.setup({
@@ -212,6 +230,7 @@ const bamboocssCss = (options) => {
212
230
  });
213
231
  await builder.emit();
214
232
  builder.extract();
233
+ await new Promise((settle) => setImmediate(settle));
215
234
  if (builder.context?.config.polyfill) throw new Error("bamboocss: the cascade-layer polyfill is incompatible with compiled atomic styles. The polyfill removes the utility-layer boundary required for safe atom reachability and renaming.");
216
235
  if (builder.context) {
217
236
  session.utilityLayer = builder.context.config.layers?.utilities ?? "utilities";
@@ -240,6 +259,8 @@ const bamboocssCss = (options) => {
240
259
  return css;
241
260
  };
242
261
  const generate = () => {
262
+ if (command === "serve" && pending && pendingGeneration === changeGeneration) return pending;
263
+ pendingGeneration = changeGeneration;
243
264
  pending = Promise.resolve(pending).catch(() => void 0).then(build);
244
265
  return pending;
245
266
  };
@@ -388,6 +409,11 @@ const bamboocssCss = (options) => {
388
409
  const query = queryOf(id);
389
410
  if (id.slice(0, id.length - query.length) !== RESOLVED_ID) return null;
390
411
  session.cssLoaded = true;
412
+ const generationAtStart = changeGeneration;
413
+ if (command === "serve" && servedCss?.generation === generationAtStart) {
414
+ if (this.addWatchFile) for (const file of session.extractedFiles) this.addWatchFile(file);
415
+ return servedCss.css;
416
+ }
391
417
  let css;
392
418
  try {
393
419
  const validateDevCss = command === "serve" ? (await loadCssOutput()).pruneStaticCss : void 0;
@@ -398,7 +424,11 @@ const bamboocssCss = (options) => {
398
424
  } catch (error) {
399
425
  throw asError(error, `failed to generate ${VIRTUAL_CSS_ID}`);
400
426
  }
401
- if (this.addWatchFile) for (const file of extractedSourceFiles()) this.addWatchFile(file);
427
+ if (command === "serve" && generationAtStart === changeGeneration) servedCss = {
428
+ generation: generationAtStart,
429
+ css
430
+ };
431
+ if (this.addWatchFile) for (const file of session.extractedFiles) this.addWatchFile(file);
402
432
  return css;
403
433
  },
404
434
  configureServer(devServer) {
@@ -422,6 +452,7 @@ const bamboocssCss = (options) => {
422
452
  if (!ctx) return;
423
453
  const absoluteFile = ctx.runtime.path.abs(ctx.config.cwd, file);
424
454
  if (!session.extractedFiles.has(absoluteFile)) return;
455
+ changeGeneration++;
425
456
  prebuilt = void 0;
426
457
  const mod = server?.moduleGraph.getModuleById(RESOLVED_ID);
427
458
  if (!mod) return;
@@ -689,6 +720,45 @@ const bamboocss = (options = {}) => {
689
720
  return /* @__PURE__ */ new Error(`bamboocss: cached transform metadata for ${JSON.stringify(id)} in the ${JSON.stringify(environment)} environment ${problem}.\n\nBamboo cannot safely rebuild from this entry because cached JavaScript may still name CSS classes whose rules would be dropped. Restart Vite to invalidate its in-memory transform cache. If this persists, clear Vite's cache directory and rebuild.`);
690
721
  };
691
722
  const transformStateByEnvironment = /* @__PURE__ */ new Map();
723
+ /**
724
+ * One fold per file content per change event, shared across environments and hooks.
725
+ *
726
+ * A single edit folds the same bytes repeatedly: `hotUpdate` provisionally re-folds every
727
+ * dependent once per environment to decide what to invalidate, then `transform` folds the
728
+ * edited module for the client graph, again for SSR, and once more for each update a
729
+ * framework re-drives — react-router's server-change trigger calls `reloadModule` per pass.
730
+ * All of them read the same shared ts-morph project under the same config, so the result is
731
+ * a function of the bytes alone and the repeats were pure cost: on the app this was measured
732
+ * on, four transforms of a 46 kB route module per edit, ~10 ms each.
733
+ *
734
+ * `watchChange` clears it, which is the exact validity window: entries are correct until a
735
+ * file event changes what a fold could resolve, and `watchChange` is the one hook Vite calls
736
+ * for every such event before any update work begins. The per-environment resolution closure
737
+ * is deliberately not memoized — `withResolutionClosure` is recomputed per consumer against
738
+ * that environment's own recorded dependencies.
739
+ *
740
+ * Keyed by path *and* content digest, not path alone, because one physical file is served
741
+ * as more than one module shape in the same event — react-router clips a route module down
742
+ * to its route exports for the client graph while SSR gets the full file — and a last-write
743
+ * key would make the two shapes evict each other on every pass.
744
+ *
745
+ * Dev only, like the verdict memo it sits beside: a build transforms each module once per
746
+ * environment with no bracketing events, and holding every module's fold for the length of a
747
+ * build is memory a one-shot pass has no reason to spend.
748
+ */
749
+ const foldMemoByContent = /* @__PURE__ */ new Map();
750
+ /** Per-event digests of the edited file's read values, shared across every dependent. */
751
+ const verifyDigestMemo = /* @__PURE__ */ new Map();
752
+ const foldMemoKey = (filePath, inputDigest) => `${filePath}\0${inputDigest}`;
753
+ /**
754
+ * The Project resolution walk `withResolutionClosure` runs, memoized per change event.
755
+ *
756
+ * The walk is the dominant per-dependent cost once the fold itself is memoized — it runs
757
+ * once per dependent per environment with identical inputs, since both environments record
758
+ * the same fold dependencies for byte-identical source. Same bracketing as the fold memo:
759
+ * `watchChange` clears it, so no entry outlives the project state it was computed against.
760
+ */
761
+ const resolutionClosureMemo = /* @__PURE__ */ new Map();
692
762
  /** The immutable generation currently occupying each configured output on disk. */
693
763
  const liveOutputSlotsByEnvironment = /* @__PURE__ */ new Map();
694
764
  /** Graphs which passed `buildEnd`, but whose output has not succeeded yet. */
@@ -723,7 +793,8 @@ const bamboocss = (options = {}) => {
723
793
  transformedModulesThisRun: /* @__PURE__ */ new Set(),
724
794
  unchangedFolds: /* @__PURE__ */ new Map(),
725
795
  changedRun: 0,
726
- cssLoaded: false
796
+ cssLoaded: false,
797
+ exportReadsByModule: /* @__PURE__ */ new Map()
727
798
  });
728
799
  const cloneEnvironmentState = (state) => ({
729
800
  transformArtifactsByModule: new Map(state.transformArtifactsByModule),
@@ -735,7 +806,8 @@ const bamboocss = (options = {}) => {
735
806
  transformedModulesThisRun: new Set(state.transformedModulesThisRun),
736
807
  unchangedFolds: new Map(state.unchangedFolds),
737
808
  changedRun: state.changedRun,
738
- cssLoaded: state.cssLoaded
809
+ cssLoaded: state.cssLoaded,
810
+ exportReadsByModule: new Map(state.exportReadsByModule)
739
811
  });
740
812
  const environmentState = (context) => {
741
813
  const identity = environmentName(context);
@@ -1213,36 +1285,75 @@ const bamboocss = (options = {}) => {
1213
1285
  * that returns nothing, a fold that throws — because "changed" is what this path did before,
1214
1286
  * and a wrong "unchanged" is a stale class string in the browser.
1215
1287
  */
1216
- const foldOutputUnchanged = (state, dependent) => {
1288
+ const foldOutputUnchanged = (state, dependent, changedFile) => {
1217
1289
  const memoized = state.unchangedFolds.get(dependent);
1218
1290
  if (memoized !== void 0) return memoized;
1219
- const unchanged = state.changedRun < CHANGED_RUN_LIMIT && refoldMatchesSignature(state, dependent);
1291
+ const unchanged = state.changedRun < CHANGED_RUN_LIMIT && refoldMatchesSignature(state, dependent, changedFile);
1220
1292
  state.changedRun = unchanged ? 0 : state.changedRun + 1;
1221
1293
  state.unchangedFolds.set(dependent, unchanged);
1222
1294
  return unchanged;
1223
1295
  };
1224
- const refoldMatchesSignature = (state, dependent) => {
1296
+ const refoldMatchesSignature = (state, dependent, changedFile) => {
1225
1297
  const signature = state.foldSignatures.get(dependent);
1226
1298
  if (!signature || !ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return false;
1227
1299
  try {
1228
1300
  const code = readFileSync(signature.path, "utf8");
1229
- if (digest(code) !== signature.input) return false;
1230
- const sourceFile = ctx.project.addSourceFile(signature.path, code);
1231
- const parserResult = ctx.project.parseSourceFile(signature.path);
1232
- if (!parserResult) return false;
1233
- const result = withResolutionClosure(signature.path, foldSourceImpl({
1234
- ctx,
1235
- code,
1236
- parserResult,
1237
- filePath: signature.path,
1238
- runtimeCss,
1239
- styleCompiler,
1240
- maxRecipeStates,
1241
- parseModule: (path) => ctx?.project.parseSourceFile(path),
1242
- recipeConfigCache: state.recipeConfigCache,
1243
- reportSurvivors: false,
1244
- sourceFile
1245
- }), parserResult.getDependencies(), state.dependenciesByModule.get(dependent));
1301
+ const inputDigest = digest(code);
1302
+ if (inputDigest !== signature.input) return false;
1303
+ /**
1304
+ * Try to answer from what the fold *read* before re-running it.
1305
+ *
1306
+ * The recorded reads carry the digest of every cross-file value and recipe config this
1307
+ * dependent's fold consumed. When each read of the edited file re-digests identically,
1308
+ * the fold's inputs did not move and its output cannot have — the whole re-fold below
1309
+ * is skipped, which is most of what an edit to a shared module used to cost. Any gap —
1310
+ * no read naming the edited file, an unverifiable digest, the verifier chunk not loaded
1311
+ * — falls through to the full re-fold, which is exactly the previous behavior. A
1312
+ * definite mismatch is equally final in the other direction: the re-fold would only
1313
+ * rediscover the change.
1314
+ */
1315
+ const reads = state.exportReadsByModule.get(dependent);
1316
+ if (reads?.length && verifyExportReadsImpl) {
1317
+ const { verdict, crossings } = verifyExportReadsImpl(ctx, (path) => ctx?.project.parseSourceFile(path), reads, normalizeFsPath(changedFile), verifyDigestMemo);
1318
+ if (verdict === "unchanged") {
1319
+ recordFoldDependencies(state, dependent, signature.path, [...state.dependenciesByModule.get(dependent) ?? [], ...crossings]);
1320
+ return true;
1321
+ }
1322
+ if (verdict === "changed") return false;
1323
+ }
1324
+ let raw;
1325
+ let parserDependencies;
1326
+ const memoKey = foldMemoKey(signature.path, inputDigest);
1327
+ const memoized = foldMemoByContent.get(memoKey);
1328
+ if (memoized) {
1329
+ raw = memoized.result;
1330
+ parserDependencies = memoized.parserDependencies;
1331
+ } else {
1332
+ const sourceFile = ctx.project.addSourceFile(signature.path, code);
1333
+ const parserResult = ctx.project.parseSourceFile(signature.path);
1334
+ if (!parserResult) return false;
1335
+ raw = foldSourceImpl({
1336
+ ctx,
1337
+ code,
1338
+ parserResult,
1339
+ filePath: signature.path,
1340
+ runtimeCss,
1341
+ styleCompiler,
1342
+ maxRecipeStates,
1343
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
1344
+ recipeConfigCache: state.recipeConfigCache,
1345
+ reportSurvivors: false,
1346
+ sourceFile
1347
+ });
1348
+ parserDependencies = parserResult.getDependencies();
1349
+ foldMemoByContent.set(memoKey, {
1350
+ result: raw,
1351
+ parserDependencies,
1352
+ valueReads: parserResult.getExportReads?.() ?? [],
1353
+ reportedSurvivors: false
1354
+ });
1355
+ }
1356
+ const result = withResolutionClosure(signature.path, raw, parserDependencies, state.dependenciesByModule.get(dependent));
1246
1357
  const unchanged = digest(result.code) === signature.output;
1247
1358
  /**
1248
1359
  * Edges re-recorded exactly on the way to suppressing a module. A changed provisional
@@ -1298,7 +1409,7 @@ const bamboocss = (options = {}) => {
1298
1409
  * one can end in a page reload, which is the honest outcome: its compiled classes really did
1299
1410
  * change, and a reload is what Vite does with any update nothing accepts.
1300
1411
  */
1301
- const foldDependentModules = (state, file, modules, graph) => {
1412
+ const foldDependentModules = (state, file, modules, graph, verify = true) => {
1302
1413
  const dependents = state.dependentsByDependency.get(normalizeFsPath(file));
1303
1414
  if (!dependents?.size) return;
1304
1415
  const added = [];
@@ -1336,7 +1447,7 @@ const bamboocss = (options = {}) => {
1336
1447
  * the edited module for a runtime value is still reached by `propagateUpdate` exactly as
1337
1448
  * it would be with no plugin here at all — that direction was never this list's to decide.
1338
1449
  */
1339
- if (foldOutputUnchanged(state, dependent)) continue;
1450
+ if (verify && foldOutputUnchanged(state, dependent, file)) continue;
1340
1451
  const exact = graph.getModuleById?.(dependent);
1341
1452
  const dependentFile = state.filesByModule.get(dependent) ?? dependent;
1342
1453
  const candidates = exact ? [exact] : graph.getModulesByFile(normalizeFsPath(dependentFile)) ?? [];
@@ -1370,6 +1481,7 @@ const bamboocss = (options = {}) => {
1370
1481
  };
1371
1482
  let ctx;
1372
1483
  let foldSourceImpl;
1484
+ let verifyExportReadsImpl;
1373
1485
  let runtimeCss;
1374
1486
  let styleCompiler;
1375
1487
  let command = "build";
@@ -1392,7 +1504,14 @@ const bamboocss = (options = {}) => {
1392
1504
  const targets = new Set(dependencies);
1393
1505
  for (const dependency of previousDependencies ?? []) targets.add(dependency);
1394
1506
  if (!targets.size) return result;
1395
- for (const dependency of ctx.project.getDependencies(filePath, [...targets])) if (!isGeneratedOutput(dependency, ctx)) dependencies.add(dependency);
1507
+ const targetList = [...targets];
1508
+ const closureKey = command === "serve" ? `${filePath}\0${targetList.slice().sort().join("|")}` : void 0;
1509
+ let reachable = closureKey ? resolutionClosureMemo.get(closureKey) : void 0;
1510
+ if (!reachable) {
1511
+ reachable = ctx.project.getDependencies(filePath, targetList);
1512
+ if (closureKey) resolutionClosureMemo.set(closureKey, reachable);
1513
+ }
1514
+ for (const dependency of reachable) if (!isGeneratedOutput(dependency, ctx)) dependencies.add(dependency);
1396
1515
  const expanded = [...dependencies];
1397
1516
  if (expanded.length === result.dependencies.length && expanded.every((dependency, index) => dependency === result.dependencies[index])) return result;
1398
1517
  return {
@@ -1416,6 +1535,7 @@ const bamboocss = (options = {}) => {
1416
1535
  const loaded = await loadCompilerState();
1417
1536
  ctx = loaded.context;
1418
1537
  foldSourceImpl = loaded.foldSource;
1538
+ verifyExportReadsImpl = loaded.verifyExportReads;
1419
1539
  runtimeCss = loaded.runtimeCss;
1420
1540
  styleCompiler = loaded.styleCompiler;
1421
1541
  };
@@ -1576,6 +1696,9 @@ const bamboocss = (options = {}) => {
1576
1696
  * the parser still holds the file.
1577
1697
  */
1578
1698
  watchChange(id, change) {
1699
+ foldMemoByContent.clear();
1700
+ resolutionClosureMemo.clear();
1701
+ verifyDigestMemo.clear();
1579
1702
  for (const state of transformStateByEnvironment.values()) {
1580
1703
  state.unchangedFolds.clear();
1581
1704
  state.changedRun = 0;
@@ -1598,6 +1721,52 @@ const bamboocss = (options = {}) => {
1598
1721
  return;
1599
1722
  }
1600
1723
  ctx.project.reloadSourceFile(filePath);
1724
+ /**
1725
+ * Fold the edited file before the browser asks for it.
1726
+ *
1727
+ * The first transform after an edit is the one fold the memo cannot already hold — the
1728
+ * bytes are new — and it sits on the repaint path: the websocket round trip plus the
1729
+ * module refetch land ~15-30ms after this hook, and the fold costs ~5-13ms of that
1730
+ * budget on a route-sized module. Folding one macrotask later, after the update hooks
1731
+ * have run and the broadcast is out, has the memo hot before the request arrives.
1732
+ *
1733
+ * `setImmediate` is the load-bearing part: this hook is awaited before Vite announces
1734
+ * anything, so the work must not run inline. Content-keyed like every memo entry, so a
1735
+ * racing save cannot poison anything — the entry states what these exact bytes fold to,
1736
+ * and a later event's `watchChange` clears the memo before that event's transforms run.
1737
+ * Failures are swallowed here; the real transform runs the same fold and owns the
1738
+ * diagnostics.
1739
+ */
1740
+ if (command === "serve") setImmediate(() => {
1741
+ if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return;
1742
+ try {
1743
+ const code = readFileSync(filePath, "utf8");
1744
+ const memoKey = foldMemoKey(filePath, digest(code));
1745
+ if (foldMemoByContent.has(memoKey)) return;
1746
+ const sourceFile = ctx.project.addSourceFile(filePath, code);
1747
+ const parserResult = ctx.project.parseSourceFile(filePath);
1748
+ if (!parserResult) return;
1749
+ const folded = foldSourceImpl({
1750
+ ctx,
1751
+ code,
1752
+ parserResult,
1753
+ filePath,
1754
+ runtimeCss,
1755
+ styleCompiler,
1756
+ maxRecipeStates,
1757
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
1758
+ recipeConfigCache: transformStateByEnvironment.get("client")?.recipeConfigCache ?? /* @__PURE__ */ new Map(),
1759
+ reportSurvivors: true,
1760
+ sourceFile
1761
+ });
1762
+ foldMemoByContent.set(memoKey, {
1763
+ result: folded,
1764
+ parserDependencies: parserResult.getDependencies(),
1765
+ valueReads: parserResult.getExportReads?.() ?? [],
1766
+ reportedSurvivors: true
1767
+ });
1768
+ } catch {}
1769
+ });
1601
1770
  },
1602
1771
  /**
1603
1772
  * Re-transform whatever folded a value out of the file that just changed.
@@ -1621,7 +1790,22 @@ const bamboocss = (options = {}) => {
1621
1790
  hotUpdate({ file, modules }) {
1622
1791
  const graph = this.environment?.moduleGraph;
1623
1792
  if (!graph) return;
1624
- return foldDependentModules(environmentState(this), file, modules, graph);
1793
+ /**
1794
+ * The provisional re-folds exist to spare the *browser*: an announced client module is a
1795
+ * refetch round trip, and behind a framework that re-drives HMR per entry, a router
1796
+ * revalidation — that is what deciding "unchanged" before Vite is told anything buys.
1797
+ *
1798
+ * A server graph has none of that economy. Its modules are re-transformed by this same
1799
+ * process the next time something renders, nothing is announced by invalidating quietly,
1800
+ * and the verification runs on the awaited path *before* the client's update can be
1801
+ * broadcast — on a react-router app, re-folding every SSR consumer of a shared style
1802
+ * module added ~15ms to each edit's repaint for work whose only reader was the next
1803
+ * `.data` revalidation. Invalidate outright there and let the next render pay lazily,
1804
+ * off the repaint path. `verify` stays on when the consumer kind is unknown — a harness
1805
+ * without environment config keeps the conservative shape.
1806
+ */
1807
+ const consumer = this.environment?.config?.consumer;
1808
+ return foldDependentModules(environmentState(this), file, modules, graph, consumer !== "server");
1625
1809
  },
1626
1810
  handleHotUpdate({ file, modules, server }) {
1627
1811
  const legacy = server;
@@ -1645,27 +1829,48 @@ const bamboocss = (options = {}) => {
1645
1829
  const previousDependencies = previousSignature && previousSignature.input === (inputDigest ??= digest(code)) ? state.dependenciesByModule.get(id) : void 0;
1646
1830
  let result;
1647
1831
  try {
1648
- const sourceFile = ctx.project.addSourceFile(filePath, code);
1649
- const parserResult = ctx.project.parseSourceFile(filePath);
1650
- if (!parserResult) {
1651
- state.transformArtifactsByModule.delete(id);
1652
- recordFoldDependencies(state, id, filePath, []);
1653
- state.foldSignatures.delete(id);
1654
- return null;
1832
+ const memoKey = command === "serve" ? foldMemoKey(filePath, inputDigest ??= digest(code)) : void 0;
1833
+ const memoized = memoKey ? foldMemoByContent.get(memoKey) : void 0;
1834
+ let valueReads = [];
1835
+ if (memoized?.reportedSurvivors) {
1836
+ valueReads = memoized.valueReads;
1837
+ result = withResolutionClosure(filePath, memoized.result, memoized.parserDependencies, previousDependencies);
1838
+ } else {
1839
+ const sourceFile = ctx.project.addSourceFile(filePath, code);
1840
+ const parserResult = ctx.project.parseSourceFile(filePath);
1841
+ if (!parserResult) {
1842
+ state.transformArtifactsByModule.delete(id);
1843
+ recordFoldDependencies(state, id, filePath, []);
1844
+ state.foldSignatures.delete(id);
1845
+ return null;
1846
+ }
1847
+ const folded = foldSourceImpl({
1848
+ ctx,
1849
+ code,
1850
+ parserResult,
1851
+ filePath,
1852
+ runtimeCss,
1853
+ styleCompiler,
1854
+ maxRecipeStates,
1855
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
1856
+ recipeConfigCache: state.recipeConfigCache,
1857
+ reportSurvivors: true,
1858
+ sourceFile
1859
+ });
1860
+ const parserDependencies = parserResult.getDependencies();
1861
+ valueReads = parserResult.getExportReads?.() ?? [];
1862
+ if (memoKey) foldMemoByContent.set(memoKey, {
1863
+ result: folded,
1864
+ parserDependencies,
1865
+ valueReads,
1866
+ reportedSurvivors: true
1867
+ });
1868
+ result = withResolutionClosure(filePath, folded, parserDependencies, previousDependencies);
1655
1869
  }
1656
- result = withResolutionClosure(filePath, foldSourceImpl({
1657
- ctx,
1658
- code,
1659
- parserResult,
1660
- filePath,
1661
- runtimeCss,
1662
- styleCompiler,
1663
- maxRecipeStates,
1664
- parseModule: (path) => ctx?.project.parseSourceFile(path),
1665
- recipeConfigCache: state.recipeConfigCache,
1666
- reportSurvivors: true,
1667
- sourceFile
1668
- }), parserResult.getDependencies(), previousDependencies);
1870
+ state.exportReadsByModule.set(id, [...valueReads.map((read) => ({
1871
+ kind: "value",
1872
+ ...read
1873
+ })), ...result.exportReads]);
1669
1874
  } catch (error) {
1670
1875
  logger.caughtError("vite:transform", `Failed to compile ${filePath}`, error);
1671
1876
  const previousDependencies = [...state.dependenciesByModule.get(id) ?? []];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/vite",
3
- "version": "1.46.0",
3
+ "version": "1.46.2",
4
4
  "description": "Vite integration for Bamboo CSS",
5
5
  "homepage": "https://bamboocss.com",
6
6
  "license": "MIT",
@@ -42,18 +42,18 @@
42
42
  "postcss": "8.5.26",
43
43
  "postcss-selector-parser": "7.1.5",
44
44
  "ts-morph": "28.0.0",
45
- "@bamboocss/config": "1.46.0",
46
- "@bamboocss/core": "1.46.0",
47
- "@bamboocss/extractor": "1.46.0",
48
- "@bamboocss/logger": "1.46.0",
49
- "@bamboocss/node": "1.46.0",
50
- "@bamboocss/shared": "1.46.0",
51
- "@bamboocss/types": "1.46.0"
45
+ "@bamboocss/config": "1.46.2",
46
+ "@bamboocss/core": "1.46.2",
47
+ "@bamboocss/extractor": "1.46.2",
48
+ "@bamboocss/node": "1.46.2",
49
+ "@bamboocss/logger": "1.46.2",
50
+ "@bamboocss/shared": "1.46.2",
51
+ "@bamboocss/types": "1.46.2"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@jridgewell/trace-mapping": "^0.3.31",
55
55
  "vite": "7.2.6",
56
- "@bamboocss/fixture": "1.46.0"
56
+ "@bamboocss/fixture": "1.46.2"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "vite": ">=5"