@bamboocss/vite 1.38.0 → 1.39.1

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
@@ -24,6 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  enumerable: true
25
25
  }) : target, mod));
26
26
  //#endregion
27
+ let _bamboocss_config = require("@bamboocss/config");
27
28
  let _bamboocss_node = require("@bamboocss/node");
28
29
  let _bamboocss_logger = require("@bamboocss/logger");
29
30
  let _bamboocss_shared = require("@bamboocss/shared");
@@ -365,6 +366,8 @@ const bamboocssCss = (options) => {
365
366
  const builder = new _bamboocss_node.Builder();
366
367
  let server;
367
368
  let command = "build";
369
+ /** The run's own `build` options, for a bundler with no per-environment config. */
370
+ let ssrBuildOptions;
368
371
  /**
369
372
  * Serialised, because both `load` and the watcher can reach it and `Builder` keeps one
370
373
  * context. Two overlapping passes would extract into the same encoder and emit the
@@ -426,6 +429,43 @@ const bamboocssCss = (options) => {
426
429
  configResolved(config) {
427
430
  command = config.command;
428
431
  session.sourcemap = config.build.sourcemap;
432
+ ssrBuildOptions = {
433
+ ssr: config.build.ssr,
434
+ ssrEmitAssets: config.build.ssrEmitAssets
435
+ };
436
+ /**
437
+ * Tell Vite that `bamboo.config.ts` is a config file, so editing one restarts the server.
438
+ *
439
+ * Tokens live there, and they are what a designer iterates on most — "restart the dev
440
+ * server to see a colour change" is the wrong instruction for the file most likely to be
441
+ * edited all afternoon. Nothing watched it: `watch` is the CLI's own watcher, and a
442
+ * project running `vite dev` never reaches it.
443
+ *
444
+ * A restart rather than re-emitting the stylesheet, because this plugin and the compiler
445
+ * hold *separate* contexts and only this one reloads its config. A token *value* edit
446
+ * came out right on the next source change, and an edit that changes what compiles —
447
+ * adding a token, a condition, a utility — left the compiler naming classes from the old
448
+ * config against a sheet emitted from the new one. Half-updated is worse than stale.
449
+ *
450
+ * Through Vite's own list rather than a watcher of ours. Vite adds these paths to the
451
+ * files it watches, which is what reaches a config *outside* `root` — a monorepo with one
452
+ * config above `apps/web`, or a preset resolved into `node_modules`, neither of which the
453
+ * project watcher covers. It also means the restart is Vite's, with its own concurrency
454
+ * guard and its own error reporting, rather than a second implementation of both.
455
+ *
456
+ * The config's own import graph is resolved the way `Builder` resolves it, minus the
457
+ * tsconfig paths it has not loaded yet at this point. `dependencies` globs are not
458
+ * expanded here: they are declared as an escape hatch for a *config reload*, and turning
459
+ * every file matching one into a full server restart is not what a project asking for
460
+ * that meant.
461
+ */
462
+ if (config.command === "serve") try {
463
+ const { deps } = (0, _bamboocss_config.getConfigDependencies)((0, _bamboocss_config.findConfig)({
464
+ cwd: cwd ?? config.root,
465
+ file: configPath
466
+ }));
467
+ config.configFileDependencies.push(...deps);
468
+ } catch {}
429
469
  if (config.builder && config.environments) session.expectedEnvironments = new Set(Object.keys(config.environments));
430
470
  },
431
471
  /**
@@ -506,6 +546,25 @@ const bamboocssCss = (options) => {
506
546
  })} ${pending.length === 1 ? "has" : "have"} not been compiled in this run. The full extracted stylesheet ships — nothing is missing from it.`);
507
547
  if (!servedEnvironments.has(environment?.name ?? "default")) return;
508
548
  if (!session.transformedFiles.size) return;
549
+ /**
550
+ * An SSR bundle emits no CSS assets, and is not supposed to.
551
+ *
552
+ * `build.ssrEmitAssets` is off by default, so Vite discards them: the client build is
553
+ * what carries the stylesheet, and a server bundle that imports `virtual:bamboo.css`
554
+ * from shared code — a root component, a layout — still asks this plugin to load it.
555
+ * Which means the environment *served* the sheet and then emitted nothing, and the
556
+ * check below read that as the failure it exists to catch.
557
+ *
558
+ * It fails a build that is entirely correct. Qwik's `vite build --ssr` is the shape
559
+ * that showed it: 7/7 calls compiled, the client bundle carrying the stylesheet, and
560
+ * the server bundle refusing to finish. React Router does not hit it only because its
561
+ * plugin turns `ssrEmitAssets` on.
562
+ *
563
+ * Read per environment where that exists, falling back to the run's own config, so
564
+ * Vite 5's single-config builds are answered by the same question.
565
+ */
566
+ const buildOptions = environment?.config?.build ?? ssrBuildOptions;
567
+ if (buildOptions?.ssr && !buildOptions.ssrEmitAssets) return;
509
568
  if (!Object.values(bundle).some((output) => {
510
569
  if (!carriesGeneratedCss(output)) return false;
511
570
  return (typeof output.source === "string" ? output.source : Buffer.from(output.source).toString()).includes("--made-with-bamboo");
@@ -3033,6 +3092,18 @@ const isGeneratedOutput = (filePath, ctx) => {
3033
3092
  };
3034
3093
  /** 1-indexed line of a source offset, for an error a user can navigate to. */
3035
3094
  const lineAt = (code, offset) => code.slice(0, offset).split("\n").length;
3095
+ /**
3096
+ * One spelling for a path used as a map key against paths from somewhere else.
3097
+ *
3098
+ * The fold reports dependencies as ts-morph sees them and Vite reports a changed file as its
3099
+ * watcher saw it. On Windows those differ by separator, and can differ by the case of the
3100
+ * drive letter alone — chokidar reports what the OS handed it, `path.resolve` preserves
3101
+ * whatever the cwd had. Either would make every lookup below miss and restore the exact
3102
+ * staleness they exist to fix, silently, since a miss is indistinguishable from a module that
3103
+ * folded nothing. Only the drive letter is case-folded: the rest of the path is compared as
3104
+ * written, because elsewhere the filesystem may well be case-sensitive.
3105
+ */
3106
+ const normalizeFsPath = (file) => (0, node_path.resolve)(file).replaceAll("\\", "/").replace(/^[a-z]:\//, (drive) => drive.toUpperCase());
3036
3107
  const formatSkipped = (id, skipped) => {
3037
3108
  const counts = /* @__PURE__ */ new Map();
3038
3109
  for (const entry of skipped) counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
@@ -3053,6 +3124,7 @@ const formatSkipped = (id, skipped) => {
3053
3124
  */
3054
3125
  const bamboocss = (options = {}) => {
3055
3126
  const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true } = options;
3127
+ (0, _bamboocss_node.markStaticCompilerActive)();
3056
3128
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
3057
3129
  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.");
3058
3130
  /**
@@ -3114,6 +3186,84 @@ const bamboocss = (options = {}) => {
3114
3186
  * would otherwise re-parse its module fifty times, which is the transform path.
3115
3187
  */
3116
3188
  const recipeConfigCache = /* @__PURE__ */ new Map();
3189
+ /**
3190
+ * Which modules folded a value read out of which other module, for dev invalidation.
3191
+ *
3192
+ * `addWatchFile` reports the same edges, and in a build that is enough — Rollup discards a
3193
+ * module whose watched file changed. Vite's dev server does not: a module that *statically
3194
+ * imports* the changed one is only **soft**-invalidated, which by design keeps its cached
3195
+ * transform result and rewrites nothing but the timestamps on its import specifiers. That
3196
+ * cached result is where the compiled class string lives, so the edit never reaches it.
3197
+ *
3198
+ * The recipe case is the one users meet, because it is the one where the class is compiled
3199
+ * into somebody else's module: an inline `cva` declaration is erased, and each *call site*
3200
+ * becomes a literal in the module that calls it. Editing the recipe then updates the class
3201
+ * in a module Vite has decided not to re-transform, so the browser and the SSR render keep
3202
+ * the old class — with no error, and with Vite and Bamboo both logging as if the edit landed.
3203
+ * A restart applies it, which is what makes it read as "recipes do not hot-reload".
3204
+ *
3205
+ * `css(sharedObject)` across modules fails identically; it is rarer only because a consumer
3206
+ * that folds *nothing but* recipe calls has its import erased, and an erased import is not a
3207
+ * static one, so Vite hard-invalidates it and the bug hides. Import one more value from the
3208
+ * same module — the shape any real `ui.ts` has — and the import survives, and so does the
3209
+ * stale class.
3210
+ *
3211
+ * Keyed by dependency, since "what changed" is the question asked, and tracked in the other
3212
+ * direction as well so a re-transform can retract edges the module no longer has.
3213
+ */
3214
+ const dependentsByDependency = /* @__PURE__ */ new Map();
3215
+ const dependenciesByFile = /* @__PURE__ */ new Map();
3216
+ const recordFoldDependencies = (file, dependencies) => {
3217
+ const next = new Set(dependencies.map(normalizeFsPath).filter((dependency) => dependency !== file));
3218
+ const previous = dependenciesByFile.get(file);
3219
+ for (const dependency of previous ?? []) {
3220
+ if (next.has(dependency)) continue;
3221
+ const dependents = dependentsByDependency.get(dependency);
3222
+ if (!dependents?.delete(file)) continue;
3223
+ if (!dependents.size) dependentsByDependency.delete(dependency);
3224
+ }
3225
+ if (!next.size) {
3226
+ dependenciesByFile.delete(file);
3227
+ return;
3228
+ }
3229
+ dependenciesByFile.set(file, next);
3230
+ for (const dependency of next) {
3231
+ const dependents = dependentsByDependency.get(dependency);
3232
+ if (dependents) dependents.add(file);
3233
+ else dependentsByDependency.set(dependency, new Set([file]));
3234
+ }
3235
+ };
3236
+ /**
3237
+ * Modules to re-transform because `file` changed, hard-invalidated on the way out.
3238
+ *
3239
+ * Both halves are load-bearing. Invalidating drops the stale compiled result, which is the
3240
+ * defect itself; returning the modules is what makes Vite propagate an update for them, so
3241
+ * the client applies one rather than waiting for whatever request happens next.
3242
+ *
3243
+ * Invalidating here rather than leaving it to `updateModules` also survives another plugin
3244
+ * filtering the list afterwards — a framework's own `hotUpdate` decides what its route
3245
+ * modules do, and the stale bytes have to go either way.
3246
+ *
3247
+ * A consumer Vite already reached costs nothing: `propagateUpdate` skips a module it has
3248
+ * traversed, and every consumer of a *resolvable* dependency is one, since `addWatchFile`
3249
+ * makes it an importer whether or not the import survived the fold. What is left is the
3250
+ * consumer of a dependency with no module of its own, where Vite matched nothing and would
3251
+ * have done nothing at all. That one can end in a page reload, which is the honest outcome:
3252
+ * its compiled classes really did change, and a reload is what Vite does with any update
3253
+ * nothing accepts.
3254
+ */
3255
+ const foldDependentModules = (file, modules, graph) => {
3256
+ const dependents = dependentsByDependency.get(normalizeFsPath(file));
3257
+ if (!dependents?.size) return;
3258
+ const added = [];
3259
+ for (const dependent of dependents) for (const module of graph.getModulesByFile(dependent) ?? []) {
3260
+ if (modules.includes(module) || added.includes(module)) continue;
3261
+ graph.invalidateModule(module);
3262
+ added.push(module);
3263
+ }
3264
+ if (!added.length) return;
3265
+ return [...modules, ...added];
3266
+ };
3117
3267
  let ctx;
3118
3268
  let runtimeCss;
3119
3269
  let styleCompiler;
@@ -3149,6 +3299,8 @@ const bamboocss = (options = {}) => {
3149
3299
  perFile.clear();
3150
3300
  survivorsByFile.clear();
3151
3301
  recipeConfigCache.clear();
3302
+ dependentsByDependency.clear();
3303
+ dependenciesByFile.clear();
3152
3304
  resetStaticCompilationSession(staticSession);
3153
3305
  }
3154
3306
  staticSession.startedEnvironments.add(environment);
@@ -3186,10 +3338,40 @@ const bamboocss = (options = {}) => {
3186
3338
  recipeConfigCache.clear();
3187
3339
  if (change.event === "delete") {
3188
3340
  ctx.project.removeSourceFile(filePath);
3341
+ recordFoldDependencies(normalizeFsPath(filePath), []);
3189
3342
  return;
3190
3343
  }
3191
3344
  ctx.project.reloadSourceFile(filePath);
3192
3345
  },
3346
+ /**
3347
+ * Re-transform whatever folded a value out of the file that just changed.
3348
+ *
3349
+ * Dev only — `hotUpdate` does not run in a build, where Rollup's own invalidation already
3350
+ * covers this — and additive: the modules Vite matched are returned alongside, so this
3351
+ * decides nothing about them.
3352
+ *
3353
+ * `handleHotUpdate` below stands in on Vite 5, which has no `hotUpdate`. Not quite the
3354
+ * same thing: Vite 5 calls that hook for an update and not for a file appearing or being
3355
+ * deleted, so a recipe file *created* while the server runs leaves its consumers stale
3356
+ * there. Vite 6 and up call `hotUpdate` for all three, and never call `handleHotUpdate`
3357
+ * when a plugin has both — including its deprecation warning — so exactly one of the two
3358
+ * runs on any supported version.
3359
+ *
3360
+ * `environment` optional-chained for the same reason `addWatchFile` is in `transform`:
3361
+ * a harness driving the hook need not supply a full plugin context, and a `TypeError`
3362
+ * here is swallowed into an HMR error payload that a middleware-mode server sends
3363
+ * nowhere.
3364
+ */
3365
+ hotUpdate({ file, modules }) {
3366
+ const graph = this.environment?.moduleGraph;
3367
+ if (!graph) return;
3368
+ return foldDependentModules(file, modules, graph);
3369
+ },
3370
+ handleHotUpdate({ file, modules, server }) {
3371
+ const legacy = server;
3372
+ if (legacy.environments) return;
3373
+ return foldDependentModules(file, modules, legacy.moduleGraph);
3374
+ },
3193
3375
  async transform(code, id) {
3194
3376
  if (!shouldTransform(id)) return null;
3195
3377
  try {
@@ -3257,6 +3439,7 @@ const bamboocss = (options = {}) => {
3257
3439
  }
3258
3440
  if (reportSkipped && result.skipped.length) _bamboocss_logger.logger.info("vite:transform", formatSkipped(filePath, result.skipped));
3259
3441
  for (const dependency of result.dependencies) this.addWatchFile?.(dependency);
3442
+ recordFoldDependencies(normalizeFsPath(filePath), result.dependencies);
3260
3443
  const forFile = survivorsByFile.get(filePath);
3261
3444
  if (command === "serve" && forFile?.length) throw createSurvivorError(forFile);
3262
3445
  if (!result.folded.length) return null;
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { Builder, loadConfigAndCreateContext } from "@bamboocss/node";
1
+ import { findConfig, getConfigDependencies } from "@bamboocss/config";
2
+ import { Builder, loadConfigAndCreateContext, markStaticCompilerActive } from "@bamboocss/node";
2
3
  import { logger } from "@bamboocss/logger";
3
4
  import { compact, createCssUncached, createMergeCss, esc, memo, toHash, truncateList, viewTransitionClassName } from "@bamboocss/shared";
4
5
  import remapping from "@ampproject/remapping";
@@ -335,6 +336,8 @@ const bamboocssCss = (options) => {
335
336
  const builder = new Builder();
336
337
  let server;
337
338
  let command = "build";
339
+ /** The run's own `build` options, for a bundler with no per-environment config. */
340
+ let ssrBuildOptions;
338
341
  /**
339
342
  * Serialised, because both `load` and the watcher can reach it and `Builder` keeps one
340
343
  * context. Two overlapping passes would extract into the same encoder and emit the
@@ -396,6 +399,43 @@ const bamboocssCss = (options) => {
396
399
  configResolved(config) {
397
400
  command = config.command;
398
401
  session.sourcemap = config.build.sourcemap;
402
+ ssrBuildOptions = {
403
+ ssr: config.build.ssr,
404
+ ssrEmitAssets: config.build.ssrEmitAssets
405
+ };
406
+ /**
407
+ * Tell Vite that `bamboo.config.ts` is a config file, so editing one restarts the server.
408
+ *
409
+ * Tokens live there, and they are what a designer iterates on most — "restart the dev
410
+ * server to see a colour change" is the wrong instruction for the file most likely to be
411
+ * edited all afternoon. Nothing watched it: `watch` is the CLI's own watcher, and a
412
+ * project running `vite dev` never reaches it.
413
+ *
414
+ * A restart rather than re-emitting the stylesheet, because this plugin and the compiler
415
+ * hold *separate* contexts and only this one reloads its config. A token *value* edit
416
+ * came out right on the next source change, and an edit that changes what compiles —
417
+ * adding a token, a condition, a utility — left the compiler naming classes from the old
418
+ * config against a sheet emitted from the new one. Half-updated is worse than stale.
419
+ *
420
+ * Through Vite's own list rather than a watcher of ours. Vite adds these paths to the
421
+ * files it watches, which is what reaches a config *outside* `root` — a monorepo with one
422
+ * config above `apps/web`, or a preset resolved into `node_modules`, neither of which the
423
+ * project watcher covers. It also means the restart is Vite's, with its own concurrency
424
+ * guard and its own error reporting, rather than a second implementation of both.
425
+ *
426
+ * The config's own import graph is resolved the way `Builder` resolves it, minus the
427
+ * tsconfig paths it has not loaded yet at this point. `dependencies` globs are not
428
+ * expanded here: they are declared as an escape hatch for a *config reload*, and turning
429
+ * every file matching one into a full server restart is not what a project asking for
430
+ * that meant.
431
+ */
432
+ if (config.command === "serve") try {
433
+ const { deps } = getConfigDependencies(findConfig({
434
+ cwd: cwd ?? config.root,
435
+ file: configPath
436
+ }));
437
+ config.configFileDependencies.push(...deps);
438
+ } catch {}
399
439
  if (config.builder && config.environments) session.expectedEnvironments = new Set(Object.keys(config.environments));
400
440
  },
401
441
  /**
@@ -476,6 +516,25 @@ const bamboocssCss = (options) => {
476
516
  })} ${pending.length === 1 ? "has" : "have"} not been compiled in this run. The full extracted stylesheet ships — nothing is missing from it.`);
477
517
  if (!servedEnvironments.has(environment?.name ?? "default")) return;
478
518
  if (!session.transformedFiles.size) return;
519
+ /**
520
+ * An SSR bundle emits no CSS assets, and is not supposed to.
521
+ *
522
+ * `build.ssrEmitAssets` is off by default, so Vite discards them: the client build is
523
+ * what carries the stylesheet, and a server bundle that imports `virtual:bamboo.css`
524
+ * from shared code — a root component, a layout — still asks this plugin to load it.
525
+ * Which means the environment *served* the sheet and then emitted nothing, and the
526
+ * check below read that as the failure it exists to catch.
527
+ *
528
+ * It fails a build that is entirely correct. Qwik's `vite build --ssr` is the shape
529
+ * that showed it: 7/7 calls compiled, the client bundle carrying the stylesheet, and
530
+ * the server bundle refusing to finish. React Router does not hit it only because its
531
+ * plugin turns `ssrEmitAssets` on.
532
+ *
533
+ * Read per environment where that exists, falling back to the run's own config, so
534
+ * Vite 5's single-config builds are answered by the same question.
535
+ */
536
+ const buildOptions = environment?.config?.build ?? ssrBuildOptions;
537
+ if (buildOptions?.ssr && !buildOptions.ssrEmitAssets) return;
479
538
  if (!Object.values(bundle).some((output) => {
480
539
  if (!carriesGeneratedCss(output)) return false;
481
540
  return (typeof output.source === "string" ? output.source : Buffer.from(output.source).toString()).includes("--made-with-bamboo");
@@ -3003,6 +3062,18 @@ const isGeneratedOutput = (filePath, ctx) => {
3003
3062
  };
3004
3063
  /** 1-indexed line of a source offset, for an error a user can navigate to. */
3005
3064
  const lineAt = (code, offset) => code.slice(0, offset).split("\n").length;
3065
+ /**
3066
+ * One spelling for a path used as a map key against paths from somewhere else.
3067
+ *
3068
+ * The fold reports dependencies as ts-morph sees them and Vite reports a changed file as its
3069
+ * watcher saw it. On Windows those differ by separator, and can differ by the case of the
3070
+ * drive letter alone — chokidar reports what the OS handed it, `path.resolve` preserves
3071
+ * whatever the cwd had. Either would make every lookup below miss and restore the exact
3072
+ * staleness they exist to fix, silently, since a miss is indistinguishable from a module that
3073
+ * folded nothing. Only the drive letter is case-folded: the rest of the path is compared as
3074
+ * written, because elsewhere the filesystem may well be case-sensitive.
3075
+ */
3076
+ const normalizeFsPath = (file) => resolve(file).replaceAll("\\", "/").replace(/^[a-z]:\//, (drive) => drive.toUpperCase());
3006
3077
  const formatSkipped = (id, skipped) => {
3007
3078
  const counts = /* @__PURE__ */ new Map();
3008
3079
  for (const entry of skipped) counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
@@ -3023,6 +3094,7 @@ const formatSkipped = (id, skipped) => {
3023
3094
  */
3024
3095
  const bamboocss = (options = {}) => {
3025
3096
  const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true } = options;
3097
+ markStaticCompilerActive();
3026
3098
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
3027
3099
  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.");
3028
3100
  /**
@@ -3084,6 +3156,84 @@ const bamboocss = (options = {}) => {
3084
3156
  * would otherwise re-parse its module fifty times, which is the transform path.
3085
3157
  */
3086
3158
  const recipeConfigCache = /* @__PURE__ */ new Map();
3159
+ /**
3160
+ * Which modules folded a value read out of which other module, for dev invalidation.
3161
+ *
3162
+ * `addWatchFile` reports the same edges, and in a build that is enough — Rollup discards a
3163
+ * module whose watched file changed. Vite's dev server does not: a module that *statically
3164
+ * imports* the changed one is only **soft**-invalidated, which by design keeps its cached
3165
+ * transform result and rewrites nothing but the timestamps on its import specifiers. That
3166
+ * cached result is where the compiled class string lives, so the edit never reaches it.
3167
+ *
3168
+ * The recipe case is the one users meet, because it is the one where the class is compiled
3169
+ * into somebody else's module: an inline `cva` declaration is erased, and each *call site*
3170
+ * becomes a literal in the module that calls it. Editing the recipe then updates the class
3171
+ * in a module Vite has decided not to re-transform, so the browser and the SSR render keep
3172
+ * the old class — with no error, and with Vite and Bamboo both logging as if the edit landed.
3173
+ * A restart applies it, which is what makes it read as "recipes do not hot-reload".
3174
+ *
3175
+ * `css(sharedObject)` across modules fails identically; it is rarer only because a consumer
3176
+ * that folds *nothing but* recipe calls has its import erased, and an erased import is not a
3177
+ * static one, so Vite hard-invalidates it and the bug hides. Import one more value from the
3178
+ * same module — the shape any real `ui.ts` has — and the import survives, and so does the
3179
+ * stale class.
3180
+ *
3181
+ * Keyed by dependency, since "what changed" is the question asked, and tracked in the other
3182
+ * direction as well so a re-transform can retract edges the module no longer has.
3183
+ */
3184
+ const dependentsByDependency = /* @__PURE__ */ new Map();
3185
+ const dependenciesByFile = /* @__PURE__ */ new Map();
3186
+ const recordFoldDependencies = (file, dependencies) => {
3187
+ const next = new Set(dependencies.map(normalizeFsPath).filter((dependency) => dependency !== file));
3188
+ const previous = dependenciesByFile.get(file);
3189
+ for (const dependency of previous ?? []) {
3190
+ if (next.has(dependency)) continue;
3191
+ const dependents = dependentsByDependency.get(dependency);
3192
+ if (!dependents?.delete(file)) continue;
3193
+ if (!dependents.size) dependentsByDependency.delete(dependency);
3194
+ }
3195
+ if (!next.size) {
3196
+ dependenciesByFile.delete(file);
3197
+ return;
3198
+ }
3199
+ dependenciesByFile.set(file, next);
3200
+ for (const dependency of next) {
3201
+ const dependents = dependentsByDependency.get(dependency);
3202
+ if (dependents) dependents.add(file);
3203
+ else dependentsByDependency.set(dependency, new Set([file]));
3204
+ }
3205
+ };
3206
+ /**
3207
+ * Modules to re-transform because `file` changed, hard-invalidated on the way out.
3208
+ *
3209
+ * Both halves are load-bearing. Invalidating drops the stale compiled result, which is the
3210
+ * defect itself; returning the modules is what makes Vite propagate an update for them, so
3211
+ * the client applies one rather than waiting for whatever request happens next.
3212
+ *
3213
+ * Invalidating here rather than leaving it to `updateModules` also survives another plugin
3214
+ * filtering the list afterwards — a framework's own `hotUpdate` decides what its route
3215
+ * modules do, and the stale bytes have to go either way.
3216
+ *
3217
+ * A consumer Vite already reached costs nothing: `propagateUpdate` skips a module it has
3218
+ * traversed, and every consumer of a *resolvable* dependency is one, since `addWatchFile`
3219
+ * makes it an importer whether or not the import survived the fold. What is left is the
3220
+ * consumer of a dependency with no module of its own, where Vite matched nothing and would
3221
+ * have done nothing at all. That one can end in a page reload, which is the honest outcome:
3222
+ * its compiled classes really did change, and a reload is what Vite does with any update
3223
+ * nothing accepts.
3224
+ */
3225
+ const foldDependentModules = (file, modules, graph) => {
3226
+ const dependents = dependentsByDependency.get(normalizeFsPath(file));
3227
+ if (!dependents?.size) return;
3228
+ const added = [];
3229
+ for (const dependent of dependents) for (const module of graph.getModulesByFile(dependent) ?? []) {
3230
+ if (modules.includes(module) || added.includes(module)) continue;
3231
+ graph.invalidateModule(module);
3232
+ added.push(module);
3233
+ }
3234
+ if (!added.length) return;
3235
+ return [...modules, ...added];
3236
+ };
3087
3237
  let ctx;
3088
3238
  let runtimeCss;
3089
3239
  let styleCompiler;
@@ -3119,6 +3269,8 @@ const bamboocss = (options = {}) => {
3119
3269
  perFile.clear();
3120
3270
  survivorsByFile.clear();
3121
3271
  recipeConfigCache.clear();
3272
+ dependentsByDependency.clear();
3273
+ dependenciesByFile.clear();
3122
3274
  resetStaticCompilationSession(staticSession);
3123
3275
  }
3124
3276
  staticSession.startedEnvironments.add(environment);
@@ -3156,10 +3308,40 @@ const bamboocss = (options = {}) => {
3156
3308
  recipeConfigCache.clear();
3157
3309
  if (change.event === "delete") {
3158
3310
  ctx.project.removeSourceFile(filePath);
3311
+ recordFoldDependencies(normalizeFsPath(filePath), []);
3159
3312
  return;
3160
3313
  }
3161
3314
  ctx.project.reloadSourceFile(filePath);
3162
3315
  },
3316
+ /**
3317
+ * Re-transform whatever folded a value out of the file that just changed.
3318
+ *
3319
+ * Dev only — `hotUpdate` does not run in a build, where Rollup's own invalidation already
3320
+ * covers this — and additive: the modules Vite matched are returned alongside, so this
3321
+ * decides nothing about them.
3322
+ *
3323
+ * `handleHotUpdate` below stands in on Vite 5, which has no `hotUpdate`. Not quite the
3324
+ * same thing: Vite 5 calls that hook for an update and not for a file appearing or being
3325
+ * deleted, so a recipe file *created* while the server runs leaves its consumers stale
3326
+ * there. Vite 6 and up call `hotUpdate` for all three, and never call `handleHotUpdate`
3327
+ * when a plugin has both — including its deprecation warning — so exactly one of the two
3328
+ * runs on any supported version.
3329
+ *
3330
+ * `environment` optional-chained for the same reason `addWatchFile` is in `transform`:
3331
+ * a harness driving the hook need not supply a full plugin context, and a `TypeError`
3332
+ * here is swallowed into an HMR error payload that a middleware-mode server sends
3333
+ * nowhere.
3334
+ */
3335
+ hotUpdate({ file, modules }) {
3336
+ const graph = this.environment?.moduleGraph;
3337
+ if (!graph) return;
3338
+ return foldDependentModules(file, modules, graph);
3339
+ },
3340
+ handleHotUpdate({ file, modules, server }) {
3341
+ const legacy = server;
3342
+ if (legacy.environments) return;
3343
+ return foldDependentModules(file, modules, legacy.moduleGraph);
3344
+ },
3163
3345
  async transform(code, id) {
3164
3346
  if (!shouldTransform(id)) return null;
3165
3347
  try {
@@ -3227,6 +3409,7 @@ const bamboocss = (options = {}) => {
3227
3409
  }
3228
3410
  if (reportSkipped && result.skipped.length) logger.info("vite:transform", formatSkipped(filePath, result.skipped));
3229
3411
  for (const dependency of result.dependencies) this.addWatchFile?.(dependency);
3412
+ recordFoldDependencies(normalizeFsPath(filePath), result.dependencies);
3230
3413
  const forFile = survivorsByFile.get(filePath);
3231
3414
  if (command === "serve" && forFile?.length) throw createSurvivorError(forFile);
3232
3415
  if (!result.folded.length) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/vite",
3
- "version": "1.38.0",
3
+ "version": "1.39.1",
4
4
  "description": "Vite integration for Bamboo CSS",
5
5
  "homepage": "https://bamboocss.com",
6
6
  "license": "MIT",
@@ -40,18 +40,18 @@
40
40
  "postcss": "8.5.26",
41
41
  "postcss-selector-parser": "7.1.5",
42
42
  "ts-morph": "28.0.0",
43
- "@bamboocss/config": "1.38.0",
44
- "@bamboocss/core": "1.38.0",
45
- "@bamboocss/logger": "1.38.0",
46
- "@bamboocss/node": "1.38.0",
47
- "@bamboocss/extractor": "1.38.0",
48
- "@bamboocss/shared": "1.38.0",
49
- "@bamboocss/types": "1.38.0"
43
+ "@bamboocss/config": "1.39.1",
44
+ "@bamboocss/extractor": "1.39.1",
45
+ "@bamboocss/core": "1.39.1",
46
+ "@bamboocss/logger": "1.39.1",
47
+ "@bamboocss/node": "1.39.1",
48
+ "@bamboocss/shared": "1.39.1",
49
+ "@bamboocss/types": "1.39.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@jridgewell/trace-mapping": "^0.3.31",
53
53
  "vite": "7.2.6",
54
- "@bamboocss/fixture": "1.38.0"
54
+ "@bamboocss/fixture": "1.39.1"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": ">=5"