diff --git a/tests/Feature/StylesheetsBundleTest.php b/tests/Feature/StylesheetsBundleTest.php index 387874f6..e768298e 100644 --- a/tests/Feature/StylesheetsBundleTest.php +++ b/tests/Feature/StylesheetsBundleTest.php @@ -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 - * of `all.css` alone, no other plugin in the graph (tests/Fixtures/dedup.vite.config.mjs — its - * header explains why this is a separate, minimal entry rather than the Workbench's own). + * Every innermost rule of a minified stylesheet — `selector{declarations}` with no block inside — + * keyed by the at-rules around it (`@layer material.components{@media (hover:hover){…`), so the + * 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 - * (unminified) output, this counts one plain, unwrapped rule per file — `[data-md-]{…}` - * 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. + * @return list */ -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'); 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()); $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) { - $needle = "[data-md-{$name}]{"; - $start = strpos($css, $needle); + foreach (['resources/css/all.css', 'tests/Fixtures/dedup-app.css'] as $entry) { + $css = File::get($outDir.'/'.$manifest[$entry]['file']); + $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"); - - $rule = substr($css, $start, strpos($css, '}', $start) - $start + 1); - - expect(substr_count($css, $rule))->toBe(1, "{$name}.css's root rule should land once"); + expect(count($leaves))->toBeGreaterThan(100, $entry) + ->and($repeated)->toBe([], "{$entry} repeats a rule, so a shared stylesheet landed more than 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 { File::deleteDirectory($outDir); } diff --git a/tests/Fixtures/dedup-app.css b/tests/Fixtures/dedup-app.css new file mode 100644 index 00000000..3b9c80ed --- /dev/null +++ b/tests/Fixtures/dedup-app.css @@ -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'; diff --git a/tests/Fixtures/dedup.vite.config.mjs b/tests/Fixtures/dedup.vite.config.mjs index 537c81f0..0412ce7a 100644 --- a/tests/Fixtures/dedup.vite.config.mjs +++ b/tests/Fixtures/dedup.vite.config.mjs @@ -1,25 +1,31 @@ import { defineConfig } from 'vite'; -// Builds resources/css/all.css alone, with no other plugin in the graph: it isolates Vite's own -// bundled postcss-import, whose `skipDuplicates` option is the mechanism -// docs/plans/material-3-alignment.md's "Tailwind's footprint" paragraph measured and -// StylesheetsBundleTest.php pins against the real bundler — a stylesheet several files import -// lands once, at its first position. +// Builds two CSS entries with no plugin in the graph, isolating Vite's own bundled postcss-import, +// whose `skipDuplicates` option is the mechanism docs/plans/material-3-alignment.md's "Tailwind's +// footprint" paragraph measured and StylesheetsBundleTest.php pins: resources/css/all.css, and +// tests/Fixtures/dedup-app.css, an application-shaped entry importing per-component stylesheets +// 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 -// plan step 39 removes it, and that plugin bundles its whole reachable module graph itself, -// independently of Vite's CSS pipeline; it does not carry the same `skipDuplicates` option, so a -// shared component stylesheet currently lands once per import site there, not once overall. That -// 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 -// fixture is what actually exercises the claim being pinned, decoupled from it. +// Not the Workbench's config: `@tailwindcss/vite` inlines the imports of any entry that uses +// Tailwind itself, without that deduplication, so a shared stylesheet repeats there. The Workbench +// keeps the package's CSS in an entry of its own (workbench/resources/css/package.css) for that +// reason, until plan step 39 removes Tailwind. +// +// DEDUP_OUT_DIR is required: the test builds into a temporary directory and deletes it, and nothing +// 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({ + publicDir: false, build: { outDir: process.env.DEDUP_OUT_DIR, emptyOutDir: true, manifest: true, rollupOptions: { - input: 'resources/css/all.css', + input: ['resources/css/all.css', 'tests/Fixtures/dedup-app.css'], }, }, logLevel: 'silent',