diff --git a/src/runtime/directives.js b/src/runtime/directives.js index 3e7a42cd..6a0bfbf0 100644 --- a/src/runtime/directives.js +++ b/src/runtime/directives.js @@ -1,7 +1,8 @@ -import { useContext, useMemo } from 'preact/hooks'; +import { useContext, useMemo, useEffect } from 'preact/hooks'; import { useSignalEffect } from '@preact/signals'; import { directive } from './hooks'; import { deepSignal } from './deepsignal'; +import { prefetch, navigate } from './router'; import { getCallback } from './utils'; const raf = window.requestAnimationFrame; @@ -81,4 +82,44 @@ export default () => { }); } ); + + // The `wp-link` directive. + directive( + 'link', + ({ + directives: { + link: { default: link }, + }, + props: { href }, + element, + }) => { + useEffect(() => { + // Prefetch the page if it is in the directive options. + if (link?.prefetch) { + prefetch(href); + } + }); + + // Don't do anything if it's falsy. + if (link !== false) { + element.props.onclick = async (event) => { + event.preventDefault(); + + // Fetch the page (or return it from cache). + await navigate(href); + + // Update the scroll, depending on the option. True by default. + if (link?.scroll === 'smooth') { + window.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth', + }); + } else if (link?.scroll !== false) { + window.scrollTo(0, 0); + } + }; + } + } + ); }; diff --git a/src/runtime/index.js b/src/runtime/index.js index b72f82db..e89ac9d4 100644 --- a/src/runtime/index.js +++ b/src/runtime/index.js @@ -1,8 +1,6 @@ -import { hydrate } from 'preact'; import registerDirectives from './directives'; import registerComponents from './components'; -import toVdom from './vdom'; -import { createRootFragment } from './utils'; +import { init } from './router'; /** * Initialize the initial vDOM. @@ -10,15 +8,6 @@ import { createRootFragment } from './utils'; document.addEventListener('DOMContentLoaded', async () => { registerDirectives(); registerComponents(); - - // Create the root fragment to hydrate everything. - const rootFragment = createRootFragment( - document.documentElement, - document.body - ); - - const vdom = toVdom(document.body); - hydrate(vdom, rootFragment); - + await init(); console.log('hydrated!'); }); diff --git a/src/runtime/router.js b/src/runtime/router.js new file mode 100644 index 00000000..ec5e4260 --- /dev/null +++ b/src/runtime/router.js @@ -0,0 +1,65 @@ +import { hydrate, render } from 'preact'; +import toVdom from './vdom'; +import { createRootFragment } from './utils'; + +// The root to render the vdom (document.body). +let rootFragment; + +// The cache of visited and prefetched pages. +export const pages = new Map(); + +// Helper to remove domain and hash from the URL. We are only interesting in +// caching the path and the query. +const cleanUrl = (url) => { + const u = new URL(url, 'http://a.bc'); + return u.pathname + u.search; +}; + +// Fetch a new page and convert it to a static virtual DOM. +const fetchPage = async (url) => { + const html = await window.fetch(url).then((res) => res.text()); + const dom = new window.DOMParser().parseFromString(html, 'text/html'); + return toVdom(dom.body); +}; + +// Prefetch a page. We store the promise to avoid triggering a second fetch for +// a page if a fetching has already started. +export const prefetch = (url) => { + url = cleanUrl(url); + if (!pages.has(url)) { + pages.set(url, fetchPage(url)); + } +}; + +// Navigate to a new page. +export const navigate = async (href) => { + const url = cleanUrl(href); + prefetch(url); + const vdom = await pages.get(url); + render(vdom, rootFragment); + window.history.pushState({ wp: { clientNavigation: true } }, '', href); +}; + +// Listen to the back and forward buttons and restore the page if it's in the +// cache. +window.addEventListener('popstate', async () => { + const url = cleanUrl(window.location); // Remove hash. + if (pages.has(url)) { + const vdom = await pages.get(url); + render(vdom, rootFragment); + } else { + window.location.reload(); + } +}); + +// Initialize the router with the initial DOM. +export const init = async () => { + const url = cleanUrl(window.location); // Remove hash. + + // Create the root fragment to hydrate everything. + rootFragment = createRootFragment(document.documentElement, document.body); + + const vdom = toVdom(document.body); + pages.set(url, Promise.resolve(vdom)); + hydrate(vdom, rootFragment); +}; diff --git a/wp-directives.php b/wp-directives.php index f947ffde..5298a26b 100644 --- a/wp-directives.php +++ b/wp-directives.php @@ -1,5 +1,4 @@ get_attribute('href')); - if (is_null($link['host']) || ($link['host'] === $site_url['host'])) { + if (!isset($link['host']) || $link['host'] === $site_url['host']) { $w->set_attribute('wp-link', 'true'); } - }; + } return (string) $w; }