@bamboocss/vite 1.45.2 → 1.45.4

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
@@ -36,6 +36,8 @@ let postcss = require("postcss");
36
36
  postcss = __toESM(postcss);
37
37
  let postcss_selector_parser = require("postcss-selector-parser");
38
38
  postcss_selector_parser = __toESM(postcss_selector_parser);
39
+ let node_crypto = require("node:crypto");
40
+ let node_fs = require("node:fs");
39
41
  let node_path = require("node:path");
40
42
  let _bamboocss_config_ts_path = require("@bamboocss/config/ts-path");
41
43
  let _bamboocss_extractor = require("@bamboocss/extractor");
@@ -3321,6 +3323,141 @@ const bamboocss = (options = {}) => {
3321
3323
  }
3322
3324
  };
3323
3325
  /**
3326
+ * What each cross-file consumer was last handed, and what it last compiled to.
3327
+ *
3328
+ * Editing a shared module re-transforms everything that folded a value out of it, and most of
3329
+ * those re-transforms recompute the bytes they already had: an edit to one export changes the
3330
+ * consumers reading *that* export, not the ones reading something else from the same file.
3331
+ * `foldDependentModules` uses this to tell those two apart.
3332
+ *
3333
+ * Digests, not text. Retaining every consumer's source and compiled output for the life of the
3334
+ * process is the same order of memory as the ts-morph project already holding it; two 44-byte
3335
+ * strings per entry is not, and it does not grow with module size. The set is bounded the way
3336
+ * `dependenciesByFile` is — an entry exists only while a module's fold actually reads another
3337
+ * file, which most modules never do — so a project that folds nothing across a boundary pays
3338
+ * neither the bytes nor the hashing.
3339
+ *
3340
+ * The *input* digest is what makes the output digest safe to act on. The check below re-folds
3341
+ * a consumer from disk, and disk is the right text only if that is what the last transform was
3342
+ * handed: a module built by another plugin's `load`, or one edited in the same save, fails
3343
+ * that comparison and is treated as changed. `path` is the spelling `transform` used, because
3344
+ * ts-morph and the fold both key on it and Windows spells it more than one way.
3345
+ */
3346
+ const foldSignatures = /* @__PURE__ */ new Map();
3347
+ const digest = (text) => (0, node_crypto.createHash)("sha256").update(text).digest("base64");
3348
+ /**
3349
+ * Consumers this edit cannot move, resolved once per watcher event.
3350
+ *
3351
+ * `hotUpdate` runs once per environment — client and ssr both — and the answer is a property
3352
+ * of the files, not of which environment is asking. Cleared in `watchChange`, which every Vite
3353
+ * in the peer range calls for every file event, before either update hook.
3354
+ */
3355
+ const unchangedFolds = /* @__PURE__ */ new Map();
3356
+ /**
3357
+ * How many dependents in a row may come back changed before the check gives up for this event.
3358
+ *
3359
+ * The check costs a re-fold — ~0.2 ms per dependent measured on a twenty-consumer fan-out — and
3360
+ * it is paid on the awaited path, before Vite is told anything. That is linear in the number of
3361
+ * consumers and does not bound itself: a shared module with three hundred of them adds ~59 ms to
3362
+ * every edit, including the edits where nothing *can* be suppressed because the change moved the
3363
+ * recipe base and every consumer really did recompile.
3364
+ *
3365
+ * The trade is worth it whenever anything is suppressible. Measured on the same fan-out, a
3366
+ * suppressed consumer saves ~2.3 ms of re-transform for the ~0.23 ms its check costs, so the
3367
+ * check pays for itself at about one consumer in ten. What does not pay is the case where the
3368
+ * answer is going to be "changed" for all of them, and that case announces itself: a run of
3369
+ * consumers that all came back changed. Stopping after eight bounds it. On a three-hundred
3370
+ * consumer fan-out, an edit to the recipe base — where nothing can be suppressed — costs 3.6 ms
3371
+ * here rather than the 59 ms checking every one of them would, stacked onto an edit already
3372
+ * spending 600 ms re-transforming. A single unchanged consumer resets the run, so the same
3373
+ * fan-out editing a value no fold reads still checks all three hundred: 49 ms spent against
3374
+ * 520 ms of re-transform not done, and a 715 ms edit reduced to 183 ms.
3375
+ *
3376
+ * Giving up is always the safe direction — an unchecked dependent is treated as changed, which
3377
+ * is what this path did before any of this existed — so the bound can only cost the
3378
+ * optimisation, never correctness. It costs it where the optimisation was worth least: for the
3379
+ * run to reach eight, the consumers seen so far have to be uniformly changed.
3380
+ */
3381
+ const CHANGED_RUN_LIMIT = 8;
3382
+ let changedRun = 0;
3383
+ /**
3384
+ * Whether re-folding `dependent` now produces exactly the bytes it produced last time.
3385
+ *
3386
+ * Only the fold can answer that. The consumer's own source has not changed, so whether its
3387
+ * compiled output moves depends entirely on what the edited module resolves to *this* time,
3388
+ * and there is no cheaper way to learn that than to resolve it. Doing it here rather than
3389
+ * waiting for the re-transform is the whole point: the decision is needed before the update is
3390
+ * announced, and for a consumer that turns out to be unchanged this fold *replaces* the
3391
+ * re-transform rather than adding to it.
3392
+ *
3393
+ * Conservative in every failure — no recorded signature, a file that will not read, a parse
3394
+ * that returns nothing, a fold that throws — because "changed" is what this path did before,
3395
+ * and a wrong "unchanged" is a stale class string in the browser.
3396
+ */
3397
+ const foldOutputUnchanged = (dependent) => {
3398
+ const memoized = unchangedFolds.get(dependent);
3399
+ if (memoized !== void 0) return memoized;
3400
+ const unchanged = changedRun < CHANGED_RUN_LIMIT && refoldMatchesSignature(dependent);
3401
+ changedRun = unchanged ? 0 : changedRun + 1;
3402
+ unchangedFolds.set(dependent, unchanged);
3403
+ return unchanged;
3404
+ };
3405
+ const refoldMatchesSignature = (dependent) => {
3406
+ const signature = foldSignatures.get(dependent);
3407
+ if (!signature || !ctx || !runtimeCss || !styleCompiler) return false;
3408
+ try {
3409
+ const code = (0, node_fs.readFileSync)(signature.path, "utf8");
3410
+ if (digest(code) !== signature.input) return false;
3411
+ const sourceFile = ctx.project.addSourceFile(signature.path, code);
3412
+ const parserResult = ctx.project.parseSourceFile(signature.path);
3413
+ if (!parserResult) return false;
3414
+ const result = foldSource({
3415
+ ctx,
3416
+ code,
3417
+ parserResult,
3418
+ filePath: signature.path,
3419
+ runtimeCss,
3420
+ styleCompiler,
3421
+ maxRecipeStates,
3422
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
3423
+ recipeConfigCache,
3424
+ reportSurvivors: false,
3425
+ sourceFile
3426
+ });
3427
+ const unchanged = digest(result.code) === signature.output;
3428
+ /**
3429
+ * Edges re-recorded on the way to suppressing a module, and never on the way to
3430
+ * invalidating one.
3431
+ *
3432
+ * The first is necessary: *which* files a module folds from can move while the bytes it
3433
+ * emits do not — a value now re-exported through a different module, say — and suppressing
3434
+ * the announcement means no transform will run to notice. Leaving the old edges would make
3435
+ * the next edit to the new dependency reach nobody.
3436
+ *
3437
+ * The second would be a bug, and is the reason this is a branch rather than an
3438
+ * unconditional write. `hotUpdate` runs once per environment, client then ssr, against one
3439
+ * shared map. A fold that now yields nothing — an export renamed, a call commented out, any
3440
+ * ordinary mid-edit state — returns `dependencies: []`, so writing it here would retract the
3441
+ * consumer's edge during the *client* pass and leave the *ssr* pass finding an empty set and
3442
+ * returning without invalidating anything: the stale compiled class kept in the SSR cache,
3443
+ * with the client half correctly updated. That is the shape of the bug the self-accepting
3444
+ * fix was about, one environment over. `transform` performs the same retraction, but only
3445
+ * after every environment has already invalidated, which is why it is safe there.
3446
+ *
3447
+ * Confining the write to the unchanged branch removes the hazard rather than sequencing
3448
+ * around it, because a retraction cannot reach that branch: `dependencies` is empty only
3449
+ * when `folded.length === 0`, and a fold that folds nothing returns the module's own source,
3450
+ * which cannot equal an output digest recorded from a pass that replaced a call with a
3451
+ * literal. And where an unchanged verdict *does* narrow the edges, both passes agree anyway
3452
+ * — neither invalidates a module it has just called unchanged.
3453
+ */
3454
+ if (unchanged) recordFoldDependencies(dependent, result.dependencies);
3455
+ return unchanged;
3456
+ } catch {
3457
+ return false;
3458
+ }
3459
+ };
3460
+ /**
3324
3461
  * Modules to re-transform because `file` changed, hard-invalidated on the way out.
3325
3462
  *
3326
3463
  * Invalidating is the fix. It drops the stale compiled result, which is the defect itself,
@@ -3355,10 +3492,46 @@ const bamboocss = (options = {}) => {
3355
3492
  const dependents = dependentsByDependency.get(normalizeFsPath(file));
3356
3493
  if (!dependents?.size) return;
3357
3494
  const added = [];
3358
- for (const dependent of dependents) for (const module of graph.getModulesByFile(dependent) ?? []) {
3359
- if (modules.includes(module) || added.includes(module)) continue;
3360
- graph.invalidateModule(module);
3361
- added.push(module);
3495
+ for (const dependent of [...dependents]) {
3496
+ /**
3497
+ * A consumer whose compiled bytes this edit does not move is left entirely alone.
3498
+ *
3499
+ * Announcing one tells the browser to refetch a module it already has verbatim: a round
3500
+ * trip, and behind it — in a framework that re-drives HMR per entry, as react-router does
3501
+ * with `reloadModule` in both its client and its ssr pass — a router revalidation.
3502
+ *
3503
+ * Skipping the *invalidation* follows from the same fact, and is where the cost actually
3504
+ * sits. Vite only soft-invalidates a module that statically imports the changed one, which
3505
+ * keeps its cached transform result and re-serves it for the price of rewriting import
3506
+ * timestamps; hard-invalidating turns that into a full re-transform through every plugin
3507
+ * in the chain. On a fan-out of twenty consumers of one shared module, editing a runtime
3508
+ * value no fold reads took the transforms Bamboo runs for that edit from 22 to 2.
3509
+ *
3510
+ * How much that is worth depends on whether the consumer's import statement *survives* the
3511
+ * fold. When it imports nothing from the module but the value being folded, the binding
3512
+ * goes dead, esbuild drops the statement, and the only edge left is the non-static one
3513
+ * `addWatchFile` created — which Vite hard-invalidates by itself, so declining to here
3514
+ * saves nothing and the re-fold is pure cost. Import one more thing from the same module,
3515
+ * which is the shape a real shared `ui.ts` has, and the statement stays, the consumer is a
3516
+ * static importer, and Bamboo's invalidation is the only reason it re-transforms at all.
3517
+ *
3518
+ * Safe by what "identical" means. The invalidation exists to drop a compiled class string
3519
+ * that no longer matches the source it was compiled from; when recompiling produces the
3520
+ * same string, there is nothing stale to drop. Whichever way the module is reached next —
3521
+ * Vite's own propagation, a later request, or nothing at all — the bytes it yields are the
3522
+ * bytes this fold just computed.
3523
+ *
3524
+ * Nor can it mask an update Vite would have sent by itself. This only ever withholds a
3525
+ * name Bamboo added; `modules` is passed through untouched. A consumer that also *imports*
3526
+ * the edited module for a runtime value is still reached by `propagateUpdate` exactly as
3527
+ * it would be with no plugin here at all — that direction was never this list's to decide.
3528
+ */
3529
+ if (foldOutputUnchanged(dependent)) continue;
3530
+ for (const module of graph.getModulesByFile(dependent) ?? []) {
3531
+ if (modules.includes(module) || added.includes(module)) continue;
3532
+ graph.invalidateModule(module);
3533
+ added.push(module);
3534
+ }
3362
3535
  }
3363
3536
  if (!added.length) return;
3364
3537
  /**
@@ -3420,6 +3593,9 @@ const bamboocss = (options = {}) => {
3420
3593
  recipeConfigCache.clear();
3421
3594
  dependentsByDependency.clear();
3422
3595
  dependenciesByFile.clear();
3596
+ foldSignatures.clear();
3597
+ unchangedFolds.clear();
3598
+ changedRun = 0;
3423
3599
  resetStaticCompilationSession(staticSession);
3424
3600
  }
3425
3601
  staticSession.startedEnvironments.add(environment);
@@ -3450,6 +3626,8 @@ const bamboocss = (options = {}) => {
3450
3626
  * the parser still holds the file.
3451
3627
  */
3452
3628
  watchChange(id, change) {
3629
+ unchangedFolds.clear();
3630
+ changedRun = 0;
3453
3631
  if (!ctx) return;
3454
3632
  if (!shouldTransform(id)) return;
3455
3633
  const [filePath] = id.split("?");
@@ -3458,6 +3636,7 @@ const bamboocss = (options = {}) => {
3458
3636
  if (change.event === "delete") {
3459
3637
  ctx.project.removeSourceFile(filePath);
3460
3638
  recordFoldDependencies(normalizeFsPath(filePath), []);
3639
+ foldSignatures.delete(normalizeFsPath(filePath));
3461
3640
  return;
3462
3641
  }
3463
3642
  ctx.project.reloadSourceFile(filePath);
@@ -3526,6 +3705,7 @@ const bamboocss = (options = {}) => {
3526
3705
  folded: 0,
3527
3706
  skipped: new Map([["compile-failed", 1]])
3528
3707
  });
3708
+ foldSignatures.delete(normalizeFsPath(filePath));
3529
3709
  addSurvivor({
3530
3710
  file: filePath,
3531
3711
  line: 1,
@@ -3558,9 +3738,19 @@ const bamboocss = (options = {}) => {
3558
3738
  }
3559
3739
  if (reportSkipped && result.skipped.length) _bamboocss_logger.logger.info("vite:transform", formatSkipped(filePath, result.skipped));
3560
3740
  for (const dependency of result.dependencies) this.addWatchFile?.(dependency);
3561
- recordFoldDependencies(normalizeFsPath(filePath), result.dependencies);
3741
+ const dependentKey = normalizeFsPath(filePath);
3742
+ recordFoldDependencies(dependentKey, result.dependencies);
3743
+ if (result.dependencies.length) foldSignatures.set(dependentKey, {
3744
+ input: digest(code),
3745
+ output: digest(result.code),
3746
+ path: filePath
3747
+ });
3748
+ else foldSignatures.delete(dependentKey);
3562
3749
  const forFile = survivorsByFile.get(filePath);
3563
- if (command === "serve" && forFile?.length) throw createSurvivorError(forFile);
3750
+ if (command === "serve" && forFile?.length) {
3751
+ foldSignatures.delete(dependentKey);
3752
+ throw createSurvivorError(forFile);
3753
+ }
3564
3754
  if (!result.folded.length) return null;
3565
3755
  _bamboocss_logger.logger.debug("vite:transform", `Compiled ${result.folded.length} call(s) in ${filePath}`);
3566
3756
  return {
package/dist/index.mjs CHANGED
@@ -6,6 +6,8 @@ import remapping from "@ampproject/remapping";
6
6
  import MagicString from "magic-string";
7
7
  import postcss from "postcss";
8
8
  import selectorParser from "postcss-selector-parser";
9
+ import { createHash } from "node:crypto";
10
+ import { readFileSync } from "node:fs";
9
11
  import { dirname, relative, resolve } from "node:path";
10
12
  import { resolveTsPathPattern } from "@bamboocss/config/ts-path";
11
13
  import { box, maybeBoxNode } from "@bamboocss/extractor";
@@ -3291,6 +3293,141 @@ const bamboocss = (options = {}) => {
3291
3293
  }
3292
3294
  };
3293
3295
  /**
3296
+ * What each cross-file consumer was last handed, and what it last compiled to.
3297
+ *
3298
+ * Editing a shared module re-transforms everything that folded a value out of it, and most of
3299
+ * those re-transforms recompute the bytes they already had: an edit to one export changes the
3300
+ * consumers reading *that* export, not the ones reading something else from the same file.
3301
+ * `foldDependentModules` uses this to tell those two apart.
3302
+ *
3303
+ * Digests, not text. Retaining every consumer's source and compiled output for the life of the
3304
+ * process is the same order of memory as the ts-morph project already holding it; two 44-byte
3305
+ * strings per entry is not, and it does not grow with module size. The set is bounded the way
3306
+ * `dependenciesByFile` is — an entry exists only while a module's fold actually reads another
3307
+ * file, which most modules never do — so a project that folds nothing across a boundary pays
3308
+ * neither the bytes nor the hashing.
3309
+ *
3310
+ * The *input* digest is what makes the output digest safe to act on. The check below re-folds
3311
+ * a consumer from disk, and disk is the right text only if that is what the last transform was
3312
+ * handed: a module built by another plugin's `load`, or one edited in the same save, fails
3313
+ * that comparison and is treated as changed. `path` is the spelling `transform` used, because
3314
+ * ts-morph and the fold both key on it and Windows spells it more than one way.
3315
+ */
3316
+ const foldSignatures = /* @__PURE__ */ new Map();
3317
+ const digest = (text) => createHash("sha256").update(text).digest("base64");
3318
+ /**
3319
+ * Consumers this edit cannot move, resolved once per watcher event.
3320
+ *
3321
+ * `hotUpdate` runs once per environment — client and ssr both — and the answer is a property
3322
+ * of the files, not of which environment is asking. Cleared in `watchChange`, which every Vite
3323
+ * in the peer range calls for every file event, before either update hook.
3324
+ */
3325
+ const unchangedFolds = /* @__PURE__ */ new Map();
3326
+ /**
3327
+ * How many dependents in a row may come back changed before the check gives up for this event.
3328
+ *
3329
+ * The check costs a re-fold — ~0.2 ms per dependent measured on a twenty-consumer fan-out — and
3330
+ * it is paid on the awaited path, before Vite is told anything. That is linear in the number of
3331
+ * consumers and does not bound itself: a shared module with three hundred of them adds ~59 ms to
3332
+ * every edit, including the edits where nothing *can* be suppressed because the change moved the
3333
+ * recipe base and every consumer really did recompile.
3334
+ *
3335
+ * The trade is worth it whenever anything is suppressible. Measured on the same fan-out, a
3336
+ * suppressed consumer saves ~2.3 ms of re-transform for the ~0.23 ms its check costs, so the
3337
+ * check pays for itself at about one consumer in ten. What does not pay is the case where the
3338
+ * answer is going to be "changed" for all of them, and that case announces itself: a run of
3339
+ * consumers that all came back changed. Stopping after eight bounds it. On a three-hundred
3340
+ * consumer fan-out, an edit to the recipe base — where nothing can be suppressed — costs 3.6 ms
3341
+ * here rather than the 59 ms checking every one of them would, stacked onto an edit already
3342
+ * spending 600 ms re-transforming. A single unchanged consumer resets the run, so the same
3343
+ * fan-out editing a value no fold reads still checks all three hundred: 49 ms spent against
3344
+ * 520 ms of re-transform not done, and a 715 ms edit reduced to 183 ms.
3345
+ *
3346
+ * Giving up is always the safe direction — an unchecked dependent is treated as changed, which
3347
+ * is what this path did before any of this existed — so the bound can only cost the
3348
+ * optimisation, never correctness. It costs it where the optimisation was worth least: for the
3349
+ * run to reach eight, the consumers seen so far have to be uniformly changed.
3350
+ */
3351
+ const CHANGED_RUN_LIMIT = 8;
3352
+ let changedRun = 0;
3353
+ /**
3354
+ * Whether re-folding `dependent` now produces exactly the bytes it produced last time.
3355
+ *
3356
+ * Only the fold can answer that. The consumer's own source has not changed, so whether its
3357
+ * compiled output moves depends entirely on what the edited module resolves to *this* time,
3358
+ * and there is no cheaper way to learn that than to resolve it. Doing it here rather than
3359
+ * waiting for the re-transform is the whole point: the decision is needed before the update is
3360
+ * announced, and for a consumer that turns out to be unchanged this fold *replaces* the
3361
+ * re-transform rather than adding to it.
3362
+ *
3363
+ * Conservative in every failure — no recorded signature, a file that will not read, a parse
3364
+ * that returns nothing, a fold that throws — because "changed" is what this path did before,
3365
+ * and a wrong "unchanged" is a stale class string in the browser.
3366
+ */
3367
+ const foldOutputUnchanged = (dependent) => {
3368
+ const memoized = unchangedFolds.get(dependent);
3369
+ if (memoized !== void 0) return memoized;
3370
+ const unchanged = changedRun < CHANGED_RUN_LIMIT && refoldMatchesSignature(dependent);
3371
+ changedRun = unchanged ? 0 : changedRun + 1;
3372
+ unchangedFolds.set(dependent, unchanged);
3373
+ return unchanged;
3374
+ };
3375
+ const refoldMatchesSignature = (dependent) => {
3376
+ const signature = foldSignatures.get(dependent);
3377
+ if (!signature || !ctx || !runtimeCss || !styleCompiler) return false;
3378
+ try {
3379
+ const code = readFileSync(signature.path, "utf8");
3380
+ if (digest(code) !== signature.input) return false;
3381
+ const sourceFile = ctx.project.addSourceFile(signature.path, code);
3382
+ const parserResult = ctx.project.parseSourceFile(signature.path);
3383
+ if (!parserResult) return false;
3384
+ const result = foldSource({
3385
+ ctx,
3386
+ code,
3387
+ parserResult,
3388
+ filePath: signature.path,
3389
+ runtimeCss,
3390
+ styleCompiler,
3391
+ maxRecipeStates,
3392
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
3393
+ recipeConfigCache,
3394
+ reportSurvivors: false,
3395
+ sourceFile
3396
+ });
3397
+ const unchanged = digest(result.code) === signature.output;
3398
+ /**
3399
+ * Edges re-recorded on the way to suppressing a module, and never on the way to
3400
+ * invalidating one.
3401
+ *
3402
+ * The first is necessary: *which* files a module folds from can move while the bytes it
3403
+ * emits do not — a value now re-exported through a different module, say — and suppressing
3404
+ * the announcement means no transform will run to notice. Leaving the old edges would make
3405
+ * the next edit to the new dependency reach nobody.
3406
+ *
3407
+ * The second would be a bug, and is the reason this is a branch rather than an
3408
+ * unconditional write. `hotUpdate` runs once per environment, client then ssr, against one
3409
+ * shared map. A fold that now yields nothing — an export renamed, a call commented out, any
3410
+ * ordinary mid-edit state — returns `dependencies: []`, so writing it here would retract the
3411
+ * consumer's edge during the *client* pass and leave the *ssr* pass finding an empty set and
3412
+ * returning without invalidating anything: the stale compiled class kept in the SSR cache,
3413
+ * with the client half correctly updated. That is the shape of the bug the self-accepting
3414
+ * fix was about, one environment over. `transform` performs the same retraction, but only
3415
+ * after every environment has already invalidated, which is why it is safe there.
3416
+ *
3417
+ * Confining the write to the unchanged branch removes the hazard rather than sequencing
3418
+ * around it, because a retraction cannot reach that branch: `dependencies` is empty only
3419
+ * when `folded.length === 0`, and a fold that folds nothing returns the module's own source,
3420
+ * which cannot equal an output digest recorded from a pass that replaced a call with a
3421
+ * literal. And where an unchanged verdict *does* narrow the edges, both passes agree anyway
3422
+ * — neither invalidates a module it has just called unchanged.
3423
+ */
3424
+ if (unchanged) recordFoldDependencies(dependent, result.dependencies);
3425
+ return unchanged;
3426
+ } catch {
3427
+ return false;
3428
+ }
3429
+ };
3430
+ /**
3294
3431
  * Modules to re-transform because `file` changed, hard-invalidated on the way out.
3295
3432
  *
3296
3433
  * Invalidating is the fix. It drops the stale compiled result, which is the defect itself,
@@ -3325,10 +3462,46 @@ const bamboocss = (options = {}) => {
3325
3462
  const dependents = dependentsByDependency.get(normalizeFsPath(file));
3326
3463
  if (!dependents?.size) return;
3327
3464
  const added = [];
3328
- for (const dependent of dependents) for (const module of graph.getModulesByFile(dependent) ?? []) {
3329
- if (modules.includes(module) || added.includes(module)) continue;
3330
- graph.invalidateModule(module);
3331
- added.push(module);
3465
+ for (const dependent of [...dependents]) {
3466
+ /**
3467
+ * A consumer whose compiled bytes this edit does not move is left entirely alone.
3468
+ *
3469
+ * Announcing one tells the browser to refetch a module it already has verbatim: a round
3470
+ * trip, and behind it — in a framework that re-drives HMR per entry, as react-router does
3471
+ * with `reloadModule` in both its client and its ssr pass — a router revalidation.
3472
+ *
3473
+ * Skipping the *invalidation* follows from the same fact, and is where the cost actually
3474
+ * sits. Vite only soft-invalidates a module that statically imports the changed one, which
3475
+ * keeps its cached transform result and re-serves it for the price of rewriting import
3476
+ * timestamps; hard-invalidating turns that into a full re-transform through every plugin
3477
+ * in the chain. On a fan-out of twenty consumers of one shared module, editing a runtime
3478
+ * value no fold reads took the transforms Bamboo runs for that edit from 22 to 2.
3479
+ *
3480
+ * How much that is worth depends on whether the consumer's import statement *survives* the
3481
+ * fold. When it imports nothing from the module but the value being folded, the binding
3482
+ * goes dead, esbuild drops the statement, and the only edge left is the non-static one
3483
+ * `addWatchFile` created — which Vite hard-invalidates by itself, so declining to here
3484
+ * saves nothing and the re-fold is pure cost. Import one more thing from the same module,
3485
+ * which is the shape a real shared `ui.ts` has, and the statement stays, the consumer is a
3486
+ * static importer, and Bamboo's invalidation is the only reason it re-transforms at all.
3487
+ *
3488
+ * Safe by what "identical" means. The invalidation exists to drop a compiled class string
3489
+ * that no longer matches the source it was compiled from; when recompiling produces the
3490
+ * same string, there is nothing stale to drop. Whichever way the module is reached next —
3491
+ * Vite's own propagation, a later request, or nothing at all — the bytes it yields are the
3492
+ * bytes this fold just computed.
3493
+ *
3494
+ * Nor can it mask an update Vite would have sent by itself. This only ever withholds a
3495
+ * name Bamboo added; `modules` is passed through untouched. A consumer that also *imports*
3496
+ * the edited module for a runtime value is still reached by `propagateUpdate` exactly as
3497
+ * it would be with no plugin here at all — that direction was never this list's to decide.
3498
+ */
3499
+ if (foldOutputUnchanged(dependent)) continue;
3500
+ for (const module of graph.getModulesByFile(dependent) ?? []) {
3501
+ if (modules.includes(module) || added.includes(module)) continue;
3502
+ graph.invalidateModule(module);
3503
+ added.push(module);
3504
+ }
3332
3505
  }
3333
3506
  if (!added.length) return;
3334
3507
  /**
@@ -3390,6 +3563,9 @@ const bamboocss = (options = {}) => {
3390
3563
  recipeConfigCache.clear();
3391
3564
  dependentsByDependency.clear();
3392
3565
  dependenciesByFile.clear();
3566
+ foldSignatures.clear();
3567
+ unchangedFolds.clear();
3568
+ changedRun = 0;
3393
3569
  resetStaticCompilationSession(staticSession);
3394
3570
  }
3395
3571
  staticSession.startedEnvironments.add(environment);
@@ -3420,6 +3596,8 @@ const bamboocss = (options = {}) => {
3420
3596
  * the parser still holds the file.
3421
3597
  */
3422
3598
  watchChange(id, change) {
3599
+ unchangedFolds.clear();
3600
+ changedRun = 0;
3423
3601
  if (!ctx) return;
3424
3602
  if (!shouldTransform(id)) return;
3425
3603
  const [filePath] = id.split("?");
@@ -3428,6 +3606,7 @@ const bamboocss = (options = {}) => {
3428
3606
  if (change.event === "delete") {
3429
3607
  ctx.project.removeSourceFile(filePath);
3430
3608
  recordFoldDependencies(normalizeFsPath(filePath), []);
3609
+ foldSignatures.delete(normalizeFsPath(filePath));
3431
3610
  return;
3432
3611
  }
3433
3612
  ctx.project.reloadSourceFile(filePath);
@@ -3496,6 +3675,7 @@ const bamboocss = (options = {}) => {
3496
3675
  folded: 0,
3497
3676
  skipped: new Map([["compile-failed", 1]])
3498
3677
  });
3678
+ foldSignatures.delete(normalizeFsPath(filePath));
3499
3679
  addSurvivor({
3500
3680
  file: filePath,
3501
3681
  line: 1,
@@ -3528,9 +3708,19 @@ const bamboocss = (options = {}) => {
3528
3708
  }
3529
3709
  if (reportSkipped && result.skipped.length) logger.info("vite:transform", formatSkipped(filePath, result.skipped));
3530
3710
  for (const dependency of result.dependencies) this.addWatchFile?.(dependency);
3531
- recordFoldDependencies(normalizeFsPath(filePath), result.dependencies);
3711
+ const dependentKey = normalizeFsPath(filePath);
3712
+ recordFoldDependencies(dependentKey, result.dependencies);
3713
+ if (result.dependencies.length) foldSignatures.set(dependentKey, {
3714
+ input: digest(code),
3715
+ output: digest(result.code),
3716
+ path: filePath
3717
+ });
3718
+ else foldSignatures.delete(dependentKey);
3532
3719
  const forFile = survivorsByFile.get(filePath);
3533
- if (command === "serve" && forFile?.length) throw createSurvivorError(forFile);
3720
+ if (command === "serve" && forFile?.length) {
3721
+ foldSignatures.delete(dependentKey);
3722
+ throw createSurvivorError(forFile);
3723
+ }
3534
3724
  if (!result.folded.length) return null;
3535
3725
  logger.debug("vite:transform", `Compiled ${result.folded.length} call(s) in ${filePath}`);
3536
3726
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/vite",
3
- "version": "1.45.2",
3
+ "version": "1.45.4",
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.45.2",
44
- "@bamboocss/core": "1.45.2",
45
- "@bamboocss/extractor": "1.45.2",
46
- "@bamboocss/node": "1.45.2",
47
- "@bamboocss/logger": "1.45.2",
48
- "@bamboocss/shared": "1.45.2",
49
- "@bamboocss/types": "1.45.2"
43
+ "@bamboocss/core": "1.45.4",
44
+ "@bamboocss/config": "1.45.4",
45
+ "@bamboocss/logger": "1.45.4",
46
+ "@bamboocss/node": "1.45.4",
47
+ "@bamboocss/extractor": "1.45.4",
48
+ "@bamboocss/shared": "1.45.4",
49
+ "@bamboocss/types": "1.45.4"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@jridgewell/trace-mapping": "^0.3.31",
53
53
  "vite": "7.2.6",
54
- "@bamboocss/fixture": "1.45.2"
54
+ "@bamboocss/fixture": "1.45.4"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": ">=5"