Skip to content

Commit b4e3856

Browse files
committed
wip: dynamic modulepreload when creating qrl
1 parent 4de469f commit b4e3856

File tree

17 files changed

+342
-74
lines changed

17 files changed

+342
-74
lines changed

packages/docs/src/entry.ssr.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import Root from './root';
55
export default function (opts: RenderToStreamOptions) {
66
return renderToStream(<Root />, {
77
manifest,
8+
// TODO make this the default
9+
prefetchStrategy: {
10+
implementation: {
11+
linkInsert: 'html-append',
12+
linkRel: 'modulepreload',
13+
prefetchEvent: null,
14+
},
15+
},
816
qwikLoader: {
917
// The docs can be long so make sure to intercept events before the end of the document.
1018
position: 'top',

packages/docs/src/root.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { component$, useContextProvider, useStore } from '@builder.io/qwik';
2-
import { QwikCityProvider, RouterOutlet, ServiceWorkerRegister } from '@builder.io/qwik-city';
1+
import { PrefetchGraph, component$, useContextProvider, useStore } from '@builder.io/qwik';
2+
import { QwikCityProvider, RouterOutlet } from '@builder.io/qwik-city';
3+
import { Insights } from '@builder.io/qwik-labs';
34
import RealMetricsOptimization from './components/real-metrics-optimization/real-metrics-optimization';
45
import { RouterHead } from './components/router-head/router-head';
6+
import { BUILDER_PUBLIC_API_KEY } from './constants';
57
import { GlobalStore, type SiteStore } from './context';
68
import './global.css';
7-
import { BUILDER_PUBLIC_API_KEY } from './constants';
8-
import { Insights } from '@builder.io/qwik-labs';
99

1010
export const uwu = /*javascript*/ `
1111
;(function () {
@@ -55,7 +55,8 @@ export default component$(() => {
5555
<meta charset="utf-8" />
5656
<script dangerouslySetInnerHTML={uwu} />
5757
<RouterHead />
58-
<ServiceWorkerRegister />
58+
{/* TODO is this the best way? */}
59+
<PrefetchGraph />
5960

6061
<script dangerouslySetInnerHTML={`(${collectSymbols})()`} />
6162
<Insights publicApiKey={import.meta.env.PUBLIC_QWIK_INSIGHTS_KEY} />

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
}
9797
],
9898
"kind": "Interface",
99-
"content": "```typescript\nexport interface PrefetchResource \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[imports](#)\n\n\n</td><td>\n\n\n</td><td>\n\n[PrefetchResource](#prefetchresource)<!-- -->\\[\\]\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\n[url](#)\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>",
99+
"content": "```typescript\nexport interface PrefetchResource \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[imports](#)\n\n\n</td><td>\n\n\n</td><td>\n\n[PrefetchResource](#prefetchresource)<!-- -->\\[\\]\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\n[priority](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\n[url](#)\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>",
100100
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/server/types.ts",
101101
"mdFile": "qwik.prefetchresource.md"
102102
},

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,19 @@ Description
362362
</td></tr>
363363
<tr><td>
364364

365+
[priority](#)
366+
367+
</td><td>
368+
369+
</td><td>
370+
371+
boolean
372+
373+
</td><td>
374+
375+
</td></tr>
376+
<tr><td>
377+
365378
[url](#)
366379

367380
</td><td>

packages/qwik/src/core/components/prefetch.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -130,29 +130,24 @@ export const PrefetchGraph = (
130130
const isTest = import.meta.env.TEST;
131131
if (isDev && !isTest) {
132132
const props = {
133-
dangerouslySetInnerHTML: '<!-- PrefetchGraph is disabled in dev mode. -->',
133+
dangerouslySetInnerHTML: '/* PrefetchGraph is disabled in dev mode. */',
134134
};
135135
return _jsxC('script', props, 0, 'prefetch-graph');
136136
}
137137
const serverData = useServerData<Record<string, string>>('containerAttributes', {});
138-
const resolvedOpts = {
139-
// /build/q-bundle-graph-${manifestHash}.json is always within the q:base location /build/
140-
base: serverData['q:base'],
141-
manifestHash: serverData['q:manifest-hash'],
142-
scope: '/',
143-
verbose: false,
144-
path: 'qwik-prefetch-service-worker.js',
145-
...opts,
146-
};
147-
const args = JSON.stringify([
148-
'graph-url',
149-
resolvedOpts.base,
150-
`q-bundle-graph-${resolvedOpts.manifestHash}.json`,
151-
]);
152-
const code = `(window.qwikPrefetchSW||(window.qwikPrefetchSW=[])).push(${args})`;
153-
const props = {
154-
dangerouslySetInnerHTML: code,
155-
nonce: opts.nonce,
156-
};
157-
return _jsxC('script', props, 0, 'prefetch-graph');
138+
// /build/q-bundle-graph-${manifestHash}.json is always within the q:base location /build/
139+
const url = `${serverData['q:base']}q-bundle-graph-${serverData['q:manifest-hash']}.json`;
140+
return _jsxC(
141+
'link',
142+
{
143+
as: 'fetch',
144+
rel: 'preload',
145+
href: url,
146+
nonce: opts.nonce,
147+
// Needed to match the fetch() we do in preload.ts
148+
crossOrigin: 'anonymous',
149+
},
150+
0,
151+
'prefetch-graph'
152+
);
158153
};

packages/qwik/src/core/components/prefetch.unit.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ describe('PrefetchGraph', () => {
108108
const output = await renderToString(<PrefetchGraph nonce="1234" />, {
109109
containerTagName: 'div',
110110
});
111-
expect(output.html).to.contain('<script nonce="1234" q:key="prefetch-graph">');
111+
expect(output.html).to.contain('nonce="1234"');
112112
});
113113
});
114114
});
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* Here we handle preloading of chunks.
3+
*
4+
* Given a symbol hash (in fact any string), we can find all the chunks that it depends on, via the
5+
* bundle graph. We then generate preload link tags for each of those chunks.
6+
*
7+
* The priority is set to high for direct imports and low for indirect imports.
8+
*
9+
* There are several parts to this:
10+
*
11+
* - Load the bundle graph from the preload link tag that was injected during SSR
12+
* - Given a string, find all the chunks that it depends on
13+
* - Generate the preload link tags if needed
14+
*/
15+
16+
import { isDev } from '@builder.io/qwik/build';
17+
import type { QwikBundleGraph } from '../../optimizer/src/types';
18+
import { QBaseAttr, QInstance } from '../util/markers';
19+
20+
import { QContainerSelector } from '../util/markers';
21+
22+
let bundlesP: Promise<void> | undefined;
23+
enum BundleImportState {
24+
None,
25+
Low,
26+
Loading,
27+
Loaded,
28+
Errored,
29+
FullyLoaded,
30+
}
31+
type BundleImport = {
32+
$url$: string | null;
33+
$state$: BundleImportState;
34+
$imports$: string[];
35+
$dynamicImports$: string[];
36+
};
37+
let bundles: Map<string, BundleImport> | undefined;
38+
type WantedBundle = {
39+
name: string;
40+
priority: boolean;
41+
};
42+
const wantedBundles: Set<WantedBundle> = new Set();
43+
44+
const parseBundleGraph = (text: string, base: string) => {
45+
try {
46+
const graph = JSON.parse(text) as QwikBundleGraph;
47+
bundles ||= new Map<string, BundleImport>();
48+
let i = 0;
49+
while (i < graph.length) {
50+
const name = graph[i++] as string;
51+
const url = name.endsWith('.js') ? `${base}${name}` : null;
52+
const imports: string[] = [];
53+
const dynamicImports: string[] = [];
54+
let idx: number | string;
55+
let collection = imports;
56+
while (((idx = graph[i]), typeof idx === 'number')) {
57+
if (idx === -1) {
58+
collection = dynamicImports;
59+
} else {
60+
collection.push(graph[idx] as string);
61+
}
62+
i++;
63+
}
64+
bundles.set(name, {
65+
$url$: url,
66+
$state$: url ? BundleImportState.None : BundleImportState.Loaded,
67+
$imports$: imports,
68+
$dynamicImports$: dynamicImports,
69+
});
70+
}
71+
for (const { name, priority } of wantedBundles) {
72+
preload(name, priority);
73+
}
74+
wantedBundles.clear();
75+
} catch (e) {
76+
console.error('Error parsing bundle graph', e, text);
77+
throw e;
78+
}
79+
};
80+
81+
export const loadBundleGraph = (element: Element) => {
82+
if (typeof window === 'undefined' || bundlesP) {
83+
return;
84+
}
85+
const container = element.closest(QContainerSelector);
86+
if (!container) {
87+
return;
88+
}
89+
const hash = container.getAttribute(QInstance);
90+
const base = container.getAttribute(QBaseAttr) || '/';
91+
const link =
92+
hash && (container.querySelector('link[q\\:key="prefetch-graph"]') as HTMLLinkElement | null);
93+
if (!link) {
94+
bundlesP = Promise.reject('No preload link found');
95+
return;
96+
}
97+
bundlesP = fetch(link.href)
98+
.then((res) => res.text())
99+
.then((text) => parseBundleGraph(text, base))
100+
.catch((e) => {
101+
console.error('Error loading bundle graph, retrying later', e);
102+
setTimeout(() => {
103+
bundlesP = undefined;
104+
}, 60000);
105+
});
106+
};
107+
108+
const makePreloadLink = (bundle: BundleImport, priority: boolean) => {
109+
const link = document.createElement('link');
110+
link.rel = 'modulepreload';
111+
link.href = bundle.$url$!;
112+
link.fetchPriority = priority ? 'high' : 'low';
113+
link.as = 'script';
114+
link.onload = () => {
115+
bundle.$state$ = BundleImportState.Loaded;
116+
};
117+
link.onerror = () => {
118+
bundle.$state$ = BundleImportState.Errored;
119+
};
120+
document.head.appendChild(link);
121+
};
122+
123+
const prioritizeLink = (url: string) => {
124+
const link = document.querySelector(`link[href="${url}"]`) as HTMLLinkElement | null;
125+
if (link) {
126+
link.fetchPriority = 'high';
127+
} else {
128+
console.warn(`Preload link ${url} not found`);
129+
}
130+
};
131+
132+
const preloadBundle = (bundle: BundleImport, priority: boolean): boolean => {
133+
if (bundle.$state$ >= BundleImportState.Loaded) {
134+
return false;
135+
}
136+
if (bundle.$state$ === BundleImportState.None) {
137+
makePreloadLink(bundle, priority);
138+
} else if (priority && bundle.$state$ === BundleImportState.Low) {
139+
prioritizeLink(bundle.$url$!);
140+
} else {
141+
return false;
142+
}
143+
bundle.$state$ = priority ? BundleImportState.Loading : BundleImportState.Low;
144+
return true;
145+
};
146+
147+
export const preload = (name: string, priority: boolean) => {
148+
if (!bundles) {
149+
wantedBundles.add({ name, priority });
150+
return;
151+
}
152+
const bundle = bundles.get(name);
153+
if (
154+
!bundle ||
155+
bundle.$state$ > BundleImportState.None ||
156+
(priority && bundle.$state$ === BundleImportState.Low)
157+
) {
158+
if (isDev && !bundle) {
159+
console.warn(`Bundle ${name} not found`);
160+
}
161+
return false;
162+
}
163+
let didAdd = preloadBundle(bundle, priority);
164+
for (const importName of bundle.$imports$) {
165+
didAdd = preload(importName, priority) || didAdd;
166+
}
167+
for (const importName of bundle.$dynamicImports$) {
168+
didAdd = preload(importName, false) || didAdd;
169+
}
170+
if (!didAdd) {
171+
bundle.$state$ = BundleImportState.FullyLoaded;
172+
}
173+
return didAdd;
174+
};

packages/qwik/src/core/qrl/qrl-class.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getQFuncs, QInstance } from '../util/markers';
1515
import { isPromise, maybeThen } from '../util/promises';
1616
import { qDev, qSerialize, qTest, seal } from '../util/qdev';
1717
import { isArray, isFunction, type ValueOrPromise } from '../util/types';
18+
import { loadBundleGraph, preload } from './preload';
1819
import type { QRLDev } from './qrl';
1920
import type { QRL, QrlArgs, QrlReturn } from './qrl.public';
2021

@@ -89,6 +90,10 @@ export const createQRL = <TYPE>(
8990
if (!_containerEl) {
9091
_containerEl = el;
9192
}
93+
// try every time just in case
94+
if (el) {
95+
loadBundleGraph(el);
96+
}
9297
return _containerEl;
9398
};
9499

@@ -221,6 +226,7 @@ export const createQRL = <TYPE>(
221226
if (qDev) {
222227
seal(qrl);
223228
}
229+
preload(hash, true);
224230
return qrl;
225231
};
226232

packages/qwik/src/core/util/markers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const getQFuncs = (document: Document, hash: string): Function[] => {
2929

3030
export const QLocaleAttr = 'q:locale';
3131
export const QContainerAttr = 'q:container';
32-
32+
export const QBaseAttr = 'q:base';
3333
export const QContainerSelector = '[q\\:container]';
3434

3535
export const ResourceEvent = 'qResource';

0 commit comments

Comments
 (0)