Skip to content

Commit 115cee1

Browse files
committed
perf(preloader): manage a download queue
1 parent d2f7fba commit 115cee1

File tree

22 files changed

+415
-229
lines changed

22 files changed

+415
-229
lines changed

packages/docs/src/routes/api/qwik-optimizer/api.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@
434434
}
435435
],
436436
"kind": "Interface",
437-
"content": "The metadata of the build. One of its uses is storing where QRL symbols are located.\n\n\n```typescript\nexport interface QwikManifest \n```\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[bundles](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ \\[fileName: string\\]: [QwikBundle](#qwikbundle)<!-- -->; }\n\n\n</td><td>\n\nAll code bundles, used to know the import graph\n\n\n</td></tr>\n<tr><td>\n\n[injections?](#)\n\n\n</td><td>\n\n\n</td><td>\n\n[GlobalInjections](#globalinjections)<!-- -->\\[\\]\n\n\n</td><td>\n\n_(Optional)_ CSS etc to inject in the document head\n\n\n</td></tr>\n<tr><td>\n\n[manifestHash](#)\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\nContent hash of the manifest, if this changes, the code changed\n\n\n</td></tr>\n<tr><td>\n\n[mapping](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ \\[symbolName: string\\]: string; }\n\n\n</td><td>\n\nWhere QRLs are located\n\n\n</td></tr>\n<tr><td>\n\n[options?](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ target?: string; buildMode?: string; entryStrategy?: { \\[key: string\\]: any; }; }\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\n[platform?](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ \\[name: string\\]: string; }\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\n[symbols](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ \\[symbolName: string\\]: [QwikSymbol](#qwiksymbol)<!-- -->; }\n\n\n</td><td>\n\nQRL symbols\n\n\n</td></tr>\n<tr><td>\n\n[version](#)\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>",
437+
"content": "The metadata of the build. One of its uses is storing where QRL symbols are located.\n\n\n```typescript\nexport interface QwikManifest \n```\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[bundles](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ \\[fileName: string\\]: [QwikBundle](#qwikbundle)<!-- -->; }\n\n\n</td><td>\n\nAll code bundles, used to know the import graph\n\n\n</td></tr>\n<tr><td>\n\n[injections?](#)\n\n\n</td><td>\n\n\n</td><td>\n\n[GlobalInjections](#globalinjections)<!-- -->\\[\\]\n\n\n</td><td>\n\n_(Optional)_ CSS etc to inject in the document head\n\n\n</td></tr>\n<tr><td>\n\n[manifestHash](#)\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\nContent hash of the manifest, if this changes, the code changed\n\n\n</td></tr>\n<tr><td>\n\n[mapping](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ \\[symbolName: string\\]: string; }\n\n\n</td><td>\n\nWhere QRLs are located\n\n\n</td></tr>\n<tr><td>\n\n[options?](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ target?: string; buildMode?: string; entryStrategy?: { \\[key: string\\]: any; }; }\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\n[platform?](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ \\[name: string\\]: string; }\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\n[preloader?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ The preloader bundle\n\n\n</td></tr>\n<tr><td>\n\n[symbols](#)\n\n\n</td><td>\n\n\n</td><td>\n\n{ \\[symbolName: string\\]: [QwikSymbol](#qwiksymbol)<!-- -->; }\n\n\n</td><td>\n\nQRL symbols\n\n\n</td></tr>\n<tr><td>\n\n[version](#)\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>",
438438
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/optimizer/src/types.ts",
439439
"mdFile": "qwik.qwikmanifest.md"
440440
},

packages/docs/src/routes/api/qwik-optimizer/index.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1477,6 +1477,21 @@ _(Optional)_
14771477
</td></tr>
14781478
<tr><td>
14791479

1480+
[preloader?](#)
1481+
1482+
</td><td>
1483+
1484+
</td><td>
1485+
1486+
string
1487+
1488+
</td><td>
1489+
1490+
_(Optional)_ The preloader bundle
1491+
1492+
</td></tr>
1493+
<tr><td>
1494+
14801495
[symbols](#)
14811496

14821497
</td><td>

packages/qwik-city/src/runtime/src/client-navigate.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { isBrowser, _preload } from '@builder.io/qwik';
1+
import { isBrowser } from '@builder.io/qwik';
2+
// @ts-expect-error we don't have types for the preloader yet
3+
import { p as preload } from '@builder.io/qwik/preloader';
24
import type { NavigationType, ScrollState } from './types';
35
import { isSamePath, toPath } from './utils';
46

@@ -43,6 +45,6 @@ export const prefetchSymbols = (path: string) => {
4345
if (isBrowser) {
4446
path = path.endsWith('/') ? path : path + '/';
4547
path = path.length > 1 && path.startsWith('/') ? path.slice(1) : path;
46-
_preload(path, true);
48+
preload(path);
4749
}
4850
};

packages/qwik/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
"import": "./dist/optimizer.mjs",
110110
"require": "./dist/optimizer.cjs"
111111
},
112+
"./preloader": {
113+
"import": "./dist/preloader.mjs"
114+
},
112115
"./server.cjs": "./dist/server.cjs",
113116
"./server.mjs": "./dist/server.mjs",
114117
"./server": {

packages/qwik/src/core/api.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -624,9 +624,6 @@ export const PrefetchServiceWorker: (opts: {
624624
nonce?: string;
625625
}) => JSXNode_2<'script'>;
626626

627-
// @internal (undocumented)
628-
export const _preload: (name: string, priority: boolean) => void;
629-
630627
// @public (undocumented)
631628
export interface ProgressHTMLAttributes<T extends Element> extends Attrs<'progress', T> {
632629
}

packages/qwik/src/core/internal.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,3 @@ export {
1616
} from './use/use-core';
1717
export { _jsxQ, _jsxC, _jsxS } from './render/jsx/jsx-runtime';
1818
export { _fnSignal } from './qrl/inlined-fn';
19-
export { preload as _preload } from './qrl/preload';
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Note: this file gets built separately from the rest of the core module, and is then kept separate
3+
* in the dist directory via manualChunks. This way it can run before the rest of the core module is
4+
* loaded, but core can still use it.
5+
*
6+
* Here we handle preloading of bundles.
7+
*
8+
* Given a symbol hash (in fact any string), we can find all the bundles that it depends on, via the
9+
* bundle graph. We then generate preload link tags for each of those bundles.
10+
*
11+
* There are several parts to this:
12+
*
13+
* - Load the bundle graph from the preload link tag that was injected during SSR
14+
* - Given a string, find all the bundles that it depends on
15+
* - Generate the preload link tags if needed
16+
*
17+
* In practice, we queue incoming requests and when we process
18+
*/
19+
/**
20+
* Todo
21+
*
22+
* - High and low priority sets
23+
* - Num preloads active at a time
24+
* - If no modulepreload support, do 1 fetch at a time
25+
*/
26+
27+
import { isBrowser } from '@builder.io/qwik/build';
28+
import type { QwikBundleGraph } from '../optimizer/src/types';
29+
30+
const enum BundleImportState {
31+
None,
32+
Low,
33+
Queued,
34+
Loading,
35+
}
36+
type BundleImport = {
37+
$url$: string | null;
38+
$state$: BundleImportState;
39+
$imports$: string[];
40+
$dynamicImports$: string[];
41+
};
42+
const bundles = new Map<string, BundleImport>();
43+
const high: BundleImport[] = [];
44+
const low: BundleImport[] = [];
45+
46+
let base: string | undefined;
47+
48+
// minification helpers
49+
const doc = isBrowser ? document : undefined!;
50+
const modulePreloadStr = 'modulepreload';
51+
const preloadStr = 'preload';
52+
53+
let highCount = 0;
54+
let lowCount = 0;
55+
/**
56+
* This is called when a bundle is queued or finished loading.
57+
*
58+
* Because Chrome doesn't treat new modulepreloads as higher priority, we only make 5 links
59+
* available at a time, so that when a new high priority bundle comes in, it is soon preloaded.
60+
*
61+
* We make sure to first empty the high priority items, first-in-last-out.
62+
*/
63+
const trigger = () => {
64+
while (highCount < 6 && high.length) {
65+
const bundle = high.pop()!;
66+
preloadOne(bundle!, true);
67+
}
68+
while (highCount + lowCount < 6 && low.length) {
69+
const bundle = low.pop()!;
70+
preloadOne(bundle!);
71+
}
72+
};
73+
74+
const rel =
75+
isBrowser && doc.createElement('link').relList.supports(modulePreloadStr)
76+
? modulePreloadStr
77+
: preloadStr;
78+
/**
79+
* Note, we considered using `preload` for low priority bundles, but those don't get preparsed and
80+
* that slows down interaction
81+
*/
82+
const preloadOne = (bundle: BundleImport, priority?: boolean) => {
83+
if (bundle.$state$ === BundleImportState.Loading) {
84+
return;
85+
}
86+
if (bundle.$url$) {
87+
const link = doc.createElement('link');
88+
link.href = bundle.$url$!;
89+
link.rel = rel;
90+
if (priority) {
91+
highCount++;
92+
} else {
93+
lowCount++;
94+
}
95+
link.as = 'script';
96+
link.onload = link.onerror = () => {
97+
link.remove();
98+
if (priority) {
99+
highCount--;
100+
} else {
101+
lowCount--;
102+
}
103+
trigger();
104+
};
105+
106+
doc.head.appendChild(link);
107+
}
108+
109+
bundle.$state$ = BundleImportState.Loading;
110+
/** Now that we processed the bundle, its dependencies are needed ASAP */
111+
preload(bundle.$dynamicImports$);
112+
preload(bundle.$imports$, priority);
113+
};
114+
115+
const makeBundle = (path: string, imports: string[], dynamicImports: string[]) => {
116+
const url = path.endsWith('.js') ? new URL(`${base}${path}`, doc.baseURI).toString() : null;
117+
return {
118+
$url$: url,
119+
$state$: BundleImportState.None,
120+
$imports$: imports,
121+
$dynamicImports$: dynamicImports,
122+
};
123+
};
124+
const ensureBundle = (name: string) => {
125+
let bundle = bundles.get(name);
126+
if (!bundle) {
127+
bundle = makeBundle(name, [], []);
128+
bundles.set(name, bundle);
129+
}
130+
if (bundle.$state$ === BundleImportState.Loading) {
131+
return;
132+
}
133+
return bundle;
134+
};
135+
136+
const parseBundleGraph = (text: string, base: string) => {
137+
const graph = JSON.parse(text) as QwikBundleGraph;
138+
let i = 0;
139+
// All existing loading bundles need imports processed
140+
const toProcess = Object.keys(bundles)
141+
.filter((name) => bundles.get(name)!.$state$ === BundleImportState.Loading)
142+
.reverse();
143+
while (i < graph.length) {
144+
const name = graph[i++] as string;
145+
const imports: string[] = [];
146+
const dynamicImports: string[] = [];
147+
let idx: number | string;
148+
let collection = imports;
149+
while (((idx = graph[i]), typeof idx === 'number')) {
150+
if (idx === -1) {
151+
collection = dynamicImports;
152+
} else {
153+
collection.push(graph[idx] as string);
154+
}
155+
i++;
156+
}
157+
if (bundles.has(name)) {
158+
const bundle = bundles.get(name)!;
159+
bundle.$imports$ = imports;
160+
bundle.$dynamicImports$ = dynamicImports;
161+
} else {
162+
bundles.set(name, makeBundle(name, imports, dynamicImports));
163+
}
164+
}
165+
for (const name of toProcess) {
166+
const bundle = bundles.get(name)!;
167+
// we assume low priority
168+
preload(bundle.$dynamicImports$);
169+
preload(bundle.$imports$);
170+
}
171+
};
172+
173+
/**
174+
* Preload a bundle or bundles. Requires calling loadBundleGraph first.
175+
*
176+
* @internal
177+
*/
178+
const preload = (name: string | string[], priority?: boolean) => {
179+
if (!isBrowser || !base) {
180+
return;
181+
}
182+
const queue = priority ? high : low;
183+
if (Array.isArray(name)) {
184+
queue.push(...(name.map(ensureBundle).filter(Boolean) as BundleImport[]).reverse());
185+
} else {
186+
const bundle = ensureBundle(name);
187+
if (bundle) {
188+
queue.push(bundle);
189+
}
190+
}
191+
trigger();
192+
};
193+
194+
/**
195+
* Lazily load the bundle graph and then import dependencies of bundles that were loaded already.
196+
*
197+
* @internal
198+
*/
199+
const loadBundleGraph = (basePath: string, manifestHash: string) => {
200+
if (!isBrowser || base) {
201+
return;
202+
}
203+
base = basePath;
204+
// TODO check TTI, maybe inject fetch link with timeout so we don't do the fetch directly
205+
fetch(`${basePath}q-bundle-graph-${manifestHash}.json`)
206+
.then((res) => res.text())
207+
.then((text) => parseBundleGraph(text, basePath))
208+
// We warn because it's not critical, and in the CI tests Windows serves up a HTML file instead the bundle graph sometimes, which breaks the tests that don't expect error logs
209+
.catch(console.warn);
210+
};
211+
212+
// Short names for minification
213+
export { loadBundleGraph as l, preload as p };

0 commit comments

Comments
 (0)