From b4f3adcb653bda7abaeb2b0f0a0a0f390b34daa1 Mon Sep 17 00:00:00 2001 From: Ryan Christian <33403762+rschristian@users.noreply.github.com> Date: Tue, 7 Jan 2025 01:56:13 -0600 Subject: [PATCH] feat: Preload routes and repl (#1222) * feat: Preload split routes & REPL/Editor chunks * refactor: Fix tiny overlap of search bar on header * chore: Remove inconsistent use of extensions * chore: Remove leftover code * fix: Cache misses in repl & tutorial content fetch --- package-lock.json | 2 +- package.json | 2 +- src/components/blog-overview/index.jsx | 8 +++-- src/components/content-region/index.jsx | 17 +++++++++-- src/components/controllers/repl-page.jsx | 2 +- src/components/controllers/repl/index.jsx | 10 ++----- src/components/controllers/tutorial-page.jsx | 5 ++-- src/components/controllers/tutorial/index.jsx | 10 ++----- src/components/header/index.jsx | 18 ++++++++++-- src/components/header/style.module.css | 1 - src/components/routes.jsx | 6 ++-- src/components/sidebar/sidebar-nav.jsx | 2 +- src/lib/use-content.js | 20 +++++++++++-- src/lib/use-repl.js | 23 +++++++++++++++ src/lib/use-resource.js | 29 ++----------------- 15 files changed, 94 insertions(+), 61 deletions(-) create mode 100644 src/lib/use-repl.js diff --git a/package-lock.json b/package-lock.json index 9e995acf9..ce4f17e66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "node-html-parser": "^6.1.13", "preact": "10.15.1", "preact-custom-element": "^4.3.0", - "preact-iso": "^2.6.3", + "preact-iso": "^2.8.1", "preact-markup": "^2.1.1", "preact-render-to-string": "^6.4.1", "prismjs": "^1.29.0", diff --git a/package.json b/package.json index 1c81b05ff..45e125408 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "node-html-parser": "^6.1.13", "preact": "10.15.1", "preact-custom-element": "^4.3.0", - "preact-iso": "^2.6.3", + "preact-iso": "^2.8.1", "preact-markup": "^2.1.1", "preact-render-to-string": "^6.4.1", "prismjs": "^1.29.0", diff --git a/src/components/blog-overview/index.jsx b/src/components/blog-overview/index.jsx index db2816833..fac5d6be1 100644 --- a/src/components/blog-overview/index.jsx +++ b/src/components/blog-overview/index.jsx @@ -2,7 +2,8 @@ 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 { prefetchContent } from '../../lib/use-content'; +import { BlogPage } from '../routes.jsx'; import s from './style.module.css'; export default function BlogOverview() { @@ -15,7 +16,10 @@ 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); + const onMouseOver = () => { + BlogPage.preload(); + prefetchContent(post.path); + }; return (
diff --git a/src/components/content-region/index.jsx b/src/components/content-region/index.jsx index 7ca0d899c..147872201 100644 --- a/src/components/content-region/index.jsx +++ b/src/components/content-region/index.jsx @@ -4,14 +4,27 @@ 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'; +import { prefetchContent } from '../../lib/use-content'; +import { preloadRepl } from '../../lib/use-repl'; +import { Repl, TutorialPage } from '../routes'; const COMPONENTS = { ...widgets, a(props) { if (props.href && props.href.startsWith('/')) { const url = new URL(props.href, location.origin); - props.onMouseOver = () => prefetchContent(url.pathname); + + props.onMouseOver = () => { + if (props.href.startsWith('/repl?code')) { + Repl.preload(); + preloadRepl(); + } else if (props.href.startsWith('/tutorial')) { + TutorialPage.preload(); + preloadRepl(); + } + + prefetchContent(url.pathname); + }; } return ; diff --git a/src/components/controllers/repl-page.jsx b/src/components/controllers/repl-page.jsx index c8c79235a..28f049ced 100644 --- a/src/components/controllers/repl-page.jsx +++ b/src/components/controllers/repl-page.jsx @@ -10,7 +10,7 @@ import style from './repl/style.module.css'; export default function ReplPage() { const { query } = useRoute(); - useContent('repl'); + useContent('/repl'); const code = useResource(() => getInitialCode(query), [query]); diff --git a/src/components/controllers/repl/index.jsx b/src/components/controllers/repl/index.jsx index 1a7f518af..7aa5a56c4 100644 --- a/src/components/controllers/repl/index.jsx +++ b/src/components/controllers/repl/index.jsx @@ -5,7 +5,7 @@ import { textToBase64 } from './query-encode.js'; import { ErrorOverlay } from './error-overlay'; import { EXAMPLES, fetchExample } from './examples'; import { useStoredValue } from '../../../lib/localstorage'; -import { useResource } from '../../../lib/use-resource'; +import { useRepl } from '../../../lib/use-repl'; import { parseStackTrace } from './errors'; import style from './style.module.css'; import REPL_CSS from './examples/style.css?raw'; @@ -26,13 +26,7 @@ export function Repl({ code }) { // TODO: Needs some work for prerendering to not cause pop-in if (typeof window === 'undefined') return null; - /** - * @type {{ Runner: import('./runner').default, CodeEditor: import('../../code-editor').default }} - */ - const { Runner, CodeEditor } = useResource(() => Promise.all([ - import('../../code-editor'), - import('./runner') - ]).then(([CodeEditor, Runner]) => ({ CodeEditor: CodeEditor.default, Runner: Runner.default })), ['repl']); + const { CodeEditor, Runner } = useRepl(); const applyExample = (e) => { const slug = e.target.value; diff --git a/src/components/controllers/tutorial-page.jsx b/src/components/controllers/tutorial-page.jsx index 423a8043b..4aa3011d9 100644 --- a/src/components/controllers/tutorial-page.jsx +++ b/src/components/controllers/tutorial-page.jsx @@ -3,8 +3,7 @@ import { useEffect } from 'preact/hooks'; import { Tutorial } from './tutorial'; import { SolutionProvider } from './tutorial/contexts'; import { NotFound } from './not-found'; -import { useContent } from '../../lib/use-content'; -import { prefetchContent } from '../../lib/use-resource.js'; +import { useContent, prefetchContent } from '../../lib/use-content'; import { tutorialRoutes } from '../../lib/route-utils'; import style from './tutorial/style.module.css'; @@ -22,7 +21,7 @@ export default function TutorialPage() { function TutorialLayout() { const { path, params } = useRoute(); - const { html, meta } = useContent(!params.step ? 'tutorial/index' : path); + const { html, meta } = useContent(!params.step ? '/tutorial/index' : path); // Preload the next chapter useEffect(() => { diff --git a/src/components/controllers/tutorial/index.jsx b/src/components/controllers/tutorial/index.jsx index 38c5f84b1..da8221cbe 100644 --- a/src/components/controllers/tutorial/index.jsx +++ b/src/components/controllers/tutorial/index.jsx @@ -13,7 +13,7 @@ import { TutorialContext, SolutionContext } from './contexts'; import { ErrorOverlay } from '../repl/error-overlay'; import { parseStackTrace } from '../repl/errors'; import cx from '../../../lib/cx'; -import { useResource } from '../../../lib/use-resource'; +import { useRepl } from '../../../lib/use-repl'; import { useLanguage } from '../../../lib/i18n'; import { Splitter } from '../../splitter'; import config from '../../../config.json'; @@ -61,13 +61,7 @@ export function Tutorial({ html, meta }) { // TODO: Needs some work for prerendering to not cause pop-in if (typeof window === 'undefined') return null; - /** - * @type {{ Runner: import('../repl/runner').default, CodeEditor: import('../../code-editor').default }} - */ - const { Runner, CodeEditor } = useResource(() => Promise.all([ - import('../../code-editor'), - import('../repl/runner') - ]).then(([CodeEditor, Runner]) => ({ CodeEditor: CodeEditor.default, Runner: Runner.default })), ['repl']); + const { CodeEditor, Runner } = useRepl(); useEffect(() => { if (meta.tutorial?.initial && editorCode !== meta.tutorial.initial) { diff --git a/src/components/header/index.jsx b/src/components/header/index.jsx index 2892d046a..099879374 100644 --- a/src/components/header/index.jsx +++ b/src/components/header/index.jsx @@ -10,7 +10,9 @@ 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'; +import { prefetchContent } from '../../lib/use-content'; +import { preloadRepl } from '../../lib/use-repl'; +import { Repl, TutorialPage } from '../routes'; const LINK_FLAIR = { logo: InvertedLogo @@ -220,10 +222,22 @@ const NavLink = ({ to, isOpen, route, ...props }) => { ? { onContextMenu: BrandingRedirect, 'aria-label': 'Home' } : {}; + const onMouseOver = () => { + if (prefetchHref.startsWith('/repl')) { + Repl.preload(); + preloadRepl(); + } else if (prefetchHref.startsWith('/tutorial')) { + TutorialPage.preload(); + preloadRepl(); + } + + prefetchContent(prefetchHref); + }; + return ( prefetchContent(prefetchHref)} + onMouseOver={onMouseOver} {...props} data-route={route} {...homeProps} diff --git a/src/components/header/style.module.css b/src/components/header/style.module.css index 6307298a3..603a3d544 100644 --- a/src/components/header/style.module.css +++ b/src/components/header/style.module.css @@ -470,7 +470,6 @@ height: 56px; min-width: 80px; overflow: visible; - background: var(--color-brand); padding-right: 0.5rem; @media (max-width: /* --header-mobile-breakpoint */ 50rem) { diff --git a/src/components/routes.jsx b/src/components/routes.jsx index f0964d77e..c3cfacba1 100644 --- a/src/components/routes.jsx +++ b/src/components/routes.jsx @@ -5,9 +5,9 @@ import { DocPage } from './controllers/doc-page'; import { NotFound } from './controllers/not-found'; import { navRoutes } from '../lib/route-utils'; -const Repl = lazy(() => import('./controllers/repl-page')); -const BlogPage = lazy(() => import('./controllers/blog-page')); -const TutorialPage = lazy(() => import('./controllers/tutorial-page')); +export const Repl = lazy(() => import('./controllers/repl-page')); +export const BlogPage = lazy(() => import('./controllers/blog-page')); +export const TutorialPage = lazy(() => import('./controllers/tutorial-page')); // @ts-ignore const routeChange = url => typeof ga === 'function' && ga('send', 'pageview', url); diff --git a/src/components/sidebar/sidebar-nav.jsx b/src/components/sidebar/sidebar-nav.jsx index fca62ee22..8e1aad027 100644 --- a/src/components/sidebar/sidebar-nav.jsx +++ b/src/components/sidebar/sidebar-nav.jsx @@ -1,6 +1,6 @@ import { useRoute } from 'preact-iso'; import cx from '../../lib/cx'; -import { prefetchContent } from '../../lib/use-resource'; +import { prefetchContent } from '../../lib/use-content'; import style from './sidebar-nav.module.css'; /** diff --git a/src/lib/use-content.js b/src/lib/use-content.js index 14cbb6367..d7b3842fa 100644 --- a/src/lib/use-content.js +++ b/src/lib/use-content.js @@ -1,20 +1,36 @@ import { useEffect } from 'preact/hooks'; import { createTitle } from './page-title'; -import { fetchContent } from './use-resource.js'; +import { getContent } from './content.js'; +import { useLanguage } from './i18n'; +import { useResource, createCacheKey, setupCacheEntry, CACHE } from './use-resource.js'; /** * @param {string} path * @returns {{ html: string, meta: any }} */ export function useContent(path) { - const { html, meta } = fetchContent(path); + const [lang] = useLanguage(); + const { html, meta } = useResource(() => getContent([lang, path]), [lang, path]); useTitle(meta.title); useDescription(meta.description); return { html, meta }; } +/** + * @param {string} path + */ +export function prefetchContent(path) { + const lang = document.documentElement.lang; + const fetch = () => getContent([lang, path]); + + const cacheKey = createCacheKey(fetch, [lang, path]); + if (CACHE.has(cacheKey)) return; + + setupCacheEntry(fetch, cacheKey); +} + /** * Set `document.title` * @param {string} title diff --git a/src/lib/use-repl.js b/src/lib/use-repl.js new file mode 100644 index 000000000..e20f60bb1 --- /dev/null +++ b/src/lib/use-repl.js @@ -0,0 +1,23 @@ +import { useResource, createCacheKey, setupCacheEntry, CACHE } from './use-resource.js'; + +const loadChunks = () => Promise.all([ + import('../components/code-editor'), + import('../components/controllers/repl/runner') +]).then(([CodeEditor, Runner]) => ({ CodeEditor: CodeEditor.default, Runner: Runner.default })); + +/** + * @returns {void} + */ +export function preloadRepl() { + const cacheKey = createCacheKey(loadChunks, ['repl']); + if (CACHE.has(cacheKey)) return; + + setupCacheEntry(loadChunks, cacheKey); +} + +/** + * @returns {{ CodeEditor: import('../components/code-editor').default, Runner: import('../components/controllers/repl/runner').default }} + */ +export function useRepl() { + return useResource(loadChunks, ['repl']); +} diff --git a/src/lib/use-resource.js b/src/lib/use-resource.js index 8a6de525a..7ccc510fb 100644 --- a/src/lib/use-resource.js +++ b/src/lib/use-resource.js @@ -1,8 +1,5 @@ import { useEffect } from 'preact/hooks'; -import { getContent } from './content.js'; -import { useLanguage } from './i18n'; - /** * @typedef {Object} CacheEntry * @property {Promise} promise @@ -12,28 +9,8 @@ import { useLanguage } from './i18n'; */ /** @type {Map} */ -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 {string} path - * @returns {{ html: string, meta: any }} - */ -export function fetchContent(path) { - const [lang] = useLanguage(); - return useResource(() => getContent([lang, path]), [lang, path]); -} +export const CACHE = new Map(); +export const createCacheKey = (fn, deps) => '' + fn + JSON.stringify(deps); export function useResource(fn, deps) { const cacheKey = createCacheKey(fn, deps); @@ -64,7 +41,7 @@ export function useResource(fn, deps) { * @param {string} cacheKey * @returns {CacheEntry} */ -function setupCacheEntry(fn, cacheKey) { +export function setupCacheEntry(fn, cacheKey) { /** @type {CacheEntry} */ const state = { promise: fn(), status: 'pending', result: undefined, users: 0 };