Skip to content

Commit cf601ff

Browse files
os-justinclaude
andauthored
perf(console): stop the eager stylesheet scanning test files (objectui#6379) (#9661)
Part of #6379. Deliberately **not** `Fixes`. This closes one measurable face of that card and leaves its actual question open: merging this must not close it. What remains open is stated under "What this does not do". ## What this does `apps/console/src/index.css` declares 27 `@source` path globs across `packages/**` plus its own tree, and carried **no `@source not` line at all** — while reaching further than any other source-declaring entry in this repo. Tailwind v4 scans source TEXT, not an import graph, so every test file under those globs was a source for the render-blocking `index-*.css` the console serves: **2373** test files in the packages it names, plus this app's own. `packages/components/src/index.css` (objectui#8446, objectui#9569) and `packages/runner/src/index.css` (objectui#8454) already carried the exclusion. This entry was the gap — and it is the one whose output is render-blocking. Two repository-root-anchored `@source not` lines close it. That anchor is the one base covering **both** trees this entry scans: the package globs, and Tailwind's automatic detection root, which is the process CWD — here `apps/console`, where the build runs. ## Evidence **Class-set diff** — every disappearing class accounted for. 19 classes leave the compiled sheet, and all 19 were traced to test files. The diff is measured on a build that still scans every shipped file, so anything a shipped file genuinely emits survives by construction: - `prose-lg`, `prose-slate` — 10,302 unminified bytes between them; the typography plugin emits a very large rule block per `prose-*` variant. - `mt-[3.7331px]` — the self-hosted sentinel of components' own `index-css-scan-excludes-tests.test.ts`: a token that exists **only** to prove that package's exclusion works, which this app was shipping to users. - `row-1`, `row-42`, `tab-1`, `zoom-10`, `top-200`, `m-9`, `order-1`, `bg-none`, `md:p-3`, `mt-[3.7331px]`, `bg-[color:var(--brand)]`, `flex-grow`, `h-[125px]`, `w-[250px]`, `[group:1]`, `pl-44`, `m-1` — each traced to a `*.test.*` or `__tests__/` file. - Near-misses checked by hand, all substring artifacts of the grep rather than real uses: `m-1` inside `before:bottom-1`; `flex-grow`/`h-[125px]`/`w-[250px]` inside runner's own comment listing the classes **it** excludes. `pl-44` is the instructive one — the shipped `KanbanImpl.tsx` emits `sm:pl-44` (which survives), the bare `pl-44` came from a test. - Reverse direction is empty: no class is **added**. **Positive control** — a negative-only result would also be produced by a scan that found nothing. A marker class injected into a shipped file (`packages/components/src/ui/button.tsx`) still compiles under the new lines; the file was restored and its blob hash re-verified against `HEAD`. **Ablation** — deleting the two lines turns the new pin test red on both sentinels (`6379.0731px`, own tree; `3.7331px`, another package's tree), confirming it can fail. Restored, hash re-verified. **Sizes** — built, minified `index-*.css`: | | raw | gzipped | |---|---|---| | before | 365,214 | 47,482 | | after | 357,216 | 46,527 | | delta | -7,998 (-2.19%) | -955 (-2.00%) | **First contentful paint** — production build, served locally with boot endpoints mocked and gzip on, Playwright, cache disabled, fresh context per run, **median of 7 runs** per cell: | profile | before | after | |---|---|---| | localhost, no throttling | 64 ms | 76 ms | | 20 Mbps / 80 ms RTT | 288 ms | 292 ms | | Fast 4G (4 Mbps / 70 ms) | 688 ms | 688 ms | **FCP does not move.** 955 gzipped bytes is under 2 ms of transfer at 4 Mbps, below this measurement's resolution. Reported as measured rather than rounded into a win. The card's attribution does reproduce: FCP lands ~22 ms after the last stylesheet finishes at every profile (Fast 4G: last stylesheet 665.8 ms, FCP 688 ms), so first paint really is gated on the render-blocking CSS. **Negative control — a scan-surface change deliberately NOT made.** This entry opens with a bare `@import 'tailwindcss'`, so automatic source detection is ON, and the sibling packages carry `source(none)`. Adding it here looks like the obvious next step. Measured: automatic detection contributes **exactly one** class beyond the explicit globs (`w-100`, 54 bytes, sourced from a doc under `apps/console/docs/`). Excluding this app's 170 KB `CHANGELOG.md` — the objectui#9569 hazard — changes the compiled sheet by **zero bytes**. So `source(none)` buys nothing here and risks dropping a class for no measured gain; it is not in this diff. ## What this does not do It does not shorten first paint, and it is not the fix for #6379. That card's three routes were carved up by triage: route 3 (shrink the sheet if its scan surface is wider than the eager route needs) is the only one needing no product ruling, and this is route 3, carried out and measured. The measurement says route 3's ceiling is ~2% of the render-blocking CSS. The remaining ~690 ms on Fast 4G is the sheet the app genuinely needs. Reaching it requires route 1 (make the app CSS non-blocking) or route 2 (inline a critical subset) — both of which change what an unstyled first frame is allowed to look like, or need a rule for what counts as critical. Those are product rulings and are **not** touched here. #6379 stays open for them. The 27 package globs were **not** narrowed. "This plugin is not on the eager route" is a fact about the JS closure; whether an eager component renders its classes is a separate question, and dropping a glob would silently unstyle a lazily-loaded plugin. No measurement here supports it. ## Gates run `build` · `type-check` · `lint` (0 errors; the new file adds 0 of the 212 pre-existing warnings) · full `@object-ui/console` suite (101 files, 1158 tests) · `check:control-bytes` · `check:new-line-citations` · `check:eager-closure` · `check:eager-locale-catalogues` · `check-changeset-presence` · `check-governed-queue-guard --test` (not a governed surface). The **performance-budget** workflow reaches this diff. Read in full: it pins `MAX_ENTRY_GZIP_KB=350` against `index-*.js` only, selected by `find ... -name 'index-*.js'`. It never measures CSS, so no pinned number moves. `check:eager-closure` passes unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_012EpHzwH4wTy5sd7ibkD2yq)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c42554e commit cf601ff

3 files changed

Lines changed: 199 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
Stop the console's eager stylesheet scanning TEST files for Tailwind classes
6+
(objectui#6379).
7+
8+
`apps/console/src/index.css` declares 27 `@source` path globs across `packages/**`
9+
plus its own tree, and carried no `@source not` line at all — while reaching further
10+
than any other entry in this repo that declares sources. Tailwind v4 scans source
11+
TEXT, not an import graph, so every test file under those globs was a source for the
12+
render-blocking `index-*.css` the console serves: 2373 test files in the packages it
13+
names, plus this app's own. `packages/components/src/index.css` (objectui#8446,
14+
objectui#9569) and `packages/runner/src/index.css` (objectui#8454) already carried the
15+
exclusion; this entry was the gap, and it is the one whose output is render-blocking.
16+
17+
Two repository-root-anchored `@source not` lines close it. The anchor is the one base
18+
that covers BOTH trees this entry scans: the package globs, and Tailwind's automatic
19+
detection root, which is the process CWD — here `apps/console`, where the build runs.
20+
21+
Measured, not assumed: 19 classes and 10,096 raw bytes leave the compiled sheet, and
22+
every one of the 19 was traced to a test file. The built, minified `index-*.css` goes
23+
from 365,214 to 357,216 raw bytes (47,482 to 46,527 gzipped) — -7,998 raw, -955
24+
gzipped. `prose-lg` and `prose-slate` are 10,302 of the unminified bytes on their own — the typography plugin emits a very large
25+
rule block per `prose-*` variant, and both were named by tests and by no shipped
26+
source. The sharpest reading is `mt-[3.7331px]`: the self-hosted sentinel of
27+
components' own `index-css-scan-excludes-tests.test.ts`, a token that exists only to
28+
prove that package's exclusion works, which this app was shipping to users.
29+
30+
This is a correctness fix with a size side-effect, not a first-paint fix. It is 2.0%
31+
of the render-blocking gzipped CSS — about 955 bytes, under 2 ms of transfer on the
32+
4 Mbps profile objectui#6379 measured — and first contentful paint does not move out
33+
of run-to-run noise. The first-paint question in that card is unchanged and still
34+
turns on a product ruling.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `apps/console/src/index.css` must not scan TEST files -- neither this app's
11+
* own nor those of the 27 packages it reaches (objectui#6379).
12+
*
13+
* ## What was wrong
14+
*
15+
* Tailwind v4 scans source TEXT, not an import graph. This entry declared 27
16+
* `@source` path globs across `packages/**` plus its own tree, and -- alone
17+
* among the entries in this repo that declare sources -- carried no `@source
18+
* not` line at all, while reaching further than any of them. Every test file
19+
* under those globs was therefore a source for the console's EAGER,
20+
* render-blocking `index-*.css`: 2373 test files in the packages it names, plus
21+
* this app's own. `packages/components/src/index.css` (objectui#8446,
22+
* objectui#9569) and `packages/runner/src/index.css` (objectui#8454) already
23+
* carried the exclusion; this entry was the gap.
24+
*
25+
* Measured at the fix: 19 classes and 10,096 raw bytes came from test files
26+
* only. The sharpest single reading is the cross-tree probe below.
27+
*
28+
* ## The instruments
29+
*
30+
* Two sentinels, because the exclusion has two halves and a probe for one says
31+
* nothing about the other:
32+
*
33+
* - `SENTINEL_OWN_TREE` is self-hosted -- written in THIS file, which is a
34+
* `*.test.ts` under `apps/console/src`, exactly the kind of file the
35+
* filename half must keep out. It is live only because it is self-hosted:
36+
* it does not depend on any other test continuing to name a fixture class.
37+
* - `SENTINEL_OTHER_TREE` is components' own sentinel, which lives under a
38+
* `__tests__/` directory in ANOTHER package. It probes the half this entry
39+
* needs and its siblings do not: that the exclusion reaches ACROSS the
40+
* repository into every `packages/*` tree these globs name, not merely into
41+
* the app's own. It was shipping to users in `index-*.css` before this fix
42+
* -- a token whose only purpose is to prove components' exclusion works.
43+
*
44+
* ## Why the positive assertions are not optional
45+
*
46+
* "No test-sourced rules" is satisfied by a stylesheet compiled from NOTHING,
47+
* so a negative-only test would pass for an entry strictly worse than the fix
48+
* (delete the `@source` lines outright). The positive half pins a
49+
* production-sourced utility, a cross-package one, and a floor on the rule
50+
* count, so an empty or gutted sheet fails.
51+
*
52+
* ## Why `base` is passed explicitly
53+
*
54+
* This entry opens with a bare `@import 'tailwindcss'`, so Tailwind's automatic
55+
* source detection is ON and rooted at the PROCESS CWD -- vitest runs from the
56+
* repo root while `pnpm --filter @object-ui/console build` runs from this app's
57+
* directory. Passing `base` pins this reading to the directory the build runs
58+
* in, so the test measures the stylesheet users are served rather than the one
59+
* the test runner's CWD happens to produce. `base` governs only the automatic
60+
* root; the `@source` lines always resolve against the entry's own directory.
61+
*/
62+
import { readFile } from 'node:fs/promises';
63+
import { dirname, resolve } from 'node:path';
64+
import { fileURLToPath } from 'node:url';
65+
import postcss from 'postcss';
66+
import tailwindPostcss from '@tailwindcss/postcss';
67+
import { describe, expect, it } from 'vitest';
68+
69+
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
70+
const repoRoot = resolve(appRoot, '../..');
71+
const entry = resolve(appRoot, 'src/index.css');
72+
73+
/**
74+
* Deliberately absent from every other file in the repository, and an
75+
* arbitrary-value utility so no production source can ever legitimately name
76+
* it. Its ONLY occurrence is this line, in a `*.test.ts` file.
77+
*/
78+
const SENTINEL_OWN_TREE = 'mt-[6379.0731px]';
79+
80+
/**
81+
* The part of the sentinel Tailwind's selector escaping leaves alone
82+
* (`.mt-\[6379\.0731px\]` escapes the brackets and the dots, never the digits).
83+
* Asserting on THIS rather than on a hand-escaped selector is what keeps the
84+
* negative assertion from passing for the wrong reason.
85+
*/
86+
const SENTINEL_OWN_TREE_VALUE = '6379.0731px';
87+
88+
/** Components' self-hosted sentinel, in a `__tests__/` dir in another package. */
89+
const SENTINEL_OTHER_TREE_VALUE = '3.7331px';
90+
const SENTINEL_OTHER_TREE_HOST =
91+
'packages/components/src/__tests__/index-css-scan-excludes-tests.test.ts';
92+
93+
async function compileConsoleStylesheet(): Promise<{ css: string; selectors: Set<string> }> {
94+
const source = await readFile(entry, 'utf8');
95+
const result = await postcss([tailwindPostcss({ base: appRoot })]).process(source, {
96+
from: entry,
97+
});
98+
const selectors = new Set<string>();
99+
postcss.parse(result.css, { from: entry }).walkRules((rule) => {
100+
selectors.add(rule.selector);
101+
});
102+
return { css: result.css, selectors };
103+
}
104+
105+
describe('apps/console/src/index.css @source scan', () => {
106+
it('does not compile tokens that only this app\'s own test files name', async () => {
107+
const { css, selectors } = await compileConsoleStylesheet();
108+
109+
// POSITIVE — a sheet compiled from nothing must not pass.
110+
expect(selectors.has('.flex-col')).toBe(true);
111+
expect(selectors.size).toBeGreaterThan(2500);
112+
113+
// NEGATIVE — this file is scanned only if the filename exclusion is gone.
114+
expect(SENTINEL_OWN_TREE).toContain(SENTINEL_OWN_TREE_VALUE);
115+
expect(css).not.toContain(SENTINEL_OWN_TREE_VALUE);
116+
}, 60_000);
117+
118+
it('does not compile tokens that only ANOTHER package\'s test files name', async () => {
119+
// CONTROL — the door this asserts is shut must still exist. If components'
120+
// sentinel is ever renamed this goes red and needs RE-POINTING at that
121+
// file's new sentinel, NOT deleting: a negative assertion about a token
122+
// nothing mentions any more passes for the wrong reason.
123+
const host = await readFile(resolve(repoRoot, SENTINEL_OTHER_TREE_HOST), 'utf8');
124+
expect(host).toContain(SENTINEL_OTHER_TREE_VALUE);
125+
126+
// This app's globs reach `packages/components/src/**`, so before the
127+
// repository-root-anchored exclusion this token compiled a real rule into
128+
// the console's eager, render-blocking stylesheet.
129+
const { css, selectors } = await compileConsoleStylesheet();
130+
expect(selectors.has('.bg-background')).toBe(true);
131+
expect(css).not.toContain(SENTINEL_OTHER_TREE_VALUE);
132+
}, 60_000);
133+
});

‎apps/console/src/index.css‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,38 @@
5555
@source '../../../packages/collaboration/src/**/*.{ts,tsx}';
5656
@source '../../../packages/mobile/src/**/*.{ts,tsx}';
5757

58+
/*
59+
* Only SHIPPED source. Tailwind v4 scans source TEXT, not an import graph, so
60+
* any class-shaped token in a scanned file compiles a real utility into this
61+
* app's eager `index-*.css` -- including a token that only ever appears as a
62+
* test's EXPECTED value. `packages/components/src/index.css` and
63+
* `packages/runner/src/index.css` already carry this pair; this entry was the
64+
* one that did not, while reaching FURTHER than either of them.
65+
*
66+
* Anchored at the repository root because that is the one base that covers
67+
* BOTH trees this entry scans: the 27 package globs above (`../../../packages`)
68+
* and Tailwind's AUTOMATIC detection root, which is the PROCESS CWD -- here
69+
* `apps/console`, where `pnpm --filter @object-ui/console build` runs. Runner's
70+
* header records the same reasoning one level down, and records the measurement
71+
* that makes it sound: negations DO apply to files the automatic root found.
72+
*
73+
* Measured (objectui#6379), not assumed: these two lines remove 19 classes --
74+
* 10,096 bytes from the unminified compile, and -7,998 raw / -955 gzipped from
75+
* the built `index-*.css` users are served -- and every removed class was
76+
* traced to a test file. That is 2.0% of the render-blocking gzipped CSS: a
77+
* correctness fix with a size side-effect, NOT a first-paint fix.
78+
*
79+
* The clearest single reading among the 19 is `mt-[3.7331px]` -- the
80+
* self-hosted sentinel of components' own
81+
* `index-css-scan-excludes-tests.test.ts`, a token that exists ONLY to prove
82+
* that package's exclusion works, and which this app was shipping to users.
83+
* `prose-lg` and `prose-slate` are 10,302 of the unminified bytes between
84+
* them: the typography plugin emits a very large rule block per `prose-*`
85+
* variant, and both variants were named by tests and by no shipped source.
86+
*/
87+
@source not '../../../**/*.test.{ts,tsx}';
88+
@source not '../../../**/__tests__/**';
89+
5890
/* Tailwind plugin for animations remove
5991
@plugin 'tailwindcss-animate';
6092
*/

0 commit comments

Comments
 (0)