Skip to content

Commit

Permalink
feat(Link): make Link prefetch similar to Next.js
Browse files Browse the repository at this point in the history
- decouple symbol prefetch
- don't add handlers when not needed

Co-authored-by: Wout Mertens <Wout.Mertens@gmail.com>
  • Loading branch information
jordanw66 and wmertens committed Jan 17, 2024
1 parent 083b7c7 commit b378163
Show file tree
Hide file tree
Showing 10 changed files with 214 additions and 137 deletions.
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 @@ -379,7 +379,7 @@
}
],
"kind": "Interface",
"content": "```typescript\nexport interface LinkProps extends AnchorAttributes \n```\n**Extends:** AnchorAttributes\n\n\n| Property | Modifiers | Type | Description |\n| --- | --- | --- | --- |\n| [\"link:app\"?](#linkprops-_link_app_) | | boolean | _(Optional)_ |\n| [prefetch?](#) | | boolean | _(Optional)_ |\n| [reload?](#) | | boolean | _(Optional)_ |\n| [replaceState?](#) | | boolean | _(Optional)_ |\n| [scroll?](#) | | boolean | _(Optional)_ |",
"content": "```typescript\nexport interface LinkProps extends AnchorAttributes \n```\n**Extends:** AnchorAttributes\n\n\n| Property | Modifiers | Type | Description |\n| --- | --- | --- | --- |\n| [\"link:app\"?](#linkprops-_link_app_) | | boolean | _(Optional)_ |\n| [prefetch?](#) | | boolean \\| 'js' | <p>_(Optional)_ \\*\\*Defaults to \\_true\\_.\\*\\*</p><p>Whether Qwik should prefetch and cache the target page of this \\*\\*<code>Link</code>\\*\\*, this includes invoking any \\*\\*<code>routeLoader$</code>\\*\\*, \\*\\*<code>onGet</code>\\*\\*, etc.</p><p>This \\*\\*improves UX performance\\*\\* for client-side (\\*\\*SPA\\*\\*) navigations.</p><p>Prefetching occurs when a the Link enters the viewport in production (\\*\\*<code>on:qvisibile</code>\\*\\*), or with \\*\\*<code>mouseover</code>/<code>focus</code>\\*\\* during dev.</p><p>Prefetching will not occur if the user has the \\*\\*data saver\\*\\* setting enabled.</p><p>Setting this value to \\*\\*<code>&quot;js&quot;</code>\\*\\* will prefetch only javascript bundles required to render this page on the client, \\*\\*<code>false</code>\\*\\* will disable prefetching altogether.</p> |\n| [reload?](#) | | boolean | _(Optional)_ |\n| [replaceState?](#) | | boolean | _(Optional)_ |\n| [scroll?](#) | | boolean | _(Optional)_ |",
"editUrl": "https://github.com/BuilderIO/qwik/tree/main/packages/qwik-city/runtime/src/link-component.tsx",
"mdFile": "qwik-city.linkprops.md"
},
Expand Down
14 changes: 7 additions & 7 deletions packages/docs/src/routes/api/qwik-city/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,13 +473,13 @@ export interface LinkProps extends AnchorAttributes
**Extends:** AnchorAttributes
| Property | Modifiers | Type | Description |
| ------------------------------------ | --------- | ------- | ------------ |
| ["link:app"?](#linkprops-_link_app_) | | boolean | _(Optional)_ |
| [prefetch?](#) | | boolean | _(Optional)_ |
| [reload?](#) | | boolean | _(Optional)_ |
| [replaceState?](#) | | boolean | _(Optional)_ |
| [scroll?](#) | | boolean | _(Optional)_ |
| Property | Modifiers | Type | Description |
| ------------------------------------ | --------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ["link:app"?](#linkprops-_link_app_) | | boolean | _(Optional)_ |
| [prefetch?](#) | | boolean \| 'js' | <p>_(Optional)_ \*\*Defaults to \_true\_.\*\*</p><p>Whether Qwik should prefetch and cache the target page of this \*\*<code>Link</code>\*\*, this includes invoking any \*\*<code>routeLoader$</code>\*\*, \*\*<code>onGet</code>\*\*, etc.</p><p>This \*\*improves UX performance\*\* for client-side (\*\*SPA\*\*) navigations.</p><p>Prefetching occurs when a the Link enters the viewport in production (\*\*<code>on:qvisibile</code>\*\*), or with \*\*<code>mouseover</code>/<code>focus</code>\*\* during dev.</p><p>Prefetching will not occur if the user has the \*\*data saver\*\* setting enabled.</p><p>Setting this value to \*\*<code>&quot;js&quot;</code>\*\* will prefetch only javascript bundles required to render this page on the client, \*\*<code>false</code>\*\* will disable prefetching altogether.</p> |
| [reload?](#) | | boolean | _(Optional)_ |
| [replaceState?](#) | | boolean | _(Optional)_ |
| [scroll?](#) | | boolean | _(Optional)_ |
[Edit this section](https://github.com/BuilderIO/qwik/tree/main/packages/qwik-city/runtime/src/link-component.tsx)
Expand Down
3 changes: 1 addition & 2 deletions packages/qwik-city/runtime/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,7 @@ export const Link: Component<LinkProps>;
export interface LinkProps extends AnchorAttributes {
// (undocumented)
'link:app'?: boolean;
// (undocumented)
prefetch?: boolean;
prefetch?: boolean | 'js';
// (undocumented)
reload?: boolean;
// (undocumented)
Expand Down
10 changes: 7 additions & 3 deletions packages/qwik-city/runtime/src/client-navigate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isBrowser } from '@builder.io/qwik/build';
import type { QPrefetchData } from './service-worker/types';
import type { NavigationType, ScrollState } from './types';
import { isSamePath, toPath } from './utils';
import { PREFETCHED_NAVIGATE_PATHS } from './constants';

export const clientNavigate = (
win: Window,
Expand Down Expand Up @@ -40,8 +40,12 @@ export const newScrollState = (): ScrollState => {
};
};

export const dispatchPrefetchEvent = (prefetchData: QPrefetchData) => {
export const prefetchSymbols = (path: string) => {
if (isBrowser) {
document.dispatchEvent(new CustomEvent('qprefetch', { detail: prefetchData }));
path = path.endsWith('/') ? path : path + '/';
if (!PREFETCHED_NAVIGATE_PATHS.has(path)) {
PREFETCHED_NAVIGATE_PATHS.add(path);
document.dispatchEvent(new CustomEvent('qprefetch', { detail: { links: [path] } }));
}
}
};
2 changes: 2 additions & 0 deletions packages/qwik-city/runtime/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const MODULE_CACHE = /*#__PURE__*/ new WeakMap<any, any>();

export const CLIENT_DATA_CACHE = new Map<string, Promise<ClientPageData | undefined>>();

export const PREFETCHED_NAVIGATE_PATHS = new Set<string>();

export const QACTION_KEY = 'qaction';

export const QFN_KEY = 'qfunc';
149 changes: 89 additions & 60 deletions packages/qwik-city/runtime/src/link-component.tsx
Original file line number Diff line number Diff line change
@@ -1,88 +1,117 @@
import {
component$,
Slot,
type QwikIntrinsicElements,
untrack,
event$,
sync$,
} from '@builder.io/qwik';
import { getClientNavPath, getPrefetchDataset } from './utils';
import { component$, Slot, type QwikIntrinsicElements, untrack, $, sync$ } from '@builder.io/qwik';
import { getClientNavPath, shouldPrefetchData, shouldPrefetchSymbols } from './utils';
import { loadClientData } from './use-endpoint';
import { useLocation, useNavigate } from './use-functions';
import { prefetchSymbols } from './client-navigate';
import { isDev } from '@builder.io/qwik/build';

/** @public */
export const Link = component$<LinkProps>((props) => {
const nav = useNavigate();
const loc = useLocation();
const originalHref = props.href;
const { onClick$, reload, replaceState, scroll, ...linkProps } = (() => props)();
const {
onClick$,
prefetch: prefetchProp,
reload,
replaceState,
scroll,
...linkProps
} = (() => props)();
const clientNavPath = untrack(() => getClientNavPath({ ...linkProps, reload }, loc));
const prefetchDataset = untrack(() => getPrefetchDataset(props, clientNavPath, loc));
linkProps['link:app'] = !!clientNavPath;
linkProps.href = clientNavPath || originalHref;
const onPrefetch =
prefetchDataset != null
? event$((ev: any, elm: HTMLAnchorElement) =>
prefetchLinkResources(elm as HTMLAnchorElement, ev.type === 'qvisible')
)
: undefined;
const preventDefault = sync$((event: MouseEvent, target: HTMLAnchorElement) => {
if (
target.hasAttribute('link:app') &&
!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
) {
event.preventDefault();
}
});
const handleClick = event$(async (event: Event, elm: HTMLAnchorElement) => {
if (event.defaultPrevented) {
// If default was prevented, than it is upto us to make client side navigation.
if (elm.hasAttribute('q:nbs')) {
// Allow bootstrapping into useNavigate.
await nav(location.href, { type: 'popstate' });
} else if (elm.href) {
elm.setAttribute('aria-pressed', 'true');
await nav(elm.href, { forceReload: reload, replaceState, scroll });
elm.removeAttribute('aria-pressed');
}
}
});

const prefetchData = untrack(
() =>
(!!clientNavPath &&
prefetchProp !== false &&
prefetchProp !== 'js' &&
shouldPrefetchData(clientNavPath, loc)) ||
undefined
);

const prefetch = untrack(
() =>
prefetchData ||
(!!clientNavPath && prefetchProp !== false && shouldPrefetchSymbols(clientNavPath, loc))
);

const handlePrefetch = prefetch
? $((_: any, elm: HTMLAnchorElement) => {
if ((navigator as any).connection?.saveData) {
return;
}

if (elm && elm.href) {
const url = new URL(elm.href);
prefetchSymbols(url.pathname);

if (elm.hasAttribute('data-prefetch')) {
loadClientData(url, elm, { prefetchSymbols: false });
}
}
})
: undefined;
const preventDefault = clientNavPath
? sync$((event: MouseEvent, target: HTMLAnchorElement) => {
if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
event.preventDefault();
}
})
: undefined;
const handleClick = clientNavPath
? $(async (event: Event, elm: HTMLAnchorElement) => {
if (event.defaultPrevented) {
// If default was prevented, than it is up to us to make client side navigation.
if (elm.hasAttribute('q:nbs')) {
// Allow bootstrapping into useNavigate.
await nav(location.href, { type: 'popstate' });
} else if (elm.href) {
elm.setAttribute('aria-pressed', 'true');
await nav(elm.href, { forceReload: reload, replaceState, scroll });
elm.removeAttribute('aria-pressed');
}
}
})
: undefined;
return (
<a
{...linkProps}
onClick$={[preventDefault, onClick$, handleClick]}
data-prefetch={prefetchDataset}
onMouseOver$={onPrefetch}
onFocus$={onPrefetch}
onQVisible$={onPrefetch}
data-prefetch={prefetchData}
onMouseOver$={[linkProps.onMouseOver$, handlePrefetch]}
onFocus$={[linkProps.onFocus$, handlePrefetch]}
// Don't prefetch on visible in dev mode
onQVisible$={[linkProps.onQVisible$, !isDev ? handlePrefetch : undefined]}
>
<Slot />
</a>
);
});

/** Client-side only */
export const prefetchLinkResources = (elm: HTMLAnchorElement, isOnVisible?: boolean) => {
if (elm && elm.href && elm.hasAttribute('data-prefetch')) {
if (!windowInnerWidth) {
windowInnerWidth = innerWidth;
}

if (!isOnVisible || (isOnVisible && windowInnerWidth < 520)) {
// either this is a mouseover event, probably on desktop
// or the link is visible, and the viewport width is less than X
loadClientData(new URL(elm.href), elm);
}
}
};

let windowInnerWidth = 0;

type AnchorAttributes = QwikIntrinsicElements['a'];

/** @public */
export interface LinkProps extends AnchorAttributes {
prefetch?: boolean;
/**
* **Defaults to _true_.**
*
* Whether Qwik should prefetch and cache the target page of this **`Link`**, this includes
* invoking any **`routeLoader$`**, **`onGet`**, etc.
*
* This **improves UX performance** for client-side (**SPA**) navigations.
*
* Prefetching occurs when a the Link enters the viewport in production (**`on:qvisibile`**), or
* with **`mouseover`/`focus`** during dev.
*
* Prefetching will not occur if the user has the **data saver** setting enabled.
*
* Setting this value to **`"js"`** will prefetch only javascript bundles required to render this
* page on the client, **`false`** will disable prefetching altogether.
*/
prefetch?: boolean | 'js';

reload?: boolean;
replaceState?: boolean;
scroll?: boolean;
Expand Down
5 changes: 4 additions & 1 deletion packages/qwik-city/runtime/src/qwik-city-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,10 @@ export const QwikCityProvider = component$<QwikCityProps>((props) => {
trackUrl.pathname
);
elm = _getContextElement();
const pageData = (clientPageData = await loadClientData(trackUrl, elm, true, action));
const pageData = (clientPageData = await loadClientData(trackUrl, elm, {
action,
clearCache: true,
}));
if (!pageData) {
// Reset the path to the current path
(routeInternal as any).untrackedValue = { type: navType, dest: trackUrl };
Expand Down
36 changes: 20 additions & 16 deletions packages/qwik-city/runtime/src/use-endpoint.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,37 @@
import { getClientDataPath } from './utils';
import { dispatchPrefetchEvent } from './client-navigate';
import { CLIENT_DATA_CACHE } from './constants';
import type { ClientPageData, RouteActionValue } from './types';
import { _deserializeData } from '@builder.io/qwik';
import { prefetchSymbols } from './client-navigate';

export const loadClientData = async (
url: URL,
element: unknown,
clearCache?: boolean,
action?: RouteActionValue
opts?: {
action?: RouteActionValue;
clearCache?: boolean;
prefetchSymbols?: boolean;
}
) => {
const pagePathname = url.pathname;
const pageSearch = url.search;
const clientDataPath = getClientDataPath(pagePathname, pageSearch, action);
const clientDataPath = getClientDataPath(pagePathname, pageSearch, opts?.action);
let qData = undefined;
if (!action) {
if (!opts?.action) {
qData = CLIENT_DATA_CACHE.get(clientDataPath);
}

dispatchPrefetchEvent({
links: [pagePathname],
});
if (opts?.prefetchSymbols !== false) {
prefetchSymbols(pagePathname);
}
let resolveFn: () => void | undefined;

if (!qData) {
const options = getFetchOptions(action);
if (action) {
action.data = undefined;
const fetchOptions = getFetchOptions(opts?.action);
if (opts?.action) {
opts.action.data = undefined;
}
qData = fetch(clientDataPath, options).then((rsp) => {
qData = fetch(clientDataPath, fetchOptions).then((rsp) => {
const redirectedURL = new URL(rsp.url);
const isQData = redirectedURL.pathname.endsWith('/q-data.json');
if (redirectedURL.origin !== location.origin || !isQData) {
Expand All @@ -43,15 +46,16 @@ export const loadClientData = async (
location.href = url.href;
return;
}
if (clearCache) {
if (opts?.clearCache) {
CLIENT_DATA_CACHE.delete(clientDataPath);
}
if (clientData.redirect) {
location.href = clientData.redirect;
} else if (action) {
} else if (opts?.action) {
const { action } = opts;
const actionData = clientData.loaders[action.id];
resolveFn = () => {
action.resolve!({ status: rsp.status, result: actionData });
action!.resolve!({ status: rsp.status, result: actionData });
};
}
return clientData;
Expand All @@ -62,7 +66,7 @@ export const loadClientData = async (
}
});

if (!action) {
if (!opts?.action) {
CLIENT_DATA_CACHE.set(clientDataPath, qData);
}
}
Expand Down

0 comments on commit b378163

Please sign in to comment.