@bamboocss/vite 1.47.0 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -48,24 +48,121 @@ const loadCssOutputModule = createLazyCssOutputModule();
48
48
  const createLazyFoldModule = (loadFold = () => Promise.resolve().then(() => require("./fold-module.cjs"))) => createRetryableLazy(loadFold);
49
49
  /** One process-wide fold-module load shared by every plugin instance and Vite environment. */
50
50
  const loadFoldModule = createLazyFoldModule();
51
- /** One Builder per CSS plugin instance, created only when a hook first needs it. */
51
+ /**
52
+ * The one Builder a run compiles against, created only when a hook first needs it.
53
+ *
54
+ * Per host rather than per plugin instance: the compiler and the stylesheet share it now.
55
+ * @see `createCompilationHost`
56
+ */
52
57
  const createLazyBuilder = (loadNode = loadNodeModule) => createRetryableLazy(async () => {
53
58
  const { Builder } = await loadNode();
54
59
  return new Builder();
55
60
  });
56
- /** Build and publish one complete fold state; no caller can observe partial initialization. */
57
- const createLazyCompilerState = (loadContext, loadFold) => createRetryableLazy(async () => {
58
- const [context, fold] = await Promise.all([loadContext(), loadFold()]);
59
- const runtimeCss = fold.createRuntimeCss(context);
60
- const styleCompiler = fold.createStaticStyleSetCompiler(context, runtimeCss);
61
+ //#endregion
62
+ //#region src/compilation-host.ts
63
+ const createCompilationHost = (options = {}) => {
64
+ const { configPath, cwd } = options;
65
+ const loadBuilder = options.loadBuilder ?? createLazyBuilder();
66
+ let command = "build";
67
+ let builder;
68
+ let generation;
69
+ let nextGenerationId = 0;
70
+ /**
71
+ * The setup covering the pass currently open.
72
+ *
73
+ * A cold start reaches this twice — the compiler's `pre` `buildStart`, then the CSS
74
+ * plugin's — for one instant in which nothing can have changed on disk. Sharing one attempt
75
+ * across both is what keeps a project from loading and evaluating its config twice per
76
+ * build. Cleared once a stylesheet pass consumes it, and on any source mutation, so no
77
+ * later pass can be answered by a setup taken before an edit.
78
+ */
79
+ let openSetup;
80
+ let openSetupStale = false;
81
+ let cssPass;
82
+ const settled = async (attempt) => {
83
+ try {
84
+ await attempt;
85
+ } catch {}
86
+ };
87
+ const publish = () => {
88
+ const context = builder.getContextOrThrow();
89
+ if (generation?.context !== context) generation = {
90
+ id: ++nextGenerationId,
91
+ context,
92
+ encoder: context.encoder.clone()
93
+ };
94
+ return generation;
95
+ };
96
+ const runSetup = async () => {
97
+ builder ??= await loadBuilder();
98
+ await builder.setup({
99
+ configPath,
100
+ cwd,
101
+ dev: command === "serve"
102
+ });
103
+ return publish();
104
+ };
105
+ /**
106
+ * The setup covering the pass currently open, started at most once.
107
+ *
108
+ * Started through a resolved promise, so a synchronous throw becomes the same
109
+ * rejected-attempt contract a failed module load has and a later hook can retry it. A
110
+ * source mutation observed while one is in flight does not cancel it — two overlapping
111
+ * `Builder.setup` calls would interleave their change detection — it queues a fresh one
112
+ * behind it.
113
+ */
114
+ const setupOnce = () => {
115
+ const previous = openSetup;
116
+ if (previous && !openSetupStale) return previous;
117
+ openSetupStale = false;
118
+ const attempt = previous ? settled(previous).then(runSetup) : Promise.resolve().then(runSetup);
119
+ openSetup = attempt;
120
+ attempt.catch(() => {
121
+ if (openSetup === attempt) openSetup = void 0;
122
+ });
123
+ return attempt;
124
+ };
61
125
  return {
62
- context,
63
- foldSource: fold.foldSource,
64
- verifyExportReads: fold.verifyExportReads,
65
- runtimeCss,
66
- styleCompiler
126
+ setCommand(next) {
127
+ command = next;
128
+ },
129
+ current: () => generation,
130
+ async ensureGeneration() {
131
+ if (cssPass) await settled(cssPass);
132
+ if (generation) return Promise.resolve(generation);
133
+ return setupOnce();
134
+ },
135
+ isCssPassActive: () => cssPass !== void 0,
136
+ async runCssPass(run) {
137
+ while (cssPass) await settled(cssPass);
138
+ let release;
139
+ cssPass = new Promise((resolve) => {
140
+ release = resolve;
141
+ });
142
+ try {
143
+ const passGeneration = await setupOnce();
144
+ return await run(builder, passGeneration);
145
+ } finally {
146
+ openSetup = void 0;
147
+ openSetupStale = false;
148
+ cssPass = void 0;
149
+ release();
150
+ }
151
+ },
152
+ async runCompilerWork(run) {
153
+ while (cssPass) await settled(cssPass);
154
+ return run();
155
+ },
156
+ reloadSource(filePath) {
157
+ openSetupStale = true;
158
+ builder?.reloadSource(filePath);
159
+ },
160
+ removeSource(filePath) {
161
+ openSetupStale = true;
162
+ builder?.removeSource(filePath);
163
+ }
67
164
  };
68
- });
165
+ };
69
166
  //#endregion
70
167
  //#region src/static-session.ts
71
168
  const createStaticCompilationSession = () => {
@@ -184,14 +281,11 @@ const asError = (error, context) => error instanceof Error ? error : new Error(`
184
281
  * process just wrote, which is a race on any watch rebuild.
185
282
  */
186
283
  const bamboocssCss = (options) => {
187
- const { configPath, cwd, loadCssOutput = loadCssOutputModule, session, pruneCss = true } = options;
284
+ const { configPath, cwd, loadCssOutput = loadCssOutputModule, session, host = createCompilationHost({
285
+ configPath,
286
+ cwd
287
+ }), pruneCss = true } = options;
188
288
  let builder;
189
- const loadBuilder = createLazyBuilder();
190
- const ensureBuilder = async () => {
191
- const loaded = await loadBuilder();
192
- builder = loaded;
193
- return loaded;
194
- };
195
289
  let server;
196
290
  let command = "build";
197
291
  /** The run's own `build` options, for a bundler with no per-environment config. */
@@ -226,34 +320,36 @@ const bamboocssCss = (options) => {
226
320
  let changeGeneration = 0;
227
321
  let pendingGeneration = -1;
228
322
  let servedCss;
229
- const build = async () => {
230
- const builder = await ensureBuilder();
231
- await builder.setup({
232
- configPath,
233
- cwd,
234
- dev: command === "serve"
235
- });
236
- await builder.emit();
237
- builder.extract();
323
+ /**
324
+ * Held by the host for its whole length, rather than only around each mutation.
325
+ *
326
+ * Extraction fills the encoder this sheet is emitted from and `toCss` reads it back, with a
327
+ * deliberate macrotask between them. The compiler shares the AST both halves run against,
328
+ * so a transform folding a module in that window would re-prepare a source the extraction
329
+ * pass has already read and `toCss` has not finished reporting on. The host makes compiler
330
+ * work wait instead; a fold is a few milliseconds and this is the one place correctness
331
+ * depends on it.
332
+ */
333
+ const build = () => host.runCssPass(async (activeBuilder) => {
334
+ builder = activeBuilder;
335
+ await activeBuilder.emit();
336
+ activeBuilder.extract();
238
337
  await new Promise((settle) => setImmediate(settle));
239
- if (builder.context) {
240
- session.utilityLayer = builder.context.config.layers?.utilities ?? "utilities";
338
+ if (activeBuilder.context) {
339
+ session.utilityLayer = activeBuilder.context.config.layers?.utilities ?? "utilities";
241
340
  session.extractedFiles.clear();
242
341
  for (const file of extractedSourceFiles()) session.extractedFiles.add(file);
243
342
  }
244
343
  let graphAtomHashes;
245
- if (builder.context) {
246
- builder.context.encoder.atomizeObservedRecipes();
247
- graphAtomHashes = new Set(builder.context.encoder.atomic);
344
+ if (activeBuilder.context) {
345
+ activeBuilder.context.encoder.atomizeObservedRecipes();
346
+ graphAtomHashes = new Set(activeBuilder.context.encoder.atomic);
248
347
  }
249
- const css = builder.toCss({
250
- layerParams: true,
251
- includeRecipes: false
252
- });
348
+ const css = activeBuilder.toCss({ layerParams: true });
253
349
  session.prunableClasses.clear();
254
350
  session.viewTransitionClasses.clear();
255
- if (graphAtomHashes && builder.context) {
256
- const decoder = builder.context.decoder.collect(builder.context.encoder);
351
+ if (graphAtomHashes && activeBuilder.context) {
352
+ const decoder = activeBuilder.context.decoder.collect(activeBuilder.context.encoder);
257
353
  for (const atom of decoder.atomic) if (graphAtomHashes.has(atom.hash)) session.prunableClasses.add(atom.className);
258
354
  for (const transition of decoder.view_transitions) {
259
355
  session.viewTransitionClasses.add(transition.className);
@@ -261,7 +357,7 @@ const bamboocssCss = (options) => {
261
357
  }
262
358
  }
263
359
  return css;
264
- };
360
+ });
265
361
  const generate = () => {
266
362
  if (command === "serve" && pending && pendingGeneration === changeGeneration) return pending;
267
363
  pendingGeneration = changeGeneration;
@@ -341,6 +437,7 @@ const bamboocssCss = (options) => {
341
437
  sharedDuringBuild: true,
342
438
  async configResolved(config) {
343
439
  command = config.command;
440
+ host.setCommand(config.command);
344
441
  session.sourcemap = config.build.sourcemap;
345
442
  ssrBuildOptions = {
346
443
  ssr: config.build.ssr,
@@ -354,11 +451,12 @@ const bamboocssCss = (options) => {
354
451
  * edited all afternoon. Nothing watched it: `watch` is the CLI's own watcher, and a
355
452
  * project running `vite dev` never reaches it.
356
453
  *
357
- * A restart rather than re-emitting the stylesheet, because this plugin and the compiler
358
- * hold *separate* contexts and only this one reloads its config. A token *value* edit
359
- * came out right on the next source change, and an edit that changes what compiles
360
- * adding a token, a condition, a utility left the compiler naming classes from the old
361
- * config against a sheet emitted from the new one. Half-updated is worse than stale.
454
+ * A restart rather than re-emitting the stylesheet. The two plugins share one context now,
455
+ * and the compiler re-derives everything it holds when `Builder.setup` replaces it so the
456
+ * half-updated state this used to prevent, with the compiler naming classes from the old
457
+ * config against a sheet emitted from the new one, can no longer happen. What a restart
458
+ * still buys is the rest of the server: a changed `outdir`, a preset that adds an entry
459
+ * point, and every module Vite has already transformed against the previous config.
362
460
  *
363
461
  * Through Vite's own list rather than a watcher of ours. Vite adds these paths to the
364
462
  * files it watches, which is what reaches a config *outside* `root` — a monorepo with one
@@ -628,6 +726,22 @@ const compilerParsePath = (id, code) => {
628
726
  return `${filePath}.__bamboo__.ts`;
629
727
  };
630
728
  /**
729
+ * Where to park a transform's text when it is not what the shared Project holds for the file.
730
+ *
731
+ * The compiler folds the bundler's view of a module — after every `enforce: 'pre'` plugin
732
+ * before it, and after Vite's own load. The stylesheet pass reads the same file off disk
733
+ * through the same ts-morph Project. When the two texts differ and the compiler writes its
734
+ * own under the file's path, that transform silently becomes the canonical source for the
735
+ * next extraction pass: the CSS would then be generated from a bundler artifact rather than
736
+ * from the checkout. Under a sibling path both readings exist and neither overwrites the
737
+ * other, which is the same reason `compilerParsePath` already does this for SFC submodules.
738
+ *
739
+ * The extension carries JSX-ness across, since it is what ts-morph keys its script kind on:
740
+ * anything but an unambiguously non-JSX `.ts`/`.mts`/`.cts` is parsed as `.tsx`, so a `<div>`
741
+ * in a `.js` file still parses and a `<T>value` assertion in a `.ts` file still means a cast.
742
+ */
743
+ const auxiliaryParsePath = (filePath) => `${filePath}.__bamboo__.${/\.[cm]?ts$/i.test(filePath) ? "ts" : "tsx"}`;
744
+ /**
631
745
  * Is this file part of the generated `styled-system` rather than the user's source?
632
746
  *
633
747
  * Resolved to a path and compared as a prefix, rather than by looking for the outdir's
@@ -682,6 +796,17 @@ const bamboocss = (options = {}) => {
682
796
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
683
797
  if ("renameCssAsset" in options) throw new Error("bamboocss: `renameCssAsset` has been replaced by `pruneCss`. Use `pruneCss: false` for what `renameCssAsset: false` did — it always disabled the pruning as well, since pruned bytes under the unpruned sheet's name is what lets a CDN serve a stale stylesheet. The new name says which of the two it is really about.");
684
798
  const staticSession = createStaticCompilationSession();
799
+ /**
800
+ * One Builder, one resolved config, one context and one ts-morph project for the run.
801
+ *
802
+ * Created here rather than by either plugin because both need it and neither may own it:
803
+ * the compiler used to load a second config of its own, which is why a token edit could
804
+ * leave it naming classes from the old one against a sheet emitted from the new one.
805
+ */
806
+ const host = createCompilationHost({
807
+ configPath,
808
+ cwd
809
+ });
685
810
  const transformArtifactIntegrityKey = (0, node_crypto.randomBytes)(32);
686
811
  const serializeTransformArtifact = (environment, artifact) => JSON.stringify([
687
812
  TRANSFORM_META_KEY,
@@ -1326,6 +1451,8 @@ const bamboocss = (options = {}) => {
1326
1451
  const foldOutputUnchanged = (state, dependent, changedFile) => {
1327
1452
  const memoized = state.unchangedFolds.get(dependent);
1328
1453
  if (memoized !== void 0) return memoized;
1454
+ if (host.isCssPassActive()) return false;
1455
+ if (!compilerStateIsCurrent()) return false;
1329
1456
  const unchanged = state.changedRun < CHANGED_RUN_LIMIT && refoldMatchesSignature(state, dependent, changedFile);
1330
1457
  state.changedRun = unchanged ? 0 : state.changedRun + 1;
1331
1458
  state.unchangedFolds.set(dependent, unchanged);
@@ -1337,7 +1464,8 @@ const bamboocss = (options = {}) => {
1337
1464
  try {
1338
1465
  const retained = state.foldInputsByModule.get(dependent);
1339
1466
  const code = retained?.input === signature.input ? retained.code : (0, node_fs.readFileSync)(signature.path, "utf8");
1340
- const parsePath = retained?.input === signature.input ? retained.parsePath : signature.path;
1467
+ const requestedParsePath = retained?.input === signature.input ? retained.parsePath : signature.path;
1468
+ const parsePath = compilerSourcePath(signature.path, requestedParsePath, code);
1341
1469
  const inputDigest = digest(code);
1342
1470
  if (inputDigest !== signature.input) return false;
1343
1471
  /**
@@ -1354,7 +1482,7 @@ const bamboocss = (options = {}) => {
1354
1482
  */
1355
1483
  const reads = state.exportReadsByModule.get(dependent);
1356
1484
  if (reads?.length && verifyExportReadsImpl) {
1357
- const { verdict, crossings } = verifyExportReadsImpl(ctx, (path) => ctx?.project.parseSourceFile(path), reads, normalizeFsPath(changedFile), verifyDigestMemo);
1485
+ const { verdict, crossings } = verifyExportReadsImpl(ctx, parseForCompiler, reads, normalizeFsPath(changedFile), verifyDigestMemo);
1358
1486
  if (verdict === "unchanged") {
1359
1487
  recordFoldDependencies(state, dependent, signature.path, [...state.dependenciesByModule.get(dependent) ?? [], ...crossings]);
1360
1488
  return true;
@@ -1369,8 +1497,9 @@ const bamboocss = (options = {}) => {
1369
1497
  raw = memoized.result;
1370
1498
  parserDependencies = memoized.parserDependencies;
1371
1499
  } else {
1372
- const sourceFile = ctx.project.addSourceFile(parsePath, code);
1373
- const parserResult = ctx.project.parseSourceFile(parsePath);
1500
+ const sourceFile = addCompilerSource(signature.path, parsePath, code);
1501
+ if (!sourceFile) return false;
1502
+ const parserResult = parseForCompiler(parsePath, requestedParsePath === signature.path ? signature.path : parsePath);
1374
1503
  if (!parserResult) return false;
1375
1504
  raw = foldSourceImpl({
1376
1505
  ctx,
@@ -1380,7 +1509,7 @@ const bamboocss = (options = {}) => {
1380
1509
  runtimeCss,
1381
1510
  styleCompiler,
1382
1511
  maxRecipeStates,
1383
- parseModule: (path) => ctx?.project.parseSourceFile(path),
1512
+ parseModule: parseForCompiler,
1384
1513
  recipeConfigCache: state.recipeConfigCache,
1385
1514
  reportSurvivors: false,
1386
1515
  sourceFile
@@ -1520,6 +1649,8 @@ const bamboocss = (options = {}) => {
1520
1649
  return [...modules, ...added];
1521
1650
  };
1522
1651
  let ctx;
1652
+ /** The compiler's private parse sink for `ctx`. @see `CompilationGeneration.encoder` */
1653
+ let parseEncoder;
1523
1654
  let foldSourceImpl;
1524
1655
  let verifyExportReadsImpl;
1525
1656
  let runtimeCss;
@@ -1559,25 +1690,97 @@ const bamboocss = (options = {}) => {
1559
1690
  dependencies: expanded
1560
1691
  };
1561
1692
  };
1562
- const loadContext = createRetryableLazy(async () => {
1563
- const { loadConfigAndCreateContext } = await loadNodeModule();
1564
- return loadConfigAndCreateContext({
1565
- configPath,
1566
- cwd,
1567
- dev: command === "serve"
1568
- });
1569
- });
1570
- const loadCompilerState = createLazyCompilerState(loadContext, loadFoldModule);
1693
+ /** Which context the published derivations below were built from. */
1694
+ let derivedGeneration = -1;
1695
+ /** Compiler-only sibling ASTs retained for each physical module. */
1696
+ const auxiliarySourcesByFile = /* @__PURE__ */ new Map();
1697
+ /**
1698
+ * Whether the compiler state below still describes the context the host is on.
1699
+ *
1700
+ * Only `ensureCompilerState` re-derives, and only an awaited hook may call it — so the two
1701
+ * synchronous entry points, the speculative prefold and the unchanged-dependent check, can
1702
+ * be reached after a stylesheet pass has published a config reload they have not seen. Both
1703
+ * decline rather than fold against a runtime `css` from the previous config.
1704
+ */
1705
+ const compilerStateIsCurrent = () => {
1706
+ const current = host.current();
1707
+ return current !== void 0 && current.id === derivedGeneration;
1708
+ };
1571
1709
  const ensureContext = async () => {
1572
- ctx = await loadContext();
1710
+ ctx = (await host.ensureGeneration()).context;
1573
1711
  };
1712
+ /**
1713
+ * Load the fold chunk and derive everything that depends on the resolved context.
1714
+ *
1715
+ * Keyed on context *identity* rather than derived once. `Builder.setup` replaces its context
1716
+ * on a config reload, and the runtime `css`, the style-set compiler and the parse sink are
1717
+ * all closures over the previous one — a stale `runtimeCss` names classes from the old
1718
+ * config while the stylesheet is emitted from the new one, and nothing downstream can see
1719
+ * the difference. Re-derivation is cheap; both factories are a handful of bound methods.
1720
+ *
1721
+ * Published as a set, and only once every part of the attempt has succeeded, so a failed
1722
+ * chunk load leaves no half-compiler visible to HMR.
1723
+ */
1574
1724
  const ensureCompilerState = async () => {
1575
- const loaded = await loadCompilerState();
1576
- ctx = loaded.context;
1577
- foldSourceImpl = loaded.foldSource;
1578
- verifyExportReadsImpl = loaded.verifyExportReads;
1579
- runtimeCss = loaded.runtimeCss;
1580
- styleCompiler = loaded.styleCompiler;
1725
+ const [initialGeneration, fold] = await Promise.all([host.ensureGeneration(), loadFoldModule()]);
1726
+ const currentGeneration = await host.ensureGeneration();
1727
+ const generation = currentGeneration.id === initialGeneration.id ? initialGeneration : currentGeneration;
1728
+ if (derivedGeneration === generation.id && foldSourceImpl) {
1729
+ ctx = generation.context;
1730
+ return;
1731
+ }
1732
+ const derivedRuntimeCss = fold.createRuntimeCss(generation.context);
1733
+ const derivedStyleCompiler = fold.createStaticStyleSetCompiler(generation.context, derivedRuntimeCss);
1734
+ ctx = generation.context;
1735
+ parseEncoder = generation.encoder;
1736
+ foldSourceImpl = fold.foldSource;
1737
+ verifyExportReadsImpl = fold.verifyExportReads;
1738
+ runtimeCss = derivedRuntimeCss;
1739
+ styleCompiler = derivedStyleCompiler;
1740
+ derivedGeneration = generation.id;
1741
+ auxiliarySourcesByFile.clear();
1742
+ };
1743
+ /**
1744
+ * Parse a module for the compiler, never for the stylesheet.
1745
+ *
1746
+ * Every compiler parse goes through here so the private encoder cannot be forgotten at one
1747
+ * call site. Forgetting it at any of them puts that module's reading into the encoder the
1748
+ * sheet is emitted from, under a `parse` owner nothing retracts.
1749
+ */
1750
+ const parseForCompiler = (filePath, hookFilePath = filePath) => ctx?.project.parseSourceFile(filePath, parseEncoder, { hookFilePath });
1751
+ /**
1752
+ * Where the compiler may hold `code` for `filePath` without displacing the checkout.
1753
+ *
1754
+ * The file's own path exactly when the shared Project already holds these bytes — then
1755
+ * `addSourceFile` is a lookup and there is nothing to displace. @see `auxiliaryParsePath`
1756
+ */
1757
+ const compilerSourcePath = (filePath, requested, code) => {
1758
+ if (requested !== filePath) return requested;
1759
+ return ctx?.project.getSourceFile(filePath)?.getFullText() === code ? filePath : auxiliaryParsePath(filePath);
1760
+ };
1761
+ /** Add one compiler-owned source without letting it displace or outlive its physical file. */
1762
+ const addCompilerSource = (filePath, parsePath, code) => {
1763
+ if (!ctx) return;
1764
+ const auxiliary = parsePath !== filePath;
1765
+ const sourceFile = ctx.project.addSourceFile(parsePath, code, { auxiliary });
1766
+ if (auxiliary) {
1767
+ const physical = normalizeFsPath(filePath);
1768
+ const paths = auxiliarySourcesByFile.get(physical) ?? /* @__PURE__ */ new Set();
1769
+ paths.add(parsePath);
1770
+ auxiliarySourcesByFile.set(physical, paths);
1771
+ }
1772
+ return sourceFile;
1773
+ };
1774
+ /** Release compiler encoder owners and sibling ASTs when their physical module disappears. */
1775
+ const releaseCompilerSources = (filePath) => {
1776
+ if (!ctx) return;
1777
+ parseEncoder?.releaseFile(filePath);
1778
+ const physical = normalizeFsPath(filePath);
1779
+ for (const auxiliary of auxiliarySourcesByFile.get(physical) ?? []) {
1780
+ parseEncoder?.releaseFile(auxiliary);
1781
+ ctx.project.removeSourceFile(auxiliary);
1782
+ }
1783
+ auxiliarySourcesByFile.delete(physical);
1581
1784
  };
1582
1785
  const outputFinalizerTag = (value) => {
1583
1786
  if (!value || typeof value !== "object") return void 0;
@@ -1694,6 +1897,7 @@ const bamboocss = (options = {}) => {
1694
1897
  },
1695
1898
  configResolved(config) {
1696
1899
  command = config.command;
1900
+ host.setCommand(config.command);
1697
1901
  defaultEmitAssets = config.build?.emitAssets ?? (!config.build?.ssr || config.build?.ssrEmitAssets === true);
1698
1902
  const plugins = config.plugins;
1699
1903
  if (plugins) {
@@ -1748,9 +1952,9 @@ const bamboocss = (options = {}) => {
1748
1952
  const [filePath] = id.split("?");
1749
1953
  if (!filePath) return;
1750
1954
  for (const state of transformStateByEnvironment.values()) state.recipeConfigCache.clear();
1751
- if (SFC_EXTENSIONS.test(filePath)) return;
1752
1955
  if (change.event === "delete") {
1753
- ctx.project.removeSourceFile(filePath);
1956
+ host.removeSource(filePath);
1957
+ releaseCompilerSources(filePath);
1754
1958
  const deleted = normalizeFsPath(filePath);
1755
1959
  for (const state of transformStateByEnvironment.values()) for (const [moduleId, moduleFile] of [...state.filesByModule]) {
1756
1960
  if (normalizeFsPath(moduleFile) !== deleted) continue;
@@ -1762,7 +1966,8 @@ const bamboocss = (options = {}) => {
1762
1966
  }
1763
1967
  return;
1764
1968
  }
1765
- ctx.project.reloadSourceFile(filePath);
1969
+ if (SFC_EXTENSIONS.test(filePath)) return;
1970
+ host.reloadSource(filePath);
1766
1971
  /**
1767
1972
  * Fold the edited file before the browser asks for it.
1768
1973
  *
@@ -1781,12 +1986,14 @@ const bamboocss = (options = {}) => {
1781
1986
  */
1782
1987
  if (command === "serve") setImmediate(() => {
1783
1988
  if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return;
1989
+ if (host.isCssPassActive() || !compilerStateIsCurrent()) return;
1784
1990
  try {
1785
1991
  const code = (0, node_fs.readFileSync)(filePath, "utf8");
1786
1992
  const memoKey = foldMemoKey(filePath, digest(code));
1787
1993
  if (foldMemoByContent.has(memoKey)) return;
1788
- const sourceFile = ctx.project.addSourceFile(filePath, code);
1789
- const parserResult = ctx.project.parseSourceFile(filePath);
1994
+ const sourceFile = addCompilerSource(filePath, filePath, code);
1995
+ if (!sourceFile) return;
1996
+ const parserResult = parseForCompiler(filePath);
1790
1997
  if (!parserResult) return;
1791
1998
  const folded = foldSourceImpl({
1792
1999
  ctx,
@@ -1796,7 +2003,7 @@ const bamboocss = (options = {}) => {
1796
2003
  runtimeCss,
1797
2004
  styleCompiler,
1798
2005
  maxRecipeStates,
1799
- parseModule: (path) => ctx?.project.parseSourceFile(path),
2006
+ parseModule: parseForCompiler,
1800
2007
  recipeConfigCache: transformStateByEnvironment.get("client")?.recipeConfigCache ?? /* @__PURE__ */ new Map(),
1801
2008
  reportSurvivors: true,
1802
2009
  sourceFile
@@ -1924,8 +2131,8 @@ const bamboocss = (options = {}) => {
1924
2131
  if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return null;
1925
2132
  const [filePath] = id.split("?");
1926
2133
  if (isGeneratedOutput(filePath, ctx)) return null;
1927
- const parsePath = compilerParsePath(id, code);
1928
- if (parsePath === null) return null;
2134
+ const requestedParsePath = compilerParsePath(id, code);
2135
+ if (requestedParsePath === null) return null;
1929
2136
  const state = environmentState(this);
1930
2137
  state.transformedModulesThisRun.add(id);
1931
2138
  let inputDigest;
@@ -1933,46 +2140,62 @@ const bamboocss = (options = {}) => {
1933
2140
  const previousDependencies = previousSignature && previousSignature.input === (inputDigest ??= digest(code)) ? state.dependenciesByModule.get(id) : void 0;
1934
2141
  let result;
1935
2142
  try {
1936
- const memoKey = command === "serve" ? foldMemoKey(parsePath, inputDigest ??= digest(code)) : void 0;
1937
- const memoized = memoKey ? foldMemoByContent.get(memoKey) : void 0;
1938
- let valueReads = [];
1939
- if (memoized?.reportedSurvivors) {
1940
- valueReads = memoized.valueReads;
1941
- result = withResolutionClosure(parsePath, memoized.result, memoized.parserDependencies, previousDependencies);
1942
- } else {
1943
- const sourceFile = ctx.project.addSourceFile(parsePath, code);
1944
- const parserResult = ctx.project.parseSourceFile(parsePath);
1945
- if (!parserResult) {
1946
- state.transformArtifactsByModule.delete(id);
1947
- recordFoldDependencies(state, id, filePath, []);
1948
- state.foldSignatures.delete(id);
1949
- state.foldInputsByModule.delete(id);
1950
- return null;
1951
- }
2143
+ /**
2144
+ * One serialized region, holding every read and every mutation of the shared AST.
2145
+ *
2146
+ * Synchronous throughout, which is what makes waiting for the stylesheet pass once at
2147
+ * the top sufficient: nothing can open a pass between the wait and the work, because
2148
+ * nothing else runs. The fold is CPU-bound anyway, so there is no await to give up.
2149
+ */
2150
+ const compiled = await host.runCompilerWork(() => {
2151
+ if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return null;
2152
+ const path = compilerSourcePath(filePath, requestedParsePath, code);
2153
+ const memoKey = command === "serve" ? foldMemoKey(path, inputDigest ??= digest(code)) : void 0;
2154
+ const memoized = memoKey ? foldMemoByContent.get(memoKey) : void 0;
2155
+ if (memoized?.reportedSurvivors) return {
2156
+ valueReads: memoized.valueReads,
2157
+ result: withResolutionClosure(path, memoized.result, memoized.parserDependencies, previousDependencies)
2158
+ };
2159
+ const sourceFile = addCompilerSource(filePath, path, code);
2160
+ if (!sourceFile) return null;
2161
+ const parserResult = parseForCompiler(path, requestedParsePath === filePath ? filePath : path);
2162
+ if (!parserResult) return { unparsed: true };
1952
2163
  const folded = foldSourceImpl({
1953
2164
  ctx,
1954
2165
  code,
1955
2166
  parserResult,
1956
- filePath: parsePath,
2167
+ filePath: path,
1957
2168
  runtimeCss,
1958
2169
  styleCompiler,
1959
2170
  maxRecipeStates,
1960
- parseModule: (path) => ctx?.project.parseSourceFile(path),
2171
+ parseModule: parseForCompiler,
1961
2172
  recipeConfigCache: state.recipeConfigCache,
1962
2173
  reportSurvivors: true,
1963
2174
  sourceFile
1964
2175
  });
1965
2176
  const parserDependencies = parserResult.getDependencies();
1966
- valueReads = parserResult.getExportReads?.() ?? [];
2177
+ const valueReads = parserResult.getExportReads?.() ?? [];
1967
2178
  if (memoKey) foldMemoByContent.set(memoKey, {
1968
2179
  result: folded,
1969
2180
  parserDependencies,
1970
2181
  valueReads,
1971
2182
  reportedSurvivors: true
1972
2183
  });
1973
- result = withResolutionClosure(parsePath, folded, parserDependencies, previousDependencies);
2184
+ return {
2185
+ valueReads,
2186
+ result: withResolutionClosure(path, folded, parserDependencies, previousDependencies)
2187
+ };
2188
+ });
2189
+ if (!compiled) return null;
2190
+ if ("unparsed" in compiled) {
2191
+ state.transformArtifactsByModule.delete(id);
2192
+ recordFoldDependencies(state, id, filePath, []);
2193
+ state.foldSignatures.delete(id);
2194
+ state.foldInputsByModule.delete(id);
2195
+ return null;
1974
2196
  }
1975
- state.exportReadsByModule.set(id, [...valueReads.map((read) => ({
2197
+ result = compiled.result;
2198
+ state.exportReadsByModule.set(id, [...compiled.valueReads.map((read) => ({
1976
2199
  kind: "value",
1977
2200
  ...read
1978
2201
  })), ...result.exportReads]);
@@ -2028,10 +2251,10 @@ const bamboocss = (options = {}) => {
2028
2251
  } } : {}
2029
2252
  });
2030
2253
  applyTransformArtifact(state, artifact, id, environmentName(this));
2031
- if (artifact.signature && (command === "serve" || parsePath !== filePath)) state.foldInputsByModule.set(id, {
2254
+ if (artifact.signature && (command === "serve" || requestedParsePath !== filePath)) state.foldInputsByModule.set(id, {
2032
2255
  code,
2033
2256
  input: artifact.signature.input,
2034
- parsePath
2257
+ parsePath: requestedParsePath
2035
2258
  });
2036
2259
  else state.foldInputsByModule.delete(id);
2037
2260
  if (reportSkipped && result.skipped.length) _bamboocss_logger.logger.info("vite:transform", formatSkipped(filePath, result.skipped));
@@ -2093,6 +2316,7 @@ const bamboocss = (options = {}) => {
2093
2316
  bamboocssCss({
2094
2317
  configPath,
2095
2318
  cwd,
2319
+ host,
2096
2320
  session: staticSession,
2097
2321
  pruneCss
2098
2322
  }),
package/dist/index.d.cts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Plugin } from "vite";
2
-
3
2
  //#region src/css.d.ts
4
3
  /**
5
4
  * What a project imports to get the stylesheet.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
+ import MagicString from "magic-string";
2
+ import { Node } from "ts-morph";
1
3
  import { Plugin } from "vite";
2
-
3
4
  //#region src/css.d.ts
4
5
  /**
5
6
  * What a project imports to get the stylesheet.
package/dist/index.mjs CHANGED
@@ -43,24 +43,121 @@ const loadCssOutputModule = createLazyCssOutputModule();
43
43
  const createLazyFoldModule = (loadFold = () => import("./fold-module.mjs")) => createRetryableLazy(loadFold);
44
44
  /** One process-wide fold-module load shared by every plugin instance and Vite environment. */
45
45
  const loadFoldModule = createLazyFoldModule();
46
- /** One Builder per CSS plugin instance, created only when a hook first needs it. */
46
+ /**
47
+ * The one Builder a run compiles against, created only when a hook first needs it.
48
+ *
49
+ * Per host rather than per plugin instance: the compiler and the stylesheet share it now.
50
+ * @see `createCompilationHost`
51
+ */
47
52
  const createLazyBuilder = (loadNode = loadNodeModule) => createRetryableLazy(async () => {
48
53
  const { Builder } = await loadNode();
49
54
  return new Builder();
50
55
  });
51
- /** Build and publish one complete fold state; no caller can observe partial initialization. */
52
- const createLazyCompilerState = (loadContext, loadFold) => createRetryableLazy(async () => {
53
- const [context, fold] = await Promise.all([loadContext(), loadFold()]);
54
- const runtimeCss = fold.createRuntimeCss(context);
55
- const styleCompiler = fold.createStaticStyleSetCompiler(context, runtimeCss);
56
+ //#endregion
57
+ //#region src/compilation-host.ts
58
+ const createCompilationHost = (options = {}) => {
59
+ const { configPath, cwd } = options;
60
+ const loadBuilder = options.loadBuilder ?? createLazyBuilder();
61
+ let command = "build";
62
+ let builder;
63
+ let generation;
64
+ let nextGenerationId = 0;
65
+ /**
66
+ * The setup covering the pass currently open.
67
+ *
68
+ * A cold start reaches this twice — the compiler's `pre` `buildStart`, then the CSS
69
+ * plugin's — for one instant in which nothing can have changed on disk. Sharing one attempt
70
+ * across both is what keeps a project from loading and evaluating its config twice per
71
+ * build. Cleared once a stylesheet pass consumes it, and on any source mutation, so no
72
+ * later pass can be answered by a setup taken before an edit.
73
+ */
74
+ let openSetup;
75
+ let openSetupStale = false;
76
+ let cssPass;
77
+ const settled = async (attempt) => {
78
+ try {
79
+ await attempt;
80
+ } catch {}
81
+ };
82
+ const publish = () => {
83
+ const context = builder.getContextOrThrow();
84
+ if (generation?.context !== context) generation = {
85
+ id: ++nextGenerationId,
86
+ context,
87
+ encoder: context.encoder.clone()
88
+ };
89
+ return generation;
90
+ };
91
+ const runSetup = async () => {
92
+ builder ??= await loadBuilder();
93
+ await builder.setup({
94
+ configPath,
95
+ cwd,
96
+ dev: command === "serve"
97
+ });
98
+ return publish();
99
+ };
100
+ /**
101
+ * The setup covering the pass currently open, started at most once.
102
+ *
103
+ * Started through a resolved promise, so a synchronous throw becomes the same
104
+ * rejected-attempt contract a failed module load has and a later hook can retry it. A
105
+ * source mutation observed while one is in flight does not cancel it — two overlapping
106
+ * `Builder.setup` calls would interleave their change detection — it queues a fresh one
107
+ * behind it.
108
+ */
109
+ const setupOnce = () => {
110
+ const previous = openSetup;
111
+ if (previous && !openSetupStale) return previous;
112
+ openSetupStale = false;
113
+ const attempt = previous ? settled(previous).then(runSetup) : Promise.resolve().then(runSetup);
114
+ openSetup = attempt;
115
+ attempt.catch(() => {
116
+ if (openSetup === attempt) openSetup = void 0;
117
+ });
118
+ return attempt;
119
+ };
56
120
  return {
57
- context,
58
- foldSource: fold.foldSource,
59
- verifyExportReads: fold.verifyExportReads,
60
- runtimeCss,
61
- styleCompiler
121
+ setCommand(next) {
122
+ command = next;
123
+ },
124
+ current: () => generation,
125
+ async ensureGeneration() {
126
+ if (cssPass) await settled(cssPass);
127
+ if (generation) return Promise.resolve(generation);
128
+ return setupOnce();
129
+ },
130
+ isCssPassActive: () => cssPass !== void 0,
131
+ async runCssPass(run) {
132
+ while (cssPass) await settled(cssPass);
133
+ let release;
134
+ cssPass = new Promise((resolve) => {
135
+ release = resolve;
136
+ });
137
+ try {
138
+ const passGeneration = await setupOnce();
139
+ return await run(builder, passGeneration);
140
+ } finally {
141
+ openSetup = void 0;
142
+ openSetupStale = false;
143
+ cssPass = void 0;
144
+ release();
145
+ }
146
+ },
147
+ async runCompilerWork(run) {
148
+ while (cssPass) await settled(cssPass);
149
+ return run();
150
+ },
151
+ reloadSource(filePath) {
152
+ openSetupStale = true;
153
+ builder?.reloadSource(filePath);
154
+ },
155
+ removeSource(filePath) {
156
+ openSetupStale = true;
157
+ builder?.removeSource(filePath);
158
+ }
62
159
  };
63
- });
160
+ };
64
161
  //#endregion
65
162
  //#region src/static-session.ts
66
163
  const createStaticCompilationSession = () => {
@@ -179,14 +276,11 @@ const asError = (error, context) => error instanceof Error ? error : new Error(`
179
276
  * process just wrote, which is a race on any watch rebuild.
180
277
  */
181
278
  const bamboocssCss = (options) => {
182
- const { configPath, cwd, loadCssOutput = loadCssOutputModule, session, pruneCss = true } = options;
279
+ const { configPath, cwd, loadCssOutput = loadCssOutputModule, session, host = createCompilationHost({
280
+ configPath,
281
+ cwd
282
+ }), pruneCss = true } = options;
183
283
  let builder;
184
- const loadBuilder = createLazyBuilder();
185
- const ensureBuilder = async () => {
186
- const loaded = await loadBuilder();
187
- builder = loaded;
188
- return loaded;
189
- };
190
284
  let server;
191
285
  let command = "build";
192
286
  /** The run's own `build` options, for a bundler with no per-environment config. */
@@ -221,34 +315,36 @@ const bamboocssCss = (options) => {
221
315
  let changeGeneration = 0;
222
316
  let pendingGeneration = -1;
223
317
  let servedCss;
224
- const build = async () => {
225
- const builder = await ensureBuilder();
226
- await builder.setup({
227
- configPath,
228
- cwd,
229
- dev: command === "serve"
230
- });
231
- await builder.emit();
232
- builder.extract();
318
+ /**
319
+ * Held by the host for its whole length, rather than only around each mutation.
320
+ *
321
+ * Extraction fills the encoder this sheet is emitted from and `toCss` reads it back, with a
322
+ * deliberate macrotask between them. The compiler shares the AST both halves run against,
323
+ * so a transform folding a module in that window would re-prepare a source the extraction
324
+ * pass has already read and `toCss` has not finished reporting on. The host makes compiler
325
+ * work wait instead; a fold is a few milliseconds and this is the one place correctness
326
+ * depends on it.
327
+ */
328
+ const build = () => host.runCssPass(async (activeBuilder) => {
329
+ builder = activeBuilder;
330
+ await activeBuilder.emit();
331
+ activeBuilder.extract();
233
332
  await new Promise((settle) => setImmediate(settle));
234
- if (builder.context) {
235
- session.utilityLayer = builder.context.config.layers?.utilities ?? "utilities";
333
+ if (activeBuilder.context) {
334
+ session.utilityLayer = activeBuilder.context.config.layers?.utilities ?? "utilities";
236
335
  session.extractedFiles.clear();
237
336
  for (const file of extractedSourceFiles()) session.extractedFiles.add(file);
238
337
  }
239
338
  let graphAtomHashes;
240
- if (builder.context) {
241
- builder.context.encoder.atomizeObservedRecipes();
242
- graphAtomHashes = new Set(builder.context.encoder.atomic);
339
+ if (activeBuilder.context) {
340
+ activeBuilder.context.encoder.atomizeObservedRecipes();
341
+ graphAtomHashes = new Set(activeBuilder.context.encoder.atomic);
243
342
  }
244
- const css = builder.toCss({
245
- layerParams: true,
246
- includeRecipes: false
247
- });
343
+ const css = activeBuilder.toCss({ layerParams: true });
248
344
  session.prunableClasses.clear();
249
345
  session.viewTransitionClasses.clear();
250
- if (graphAtomHashes && builder.context) {
251
- const decoder = builder.context.decoder.collect(builder.context.encoder);
346
+ if (graphAtomHashes && activeBuilder.context) {
347
+ const decoder = activeBuilder.context.decoder.collect(activeBuilder.context.encoder);
252
348
  for (const atom of decoder.atomic) if (graphAtomHashes.has(atom.hash)) session.prunableClasses.add(atom.className);
253
349
  for (const transition of decoder.view_transitions) {
254
350
  session.viewTransitionClasses.add(transition.className);
@@ -256,7 +352,7 @@ const bamboocssCss = (options) => {
256
352
  }
257
353
  }
258
354
  return css;
259
- };
355
+ });
260
356
  const generate = () => {
261
357
  if (command === "serve" && pending && pendingGeneration === changeGeneration) return pending;
262
358
  pendingGeneration = changeGeneration;
@@ -336,6 +432,7 @@ const bamboocssCss = (options) => {
336
432
  sharedDuringBuild: true,
337
433
  async configResolved(config) {
338
434
  command = config.command;
435
+ host.setCommand(config.command);
339
436
  session.sourcemap = config.build.sourcemap;
340
437
  ssrBuildOptions = {
341
438
  ssr: config.build.ssr,
@@ -349,11 +446,12 @@ const bamboocssCss = (options) => {
349
446
  * edited all afternoon. Nothing watched it: `watch` is the CLI's own watcher, and a
350
447
  * project running `vite dev` never reaches it.
351
448
  *
352
- * A restart rather than re-emitting the stylesheet, because this plugin and the compiler
353
- * hold *separate* contexts and only this one reloads its config. A token *value* edit
354
- * came out right on the next source change, and an edit that changes what compiles
355
- * adding a token, a condition, a utility left the compiler naming classes from the old
356
- * config against a sheet emitted from the new one. Half-updated is worse than stale.
449
+ * A restart rather than re-emitting the stylesheet. The two plugins share one context now,
450
+ * and the compiler re-derives everything it holds when `Builder.setup` replaces it so the
451
+ * half-updated state this used to prevent, with the compiler naming classes from the old
452
+ * config against a sheet emitted from the new one, can no longer happen. What a restart
453
+ * still buys is the rest of the server: a changed `outdir`, a preset that adds an entry
454
+ * point, and every module Vite has already transformed against the previous config.
357
455
  *
358
456
  * Through Vite's own list rather than a watcher of ours. Vite adds these paths to the
359
457
  * files it watches, which is what reaches a config *outside* `root` — a monorepo with one
@@ -623,6 +721,22 @@ const compilerParsePath = (id, code) => {
623
721
  return `${filePath}.__bamboo__.ts`;
624
722
  };
625
723
  /**
724
+ * Where to park a transform's text when it is not what the shared Project holds for the file.
725
+ *
726
+ * The compiler folds the bundler's view of a module — after every `enforce: 'pre'` plugin
727
+ * before it, and after Vite's own load. The stylesheet pass reads the same file off disk
728
+ * through the same ts-morph Project. When the two texts differ and the compiler writes its
729
+ * own under the file's path, that transform silently becomes the canonical source for the
730
+ * next extraction pass: the CSS would then be generated from a bundler artifact rather than
731
+ * from the checkout. Under a sibling path both readings exist and neither overwrites the
732
+ * other, which is the same reason `compilerParsePath` already does this for SFC submodules.
733
+ *
734
+ * The extension carries JSX-ness across, since it is what ts-morph keys its script kind on:
735
+ * anything but an unambiguously non-JSX `.ts`/`.mts`/`.cts` is parsed as `.tsx`, so a `<div>`
736
+ * in a `.js` file still parses and a `<T>value` assertion in a `.ts` file still means a cast.
737
+ */
738
+ const auxiliaryParsePath = (filePath) => `${filePath}.__bamboo__.${/\.[cm]?ts$/i.test(filePath) ? "ts" : "tsx"}`;
739
+ /**
626
740
  * Is this file part of the generated `styled-system` rather than the user's source?
627
741
  *
628
742
  * Resolved to a path and compared as a prefix, rather than by looking for the outdir's
@@ -677,6 +791,17 @@ const bamboocss = (options = {}) => {
677
791
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
678
792
  if ("renameCssAsset" in options) throw new Error("bamboocss: `renameCssAsset` has been replaced by `pruneCss`. Use `pruneCss: false` for what `renameCssAsset: false` did — it always disabled the pruning as well, since pruned bytes under the unpruned sheet's name is what lets a CDN serve a stale stylesheet. The new name says which of the two it is really about.");
679
793
  const staticSession = createStaticCompilationSession();
794
+ /**
795
+ * One Builder, one resolved config, one context and one ts-morph project for the run.
796
+ *
797
+ * Created here rather than by either plugin because both need it and neither may own it:
798
+ * the compiler used to load a second config of its own, which is why a token edit could
799
+ * leave it naming classes from the old one against a sheet emitted from the new one.
800
+ */
801
+ const host = createCompilationHost({
802
+ configPath,
803
+ cwd
804
+ });
680
805
  const transformArtifactIntegrityKey = randomBytes(32);
681
806
  const serializeTransformArtifact = (environment, artifact) => JSON.stringify([
682
807
  TRANSFORM_META_KEY,
@@ -1321,6 +1446,8 @@ const bamboocss = (options = {}) => {
1321
1446
  const foldOutputUnchanged = (state, dependent, changedFile) => {
1322
1447
  const memoized = state.unchangedFolds.get(dependent);
1323
1448
  if (memoized !== void 0) return memoized;
1449
+ if (host.isCssPassActive()) return false;
1450
+ if (!compilerStateIsCurrent()) return false;
1324
1451
  const unchanged = state.changedRun < CHANGED_RUN_LIMIT && refoldMatchesSignature(state, dependent, changedFile);
1325
1452
  state.changedRun = unchanged ? 0 : state.changedRun + 1;
1326
1453
  state.unchangedFolds.set(dependent, unchanged);
@@ -1332,7 +1459,8 @@ const bamboocss = (options = {}) => {
1332
1459
  try {
1333
1460
  const retained = state.foldInputsByModule.get(dependent);
1334
1461
  const code = retained?.input === signature.input ? retained.code : readFileSync(signature.path, "utf8");
1335
- const parsePath = retained?.input === signature.input ? retained.parsePath : signature.path;
1462
+ const requestedParsePath = retained?.input === signature.input ? retained.parsePath : signature.path;
1463
+ const parsePath = compilerSourcePath(signature.path, requestedParsePath, code);
1336
1464
  const inputDigest = digest(code);
1337
1465
  if (inputDigest !== signature.input) return false;
1338
1466
  /**
@@ -1349,7 +1477,7 @@ const bamboocss = (options = {}) => {
1349
1477
  */
1350
1478
  const reads = state.exportReadsByModule.get(dependent);
1351
1479
  if (reads?.length && verifyExportReadsImpl) {
1352
- const { verdict, crossings } = verifyExportReadsImpl(ctx, (path) => ctx?.project.parseSourceFile(path), reads, normalizeFsPath(changedFile), verifyDigestMemo);
1480
+ const { verdict, crossings } = verifyExportReadsImpl(ctx, parseForCompiler, reads, normalizeFsPath(changedFile), verifyDigestMemo);
1353
1481
  if (verdict === "unchanged") {
1354
1482
  recordFoldDependencies(state, dependent, signature.path, [...state.dependenciesByModule.get(dependent) ?? [], ...crossings]);
1355
1483
  return true;
@@ -1364,8 +1492,9 @@ const bamboocss = (options = {}) => {
1364
1492
  raw = memoized.result;
1365
1493
  parserDependencies = memoized.parserDependencies;
1366
1494
  } else {
1367
- const sourceFile = ctx.project.addSourceFile(parsePath, code);
1368
- const parserResult = ctx.project.parseSourceFile(parsePath);
1495
+ const sourceFile = addCompilerSource(signature.path, parsePath, code);
1496
+ if (!sourceFile) return false;
1497
+ const parserResult = parseForCompiler(parsePath, requestedParsePath === signature.path ? signature.path : parsePath);
1369
1498
  if (!parserResult) return false;
1370
1499
  raw = foldSourceImpl({
1371
1500
  ctx,
@@ -1375,7 +1504,7 @@ const bamboocss = (options = {}) => {
1375
1504
  runtimeCss,
1376
1505
  styleCompiler,
1377
1506
  maxRecipeStates,
1378
- parseModule: (path) => ctx?.project.parseSourceFile(path),
1507
+ parseModule: parseForCompiler,
1379
1508
  recipeConfigCache: state.recipeConfigCache,
1380
1509
  reportSurvivors: false,
1381
1510
  sourceFile
@@ -1515,6 +1644,8 @@ const bamboocss = (options = {}) => {
1515
1644
  return [...modules, ...added];
1516
1645
  };
1517
1646
  let ctx;
1647
+ /** The compiler's private parse sink for `ctx`. @see `CompilationGeneration.encoder` */
1648
+ let parseEncoder;
1518
1649
  let foldSourceImpl;
1519
1650
  let verifyExportReadsImpl;
1520
1651
  let runtimeCss;
@@ -1554,25 +1685,97 @@ const bamboocss = (options = {}) => {
1554
1685
  dependencies: expanded
1555
1686
  };
1556
1687
  };
1557
- const loadContext = createRetryableLazy(async () => {
1558
- const { loadConfigAndCreateContext } = await loadNodeModule();
1559
- return loadConfigAndCreateContext({
1560
- configPath,
1561
- cwd,
1562
- dev: command === "serve"
1563
- });
1564
- });
1565
- const loadCompilerState = createLazyCompilerState(loadContext, loadFoldModule);
1688
+ /** Which context the published derivations below were built from. */
1689
+ let derivedGeneration = -1;
1690
+ /** Compiler-only sibling ASTs retained for each physical module. */
1691
+ const auxiliarySourcesByFile = /* @__PURE__ */ new Map();
1692
+ /**
1693
+ * Whether the compiler state below still describes the context the host is on.
1694
+ *
1695
+ * Only `ensureCompilerState` re-derives, and only an awaited hook may call it — so the two
1696
+ * synchronous entry points, the speculative prefold and the unchanged-dependent check, can
1697
+ * be reached after a stylesheet pass has published a config reload they have not seen. Both
1698
+ * decline rather than fold against a runtime `css` from the previous config.
1699
+ */
1700
+ const compilerStateIsCurrent = () => {
1701
+ const current = host.current();
1702
+ return current !== void 0 && current.id === derivedGeneration;
1703
+ };
1566
1704
  const ensureContext = async () => {
1567
- ctx = await loadContext();
1705
+ ctx = (await host.ensureGeneration()).context;
1568
1706
  };
1707
+ /**
1708
+ * Load the fold chunk and derive everything that depends on the resolved context.
1709
+ *
1710
+ * Keyed on context *identity* rather than derived once. `Builder.setup` replaces its context
1711
+ * on a config reload, and the runtime `css`, the style-set compiler and the parse sink are
1712
+ * all closures over the previous one — a stale `runtimeCss` names classes from the old
1713
+ * config while the stylesheet is emitted from the new one, and nothing downstream can see
1714
+ * the difference. Re-derivation is cheap; both factories are a handful of bound methods.
1715
+ *
1716
+ * Published as a set, and only once every part of the attempt has succeeded, so a failed
1717
+ * chunk load leaves no half-compiler visible to HMR.
1718
+ */
1569
1719
  const ensureCompilerState = async () => {
1570
- const loaded = await loadCompilerState();
1571
- ctx = loaded.context;
1572
- foldSourceImpl = loaded.foldSource;
1573
- verifyExportReadsImpl = loaded.verifyExportReads;
1574
- runtimeCss = loaded.runtimeCss;
1575
- styleCompiler = loaded.styleCompiler;
1720
+ const [initialGeneration, fold] = await Promise.all([host.ensureGeneration(), loadFoldModule()]);
1721
+ const currentGeneration = await host.ensureGeneration();
1722
+ const generation = currentGeneration.id === initialGeneration.id ? initialGeneration : currentGeneration;
1723
+ if (derivedGeneration === generation.id && foldSourceImpl) {
1724
+ ctx = generation.context;
1725
+ return;
1726
+ }
1727
+ const derivedRuntimeCss = fold.createRuntimeCss(generation.context);
1728
+ const derivedStyleCompiler = fold.createStaticStyleSetCompiler(generation.context, derivedRuntimeCss);
1729
+ ctx = generation.context;
1730
+ parseEncoder = generation.encoder;
1731
+ foldSourceImpl = fold.foldSource;
1732
+ verifyExportReadsImpl = fold.verifyExportReads;
1733
+ runtimeCss = derivedRuntimeCss;
1734
+ styleCompiler = derivedStyleCompiler;
1735
+ derivedGeneration = generation.id;
1736
+ auxiliarySourcesByFile.clear();
1737
+ };
1738
+ /**
1739
+ * Parse a module for the compiler, never for the stylesheet.
1740
+ *
1741
+ * Every compiler parse goes through here so the private encoder cannot be forgotten at one
1742
+ * call site. Forgetting it at any of them puts that module's reading into the encoder the
1743
+ * sheet is emitted from, under a `parse` owner nothing retracts.
1744
+ */
1745
+ const parseForCompiler = (filePath, hookFilePath = filePath) => ctx?.project.parseSourceFile(filePath, parseEncoder, { hookFilePath });
1746
+ /**
1747
+ * Where the compiler may hold `code` for `filePath` without displacing the checkout.
1748
+ *
1749
+ * The file's own path exactly when the shared Project already holds these bytes — then
1750
+ * `addSourceFile` is a lookup and there is nothing to displace. @see `auxiliaryParsePath`
1751
+ */
1752
+ const compilerSourcePath = (filePath, requested, code) => {
1753
+ if (requested !== filePath) return requested;
1754
+ return ctx?.project.getSourceFile(filePath)?.getFullText() === code ? filePath : auxiliaryParsePath(filePath);
1755
+ };
1756
+ /** Add one compiler-owned source without letting it displace or outlive its physical file. */
1757
+ const addCompilerSource = (filePath, parsePath, code) => {
1758
+ if (!ctx) return;
1759
+ const auxiliary = parsePath !== filePath;
1760
+ const sourceFile = ctx.project.addSourceFile(parsePath, code, { auxiliary });
1761
+ if (auxiliary) {
1762
+ const physical = normalizeFsPath(filePath);
1763
+ const paths = auxiliarySourcesByFile.get(physical) ?? /* @__PURE__ */ new Set();
1764
+ paths.add(parsePath);
1765
+ auxiliarySourcesByFile.set(physical, paths);
1766
+ }
1767
+ return sourceFile;
1768
+ };
1769
+ /** Release compiler encoder owners and sibling ASTs when their physical module disappears. */
1770
+ const releaseCompilerSources = (filePath) => {
1771
+ if (!ctx) return;
1772
+ parseEncoder?.releaseFile(filePath);
1773
+ const physical = normalizeFsPath(filePath);
1774
+ for (const auxiliary of auxiliarySourcesByFile.get(physical) ?? []) {
1775
+ parseEncoder?.releaseFile(auxiliary);
1776
+ ctx.project.removeSourceFile(auxiliary);
1777
+ }
1778
+ auxiliarySourcesByFile.delete(physical);
1576
1779
  };
1577
1780
  const outputFinalizerTag = (value) => {
1578
1781
  if (!value || typeof value !== "object") return void 0;
@@ -1689,6 +1892,7 @@ const bamboocss = (options = {}) => {
1689
1892
  },
1690
1893
  configResolved(config) {
1691
1894
  command = config.command;
1895
+ host.setCommand(config.command);
1692
1896
  defaultEmitAssets = config.build?.emitAssets ?? (!config.build?.ssr || config.build?.ssrEmitAssets === true);
1693
1897
  const plugins = config.plugins;
1694
1898
  if (plugins) {
@@ -1743,9 +1947,9 @@ const bamboocss = (options = {}) => {
1743
1947
  const [filePath] = id.split("?");
1744
1948
  if (!filePath) return;
1745
1949
  for (const state of transformStateByEnvironment.values()) state.recipeConfigCache.clear();
1746
- if (SFC_EXTENSIONS.test(filePath)) return;
1747
1950
  if (change.event === "delete") {
1748
- ctx.project.removeSourceFile(filePath);
1951
+ host.removeSource(filePath);
1952
+ releaseCompilerSources(filePath);
1749
1953
  const deleted = normalizeFsPath(filePath);
1750
1954
  for (const state of transformStateByEnvironment.values()) for (const [moduleId, moduleFile] of [...state.filesByModule]) {
1751
1955
  if (normalizeFsPath(moduleFile) !== deleted) continue;
@@ -1757,7 +1961,8 @@ const bamboocss = (options = {}) => {
1757
1961
  }
1758
1962
  return;
1759
1963
  }
1760
- ctx.project.reloadSourceFile(filePath);
1964
+ if (SFC_EXTENSIONS.test(filePath)) return;
1965
+ host.reloadSource(filePath);
1761
1966
  /**
1762
1967
  * Fold the edited file before the browser asks for it.
1763
1968
  *
@@ -1776,12 +1981,14 @@ const bamboocss = (options = {}) => {
1776
1981
  */
1777
1982
  if (command === "serve") setImmediate(() => {
1778
1983
  if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return;
1984
+ if (host.isCssPassActive() || !compilerStateIsCurrent()) return;
1779
1985
  try {
1780
1986
  const code = readFileSync(filePath, "utf8");
1781
1987
  const memoKey = foldMemoKey(filePath, digest(code));
1782
1988
  if (foldMemoByContent.has(memoKey)) return;
1783
- const sourceFile = ctx.project.addSourceFile(filePath, code);
1784
- const parserResult = ctx.project.parseSourceFile(filePath);
1989
+ const sourceFile = addCompilerSource(filePath, filePath, code);
1990
+ if (!sourceFile) return;
1991
+ const parserResult = parseForCompiler(filePath);
1785
1992
  if (!parserResult) return;
1786
1993
  const folded = foldSourceImpl({
1787
1994
  ctx,
@@ -1791,7 +1998,7 @@ const bamboocss = (options = {}) => {
1791
1998
  runtimeCss,
1792
1999
  styleCompiler,
1793
2000
  maxRecipeStates,
1794
- parseModule: (path) => ctx?.project.parseSourceFile(path),
2001
+ parseModule: parseForCompiler,
1795
2002
  recipeConfigCache: transformStateByEnvironment.get("client")?.recipeConfigCache ?? /* @__PURE__ */ new Map(),
1796
2003
  reportSurvivors: true,
1797
2004
  sourceFile
@@ -1919,8 +2126,8 @@ const bamboocss = (options = {}) => {
1919
2126
  if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return null;
1920
2127
  const [filePath] = id.split("?");
1921
2128
  if (isGeneratedOutput(filePath, ctx)) return null;
1922
- const parsePath = compilerParsePath(id, code);
1923
- if (parsePath === null) return null;
2129
+ const requestedParsePath = compilerParsePath(id, code);
2130
+ if (requestedParsePath === null) return null;
1924
2131
  const state = environmentState(this);
1925
2132
  state.transformedModulesThisRun.add(id);
1926
2133
  let inputDigest;
@@ -1928,46 +2135,62 @@ const bamboocss = (options = {}) => {
1928
2135
  const previousDependencies = previousSignature && previousSignature.input === (inputDigest ??= digest(code)) ? state.dependenciesByModule.get(id) : void 0;
1929
2136
  let result;
1930
2137
  try {
1931
- const memoKey = command === "serve" ? foldMemoKey(parsePath, inputDigest ??= digest(code)) : void 0;
1932
- const memoized = memoKey ? foldMemoByContent.get(memoKey) : void 0;
1933
- let valueReads = [];
1934
- if (memoized?.reportedSurvivors) {
1935
- valueReads = memoized.valueReads;
1936
- result = withResolutionClosure(parsePath, memoized.result, memoized.parserDependencies, previousDependencies);
1937
- } else {
1938
- const sourceFile = ctx.project.addSourceFile(parsePath, code);
1939
- const parserResult = ctx.project.parseSourceFile(parsePath);
1940
- if (!parserResult) {
1941
- state.transformArtifactsByModule.delete(id);
1942
- recordFoldDependencies(state, id, filePath, []);
1943
- state.foldSignatures.delete(id);
1944
- state.foldInputsByModule.delete(id);
1945
- return null;
1946
- }
2138
+ /**
2139
+ * One serialized region, holding every read and every mutation of the shared AST.
2140
+ *
2141
+ * Synchronous throughout, which is what makes waiting for the stylesheet pass once at
2142
+ * the top sufficient: nothing can open a pass between the wait and the work, because
2143
+ * nothing else runs. The fold is CPU-bound anyway, so there is no await to give up.
2144
+ */
2145
+ const compiled = await host.runCompilerWork(() => {
2146
+ if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return null;
2147
+ const path = compilerSourcePath(filePath, requestedParsePath, code);
2148
+ const memoKey = command === "serve" ? foldMemoKey(path, inputDigest ??= digest(code)) : void 0;
2149
+ const memoized = memoKey ? foldMemoByContent.get(memoKey) : void 0;
2150
+ if (memoized?.reportedSurvivors) return {
2151
+ valueReads: memoized.valueReads,
2152
+ result: withResolutionClosure(path, memoized.result, memoized.parserDependencies, previousDependencies)
2153
+ };
2154
+ const sourceFile = addCompilerSource(filePath, path, code);
2155
+ if (!sourceFile) return null;
2156
+ const parserResult = parseForCompiler(path, requestedParsePath === filePath ? filePath : path);
2157
+ if (!parserResult) return { unparsed: true };
1947
2158
  const folded = foldSourceImpl({
1948
2159
  ctx,
1949
2160
  code,
1950
2161
  parserResult,
1951
- filePath: parsePath,
2162
+ filePath: path,
1952
2163
  runtimeCss,
1953
2164
  styleCompiler,
1954
2165
  maxRecipeStates,
1955
- parseModule: (path) => ctx?.project.parseSourceFile(path),
2166
+ parseModule: parseForCompiler,
1956
2167
  recipeConfigCache: state.recipeConfigCache,
1957
2168
  reportSurvivors: true,
1958
2169
  sourceFile
1959
2170
  });
1960
2171
  const parserDependencies = parserResult.getDependencies();
1961
- valueReads = parserResult.getExportReads?.() ?? [];
2172
+ const valueReads = parserResult.getExportReads?.() ?? [];
1962
2173
  if (memoKey) foldMemoByContent.set(memoKey, {
1963
2174
  result: folded,
1964
2175
  parserDependencies,
1965
2176
  valueReads,
1966
2177
  reportedSurvivors: true
1967
2178
  });
1968
- result = withResolutionClosure(parsePath, folded, parserDependencies, previousDependencies);
2179
+ return {
2180
+ valueReads,
2181
+ result: withResolutionClosure(path, folded, parserDependencies, previousDependencies)
2182
+ };
2183
+ });
2184
+ if (!compiled) return null;
2185
+ if ("unparsed" in compiled) {
2186
+ state.transformArtifactsByModule.delete(id);
2187
+ recordFoldDependencies(state, id, filePath, []);
2188
+ state.foldSignatures.delete(id);
2189
+ state.foldInputsByModule.delete(id);
2190
+ return null;
1969
2191
  }
1970
- state.exportReadsByModule.set(id, [...valueReads.map((read) => ({
2192
+ result = compiled.result;
2193
+ state.exportReadsByModule.set(id, [...compiled.valueReads.map((read) => ({
1971
2194
  kind: "value",
1972
2195
  ...read
1973
2196
  })), ...result.exportReads]);
@@ -2023,10 +2246,10 @@ const bamboocss = (options = {}) => {
2023
2246
  } } : {}
2024
2247
  });
2025
2248
  applyTransformArtifact(state, artifact, id, environmentName(this));
2026
- if (artifact.signature && (command === "serve" || parsePath !== filePath)) state.foldInputsByModule.set(id, {
2249
+ if (artifact.signature && (command === "serve" || requestedParsePath !== filePath)) state.foldInputsByModule.set(id, {
2027
2250
  code,
2028
2251
  input: artifact.signature.input,
2029
- parsePath
2252
+ parsePath: requestedParsePath
2030
2253
  });
2031
2254
  else state.foldInputsByModule.delete(id);
2032
2255
  if (reportSkipped && result.skipped.length) logger.info("vite:transform", formatSkipped(filePath, result.skipped));
@@ -2088,6 +2311,7 @@ const bamboocss = (options = {}) => {
2088
2311
  bamboocssCss({
2089
2312
  configPath,
2090
2313
  cwd,
2314
+ host,
2091
2315
  session: staticSession,
2092
2316
  pruneCss
2093
2317
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/vite",
3
- "version": "1.47.0",
3
+ "version": "1.48.0",
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/core": "1.47.0",
46
- "@bamboocss/config": "1.47.0",
47
- "@bamboocss/extractor": "1.47.0",
48
- "@bamboocss/logger": "1.47.0",
49
- "@bamboocss/node": "1.47.0",
50
- "@bamboocss/shared": "1.47.0",
51
- "@bamboocss/types": "1.47.0"
45
+ "@bamboocss/logger": "1.48.0",
46
+ "@bamboocss/config": "1.48.0",
47
+ "@bamboocss/core": "1.48.0",
48
+ "@bamboocss/node": "1.48.0",
49
+ "@bamboocss/extractor": "1.48.0",
50
+ "@bamboocss/shared": "1.48.0",
51
+ "@bamboocss/types": "1.48.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@jridgewell/trace-mapping": "^0.3.31",
55
55
  "vite": "7.2.6",
56
- "@bamboocss/fixture": "1.47.0"
56
+ "@bamboocss/fixture": "1.48.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "vite": ">=5"