gatsby-matrix-theme 53.21.4 → 53.22.0
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/CHANGELOG.md +26 -0
- package/critical-css/README.md +106 -0
- package/critical-css/config.js +32 -0
- package/critical-css/injectCriticalCss.js +171 -0
- package/critical-css/injectCriticalCss.test.js +119 -0
- package/critical-css/selectChunks.js +75 -0
- package/critical-css/selectChunks.test.js +73 -0
- package/gatsby-node.js +57 -0
- package/package.json +2 -2
- package/storybook/public/{251.b339b2c6.iframe.bundle.js → 251.58736632.iframe.bundle.js} +3 -3
- package/storybook/public/{251.b339b2c6.iframe.bundle.js.map → 251.58736632.iframe.bundle.js.map} +1 -1
- package/storybook/public/iframe.html +1 -1
- package/storybook/public/project.json +1 -1
- /package/storybook/public/{251.b339b2c6.iframe.bundle.js.LICENSE.txt → 251.58736632.iframe.bundle.js.LICENSE.txt} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
# [53.22.0](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/compare/v53.21.4...v53.22.0) (2026-06-25)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **critical-css:** per-page render-blocking chunk linking to eliminate CLS ([6c75d03](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/6c75d03d95972b4fd1593e491d8cd18dd62b32ef))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Config
|
|
10
|
+
|
|
11
|
+
* update core theme version ([30b9e87](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/30b9e87c903f2a0c8b30df8a96cff5ecd7ef868b))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
* Merge branch 'en-391-unused-css' into 'master' ([1d616f7](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/1d616f7e2b5861d99b0ae6898a64056b5fc606ca))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **critical-css:** flag-gated critical-CSS deferral (CSS split + manifest + above-fold re-inline) ([ae6bc2b](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/ae6bc2bd3bf73f2386961d12992b679ff9665297))
|
|
20
|
+
* remove unused scripts ([5ba0309](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/5ba0309d196acf6023a267dbb0f730deaab77cbe))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Tests
|
|
24
|
+
|
|
25
|
+
* **critical-css:** integration tests for injectCriticalCss to restore coverage ([74eb340](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/74eb340401593b8b93ce59eb177175e80b90d90b))
|
|
26
|
+
|
|
1
27
|
## [53.21.4](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/compare/v53.21.3...v53.21.4) (2026-06-24)
|
|
2
28
|
|
|
3
29
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Critical-CSS split (per-page render-blocking)
|
|
2
|
+
|
|
3
|
+
Splits the single merged stylesheet (~400–520 KB) that the core theme inlines render-blocking on
|
|
4
|
+
**every** page into separate cacheable chunks, then, for each page, references **only the chunks that
|
|
5
|
+
page actually renders** as render-blocking `<link rel="stylesheet">` in `<head>`. Each page paints
|
|
6
|
+
fully styled on first paint (**no FOUC, no CLS**) while carrying only its own CSS — well under the
|
|
7
|
+
stock merged stylesheet — and the chunks are external cacheable files instead of bytes re-inlined into
|
|
8
|
+
every HTML document.
|
|
9
|
+
|
|
10
|
+
**Why this shape (history):**
|
|
11
|
+
|
|
12
|
+
- The merged baseline inlines all CSS render-blocking into every page → no CLS, but ~520 KB re-shipped
|
|
13
|
+
per page, uncacheable.
|
|
14
|
+
- A first attempt *deferred* below-the-fold lazy-module CSS to **async** chunks and re-inlined only an
|
|
15
|
+
above-the-fold subset. That caused **CLS** — async CSS paints unstyled then reflows. With browser
|
|
16
|
+
**scroll restoration** (reload returns the user to wherever they scrolled) the shifting content lands
|
|
17
|
+
in the viewport, so no fixed above-the-fold safelist can prevent it.
|
|
18
|
+
- Making **all** chunks render-blocking on every page killed the CLS but doubled render-blocking CSS
|
|
19
|
+
(~1 MB), because the split duplicates shared rules across chunks → worse cold FCP than baseline.
|
|
20
|
+
- **This version** render-blocks only each page's *own* chunks → no CLS and below-baseline CSS per page.
|
|
21
|
+
|
|
22
|
+
**Trade-off:** the win is on every axis vs the merged baseline *except* request count — each chunk is a
|
|
23
|
+
separate `<link>` (HTTP/2 multiplexed). Per-page CSS is smaller than baseline, and chunks are cached
|
|
24
|
+
across navigation/reload. **LCP is unaffected** (hydration/JS-bound here — a separate track).
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
Two Gatsby hooks in `gatsby-node.js`, both guarded (a failure degrades gracefully, never breaks the build):
|
|
29
|
+
|
|
30
|
+
1. **`onCreateWebpackConfig`** — sets the `styles` cacheGroup to `chunks: 'initial'`, so CSS splits
|
|
31
|
+
into per-route + lazy-module chunks instead of one merged file.
|
|
32
|
+
2. **`onPostBuild`** (`injectCriticalCss.js`) — builds a `className → chunk` index from the emitted
|
|
33
|
+
`.css` files, then for each page adds a render-blocking
|
|
34
|
+
`<link rel="stylesheet" href="/…" data-crit="…">` for every chunk whose CSS-module class names
|
|
35
|
+
appear in that page's server-rendered HTML (see `selectChunks.js`). A used module's wrapper class is
|
|
36
|
+
always in the SSR HTML, so its chunk is linked; chunks for modules the page doesn't render are
|
|
37
|
+
skipped. This needs **no module→component registry** and auto-covers header variants. Idempotent and
|
|
38
|
+
deduped against CSS already inlined by the core theme (the base chunk) or already linked.
|
|
39
|
+
|
|
40
|
+
## Enabling it (per site)
|
|
41
|
+
|
|
42
|
+
Off by default. Enable via theme plugin options in the consumer's `gatsby-config.js`:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
plugins: [
|
|
46
|
+
{
|
|
47
|
+
resolve: 'gatsby-matrix-theme',
|
|
48
|
+
options: {
|
|
49
|
+
criticalCss: {
|
|
50
|
+
enabled: true,
|
|
51
|
+
// optional:
|
|
52
|
+
skipRoutes: ['preview'], // route first-segments left with default (full) inlining
|
|
53
|
+
minClassLen: 5, // advanced: min classname length used for chunk matching
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The feature only runs in production `gatsby build` (the split is `build-javascript`-stage;
|
|
61
|
+
`onPostBuild` is build-only).
|
|
62
|
+
|
|
63
|
+
### Options
|
|
64
|
+
|
|
65
|
+
| option | default | meaning |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `enabled` | `false` | hard gate |
|
|
68
|
+
| `skipRoutes` | `['preview']` | route first-segments left untouched; preview/tracker pages are also skipped via `isPreview`/`isTracker` |
|
|
69
|
+
| `minClassLen` | `5` | minimum classname length considered when matching chunks to a page's HTML. Lower → looser matching (more chunks); higher → tighter (risk of missing a chunk). |
|
|
70
|
+
|
|
71
|
+
## Caveats / QA
|
|
72
|
+
|
|
73
|
+
- **No CLS by design** — a page render-blocks exactly the chunks whose classes it renders, so all its CSS
|
|
74
|
+
is present on first paint at any scroll position. Still spot-check CLS after enabling.
|
|
75
|
+
- **Residual miss risk** — a chunk that styles rendered content using *only* global/element selectors
|
|
76
|
+
(no CSS-module class) would not be matched and would stay async. Module SCSS almost always has a hashed
|
|
77
|
+
wrapper class, so this is rare; watch for it in QA, and lower `minClassLen` if a chunk is missed.
|
|
78
|
+
- **Request count** — each chunk is a separate render-blocking `<link>` (HTTP/2 multiplexes). Per-page
|
|
79
|
+
CSS is smaller than baseline; the payoff over baseline is caching on navigation/reload.
|
|
80
|
+
- **LCP** is unaffected (hydration-bound here).
|
|
81
|
+
|
|
82
|
+
## Per-site rollout checklist
|
|
83
|
+
|
|
84
|
+
Adopting a site is QA, not code:
|
|
85
|
+
|
|
86
|
+
1. **Bump** `gatsby-matrix-theme` to a version that includes this feature.
|
|
87
|
+
2. **Enable** `options.criticalCss.enabled: true`.
|
|
88
|
+
3. **Build** (`gatsby build`). Confirm the log shows `CSS split enabled` and
|
|
89
|
+
`render-blocking-linked page chunks on X/Y pages`.
|
|
90
|
+
4. **Measure** with the harness on the homepage + the top template(s):
|
|
91
|
+
```bash
|
|
92
|
+
CHROME_PATH=/path/to/chrome \
|
|
93
|
+
node scripts/lh-critical-compare.mjs public / /<operator-route>/ --runs 3
|
|
94
|
+
```
|
|
95
|
+
Run it once before enabling and once after for a before/after.
|
|
96
|
+
5. **Verify**: **no FOUC**, **CLS = 0 / not regressed** at top *and* after a mid-page reload (scroll
|
|
97
|
+
restoration), per-page CSS smaller than baseline, and reload serves chunks from cache.
|
|
98
|
+
6. **Ship behind the flag**, then ramp.
|
|
99
|
+
|
|
100
|
+
> Chrome for the harness: snap chromium does not work. Download a standalone build, e.g.
|
|
101
|
+
> `npx @puppeteer/browsers install chrome@stable`, and point `CHROME_PATH` at it.
|
|
102
|
+
|
|
103
|
+
## Disabling
|
|
104
|
+
|
|
105
|
+
Set `criticalCss.enabled: false` (or remove the option). The build reverts to the stock single
|
|
106
|
+
merged inlined stylesheet.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critical-CSS feature config — resolved from theme plugin options.
|
|
3
|
+
*
|
|
4
|
+
* A consumer site enables/configures the feature via its gatsby-config:
|
|
5
|
+
* {
|
|
6
|
+
* resolve: 'gatsby-matrix-theme',
|
|
7
|
+
* options: {
|
|
8
|
+
* criticalCss: {
|
|
9
|
+
* enabled: true, // hard gate, default OFF
|
|
10
|
+
* skipRoutes: ['preview'], // route prefixes to leave with default (full) inlining
|
|
11
|
+
* minClassLen: 5, // advanced: min classname length for chunk matching
|
|
12
|
+
* },
|
|
13
|
+
* },
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* Everything here is build-time Node code (read in gatsby-node), NOT bundled app code —
|
|
17
|
+
* which is why config goes through plugin options rather than component shadowing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function resolveConfig(pluginOptions = {}) {
|
|
21
|
+
const cc = (pluginOptions && pluginOptions.criticalCss) || {};
|
|
22
|
+
return {
|
|
23
|
+
enabled: cc.enabled === true,
|
|
24
|
+
skipRoutes:
|
|
25
|
+
Array.isArray(cc.skipRoutes) && cc.skipRoutes.length
|
|
26
|
+
? cc.skipRoutes.filter((s) => typeof s === 'string')
|
|
27
|
+
: ['preview'],
|
|
28
|
+
minClassLen: Number.isInteger(cc.minClassLen) && cc.minClassLen >= 1 ? cc.minClassLen : 5,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { resolveConfig };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* injectCriticalCss — post-build: make each page's own split CSS chunks render-blocking.
|
|
3
|
+
*
|
|
4
|
+
* The webpack split (styles cacheGroup -> chunks:'initial') moves lazy-module CSS into async
|
|
5
|
+
* chunks that the runtime injects at hydration. Async CSS paints unstyled then reflows => FOUC +
|
|
6
|
+
* CLS, and with browser scroll restoration (reload returns the user to wherever they scrolled) the
|
|
7
|
+
* shifting content is often in the viewport, so a fixed above-the-fold safelist can't prevent it.
|
|
8
|
+
*
|
|
9
|
+
* Fix: for each page, add a render-blocking <link rel="stylesheet"> for exactly the chunks that
|
|
10
|
+
* page renders — detected by classname presence (a chunk is needed iff one of its CSS-module class
|
|
11
|
+
* names appears in the page's server-rendered HTML; see ./selectChunks). All CSS the page uses is
|
|
12
|
+
* then present on first paint => no FOUC/CLS at any scroll position, while pages only carry their
|
|
13
|
+
* own chunks (well under the stock single merged inlined stylesheet) and the chunks stay separate
|
|
14
|
+
* cacheable files (cross-page / reload caching, smaller HTML).
|
|
15
|
+
*
|
|
16
|
+
* Idempotent (skips chunks already inlined by the core theme or already linked) and fully guarded —
|
|
17
|
+
* never throws out of the function.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { buildClassIndex, selectChunksForHtml } = require('./selectChunks');
|
|
23
|
+
|
|
24
|
+
// Lazy-module chunk .css files live at the public root (Gatsby / mini-css-extract). Exclude the
|
|
25
|
+
// initial/route stylesheets: the base (`styles.*`) and route-component shells (`component---*`) are
|
|
26
|
+
// already inlined render-blocking by the core theme (or are duplicate SSR/preview entry CSS that a
|
|
27
|
+
// normal page never loads). Only the lazy *module* chunks otherwise load async and need re-linking.
|
|
28
|
+
function isModuleChunk(name) {
|
|
29
|
+
return name.endsWith('.css') && !/^styles\./.test(name) && !/^component---/.test(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function listCssChunks(publicDir) {
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = fs.readdirSync(publicDir, { withFileTypes: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return entries.filter((e) => e.isFile() && isModuleChunk(e.name)).map((e) => e.name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function injectCriticalCss({ publicDir, config, reporter }) {
|
|
43
|
+
const log = reporter || { info() {}, warn() {} };
|
|
44
|
+
const minLen = config && Number.isInteger(config.minClassLen) ? config.minClassLen : 5;
|
|
45
|
+
|
|
46
|
+
const chunkFiles = listCssChunks(publicDir);
|
|
47
|
+
if (!chunkFiles.length) {
|
|
48
|
+
log.warn('[critical-css] no css chunks found in public/; nothing to link');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build the classname -> chunk index once for the whole build.
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for (const f of chunkFiles) {
|
|
55
|
+
try {
|
|
56
|
+
chunks.push({ file: f, css: fs.readFileSync(path.join(publicDir, f), 'utf8') });
|
|
57
|
+
} catch {
|
|
58
|
+
/* skip unreadable chunk */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const classIndex = buildClassIndex(chunks, minLen);
|
|
62
|
+
|
|
63
|
+
const skipRoutes = Array.isArray(config && config.skipRoutes) ? config.skipRoutes : ['preview'];
|
|
64
|
+
const pageDataRoot = path.join(publicDir, 'page-data');
|
|
65
|
+
let scanned = 0;
|
|
66
|
+
let patched = 0;
|
|
67
|
+
let skipped = 0;
|
|
68
|
+
let missedHead = 0;
|
|
69
|
+
let totalLinked = 0;
|
|
70
|
+
|
|
71
|
+
const walk = (dir) => {
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const ent of entries) {
|
|
79
|
+
const full = path.join(dir, ent.name);
|
|
80
|
+
if (ent.isDirectory()) {
|
|
81
|
+
walk(full);
|
|
82
|
+
// eslint-disable-next-line no-continue
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// eslint-disable-next-line no-continue
|
|
86
|
+
if (ent.name !== 'page-data.json') continue;
|
|
87
|
+
scanned += 1;
|
|
88
|
+
|
|
89
|
+
// page-data/<route>/page-data.json -> public/<route>/index.html ("index" => public/index.html)
|
|
90
|
+
const route = path.relative(pageDataRoot, dir);
|
|
91
|
+
|
|
92
|
+
// never touch preview/tracker routes — they keep default (full) inlining
|
|
93
|
+
const firstSeg = route.split(path.sep)[0];
|
|
94
|
+
if (skipRoutes.indexOf(firstSeg) !== -1) {
|
|
95
|
+
skipped += 1;
|
|
96
|
+
// eslint-disable-next-line no-continue
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let pageCtx;
|
|
101
|
+
try {
|
|
102
|
+
const pd = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
103
|
+
pageCtx = pd && pd.result && pd.result.pageContext ? pd.result.pageContext : undefined;
|
|
104
|
+
} catch {
|
|
105
|
+
// eslint-disable-next-line no-continue
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// skip preview/tracker pages regardless of route
|
|
110
|
+
if (pageCtx && (pageCtx.isPreview === true || pageCtx.isTracker === true)) {
|
|
111
|
+
skipped += 1;
|
|
112
|
+
// eslint-disable-next-line no-continue
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const htmlPath =
|
|
117
|
+
route === 'index'
|
|
118
|
+
? path.join(publicDir, 'index.html')
|
|
119
|
+
: path.join(publicDir, route, 'index.html');
|
|
120
|
+
// eslint-disable-next-line no-continue
|
|
121
|
+
if (!fs.existsSync(htmlPath)) continue;
|
|
122
|
+
|
|
123
|
+
let html;
|
|
124
|
+
try {
|
|
125
|
+
html = fs.readFileSync(htmlPath, 'utf8');
|
|
126
|
+
} catch {
|
|
127
|
+
// eslint-disable-next-line no-continue
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const needed = selectChunksForHtml(html, classIndex);
|
|
132
|
+
let add = '';
|
|
133
|
+
for (const f of needed) {
|
|
134
|
+
// skip chunks already inlined by the core theme (data-href, e.g. the base chunk) or
|
|
135
|
+
// already linked (href) — idempotent, avoids double-loading.
|
|
136
|
+
// eslint-disable-next-line no-continue
|
|
137
|
+
if (html.includes(`data-href="/${f}"`) || html.includes(`href="/${f}"`)) continue;
|
|
138
|
+
add += `<link rel="stylesheet" href="/${f}" data-crit="${f}">`;
|
|
139
|
+
totalLinked += 1;
|
|
140
|
+
}
|
|
141
|
+
// eslint-disable-next-line no-continue
|
|
142
|
+
if (!add) continue;
|
|
143
|
+
|
|
144
|
+
// if there's no </head> (error/custom pages), leave it split-only but record the miss
|
|
145
|
+
if (html.indexOf('</head>') === -1) {
|
|
146
|
+
missedHead += 1;
|
|
147
|
+
// eslint-disable-next-line no-continue
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
fs.writeFileSync(htmlPath, html.replace('</head>', `${add}</head>`));
|
|
153
|
+
patched += 1;
|
|
154
|
+
} catch {
|
|
155
|
+
/* leave the page as split-only on write failure */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
walk(pageDataRoot);
|
|
161
|
+
|
|
162
|
+
const avg = patched ? (totalLinked / patched).toFixed(1) : '0';
|
|
163
|
+
const parts = [
|
|
164
|
+
`[critical-css] render-blocking-linked page chunks on ${patched}/${scanned} pages (avg ${avg}/page)`,
|
|
165
|
+
];
|
|
166
|
+
if (skipped) parts.push(`(${skipped} preview/tracker skipped)`);
|
|
167
|
+
if (missedHead) parts.push(`(${missedHead} pages had no </head>, left split-only)`);
|
|
168
|
+
log.info(parts.join(' '));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { injectCriticalCss, listCssChunks, isModuleChunk };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { injectCriticalCss, isModuleChunk } from './injectCriticalCss';
|
|
5
|
+
|
|
6
|
+
describe('isModuleChunk', () => {
|
|
7
|
+
test('accepts lazy module chunks (numeric / hashed)', () => {
|
|
8
|
+
expect(isModuleChunk('7058.85be7b8d3f97fe1255e9.css')).toBe(true);
|
|
9
|
+
expect(isModuleChunk('922.3161d0beba28e0a349fc.css')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('rejects the inlined base and route-component shells', () => {
|
|
13
|
+
expect(isModuleChunk('styles.5f846a3bcbc9df9c2721.css')).toBe(false);
|
|
14
|
+
expect(isModuleChunk('component---node-modules-gatsby-core-theme-src-components-app-ssr-js.832a.css')).toBe(false);
|
|
15
|
+
expect(isModuleChunk('component---node-modules-gatsby-core-theme-src-pages-preview-index-js.832a.css')).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('rejects non-css', () => {
|
|
19
|
+
expect(isModuleChunk('7058.85be7b8d3f97fe1255e9.js')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('injectCriticalCss (integration over a temp public/)', () => {
|
|
24
|
+
let dir;
|
|
25
|
+
const silent = { info() {}, warn() {} };
|
|
26
|
+
const html = (body) => `<html><head><title>t</title></head><body>${body}</body></html>`;
|
|
27
|
+
const pageData = (extra = {}) =>
|
|
28
|
+
JSON.stringify({ result: { pageContext: { page: { relation_type: 'page' }, ...extra } } });
|
|
29
|
+
|
|
30
|
+
const write = (rel, content) => {
|
|
31
|
+
const full = path.join(dir, rel);
|
|
32
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
33
|
+
fs.writeFileSync(full, content);
|
|
34
|
+
};
|
|
35
|
+
const read = (rel) => fs.readFileSync(path.join(dir, rel), 'utf8');
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crit-'));
|
|
39
|
+
// module chunks
|
|
40
|
+
write('100.aaa.css', '.Bonus_wrap__x1{color:red}');
|
|
41
|
+
write('200.bbb.css', '.Toplist_row__y2{margin:0}');
|
|
42
|
+
write('300.ccc.css', '.Unused_z__z3{display:none}'); // no page renders this
|
|
43
|
+
// base + route shell — must be ignored as candidates
|
|
44
|
+
write('styles.deadbeef.css', '.Base_thing__b1{}');
|
|
45
|
+
write('component---node-modules-gatsby-core-theme-src-components-app-js.832a.css', '.App_shell__s1{}');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const run = () =>
|
|
53
|
+
injectCriticalCss({
|
|
54
|
+
publicDir: dir,
|
|
55
|
+
config: { enabled: true, skipRoutes: ['preview'], minClassLen: 5 },
|
|
56
|
+
reporter: silent,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('links only the module chunks whose classes the page renders', () => {
|
|
60
|
+
write('page-data/index/page-data.json', pageData());
|
|
61
|
+
write('index.html', html('<div class="Bonus_wrap__x1 bonus">x</div>'));
|
|
62
|
+
|
|
63
|
+
run();
|
|
64
|
+
const out = read('index.html');
|
|
65
|
+
expect(out).toContain('<link rel="stylesheet" href="/100.aaa.css" data-crit="100.aaa.css">');
|
|
66
|
+
expect(out).not.toContain('200.bbb.css'); // toplist not rendered
|
|
67
|
+
expect(out).not.toContain('300.ccc.css'); // unused
|
|
68
|
+
expect(out).not.toContain('styles.deadbeef.css'); // base, excluded candidate
|
|
69
|
+
expect(out).not.toContain('app-js.832a.css'); // route shell, excluded candidate
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('does not double-link a chunk already inlined by the core theme (data-href)', () => {
|
|
73
|
+
write('page-data/index/page-data.json', pageData());
|
|
74
|
+
write(
|
|
75
|
+
'index.html',
|
|
76
|
+
html('<div class="Bonus_wrap__x1">x</div>').replace(
|
|
77
|
+
'<title>t</title>',
|
|
78
|
+
'<title>t</title><style data-href="/100.aaa.css">.Bonus_wrap__x1{}</style>',
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
run();
|
|
82
|
+
const out = read('index.html');
|
|
83
|
+
expect(out).not.toContain('data-crit="100.aaa.css"'); // already inlined -> skipped
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('is idempotent — running twice does not add duplicate links', () => {
|
|
87
|
+
write('page-data/index/page-data.json', pageData());
|
|
88
|
+
write('index.html', html('<div class="Bonus_wrap__x1 Toplist_row__y2">x</div>'));
|
|
89
|
+
run();
|
|
90
|
+
run();
|
|
91
|
+
const out = read('index.html');
|
|
92
|
+
expect(out.match(/data-crit="100\.aaa\.css"/g)).toHaveLength(1);
|
|
93
|
+
expect(out.match(/data-crit="200\.bbb\.css"/g)).toHaveLength(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('skips preview/tracker pages (route prefix and pageContext flags)', () => {
|
|
97
|
+
write('page-data/preview/page-data.json', pageData());
|
|
98
|
+
write('preview/index.html', html('<div class="Bonus_wrap__x1">x</div>'));
|
|
99
|
+
write('page-data/tracked/page-data.json', pageData({ isTracker: true }));
|
|
100
|
+
write('tracked/index.html', html('<div class="Bonus_wrap__x1">x</div>'));
|
|
101
|
+
run();
|
|
102
|
+
expect(read('preview/index.html')).not.toContain('data-crit');
|
|
103
|
+
expect(read('tracked/index.html')).not.toContain('data-crit');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('leaves a page untouched when it renders no module chunks', () => {
|
|
107
|
+
write('page-data/index/page-data.json', pageData());
|
|
108
|
+
const original = html('<div class="onlyGlobalClass">x</div>');
|
|
109
|
+
write('index.html', original);
|
|
110
|
+
run();
|
|
111
|
+
expect(read('index.html')).toBe(original);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('guards: missing public dir does not throw', () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
injectCriticalCss({ publicDir: path.join(dir, 'nope'), config: { enabled: true }, reporter: silent }),
|
|
117
|
+
).not.toThrow();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selectChunks — pure helpers for per-page CSS-chunk selection by classname presence.
|
|
3
|
+
*
|
|
4
|
+
* The split emits one .css file per (lazy) module chunk. To know which chunks a page actually
|
|
5
|
+
* needs — without a brittle module→component registry — we look at what's really in the page's
|
|
6
|
+
* server-rendered HTML: a chunk is needed iff at least one of its (CSS-module, hashed) class
|
|
7
|
+
* names appears in the page's `class="…"` attributes. SSR renders every module the page uses, so
|
|
8
|
+
* each used module's wrapper class is present; chunks for modules the page doesn't render are
|
|
9
|
+
* skipped. This auto-covers header variants and needs no per-site config.
|
|
10
|
+
*
|
|
11
|
+
* All functions are pure & total (no I/O, never throw on string input).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// class selector in CSS: a dot followed by an identifier (letters/_/-, then word chars). Excludes
|
|
15
|
+
// fractional values (.5em) and file extensions short enough to fall under minLen (.png/.svg/…).
|
|
16
|
+
const CSS_CLASS_RE = /\.(-?[_a-zA-Z][\w-]*)/g;
|
|
17
|
+
const CLASS_ATTR_RE = /class="([^"]*)"/g;
|
|
18
|
+
|
|
19
|
+
function extractCssClasses(css, minLen = 5) {
|
|
20
|
+
const out = new Set();
|
|
21
|
+
if (typeof css !== 'string') return out;
|
|
22
|
+
let m;
|
|
23
|
+
// eslint-disable-next-line no-cond-assign
|
|
24
|
+
while ((m = CSS_CLASS_RE.exec(css))) {
|
|
25
|
+
if (m[1].length >= minLen) out.add(m[1]);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractHtmlClasses(html) {
|
|
31
|
+
const out = new Set();
|
|
32
|
+
if (typeof html !== 'string') return out;
|
|
33
|
+
let m;
|
|
34
|
+
// eslint-disable-next-line no-cond-assign
|
|
35
|
+
while ((m = CLASS_ATTR_RE.exec(html))) {
|
|
36
|
+
for (const tok of m[1].split(/\s+/)) if (tok) out.add(tok);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* chunks: [{ file, css }] -> Map<className, Set<file>>
|
|
43
|
+
*/
|
|
44
|
+
function buildClassIndex(chunks, minLen = 5) {
|
|
45
|
+
const index = new Map();
|
|
46
|
+
if (!Array.isArray(chunks)) return index;
|
|
47
|
+
for (const c of chunks) {
|
|
48
|
+
if (c && typeof c.file === 'string') {
|
|
49
|
+
for (const cls of extractCssClasses(c.css, minLen)) {
|
|
50
|
+
let set = index.get(cls);
|
|
51
|
+
if (!set) {
|
|
52
|
+
set = new Set();
|
|
53
|
+
index.set(cls, set);
|
|
54
|
+
}
|
|
55
|
+
set.add(c.file);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return index;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Given a page's HTML and the class index, return the Set of chunk files the page needs.
|
|
64
|
+
*/
|
|
65
|
+
function selectChunksForHtml(html, classIndex) {
|
|
66
|
+
const files = new Set();
|
|
67
|
+
if (!(classIndex instanceof Map)) return files;
|
|
68
|
+
for (const tok of extractHtmlClasses(html)) {
|
|
69
|
+
const set = classIndex.get(tok);
|
|
70
|
+
if (set) for (const f of set) files.add(f);
|
|
71
|
+
}
|
|
72
|
+
return files;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { extractCssClasses, extractHtmlClasses, buildClassIndex, selectChunksForHtml };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
extractCssClasses,
|
|
3
|
+
extractHtmlClasses,
|
|
4
|
+
buildClassIndex,
|
|
5
|
+
selectChunksForHtml,
|
|
6
|
+
} from './selectChunks';
|
|
7
|
+
|
|
8
|
+
describe('extractCssClasses', () => {
|
|
9
|
+
test('captures class selectors, skips fractional values and short tokens', () => {
|
|
10
|
+
const css = '.Bonus_wrap__a1b2c{margin:.5em;width:50px}.x{}.short{}a.Toplist_row__9zz{}';
|
|
11
|
+
const out = extractCssClasses(css, 5);
|
|
12
|
+
expect(out.has('Bonus_wrap__a1b2c')).toBe(true);
|
|
13
|
+
expect(out.has('Toplist_row__9zz')).toBe(true);
|
|
14
|
+
expect(out.has('short')).toBe(true); // exactly 5 chars
|
|
15
|
+
expect(out.has('x')).toBe(false); // below minLen
|
|
16
|
+
expect([...out]).not.toContain('5em'); // fractional value, not a class
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('respects minLen and tolerates non-string', () => {
|
|
20
|
+
expect([...extractCssClasses('.abcdefg{}', 8)]).toEqual([]);
|
|
21
|
+
expect([...extractCssClasses(null)]).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('extractHtmlClasses', () => {
|
|
26
|
+
test('collects whitespace-separated tokens from every class attribute', () => {
|
|
27
|
+
const html = '<div class="Bonus_wrap__a1b2c bonus module"></div><span class="Toplist_row__9zz"></span>';
|
|
28
|
+
const out = extractHtmlClasses(html);
|
|
29
|
+
expect(out.has('Bonus_wrap__a1b2c')).toBe(true);
|
|
30
|
+
expect(out.has('bonus')).toBe(true);
|
|
31
|
+
expect(out.has('Toplist_row__9zz')).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('tolerates non-string', () => {
|
|
35
|
+
expect([...extractHtmlClasses(undefined)]).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('buildClassIndex + selectChunksForHtml', () => {
|
|
40
|
+
const chunks = [
|
|
41
|
+
{ file: '922.aaa.css', css: '.Bonus_wrap__a1b2c{}.Bonus_logo__c3d{}' },
|
|
42
|
+
{ file: '700.bbb.css', css: '.Toplist_row__9zz{}' },
|
|
43
|
+
{ file: '404.ccc.css', css: '.NotFound_box__qq1{}' },
|
|
44
|
+
];
|
|
45
|
+
const index = buildClassIndex(chunks, 5);
|
|
46
|
+
|
|
47
|
+
test('selects only chunks whose classes appear in the page HTML', () => {
|
|
48
|
+
const html = '<div class="Bonus_wrap__a1b2c bonus"></div><div class="Toplist_row__9zz"></div>';
|
|
49
|
+
const sel = selectChunksForHtml(html, index);
|
|
50
|
+
expect(sel).toEqual(new Set(['922.aaa.css', '700.bbb.css']));
|
|
51
|
+
expect(sel.has('404.ccc.css')).toBe(false); // not rendered on this page
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a chunk with no matching class is not selected (empty page)', () => {
|
|
55
|
+
expect(selectChunksForHtml('<div class="someGlobal"></div>', index)).toEqual(new Set());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a class shared by two chunks selects both', () => {
|
|
59
|
+
const idx = buildClassIndex(
|
|
60
|
+
[
|
|
61
|
+
{ file: 'a.css', css: '.Shared_x__h1{}' },
|
|
62
|
+
{ file: 'b.css', css: '.Shared_x__h1{}' },
|
|
63
|
+
],
|
|
64
|
+
5,
|
|
65
|
+
);
|
|
66
|
+
expect(selectChunksForHtml('<i class="Shared_x__h1"></i>', idx)).toEqual(new Set(['a.css', 'b.css']));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('guards: bad inputs never throw', () => {
|
|
70
|
+
expect(buildClassIndex(null)).toEqual(new Map());
|
|
71
|
+
expect(selectChunksForHtml('<div class="x"></div>', null)).toEqual(new Set());
|
|
72
|
+
});
|
|
73
|
+
});
|
package/gatsby-node.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gatsby-matrix-theme — build-time hooks.
|
|
3
|
+
*
|
|
4
|
+
* Critical-CSS split (default OFF; enable per-site via options.criticalCss.enabled):
|
|
5
|
+
* 1. onCreateWebpackConfig — split CSS so only initial-chunk CSS stays inlined; lazy-module CSS
|
|
6
|
+
* goes to separate cacheable chunks.
|
|
7
|
+
* 2. onPostBuild — make each page's own chunks render-blocking (no async => no FOUC/CLS).
|
|
8
|
+
*
|
|
9
|
+
* All hooks are guarded so a failure degrades gracefully and never breaks a consumer build.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { resolveConfig } = require('./critical-css/config');
|
|
14
|
+
const { injectCriticalCss } = require('./critical-css/injectCriticalCss');
|
|
15
|
+
|
|
16
|
+
exports.onCreateWebpackConfig = ({ stage, getConfig, actions, reporter }, pluginOptions) => {
|
|
17
|
+
// The split only matters for the production JS/CSS build.
|
|
18
|
+
if (stage !== 'build-javascript') return;
|
|
19
|
+
|
|
20
|
+
const cfg = resolveConfig(pluginOptions);
|
|
21
|
+
if (!cfg.enabled) return;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const config = getConfig();
|
|
25
|
+
const cacheGroups =
|
|
26
|
+
config.optimization &&
|
|
27
|
+
config.optimization.splitChunks &&
|
|
28
|
+
config.optimization.splitChunks.cacheGroups;
|
|
29
|
+
|
|
30
|
+
if (cacheGroups && cacheGroups.styles) {
|
|
31
|
+
// Only CSS reachable from initial (sync) chunks gets merged into the per-route
|
|
32
|
+
// stylesheet that Gatsby inlines; lazy-module CSS goes to separate chunks (which
|
|
33
|
+
// onPostBuild then re-links render-blocking, per page).
|
|
34
|
+
cacheGroups.styles.chunks = 'initial';
|
|
35
|
+
reporter.info('[critical-css] CSS split enabled (styles cacheGroup -> chunks:"initial")');
|
|
36
|
+
} else {
|
|
37
|
+
reporter.warn('[critical-css] no styles cacheGroup found; CSS split NOT applied');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
actions.replaceWebpackConfig(config);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
reporter.warn(`[critical-css] onCreateWebpackConfig skipped: ${e && e.message}`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
exports.onPostBuild = async ({ reporter, store }, pluginOptions) => {
|
|
47
|
+
const cfg = resolveConfig(pluginOptions);
|
|
48
|
+
if (!cfg.enabled) return;
|
|
49
|
+
try {
|
|
50
|
+
const dir =
|
|
51
|
+
(store && store.getState && store.getState().program && store.getState().program.directory) ||
|
|
52
|
+
process.cwd();
|
|
53
|
+
injectCriticalCss({ publicDir: path.join(dir, 'public'), config: cfg, reporter });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
reporter.warn(`[critical-css] onPostBuild skipped: ${e && e.message}`);
|
|
56
|
+
}
|
|
57
|
+
};
|