Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/docs/src/routes/api/qwik-city/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@
}
],
"kind": "Interface",
"content": "```typescript\nexport interface LinkProps extends AnchorAttributes \n```\n**Extends:** AnchorAttributes\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[prefetch?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean \\| 'js'\n\n\n</td><td>\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</td></tr>\n<tr><td>\n\n[reload?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\n[replaceState?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\n[scroll?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n</tbody></table>",
"content": "```typescript\nexport interface LinkProps extends AnchorAttributes \n```\n**Extends:** AnchorAttributes\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[fallbackToMpa?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\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</td></tr>\n<tr><td>\n\n[prefetch?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean \\| 'js'\n\n\n</td><td>\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</td></tr>\n<tr><td>\n\n[reload?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\n[replaceState?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\n[scroll?](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n</tbody></table>",
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/link-component.tsx",
"mdFile": "qwik-city.linkprops.md"
},
Expand Down
17 changes: 17 additions & 0 deletions packages/docs/src/routes/api/qwik-city/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1506,6 +1506,23 @@ Description
</th></tr></thead>
<tbody><tr><td>

[fallbackToMpa?](#)

</td><td>

</td><td>

boolean

</td><td>

_(Optional)_ \*\*Defaults to \_true\_.\*\*

Whether Qwik should fallback to MPA navigation if too many bundles are queued for preloading.

</td></tr>
<tr><td>

[prefetch?](#)

</td><td>
Expand Down
23 changes: 22 additions & 1 deletion packages/qwik-city/src/runtime/src/link-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,30 @@ 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$<LinkProps>((props) => {
const nav = useNavigate();
const loc = useLocation();
const originalHref = props.href;
const anchorRef = useSignal<HTMLAnchorElement>();

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;

Expand Down Expand Up @@ -68,6 +76,9 @@ export const Link = component$<LinkProps>((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();
}
})
Expand All @@ -85,6 +96,9 @@ export const Link = component$<LinkProps>((props) => {
await nav(elm.href, { forceReload: reload, replaceState, scroll });
elm.removeAttribute('aria-pressed');
}
if (fallbackToMpa) {
setMpaFallbackHref(null);
}
}
})
: undefined;
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export const Link: Component<LinkProps>;
//
// @public (undocumented)
export interface LinkProps extends AnchorAttributes {
fallbackToMpa?: boolean;
prefetch?: boolean | 'js';
// (undocumented)
reload?: boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/qwik/src/core/preloader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 1 addition & 1 deletion packages/qwik/src/core/preloader/preloader.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
67 changes: 66 additions & 1 deletion packages/qwik/src/core/preloader/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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$);
Expand All @@ -217,6 +233,8 @@ export const adjustProbabilities = (
dep.$factor$ = factor;
}

fallbackToMpa();

adjustProbabilities(depBundle, newInverseProbability, seen);
}
}
Expand All @@ -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);
}
};
Expand Down Expand Up @@ -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);
}
}
};
6 changes: 3 additions & 3 deletions starters/apps/preloader-test/src/routes/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ export default component$(() => {
<div class="container nav-container">
<nav class="nav">
<LinkCmp href="/">Home</LinkCmp>
<LinkCmp href="/form">Form</LinkCmp>
<LinkCmp href="/about">About</LinkCmp>
<LinkCmp href="/form/">Form</LinkCmp>
<LinkCmp href="/about/">About</LinkCmp>
<LinkCmp
href="/counters"
href="/counters/"
onQVisible$={() => console.log("visible")}
>
Counters
Expand Down
Loading