Skip to content

Commit

Permalink
feat: Basic content prefetch (#1186)
Browse files Browse the repository at this point in the history
* feat: Basic content prefetch for links in header & sidebar

* refactor: Content retrieval

* refactor: Grab language from documentElement for prefetching

* feat: Prefetch content for markdown links

* refactor: Use URL constructor instead
  • Loading branch information
rschristian authored Nov 5, 2024
1 parent b042fc5 commit f227977
Show file tree
Hide file tree
Showing 11 changed files with 106 additions and 68 deletions.
6 changes: 4 additions & 2 deletions src/components/blog-overview/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import config from '../../config.json';
import { useLanguage, useTranslation } from '../../lib/i18n';
import { getRouteName } from '../header';
import { Time } from '../time';
import { prefetchContent } from '../../lib/use-resource';
import s from './style.module.css';

export default function BlogOverview() {
Expand All @@ -14,17 +15,18 @@ export default function BlogOverview() {
{config.blog.map(post => {
const name = getRouteName(post, lang);
const excerpt = post.excerpt[lang] || post.excerpt.en;
const onMouseOver = () => prefetchContent(post.path);

return (
<article class={s.post}>
<div class={s.meta}>
<Time value={post.date} />
</div>
<h2 class={s.title}>
<a href={post.path}>{name}</a>
<a href={post.path} onMouseOver={onMouseOver}>{name}</a>
</h2>
<p class={s.excerpt}>{excerpt}</p>
<a href={post.path} class="btn-small">
<a href={post.path} onMouseOver={onMouseOver} class="btn-small">
{continueReading} &rarr;
</a>
</article>
Expand Down
7 changes: 7 additions & 0 deletions src/components/content-region/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import widgets from '../widgets';
import style from './style.module.css';
import { useTranslation } from '../../lib/i18n';
import { TocContext } from '../table-of-contents';
import { prefetchContent } from '../../lib/use-resource';

const COMPONENTS = {
...widgets,
Expand All @@ -16,6 +17,12 @@ const COMPONENTS = {
props.target = '_blank';
props.rel = 'noopener noreferrer';
}

if (props.href && props.href.startsWith('/')) {
const url = new URL(props.href, location.origin);
props.onMouseOver = () => prefetchContent(url.pathname);
}

return <a {...props} />;
}
};
Expand Down
10 changes: 2 additions & 8 deletions src/components/controllers/blog-page.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useRoute } from 'preact-iso';
import { useContent } from '../../lib/use-resource';
import { useTitle, useDescription } from './utils';
import { useContent } from '../../lib/use-content';
import { NotFound } from './not-found';
import { useLanguage } from '../../lib/i18n';
import { MarkdownRegion } from './markdown-region';
import Footer from '../footer/index';
import { blogRoutes } from '../../lib/route-utils';
Expand All @@ -21,11 +19,7 @@ export default function BlogPage() {

function BlogLayout() {
const { path } = useRoute();
const [lang] = useLanguage();

const { html, meta } = useContent([lang, path === '/' ? 'index' : path]);
useTitle(meta.title);
useDescription(meta.description);
const { html, meta } = useContent(path);

return (
<div class={style.page}>
Expand Down
10 changes: 2 additions & 8 deletions src/components/controllers/doc-page.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { useRoute } from 'preact-iso';
import { useContent } from '../../lib/use-resource';
import { useTitle, useDescription } from './utils';
import { useContent } from '../../lib/use-content';
import config from '../../config.json';
import { NotFound } from './not-found';
import cx from '../../lib/cx';
import { useLanguage } from '../../lib/i18n';
import { MarkdownRegion } from './markdown-region';
import Sidebar from '../sidebar';
import Footer from '../footer/index';
Expand All @@ -25,11 +23,7 @@ export function DocPage() {

export function DocLayout({ isGuide = false }) {
const { path } = useRoute();
const [lang] = useLanguage();

const { html, meta } = useContent([lang, path === '/' ? 'index' : path]);
useTitle(meta.title);
useDescription(meta.description);
const { html, meta } = useContent(path === '/' ? 'index' : path);

return (
<div class={cx(style.page, isGuide && style.withSidebar)}>
Expand Down
10 changes: 2 additions & 8 deletions src/components/controllers/not-found.jsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { useLanguage } from '../../lib/i18n';
import { useContent } from '../../lib/use-resource';
import { useTitle, useDescription } from './utils';
import { useContent } from '../../lib/use-content';
import Footer from '../footer';
import { MarkdownRegion } from './markdown-region';
import style from './style.module.css';

export function NotFound() {
const [lang] = useLanguage();

const { html, meta } = useContent([lang, '404']);
useTitle(meta.title);
useDescription(meta.description);
const { html, meta } = useContent('404');

return (
<div class={style.page}>
Expand Down
10 changes: 3 additions & 7 deletions src/components/controllers/repl-page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,15 @@ import { useLocation, useRoute } from 'preact-iso';
import { Repl } from './repl';
import { base64ToText } from './repl/query-encode.js';
import { fetchExample } from './repl/examples';
import { useContent, useResource } from '../../lib/use-resource';
import { useTitle, useDescription } from './utils';
import { useLanguage } from '../../lib/i18n';
import { useResource } from '../../lib/use-resource';
import { useContent } from '../../lib/use-content';

import style from './repl/style.module.css';

export default function ReplPage() {
const { query } = useRoute();
const [lang] = useLanguage();

const { meta } = useContent([lang, 'repl']);
useTitle(meta.title);
useDescription(meta.description);
useContent('repl');

const code = useResource(() => getInitialCode(query), [query]);

Expand Down
14 changes: 4 additions & 10 deletions src/components/controllers/tutorial-page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ import { useEffect } from 'preact/hooks';
import { Tutorial } from './tutorial';
import { SolutionProvider } from './tutorial/contexts';
import { NotFound } from './not-found';
import { useTitle, useDescription } from './utils';
import { getContent } from '../../lib/content';
import { useContent } from '../../lib/use-resource';
import { useLanguage } from '../../lib/i18n';
import { useContent } from '../../lib/use-content';
import { prefetchContent } from '../../lib/use-resource.js';
import { tutorialRoutes } from '../../lib/route-utils';

import style from './tutorial/style.module.css';
Expand All @@ -24,16 +22,12 @@ export default function TutorialPage() {

function TutorialLayout() {
const { path, params } = useRoute();
const [lang] = useLanguage();

const { html, meta } = useContent([lang, !params.step ? 'tutorial/index' : path]);
useTitle(meta.title);
useDescription(meta.description);
const { html, meta } = useContent(!params.step ? 'tutorial/index' : path);

// Preload the next chapter
useEffect(() => {
if (meta && meta.next) {
getContent([lang, meta.next]);
prefetchContent(meta.next);
}
}, [meta.next, path]);

Expand Down
11 changes: 10 additions & 1 deletion src/components/header/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Corner from './corner';
import { useOverlayToggle } from '../../lib/toggle-overlay';
import { useLocation } from 'preact-iso';
import { useLanguage } from '../../lib/i18n';
import { prefetchContent } from '../../lib/use-resource';

const LINK_FLAIR = {
logo: InvertedLogo
Expand Down Expand Up @@ -197,13 +198,21 @@ const NavLink = ({ to, isOpen, route, ...props }) => {
e.preventDefault();
location.route('/branding');
}

const href = to.href || to.path;
const prefetchHref = href == '/tutorial'
? '/tutorial/index'
: href == '/'
? '/index'
: href;
const homeProps = to.href == '/' || to.path == '/'
? { onContextMenu: BrandingRedirect, 'aria-label': 'Home' }
: {};

return (
<a
href={to.href || to.path}
href={href}
onMouseOver={() => prefetchContent(prefetchHref)}
{...props}
data-route={route}
{...homeProps}
Expand Down
2 changes: 2 additions & 0 deletions src/components/sidebar/sidebar-nav.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRoute } from 'preact-iso';
import cx from '../../lib/cx';
import { prefetchContent } from '../../lib/use-resource';
import style from './sidebar-nav.module.css';

/**
Expand Down Expand Up @@ -68,6 +69,7 @@ function SidebarNavLink(props) {
<a
href={href}
onClick={onClick}
onMouseOver={() => prefetchContent(href)}
class={cx(style.link, activeCss, style['level-' + level])}
>
{children}
Expand Down
15 changes: 14 additions & 1 deletion src/components/controllers/utils.js → src/lib/use-content.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { useEffect } from 'preact/hooks';

import { createTitle } from '../../lib/page-title';
import { createTitle } from './page-title';
import { fetchContent } from './use-resource.js';

/**
* @param {string} path
* @returns {{ html: string, meta: any }}
*/
export function useContent(path) {
const { html, meta } = fetchContent(path);
useTitle(meta.title);
useDescription(meta.description);

return { html, meta };
}

/**
* Set `document.title`
Expand Down
79 changes: 56 additions & 23 deletions src/lib/use-resource.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,46 @@
import { useEffect } from 'preact/hooks';

import { getContent } from './content.js';
import { useLanguage } from './i18n';

/**
* @typedef {Object} CacheEntry
* @property {Promise<any>} promise
* @property {'pending'|'success'|'error'} status
* @property {any} result
* @property {number} users
*/

/** @type {Map<string, Promise<any>>} */
/** @type {Map<string, CacheEntry>} */
const CACHE = new Map();
const createCacheKey = (fn, deps) => '' + fn + JSON.stringify(deps);

/**
* @param {string} path
*/
export function prefetchContent(path) {
const lang = document.documentElement.lang;
const cacheKey = createCacheKey(() => getContent([lang, path]), [lang, path]);
if (CACHE.has(cacheKey)) return;

setupCacheEntry(() => getContent([lang, path]), cacheKey);
}

/**
* @param {[ lang: string, path: string ]} args
* @param {string} path
* @returns {{ html: string, meta: any }}
*/
export function useContent([lang, path]) {
export function fetchContent(path) {
const [lang] = useLanguage();
return useResource(() => getContent([lang, path]), [lang, path]);
}

export function useResource(fn, deps) {
const cacheKey = '' + fn + JSON.stringify(deps);
const cacheKey = createCacheKey(fn, deps);

let state = CACHE.get(cacheKey);
if (!state) {
state = { promise: null, status: 'pending', result: undefined, users: 0 };

state.promise = fn();
if (state.promise.then) {
state.promise
.then(r => {
state.status = 'success';
state.result = r;
})
.catch(err => {
state.status = 'error';
state.result = err;
});
} else {
state.status = 'success';
state.result = state.promise;
}

CACHE.set(cacheKey, state);
state = setupCacheEntry(fn, cacheKey);
}

useEffect(() => {
Expand All @@ -53,3 +58,31 @@ export function useResource(fn, deps) {
else if (state.status === 'error') throw state.result;
throw state.promise;
}

/**
* @param {() => Promise<any>} fn
* @param {string} cacheKey
* @returns {CacheEntry}
*/
function setupCacheEntry(fn, cacheKey) {
/** @type {CacheEntry} */
const state = { promise: fn(), status: 'pending', result: undefined, users: 0 };

if (state.promise.then) {
state.promise
.then(r => {
state.status = 'success';
state.result = r;
})
.catch(err => {
state.status = 'error';
state.result = err;
});
} else {
state.status = 'success';
state.result = state.promise;
}

CACHE.set(cacheKey, state);
return state;
}

0 comments on commit f227977

Please sign in to comment.