diff --git a/packages/docs/src/routes/api/qwik-city/api.json b/packages/docs/src/routes/api/qwik-city/api.json index fe22bfc12ca..24e77bb57f2 100644 --- a/packages/docs/src/routes/api/qwik-city/api.json +++ b/packages/docs/src/routes/api/qwik-city/api.json @@ -404,7 +404,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface LinkProps extends AnchorAttributes \n```\n**Extends:** AnchorAttributes\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\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
\n\n[reload?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\n[replaceState?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\n[scroll?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
", + "content": "```typescript\nexport interface LinkProps extends AnchorAttributes \n```\n**Extends:** AnchorAttributes\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[fallbackToMpa?](#)\n\n\n\n\n\n\n\nboolean\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[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
\n\n[reload?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\n[replaceState?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\n[scroll?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/link-component.tsx", "mdFile": "qwik-city.linkprops.md" }, diff --git a/packages/docs/src/routes/api/qwik-city/index.mdx b/packages/docs/src/routes/api/qwik-city/index.mdx index db175d19d9f..8309e8897df 100644 --- a/packages/docs/src/routes/api/qwik-city/index.mdx +++ b/packages/docs/src/routes/api/qwik-city/index.mdx @@ -1506,6 +1506,23 @@ Description +[fallbackToMpa?](#) + + + + + +boolean + + + +_(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$(() => {