Skip to content

Commit ea67bae

Browse files
authored
Merge pull request #8615 from QwikDev/improve-preloader-size-test
test(e2e): test preloader, qwikloader and core modules don't exceed the defined budget size
2 parents 8994d30 + 84d7ba1 commit ea67bae

4 files changed

Lines changed: 87 additions & 103 deletions

File tree

e2e/qwik-e2e/tests/qwikrouter/ssg-snapshot.e2e.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,20 @@ import { qwikVite } from '@qwik.dev/core/optimizer';
33
import type { QwikManifest } from '@qwik.dev/core/optimizer';
44
import { ssgAdapter } from '@qwik.dev/router/adapters/ssg/vite';
55
import { qwikRouter } from '@qwik.dev/router/vite';
6+
import compress from 'brotli/compress.js';
67
import { readFile, rm, writeFile } from 'node:fs/promises';
78
import { resolve } from 'node:path';
89
import { fileURLToPath } from 'node:url';
910
import { build, type InlineConfig, type PluginOption } from 'vite';
1011
import tsconfigPaths from 'vite-tsconfig-paths';
1112

13+
// Brotli size budgets for production bundles. These are ceilings, not exact matches: any size
14+
// at or below the budget is fine. Bump the budget intentionally when a real feature justifies
15+
// the growth.
16+
const PRELOADER_BROTLI_BUDGET = 1800; // We currently group the vite preload helper with the preloader, adding ~500bytes brotli.
17+
const CORE_BROTLI_BUDGET = 35000;
18+
const QWIKLOADER_BROTLI_BUDGET = 2000;
19+
1220
const __dirname = fileURLToPath(new URL('.', import.meta.url));
1321
const repoRoot = resolve(__dirname, '../../../../');
1422
const appDir = resolve(repoRoot, 'e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot');
@@ -18,10 +26,19 @@ const expectedHtmlPath = resolve(appDir, 'expected.ssg.html');
1826
const expectedStatePath = resolve(appDir, 'expected.state.txt');
1927

2028
test.describe('router ssg snapshot', () => {
29+
// All tests share one production build via `beforeAll`. Without `serial`, Playwright's
30+
// `fullyParallel` config distributes the tests across workers, each re-running the build
31+
// against the same `serverDir` — that race wipes `run-ssg.js` mid-build and the SSG adapter
32+
// then fails with "Cannot find module .../server/run-ssg.js".
33+
test.describe.configure({ mode: 'serial' });
34+
2135
test.skip(({ browserName }) => browserName !== 'chromium', 'Runs once in Chromium e2e.');
2236

23-
test('build output matches the checked-in normalized html and state dump', async () => {
37+
test.beforeAll(async () => {
2438
await buildFixtureApp();
39+
});
40+
41+
test('build output matches the checked-in normalized html and state dump', async () => {
2542
const { _dumpState } = await import(resolve(serverDir, 'entry.ssr.js'));
2643

2744
const html = await readFile(resolve(distDir, 'index.html'), 'utf-8');
@@ -57,8 +74,55 @@ test.describe('router ssg snapshot', () => {
5774
expect(normalizedState).toEqual(expectedState);
5875
expect(normalizedHtml).toEqual(expectedHtml);
5976
});
77+
78+
test('preloader chunk brotli size stays within budget', async () => {
79+
const manifest = JSON.parse(await readFile(resolve(distDir, 'q-manifest.json'), 'utf-8'));
80+
const preloaderFile = manifest.preloader as string | undefined;
81+
expect(preloaderFile, 'q-manifest.json should expose `preloader`').toBeTruthy();
82+
83+
const code = await readFile(resolve(distDir, 'build', preloaderFile!), 'utf-8');
84+
await checkBrotliBudget('preloader', code, PRELOADER_BROTLI_BUDGET);
85+
});
86+
87+
test('core chunk brotli size stays within budget', async () => {
88+
const manifest = JSON.parse(await readFile(resolve(distDir, 'q-manifest.json'), 'utf-8'));
89+
const coreFile = manifest.core as string | undefined;
90+
expect(coreFile, 'q-manifest.json should expose `core`').toBeTruthy();
91+
92+
const code = await readFile(resolve(distDir, 'build', coreFile!), 'utf-8');
93+
await checkBrotliBudget('core', code, CORE_BROTLI_BUDGET);
94+
});
95+
96+
test('qwikloader chunk brotli size stays within budget', async () => {
97+
const manifest = JSON.parse(await readFile(resolve(distDir, 'q-manifest.json'), 'utf-8'));
98+
const qwikLoaderFile = manifest.qwikLoader as string | undefined;
99+
expect(qwikLoaderFile, 'q-manifest.json should expose `qwikLoader`').toBeTruthy();
100+
101+
const code = await readFile(resolve(distDir, 'build', qwikLoaderFile!), 'utf-8');
102+
await checkBrotliBudget('qwikloader', code, QWIKLOADER_BROTLI_BUDGET);
103+
});
60104
});
61105

106+
/**
107+
* Brotli size guardrail. The bundle's brotli size must stay at or below `budget`. If it grows past
108+
* the budget the test fails with the actual size — bump the budget at the top of this file when the
109+
* growth is expected.
110+
*/
111+
async function checkBrotliBudget(label: string, content: string, budget: number) {
112+
expect(content.length).toBeGreaterThan(0);
113+
const brotli = compress(Buffer.from(content), { mode: 1, quality: 11 }).length;
114+
const pct = ((brotli / budget) * 100).toFixed(1);
115+
// Logged on every run so size trends are visible in test output without having to fail first.
116+
// `console.warn` (not `console.log`) because the repo's `no-console` rule allows warn/error only.
117+
console.warn(`[ssg-snapshot] ${label.padEnd(10)} brotli=${brotli} raw=${content.length}`);
118+
const constantName = `${label.toUpperCase()}_BROTLI_BUDGET`;
119+
const overage = brotli - budget;
120+
expect(
121+
brotli,
122+
`${label} bundle is ${brotli} bytes brotli — ${overage} bytes over the ${budget}-byte budget. If this growth is intentional, bump \`${constantName}\` to a higher value.`
123+
).toBeLessThanOrEqual(budget);
124+
}
125+
62126
async function buildFixtureApp() {
63127
await rm(distDir, { recursive: true, force: true });
64128
await rm(serverDir, { recursive: true, force: true });
@@ -71,8 +135,9 @@ async function buildFixtureApp() {
71135
mode: 'production',
72136
configFile: false,
73137
clearScreen: false,
138+
logLevel: 'error',
74139
build: {
75-
minify: false,
140+
minify: 'terser',
76141
},
77142
...extra,
78143
});
@@ -96,7 +161,7 @@ async function buildFixtureApp() {
96161
await build(
97162
getConfig({
98163
build: {
99-
minify: false,
164+
minify: true,
100165
ssr: true,
101166
outDir: serverDir,
102167
},

packages/qwik/src/core/preloader/preloader.unit.ts

Lines changed: 0 additions & 40 deletions
This file was deleted.

packages/qwik/src/qwikloader.unit.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { readFileSync } from 'fs';
21
import { expect, test } from 'vitest';
3-
import compress from 'brotli/compress.js';
2+
import { readFileSync } from 'fs';
43
import { fileURLToPath } from 'url';
54
import { dirname, resolve } from 'path';
65

@@ -14,20 +13,6 @@ test('qwikloader script', () => {
1413
} catch {
1514
// ignore, we didn't build yet
1615
}
17-
// This is to ensure we are deliberate about changes to qwikloader.
18-
expect(qwikLoader.length).toBeGreaterThan(0);
19-
/**
20-
* Note that the source length can be shorter by using strings in variables and using those to
21-
* dereference objects etc, but that actually results in worse compression
22-
*/
23-
const compressed = compress(Buffer.from(qwikLoader), { mode: 1, quality: 11 });
24-
expect([compressed.length, qwikLoader.length]).toMatchInlineSnapshot(`
25-
[
26-
1943,
27-
4913,
28-
]
29-
`);
30-
3116
expect(qwikLoader).toMatchInlineSnapshot(
3217
`"const e=document,t=window,r="w",n="wp",o="d",s="dp",i="e",c="ep",l="capture:",a=new Set,p=new Set([e]),q=new Map;let u,f,h;const d=(e,t)=>Array.from(e.querySelectorAll(t)),b=e=>{const t=[];return p.forEach(r=>t.push(...d(r,e))),t},m=(e,t,r,n=!1,o=!1)=>e.addEventListener(t,r,{capture:n,passive:o}),g=e=>{J(e);const t=d(e,"[q\\\\:shadowroot]");for(let e=0;e<t.length;e++){const r=t[e].shadowRoot;r&&g(r)}},v=e=>e&&"function"==typeof e.then,y=async e=>{for(let t=0;t<e.length;t++)await e[t]()},w=e=>{if(e.length){const t=()=>y(e);h=h?h.then(t,t):t()}},E=t=>{if(void 0===t._qwikjson_){let r=(t===e.documentElement?e.body:t).lastElementChild;for(;r;){if("SCRIPT"===r.tagName&&"qwik/json"===r.getAttribute("type")){t._qwikjson_=JSON.parse(r.textContent.replace(/\\\\x3C(\\/?script)/gi,"<$1"));break}r=r.previousElementSibling}}},A=(e,t)=>new CustomEvent(e,{detail:t}),C=(t,r)=>{e.dispatchEvent(A(t,r))},_=e=>e.replace(/([A-Z-])/g,e=>"-"+e.toLowerCase()),k=e=>e.replace(/-./g,e=>e[1].toUpperCase()),B=e=>{const t=e.indexOf(":");return{scope:e.slice(0,t),eventName:k(e.slice(t+1))}},S=e=>2===e.length,I=e=>e.charAt(0),N=e=>!!e&&1===e.nodeType,T=(e,t,r)=>e.hasAttribute(r)&&(!!e._qDispatch?.[t]||e.hasAttribute("q-"+t)),$=(t,r,n,o,s,i,c)=>{const l={qBase:n,symbol:i,element:r,reqTime:c};if(""===s){const r=t.getAttribute("q:instance"),n=(e["qFuncs_"+r]||[])[Number.parseInt(i)];if(!n){const e=Error("sym:"+i);C("qerror",{importError:"sync",error:e,...l}),console.error(e)}return n}const a=\`\${i}|\${n}|\${s}\`,p=q.get(a);if(p)return p;const u=new URL(s,o).href,f=import(u);return E(t),f.then(e=>{const t=e[i];if(t)q.set(a,t),C("qsymbol",l);else{const e=Error(\`\${i} not in \${u}\`);C("qerror",{importError:"no-symbol",error:e,...l}),console.error(e)}return t},e=>{C("qerror",{importError:"async",error:e,...l}),console.error(e)})},R=(t,r,n,o,s,i=!0)=>{let c=!1;s&&(i&&t.hasAttribute("preventdefault:"+s)&&r.preventDefault(),t.hasAttribute("stoppropagation:"+s)&&r.stopPropagation());const l=t._qDispatch?.[n];if(l){if("function"==typeof l){const e=()=>l(r,t);if(c)o.push(async()=>{const t=e();v(t)&&await t});else{const t=e();v(t)&&(c=!0,o.push(()=>t))}}else if(l.length)for(let e=0;e<l.length;e++){const n=l[e];if(n){const e=()=>n(r,t);if(c)o.push(async()=>{const t=e();v(t)&&await t});else{const t=e();v(t)&&(c=!0,o.push(()=>t))}}}return}const a=t.getAttribute("q-"+n);if(a){const n=t.closest("[q\\\\:container]:not([q\\\\:container=html]):not([q\\\\:container=text])"),s=n.getAttribute("q:base"),i=new URL(s,e.baseURI),l=a.split("|");for(let e=0;e<l.length;e++){const a=l[e],p=performance.now(),[q,u,f]=a.split("#"),h=e=>{if(e&&t.isConnected)try{const n=e.call(f,r,t);if(v(n))return n.catch(e=>{C("qerror",{error:e,qBase:s,symbol:u,element:t,reqTime:p})})}catch(e){C("qerror",{error:e,qBase:s,symbol:u,element:t,reqTime:p})}},d=$(n,t,s,i,q,u,p);if(c||v(d))c=!0,o.push(async()=>{await h(v(d)?await d:d)});else{const e=h(d);v(e)&&(c=!0,o.push(()=>e))}}}},x=(e,t=i,r=!0)=>{const n=_(e.type),o=t+":"+n,s=l+n,c=[],a=[],p=[];let q=e.target;for(;q;)N(q)?(c.push(q),a.push(T(q,o,s)),q=q.parentElement):q=q.parentElement;for(let t=c.length-1;t>=0;t--)if(a[t]&&(R(c[t],e,o,p,n,r),e.cancelBubble||e.cancelBubble))return void w(p);for(let t=0;t<c.length;t++)if(!a[t]&&(R(c[t],e,o,p,n,r),!e.bubbles||e.cancelBubble||e.cancelBubble))return void w(p);w(p)},L=e=>x(e,c,!1),U=(e,t,r=!0)=>{const n=_(t.type),o=e+":"+n,s=b("[q-"+e+"\\\\:"+n+"]"),i=[];for(let e=0;e<s.length;e++){const c=s[e];R(c,t,o,i,n,r)}w(i)},j=e=>{U(o,e)},D=e=>{U(s,e,!1)},O=e=>{U(r,e)},P=e=>{U(n,e,!1)},F=()=>{const r=e.readyState;if("interactive"==r||"complete"==r){if(f=1,p.forEach(g),a.has("d:qinit")){a.delete("d:qinit");const e=A("qinit"),t=b("[q-d\\\\:qinit]"),r=[];for(let n=0;n<t.length;n++){const o=t[n];R(o,e,"d:qinit",r),o.removeAttribute("q-d:qinit")}w(r)}if(a.has("d:qidle")&&(a.delete("d:qidle"),(t.requestIdleCallback??t.setTimeout).bind(t)(()=>{const e=A("qidle"),t=b("[q-d\\\\:qidle]"),r=[];for(let n=0;n<t.length;n++){const o=t[n];R(o,e,"d:qidle",r),o.removeAttribute("q-d:qidle")}w(r)})),a.has("e:qvisible")){u||(u=new IntersectionObserver(e=>{const t=[];for(let r=0;r<e.length;r++){const n=e[r];n.isIntersecting&&(u.unobserve(n.target),R(n.target,A("qvisible",n),"e:qvisible",t))}w(t)}));const e=b("[q-e\\\\:qvisible]:not([q\\\\:observed])");for(let t=0;t<e.length;t++){const r=e[t];u.observe(r),r.setAttribute("q:observed","true")}}}},J=(...e)=>{for(let n=0;n<e.length;n++){const s=e[n];if("string"==typeof s){if(!a.has(s)){a.add(s);const{scope:e,eventName:n}=B(s),i=S(e),c=I(e);c===r?m(t,n,i?P:O,!0,i):p.forEach(e=>m(e,n,c===o?i?D:j:i?L:x,!0,i)),1!==f||"e:qvisible"!==s&&"d:qinit"!==s&&"d:qidle"!==s||F()}}else p.has(s)||(a.forEach(e=>{const{scope:t,eventName:n}=B(e),i=S(t),c=I(t);c!==r&&m(s,n,c===o?i?D:j:i?L:x,!0,i)}),p.add(s))}},M=t._qwikEv;M?.roots||(Array.isArray(M)?J(...M):J("e:click","e:input"),t._qwikEv={events:a,roots:p,push:J},m(e,"readystatechange",F),F());"`
3318
);

scripts/submodule-preloader.ts

Lines changed: 18 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,28 @@
11
import { join } from 'node:path';
22
import { build } from 'vite';
33
import { fileSize, type BuildConfig } from './util.ts';
4-
import { minify } from 'terser';
5-
import type { Plugin } from 'vite';
64
import { MANGLE_PROPS_REGEX } from './submodule-core.ts';
75

86
/**
9-
* Custom plugin to apply terser during the bundle generation. Vite doesn't minify library ES
10-
* modules.
7+
* Builds the preloader script as a stand-alone ES module. Vite handles the minification via Terser
8+
* — we only need to pass the property-mangling regex so `$...$` internal properties stay in sync
9+
* with the names mangled by the core/server bundles (see `MANGLE_PROPS_REGEX`).
1110
*/
12-
function customTerserPlugin(): Plugin {
13-
return {
14-
name: 'custom-terser',
15-
async renderChunk(code, chunk) {
16-
// Only process JavaScript chunks
17-
if (!chunk.fileName.endsWith('.mjs') && !chunk.fileName.endsWith('.js')) {
18-
return null;
19-
}
20-
21-
// Keep the result readable for debugging
22-
const result = await minify(code, {
11+
export async function submodulePreloader(config: BuildConfig): Promise<void> {
12+
await build({
13+
build: {
14+
emptyOutDir: false,
15+
copyPublicDir: false,
16+
lib: {
17+
entry: join(config.srcQwikDir, 'core/preloader'),
18+
formats: ['es'],
19+
fileName: () => 'preloader.mjs',
20+
},
21+
rollupOptions: {
22+
external: ['@qwik.dev/core/build'],
23+
},
24+
minify: 'terser',
25+
terserOptions: {
2326
compress: {
2427
defaults: false,
2528
module: true,
@@ -34,39 +37,10 @@ function customTerserPlugin(): Plugin {
3437
regex: MANGLE_PROPS_REGEX,
3538
},
3639
},
37-
format: {
38-
comments: true,
39-
},
40-
});
41-
42-
return result.code || null;
43-
},
44-
};
45-
}
46-
47-
/**
48-
* Builds the qwikloader javascript files using Vite. These files can be used by other tooling, and
49-
* are provided in the package so CDNs could point to them. The @builder.io/optimizer submodule also
50-
* provides a utility function.
51-
*/
52-
export async function submodulePreloader(config: BuildConfig): Promise<void> {
53-
await build({
54-
build: {
55-
emptyOutDir: false,
56-
copyPublicDir: false,
57-
lib: {
58-
entry: join(config.srcQwikDir, 'core/preloader'),
59-
formats: ['es'],
60-
fileName: () => 'preloader.mjs',
61-
},
62-
rollupOptions: {
63-
external: ['@qwik.dev/core/build'],
6440
},
65-
minify: false, // This is the default, just to be explicit
6641
outDir: config.distQwikPkgDir,
6742
},
6843
define: { 'globalThis.qTest': 'false' }, // In vitest environments, `qTest` is `true` which allows test-only code to run, but in production builds it should be `false` to allow dead code elimination.
69-
plugins: [customTerserPlugin()],
7044
});
7145

7246
const preloaderSize = await fileSize(join(config.distQwikPkgDir, 'preloader.mjs'));

0 commit comments

Comments
 (0)