\n\n_(Optional)_ \\*\\*Defaults to \\_true\\_.\\*\\*\n\nWhether Qwik should prefetch and cache the target page of this \\*\\*`Link`\\*\\*, this includes invoking any \\*\\*`routeLoader$`\\*\\*, \\*\\*`onGet`\\*\\*, etc.\n\nThis \\*\\*improves UX performance\\*\\* for client-side (\\*\\*SPA\\*\\*) navigations.\n\nPrefetching occurs when a the Link enters the viewport in production (\\*\\*`on:qvisible`\\*\\*), or with \\*\\*`mouseover`/`focus`\\*\\* during dev.\n\nPrefetching will not occur if the user has the \\*\\*data saver\\*\\* setting enabled.\n\nSetting this value to \\*\\*`\"js\"`\\*\\* will prefetch only javascript bundles required to render this page on the client, \\*\\*`false`\\*\\* will disable prefetching altogether.\n\n\n
\n\n_(Optional)_ \\*\\*Defaults to \\_true\\_.\\*\\*\n\nWhether Qwik should fallback to MPA navigation if too many bundles are queued for preloading.\n\n\n
\n
\n\n[prefetch?](#)\n\n\n
\n\n\n
\n\nboolean \\| 'js'\n\n\n
\n\n_(Optional)_ \\*\\*Defaults to \\_true\\_.\\*\\*\n\nWhether Qwik should prefetch and cache the target page of this \\*\\*`Link`\\*\\*, this includes invoking any \\*\\*`routeLoader$`\\*\\*, \\*\\*`onGet`\\*\\*, etc.\n\nThis \\*\\*improves UX performance\\*\\* for client-side (\\*\\*SPA\\*\\*) navigations.\n\nPrefetching occurs when a the Link enters the viewport in production (\\*\\*`on:qvisible`\\*\\*), or with \\*\\*`mouseover`/`focus`\\*\\* during dev.\n\nPrefetching will not occur if the user has the \\*\\*data saver\\*\\* setting enabled.\n\nSetting this value to \\*\\*`\"js\"`\\*\\* will prefetch only javascript bundles required to render this page on the client, \\*\\*`false`\\*\\* will disable prefetching altogether.\n\n\n
+
+_(Optional)_ \*\*Defaults to \_true\_.\*\*
+
+Whether Qwik should fallback to MPA navigation if too many bundles are queued for preloading.
+
+
+
+
[prefetch?](#)
diff --git a/packages/qwik-city/src/runtime/src/link-component.tsx b/packages/qwik-city/src/runtime/src/link-component.tsx
index 9ae80e3e20d..693b49bfe82 100644
--- a/packages/qwik-city/src/runtime/src/link-component.tsx
+++ b/packages/qwik-city/src/runtime/src/link-component.tsx
@@ -16,7 +16,7 @@ import { useLocation, useNavigate } from './use-functions';
import { preloadRouteBundles } from './client-navigate';
import { isDev } from '@builder.io/qwik';
// @ts-expect-error we don't have types for the preloader yet
-import { p as preload } from '@builder.io/qwik/preloader';
+import { p as preload, f as setMpaFallbackHref } from '@builder.io/qwik/preloader';
/** @public */
export const Link = component$((props) => {
@@ -24,14 +24,22 @@ export const Link = component$((props) => {
const loc = useLocation();
const originalHref = props.href;
const anchorRef = useSignal();
+
const {
onClick$,
prefetch: prefetchProp,
reload,
replaceState,
scroll,
+ fallbackToMpa: fallbackToMpaProp,
...linkProps
} = (() => props)();
+
+ // We need an RFC to assess whether or not we want to provide the ability to pass a number to customize the MPA fallback threshold.
+ // This depends on how well this feature is received in real projects, but also on what the threshold is based on: number of bundles needed to be preloaded for the next route, or the size of these bundles.
+ // In the future, we might be able to speed up SPA so much that falling back to MPA will never make sense.
+ const fallbackToMpa = untrack(() => Boolean(fallbackToMpaProp ?? true));
+
const clientNavPath = untrack(() => getClientNavPath({ ...linkProps, reload }, loc));
linkProps.href = clientNavPath || originalHref;
@@ -68,6 +76,9 @@ export const Link = component$((props) => {
const preventDefault = clientNavPath
? sync$((event: MouseEvent, target: HTMLAnchorElement) => {
if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
+ if (fallbackToMpa) {
+ setMpaFallbackHref(target.href);
+ }
event.preventDefault();
}
})
@@ -85,6 +96,9 @@ export const Link = component$((props) => {
await nav(elm.href, { forceReload: reload, replaceState, scroll });
elm.removeAttribute('aria-pressed');
}
+ if (fallbackToMpa) {
+ setMpaFallbackHref(null);
+ }
}
})
: undefined;
@@ -166,4 +180,11 @@ export interface LinkProps extends AnchorAttributes {
reload?: boolean;
replaceState?: boolean;
scroll?: boolean;
+
+ /**
+ * **Defaults to _true_.**
+ *
+ * Whether Qwik should fallback to MPA navigation if too many bundles are queued for preloading.
+ */
+ fallbackToMpa?: boolean;
}
diff --git a/packages/qwik-city/src/runtime/src/qwik-city.runtime.api.md b/packages/qwik-city/src/runtime/src/qwik-city.runtime.api.md
index e4773ede255..2d5a2faa658 100644
--- a/packages/qwik-city/src/runtime/src/qwik-city.runtime.api.md
+++ b/packages/qwik-city/src/runtime/src/qwik-city.runtime.api.md
@@ -277,6 +277,7 @@ export const Link: Component;
//
// @public (undocumented)
export interface LinkProps extends AnchorAttributes {
+ fallbackToMpa?: boolean;
prefetch?: boolean | 'js';
// (undocumented)
reload?: boolean;
diff --git a/packages/qwik/src/core/preloader/index.ts b/packages/qwik/src/core/preloader/index.ts
index 8a4b411dd53..b1e831a488e 100644
--- a/packages/qwik/src/core/preloader/index.ts
+++ b/packages/qwik/src/core/preloader/index.ts
@@ -8,4 +8,4 @@
// Short names for minification
export { loadBundleGraph as l, parseBundleGraph as g } from './bundle-graph';
-export { preload as p, handleBundle as h } from './queue';
+export { preload as p, handleBundle as h, setMpaFallbackHref as f } from './queue';
diff --git a/packages/qwik/src/core/preloader/preloader.unit.ts b/packages/qwik/src/core/preloader/preloader.unit.ts
index 3cf4fa49f5a..b7568c3abc9 100644
--- a/packages/qwik/src/core/preloader/preloader.unit.ts
+++ b/packages/qwik/src/core/preloader/preloader.unit.ts
@@ -21,5 +21,5 @@ test('preloader script', () => {
* dereference objects etc, but that actually results in worse compression
*/
const compressed = compress(Buffer.from(preLoader), { mode: 1, quality: 11 });
- expect([compressed.length, preLoader.length]).toEqual([1818, 5417]);
+ expect([compressed.length, preLoader.length]).toEqual([2031, 6129]);
});
diff --git a/packages/qwik/src/core/preloader/queue.ts b/packages/qwik/src/core/preloader/queue.ts
index f31e4eb83ba..ba278f37d9d 100644
--- a/packages/qwik/src/core/preloader/queue.ts
+++ b/packages/qwik/src/core/preloader/queue.ts
@@ -14,7 +14,9 @@ export const bundles: BundleImports = new Map();
export let shouldResetFactor: boolean;
let queueDirty: boolean;
let preloadCount = 0;
+let pendingHref: string | undefined;
const queue: BundleImport[] = [];
+const MPA_FALLBACK_THRESHOLD = 100;
export const log = (...args: any[]) => {
// eslint-disable-next-line no-console
@@ -203,7 +205,21 @@ export const adjustProbabilities = (
* too.
*/
let newInverseProbability: number;
- if (probability === 1 || (probability >= 0.99 && depsCount < 100)) {
+
+ /**
+ * 100 deps to be preloaded at once would mean a ~10s delay on chrome 3G throttling.
+ *
+ * This can happen for Link components as they load all of the route bundles at once, but in
+ * this case we fallback to MPA.
+ *
+ * This should never happen for a normal component. But in case it happens, we set the limit
+ * based on MPA_FALLBACK_THRESHOLD + 1 === 101 (to ensure the fallback works), because if the
+ * user has to wait for 10s before anything happens it is possible that they try to click on
+ * something else, in which case we don't want to block reprioritization of this new event
+ * bundles for too long. (If browsers supported aborting modulepreloads, we wouldn't have to
+ * do this.)
+ */
+ if (probability === 1 || (probability >= 0.99 && depsCount <= MPA_FALLBACK_THRESHOLD + 1)) {
depsCount++;
// we're loaded at max probability, so elevate dynamic imports to 99% sure
newInverseProbability = Math.min(0.01, 1 - dep.$importProbability$);
@@ -217,6 +233,8 @@ export const adjustProbabilities = (
dep.$factor$ = factor;
}
+ fallbackToMpa();
+
adjustProbabilities(depBundle, newInverseProbability, seen);
}
}
@@ -225,6 +243,7 @@ export const adjustProbabilities = (
export const handleBundle = (name: string, inverseProbability: number) => {
const bundle = getBundle(name);
if (bundle && bundle.$inverseProbability$ > inverseProbability) {
+ // prioritize the event bundles first
adjustProbabilities(bundle, inverseProbability);
}
};
@@ -267,3 +286,49 @@ if (isBrowser) {
}
});
}
+
+/**
+ * On chrome 3G throttling, 10kb takes ~1s to download Bundles weight ~1kb on average, so 100
+ * bundles is ~100kb which takes ~10s to download.
+ *
+ * We want to fallback to MPA if more than 100 bundles are queued because MPA is always faster than
+ * ~10s (usually between 3-7s).
+ *
+ * Note: if the next route bundles have already been preloaded, the fallback won't be triggered.
+ */
+const fallbackToMpa = () => {
+ if (!pendingHref) {
+ return;
+ }
+ const nextRouteBundles = queue.filter((item) => item.$inverseProbability$ <= 0.1);
+ if (nextRouteBundles.length >= MPA_FALLBACK_THRESHOLD) {
+ if (pendingHref !== window.location.href) {
+ window.location.href = pendingHref;
+ }
+ }
+};
+
+/**
+ * Sets the MPA fallback href. When too many bundles are queued for preloading, the preloader will
+ * redirect to this href using the browser navigation.
+ *
+ * @param href - The target URL for MPA fallback. Should be an absolute URL string or null/undefined
+ * to clear it.
+ * @returns Void
+ */
+export const setMpaFallbackHref = (href: string | null | undefined) => {
+ if (!href || typeof href !== 'string') {
+ pendingHref = undefined;
+ return;
+ }
+
+ try {
+ const url = new URL(href, window.location.origin);
+ pendingHref = url.href;
+ } catch (error) {
+ pendingHref = undefined;
+ if (config.$DEBUG$) {
+ console.warn('[Qwik Preloader] Invalid href provided to setSpaPendingHref:', href);
+ }
+ }
+};
diff --git a/starters/apps/preloader-test/src/routes/layout.tsx b/starters/apps/preloader-test/src/routes/layout.tsx
index bf44e47e9d8..cf8c3eae82b 100644
--- a/starters/apps/preloader-test/src/routes/layout.tsx
+++ b/starters/apps/preloader-test/src/routes/layout.tsx
@@ -65,10 +65,10 @@ export default component$(() => {