Pin Vite's deduplication for an application-shaped entry
Plan step 37 review. The Vite test counted each of four files' plain root rule, which the minifier folds into one even when a build repeats the whole stylesheet: it passed against the Workbench's Tailwind entry, which repeated 231 rules. It now asserts that no innermost rule repeats under the same at-rules, which that build fails and a deduplicating one passes, and it builds a second entry shaped like an application's — outside the package, the foundation, then four component stylesheets that each import button.css, and button.css again — where button.css must also keep its first position. The fixture config refuses to run without DEDUP_OUT_DIR, so it never writes into the package tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
3854f4321c
commit
5e7c8b2b58
@@ -315,24 +315,72 @@ it('caches per file list and mtime, and resetCache() forces a fresh read', funct
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Vite deduplication `bundle()` is modelled on, pinned against the real bundler: a Vite build
|
* Every innermost rule of a minified stylesheet — `selector{declarations}` with no block inside —
|
||||||
* of `all.css` alone, no other plugin in the graph (tests/Fixtures/dedup.vite.config.mjs — its
|
* keyed by the at-rules around it (`@layer material.components{@media (hover:hover){…`), so the
|
||||||
* header explains why this is a separate, minimal entry rather than the Workbench's own).
|
* same rule written for two different conditions counts as two rules, not one repeated.
|
||||||
*
|
*
|
||||||
* The built CSS is minified, so rather than compare byte-for-byte against `bundle()`'s own
|
* @return list<string>
|
||||||
* (unminified) output, this counts one plain, unwrapped rule per file — `[data-md-<name>]{…}`
|
|
||||||
* exactly as button.css, icon.css, menu.css and list-item.css each write it before any `:not()`,
|
|
||||||
* `:is()` or `:where()` wrapping — and asserts each appears exactly once. A rule behind such
|
|
||||||
* wrapping is not used as a marker because Lightning CSS (Tailwind's minifier, which the
|
|
||||||
* Workbench's own build also carries) may re-split a single nested rule into several flat ones
|
|
||||||
* sharing a declaration block, which would inflate a literal count for a reason that has nothing
|
|
||||||
* to do with import deduplication; these four files' very first, plain, unwrapped selector is not
|
|
||||||
* affected by that and stays a reliable one-file-once fingerprint. They are also each imported
|
|
||||||
* from several other stylesheets (button.css: 11 places; icon.css and menu.css: a dozen more
|
|
||||||
* between them; list-item.css: list.css and carousel-item.css), so a broken dedup would double or
|
|
||||||
* triple the count rather than leave it at one.
|
|
||||||
*/
|
*/
|
||||||
it('lands each of four widely-imported component stylesheets exactly once in a real Vite build', function () {
|
function stylesheetsBundleLeafRules(string $css): array
|
||||||
|
{
|
||||||
|
$leaves = [];
|
||||||
|
$stack = [];
|
||||||
|
$start = 0;
|
||||||
|
$length = strlen($css);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$char = $css[$i];
|
||||||
|
|
||||||
|
if ($char === '"' || $char === "'") {
|
||||||
|
$close = strpos($css, $char, $i + 1);
|
||||||
|
|
||||||
|
if ($close === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$i = $close;
|
||||||
|
} elseif ($char === '{') {
|
||||||
|
$stack[] = ['prelude' => trim(substr($css, $start, $i - $start)), 'open' => $i, 'leaf' => true];
|
||||||
|
|
||||||
|
if (count($stack) > 1) {
|
||||||
|
$stack[count($stack) - 2]['leaf'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $i + 1;
|
||||||
|
} elseif ($char === '}') {
|
||||||
|
$block = array_pop($stack);
|
||||||
|
|
||||||
|
if ($block !== null && $block['leaf']) {
|
||||||
|
$context = implode('{', array_column($stack, 'prelude'));
|
||||||
|
$leaves[] = $context.'{'.$block['prelude'].'{'.substr($css, $block['open'] + 1, $i - $block['open'] - 1).'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $i + 1;
|
||||||
|
} elseif ($char === ';' && ($stack === [] || ! $stack[count($stack) - 1]['leaf'])) {
|
||||||
|
// A statement between blocks (`@layer a,b;`) is not part of the next rule's selector.
|
||||||
|
$start = $i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $leaves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Vite deduplication `bundle()` is modelled on, pinned against the real bundler
|
||||||
|
* (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph): `all.css`, and
|
||||||
|
* tests/Fixtures/dedup-app.css, an entry shaped like an application's — outside the package, the
|
||||||
|
* foundation, then split button, app bar, modal and pagination, which each import button.css, and
|
||||||
|
* button.css once more.
|
||||||
|
*
|
||||||
|
* The built CSS is minified, so this compares rules rather than files: no innermost rule may
|
||||||
|
* appear twice under the same at-rules. A build that inlined every occurrence of a shared
|
||||||
|
* stylesheet repeats hundreds of them (the Workbench's entry did while it shared one file with
|
||||||
|
* `@tailwindcss/vite`: button.css's hover and disabled rules thirteen times), while a file's plain
|
||||||
|
* root rule alone is no evidence — the minifier folds identical copies of that one together. In
|
||||||
|
* the application entry, button.css must also keep its first position: before split-button.css,
|
||||||
|
* the first file that imports it, not at the end where the application named it again.
|
||||||
|
*/
|
||||||
|
it('lands every rule once, at its first position, in a real Vite build of all.css and of an application entry', function () {
|
||||||
$node = config('livewire-material.node', 'node');
|
$node = config('livewire-material.node', 'node');
|
||||||
|
|
||||||
if (Process::run([$node, '--version'])->failed()) {
|
if (Process::run([$node, '--version'])->failed()) {
|
||||||
@@ -356,18 +404,21 @@ it('lands each of four widely-imported component stylesheets exactly once in a r
|
|||||||
expect($result->successful())->toBeTrue($result->errorOutput());
|
expect($result->successful())->toBeTrue($result->errorOutput());
|
||||||
|
|
||||||
$manifest = json_decode(File::get("{$outDir}/.vite/manifest.json"), true, flags: JSON_THROW_ON_ERROR);
|
$manifest = json_decode(File::get("{$outDir}/.vite/manifest.json"), true, flags: JSON_THROW_ON_ERROR);
|
||||||
$css = File::get($outDir.'/'.$manifest['resources/css/all.css']['file']);
|
|
||||||
|
|
||||||
foreach (['button', 'icon', 'menu', 'list-item'] as $name) {
|
foreach (['resources/css/all.css', 'tests/Fixtures/dedup-app.css'] as $entry) {
|
||||||
$needle = "[data-md-{$name}]{";
|
$css = File::get($outDir.'/'.$manifest[$entry]['file']);
|
||||||
$start = strpos($css, $needle);
|
$leaves = stylesheetsBundleLeafRules($css);
|
||||||
|
$repeated = array_keys(array_filter(array_count_values($leaves), fn (int $count): bool => $count > 1));
|
||||||
|
|
||||||
expect($start)->toBeInt("{$needle} should be in the built CSS");
|
expect(count($leaves))->toBeGreaterThan(100, $entry)
|
||||||
|
->and($repeated)->toBe([], "{$entry} repeats a rule, so a shared stylesheet landed more than once");
|
||||||
$rule = substr($css, $start, strpos($css, '}', $start) - $start + 1);
|
|
||||||
|
|
||||||
expect(substr_count($css, $rule))->toBe(1, "{$name}.css's root rule should land once");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$app = File::get($outDir.'/'.$manifest['tests/Fixtures/dedup-app.css']['file']);
|
||||||
|
|
||||||
|
expect(strpos($app, '[data-md-button]{'))->toBeInt()
|
||||||
|
->toBeLessThan((int) strpos($app, '[data-md-split-button]{'))
|
||||||
|
->and(strpos($app, '[data-md-app-bar]'))->toBeGreaterThan((int) strpos($app, '[data-md-split-button]{'));
|
||||||
} finally {
|
} finally {
|
||||||
File::deleteDirectory($outDir);
|
File::deleteDirectory($outDir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* An application's own CSS entry, the way plan step 37's claim is about: outside the package, the
|
||||||
|
* foundation first, then the stylesheet of each component its views render. Split button, app bar,
|
||||||
|
* modal and pagination each import button.css (and icon.css, through it), and the application
|
||||||
|
* names button.css itself once more at the end. Built by tests/Fixtures/dedup.vite.config.mjs for
|
||||||
|
* StylesheetsBundleTest.php, which expects every shared rule once, at its first position.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import '../../resources/css/foundation.css';
|
||||||
|
@import '../../resources/css/components/split-button.css';
|
||||||
|
@import '../../resources/css/components/app-bar.css';
|
||||||
|
@import '../../resources/css/components/modal.css';
|
||||||
|
@import '../../resources/css/components/pagination.css';
|
||||||
|
@import '../../resources/css/components/button.css';
|
||||||
@@ -1,25 +1,31 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
// Builds resources/css/all.css alone, with no other plugin in the graph: it isolates Vite's own
|
// Builds two CSS entries with no plugin in the graph, isolating Vite's own bundled postcss-import,
|
||||||
// bundled postcss-import, whose `skipDuplicates` option is the mechanism
|
// whose `skipDuplicates` option is the mechanism docs/plans/material-3-alignment.md's "Tailwind's
|
||||||
// docs/plans/material-3-alignment.md's "Tailwind's footprint" paragraph measured and
|
// footprint" paragraph measured and StylesheetsBundleTest.php pins: resources/css/all.css, and
|
||||||
// StylesheetsBundleTest.php pins against the real bundler — a stylesheet several files import
|
// tests/Fixtures/dedup-app.css, an application-shaped entry importing per-component stylesheets
|
||||||
// lands once, at its first position.
|
// that share button.css. A stylesheet several files import lands once per entry, at its first
|
||||||
|
// position.
|
||||||
//
|
//
|
||||||
// The Workbench's own entry (vite.config.js) still shares one file with `@tailwindcss/vite` until
|
// Not the Workbench's config: `@tailwindcss/vite` inlines the imports of any entry that uses
|
||||||
// plan step 39 removes it, and that plugin bundles its whole reachable module graph itself,
|
// Tailwind itself, without that deduplication, so a shared stylesheet repeats there. The Workbench
|
||||||
// independently of Vite's CSS pipeline; it does not carry the same `skipDuplicates` option, so a
|
// keeps the package's CSS in an entry of its own (workbench/resources/css/package.css) for that
|
||||||
// shared component stylesheet currently lands once per import site there, not once overall. That
|
// reason, until plan step 39 removes Tailwind.
|
||||||
// is a pre-existing limitation of mixing the two in one entry, not something plan step 37
|
//
|
||||||
// introduces or can fix without moving Tailwind out of the Workbench's entry (step 39's job) — this
|
// DEDUP_OUT_DIR is required: the test builds into a temporary directory and deletes it, and nothing
|
||||||
// fixture is what actually exercises the claim being pinned, decoupled from it.
|
// should land in the package's own tree, least of all workbench/public/build.
|
||||||
|
if (!process.env.DEDUP_OUT_DIR) {
|
||||||
|
throw new Error('Set DEDUP_OUT_DIR to the directory the dedup fixture should build into.');
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
publicDir: false,
|
||||||
build: {
|
build: {
|
||||||
outDir: process.env.DEDUP_OUT_DIR,
|
outDir: process.env.DEDUP_OUT_DIR,
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
manifest: true,
|
manifest: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: 'resources/css/all.css',
|
input: ['resources/css/all.css', 'tests/Fixtures/dedup-app.css'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
logLevel: 'silent',
|
logLevel: 'silent',
|
||||||
|
|||||||
Reference in New Issue
Block a user