diff --git a/README.md b/README.md index 647d34a..fcd3d8d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Isomorphic async tools for Preact. - [hydrate()](#hydrate) - [prerender()](#prerender) - [locationStub()](#locationstub) + - [Navigation API Entry Docs](#navigation-api-entry-docs) + - [Differences in usage](#differences-in-usage) --- @@ -438,6 +440,18 @@ locationStub('/foo/bar?baz=qux#quux'); console.log(location.pathname); // "/foo/bar" ``` +## Navigation API Entry Docs + +The Navigation API is a new web standard that provides an updated method of handling "navigation" in web applications, supporting SPA-style routing as a first-class citizen. The older History API can be wrangled to support this and has been the standard for many years, but the Navigation API provides a much more robust set of tools that are really, really attractive for routers like `preact-iso` to take advantage of. + +Whilst the API [sees fairly wide support](https://caniuse.com/wf-navigation), it is still newly available and thus may not be viable for some targets. As such, we've provided a new entry point that will allow you to take advantage of this API if you wish, but the default remains targetting the History API. The Navigation API entry point is available at `preact-iso/router/navigation-api`. + +### Differences in usage + +The differences lie entirely within the [`useLocation()`](#uselocation) hook: instead of returning a `route()` function, you use the global `navigation` object to perform all navigations. + +The [`navigation` object](https://developer.mozilla.org/en-US/docs/Web/API/Navigation) contains many of the useful utilities that go along with a router, like `.forward()`, `.back()`, `.canGoForward()`, `.canGoBack()`, `.entries()`, etc. It actually offers far more utilities than the base router did, and does so with less library code overall, so if you have access to it, it's a really nice upgrade. + ## License [MIT](./LICENSE) diff --git a/package-lock.json b/package-lock.json index b1af019..d1f3467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "chai": "^5.1.1", "htm": "^3.1.1", "kleur": "^4.1.5", + "navigation-api-types": "^0.6.1", "preact": "^10.26.5", "preact-render-to-string": "^6.6.1", "sinon": "^18.0.0", @@ -1855,7 +1856,6 @@ "version": "5.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -2277,8 +2277,7 @@ "node_modules/devtools-protocol": { "version": "0.0.1312386", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "5.2.0", @@ -3610,6 +3609,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/navigation-api-types": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/navigation-api-types/-/navigation-api-types-0.6.1.tgz", + "integrity": "sha512-e1BbABfPRKLkBbZfAVuRFR2CLFWOtSt8e0ryJivjvLdw8yxD7ASPgyfl+klcGYvrPcP4zhOtZ4KpmQcEo1FgQw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "dev": true, @@ -3892,7 +3898,6 @@ "integrity": "sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -4186,7 +4191,6 @@ "version": "4.21.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.5" }, diff --git a/package.json b/package.json index 4f082d5..d0895c2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "exports": { ".": "./src/index.js", "./router": "./src/router.js", + "./router/navigation-api": "./src/router-navigation-api.js", "./lazy": "./src/lazy.js", "./prerender": "./src/prerender.js", "./hydrate": "./src/hydrate.js" @@ -44,6 +45,7 @@ "chai": "^5.1.1", "htm": "^3.1.1", "kleur": "^4.1.5", + "navigation-api-types": "^0.6.1", "preact": "^10.26.5", "preact-render-to-string": "^6.6.1", "sinon": "^18.0.0", diff --git a/src/internal.d.ts b/src/internal.d.ts index eb1ca3a..b120caf 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -1,3 +1,4 @@ +/// import { Component } from 'preact'; export interface AugmentedComponent extends Component { diff --git a/src/router-navigation-api.d.ts b/src/router-navigation-api.d.ts new file mode 100644 index 0000000..6599869 --- /dev/null +++ b/src/router-navigation-api.d.ts @@ -0,0 +1,101 @@ +import { AnyComponent, ComponentChildren, Context, VNode } from 'preact'; + +export const LocationProvider: { + (props: { scope?: string | RegExp; children?: ComponentChildren; }): VNode; + ctx: Context; +}; + +type NestedArray = Array>; + +interface KnownProps { + path: string; + query: Record; + params: Record; + default?: boolean; + rest?: string; + component?: AnyComponent; +} + +interface ArbitraryProps { + [prop: string]: any; +} + +type MatchProps = KnownProps & ArbitraryProps; + +/** + * Check if a URL path matches against a URL path pattern. + * + * Warning: This is largely an internal API, it may change in the future + * @param url - URL path (e.g. /user/12345) + * @param route - URL pattern (e.g. /user/:id) + */ +export function exec(url: string, route: string, matches?: MatchProps): MatchProps + +export function Router(props: { + onRouteChange?: (url: string) => void; + onLoadEnd?: (url: string) => void; + onLoadStart?: (url: string) => void; + children?: NestedArray; +}): VNode; + +interface LocationHook { + url: string; + path: string; + query: Record; +} +export const useLocation: () => LocationHook; + +interface RouteHook { + path: string; + query: Record; + params: Record; +} +export const useRoute: () => RouteHook; + +type RoutableProps = + | { path: string; default?: false; } + | { path?: never; default: true; } + +export type RouteProps = RoutableProps & { component: AnyComponent }; + +export type RoutePropsForPath = Path extends '*' + ? { params: {}; rest: string } + + : Path extends `:${infer placeholder}?/${infer rest}` + ? { [k in placeholder]?: string } & { params: RoutePropsForPath['params'] & { [k in placeholder]?: string } } & Omit, 'params'> + + : Path extends `:${infer placeholder}/${infer rest}` + ? { [k in placeholder]: string } & { params: RoutePropsForPath['params'] & { [k in placeholder]: string } } & Omit, 'params'> + + : Path extends `:${infer placeholder}?` + ? { [k in placeholder]?: string } & { params: { [k in placeholder]?: string } } + + : Path extends `:${infer placeholder}*` + ? { [k in placeholder]?: string } & { params: { [k in placeholder]?: string } } + + : Path extends `:${infer placeholder}+` + ? { [k in placeholder]: string } & { params: { [k in placeholder]: string } } + + : Path extends `:${infer placeholder}` + ? { [k in placeholder]: string } & { params: { [k in placeholder]: string } } + + : Path extends (`/${infer rest}` | `${infer _}/${infer rest}`) + ? RoutePropsForPath + + : { params: {} }; + +export function Route(props: RouteProps & Partial): VNode; + +declare module 'preact' { + // The code below automatically adds `path` and `default` as optional props for every component + // (effectively reserving those names, so no component should use those names in its own props). + // These declarations extend from `RouteableProps`, which is not allowed in modern TypeScript and + // causes a TS2312 error. However, the compiler does seems to honor the intent of this code, so + // to avoid an API regression, let's ignore the error rather than loosening the type validation. + namespace JSX { + /** @ts-ignore */ + interface IntrinsicAttributes extends RoutableProps {} + } + /** @ts-ignore */ + interface Attributes extends RoutableProps {} +} diff --git a/src/router-navigation-api.js b/src/router-navigation-api.js new file mode 100644 index 0000000..a24c0fd --- /dev/null +++ b/src/router-navigation-api.js @@ -0,0 +1,290 @@ +import { h, createContext, cloneElement, toChildArray } from 'preact'; +import { useContext, useMemo, useReducer, useLayoutEffect, useRef } from 'preact/hooks'; + +/** + * @template T + * @typedef {import('preact').RefObject} RefObject + * @typedef {import('./internal.d.ts').VNode} VNode + */ + +/** + * @param {NavigateEvent} e + */ +function isSameWindow(e) { + const sourceElement = /** @type {HTMLAnchorElement | null} */ (e.sourceElement); + return ( + !sourceElement || + !sourceElement.target || + /^(_self)?$/i.test(sourceElement.target) + ); +} + +/** @type {string | RegExp | undefined} */ +let scope; + +/** + * @param {URL} url + * @returns {boolean} + */ +function isInScope(url) { + return !scope || (typeof scope == 'string' + ? url.pathname.startsWith(scope) + : scope.test(url.pathname) + ); +} + +/** + * @param {string} state + * @param {NavigateEvent} e + */ +function handleNav(state, e) { + const url = new URL(e.destination.url); + + if ( + !e.canIntercept || + e.hashChange || + e.downloadRequest !== null || + !isSameWindow(e) || + !isInScope(url) + ) { + // This is set purely for our test suite so that we can check + // if the event was ignored in another `navigate` handler. + e['preact-iso-ignored'] = true; + return state; + } + + e.intercept(); + return url.href.replace(url.origin, ''); +} + +export const exec = (url, route, matches = {}) => { + url = url.split('/').filter(Boolean); + route = (route || '').split('/').filter(Boolean); + if (!matches.params) matches.params = {}; + for (let i = 0, val, rest; i < Math.max(url.length, route.length); i++) { + let [, m, param, flag] = (route[i] || '').match(/^(:?)(.*?)([+*?]?)$/); + val = url[i]; + // segment match: + if (!m && param == val) continue; + // /foo/* match + if (!m && val && flag == '*') { + matches.rest = '/' + url.slice(i).map(decodeURIComponent).join('/'); + break; + } + // segment mismatch / missing required field: + if (!m || (!val && flag != '?' && flag != '*')) return; + rest = flag == '+' || flag == '*'; + // rest (+/*) match: + if (rest) val = url.slice(i).join('/') || undefined; + // normal/optional field: + else if (val) val = decodeURIComponent(val); + matches.params[param] = val; + if (!(param in matches)) matches[param] = val; + if (rest) break; + } + return matches; +}; + +/** + * @param {Object} props + * @param {string | RegExp} [props.scope] + * @param {import('preact').ComponentChildren} [props.children] + */ +export function LocationProvider(props) { + const [url, route] = useReducer(handleNav, location.pathname + location.search); + if (props.scope) scope = props.scope; + + const value = useMemo(() => { + const u = new URL(url, location.origin); + const path = u.pathname.replace(/\/+$/g, '') || '/'; + return { + url, + path, + query: Object.fromEntries(u.searchParams), + }; + }, [url]); + + useLayoutEffect(() => { + navigation.addEventListener('navigate', route) + + return () => { + navigation.removeEventListener('navigate', route) + }; + }, []); + + return h(LocationProvider.ctx.Provider, { value }, props.children); +} + +const RESOLVED = Promise.resolve(); +/** @this {import('./internal.d.ts').AugmentedComponent} */ +export function Router(props) { + const [c, update] = useReducer(c => c + 1, 0); + + const { url, query, path } = useLocation(); + if (!url) { + throw new Error(`preact-iso's must be used within a , see: https://github.com/preactjs/preact-iso#locationprovider`); + } + const { rest = path, params = {} } = useContext(RouteContext); + + const isLoading = useRef(false); + const prevRoute = useRef(path); + // Monotonic counter used to check if an un-suspending route is still the current route: + const count = useRef(0); + // The current route: + const cur = /** @type {RefObject>} */ (useRef()); + // Previous route (if current route is suspended): + const prev = /** @type {RefObject>} */ (useRef()); + // A not-yet-hydrated DOM root to remove once we commit: + const pendingBase = /** @type {RefObject} */ (useRef()); + // has this component ever successfully rendered without suspending: + const hasEverCommitted = useRef(false); + // was the most recent render successful (did not suspend): + const didSuspend = /** @type {RefObject} */ (useRef()); + didSuspend.current = false; + + let pathRoute, defaultRoute, matchProps; + toChildArray(props.children).some((/** @type {VNode} */ vnode) => { + const matches = exec( + rest, + vnode.props.path, + (matchProps = { + ...vnode.props, + path: rest, + query, + params: Object.assign({}, params), + rest: '' + }) + ); + if (matches) return (pathRoute = cloneElement(vnode, matchProps)); + if (vnode.props.default) defaultRoute = cloneElement(vnode, matchProps); + }); + + /** @type {VNode | undefined} */ + let incoming = pathRoute || defaultRoute; + + const isHydratingSuspense = cur.current && cur.current.__u & MODE_HYDRATE && cur.current.__u & MODE_SUSPENDED; + const isHydratingBool = cur.current && cur.current.__h; + const routeChanged = useMemo(() => { + prev.current = cur.current; + + cur.current = /** @type {VNode} */ (h(RouteContext.Provider, { value: matchProps }, incoming)); + + // Only mark as an update if the route component changed. + const outgoing = prev.current && prev.current.props.children; + if (!outgoing || !incoming || incoming.type !== outgoing.type || incoming.props.component !== outgoing.props.component) { + // This hack prevents Preact from diffing when we swap `cur` to `prev`: + if (this.__v && this.__v.__k) this.__v.__k.reverse(); + count.current++; + return true; + } + return false; + }, [url, JSON.stringify(matchProps)]); + + if (isHydratingSuspense) { + cur.current.__u |= MODE_HYDRATE; + cur.current.__u |= MODE_SUSPENDED; + } else if (isHydratingBool) { + cur.current.__h = true; + } + + // Reset previous children - if rendering succeeds synchronously, we shouldn't render the previous children. + const p = prev.current; + prev.current = null; + + // This borrows the _childDidSuspend() solution from compat. + this.__c = (e, suspendedVNode) => { + // Mark the current render as having suspended: + didSuspend.current = true; + + // The new route suspended, so keep the previous route around while it loads: + prev.current = p; + + // Fire an event saying we're waiting for the route: + if (props.onLoadStart) props.onLoadStart(url); + isLoading.current = true; + + // Re-render on unsuspend: + let c = count.current; + e.then(() => { + // Ignore this update if it isn't the most recently suspended update: + if (c !== count.current) return; + + // Successful route transition: un-suspend after a tick and stop rendering the old route: + prev.current = null; + if (cur.current) { + if (suspendedVNode.__h) { + // _hydrating + cur.current.__h = suspendedVNode.__h; + } + + if (suspendedVNode.__u & MODE_SUSPENDED) { + // _flags + cur.current.__u |= MODE_SUSPENDED; + } + + if (suspendedVNode.__u & MODE_HYDRATE) { + cur.current.__u |= MODE_HYDRATE; + } + } + + RESOLVED.then(update); + }); + }; + + useLayoutEffect(() => { + const currentDom = this.__v && this.__v.__e; + + // Ignore suspended renders (failed commits): + if (didSuspend.current) { + // If we've never committed, mark any hydration DOM for removal on the next commit: + if (!hasEverCommitted.current && !pendingBase.current) { + pendingBase.current = currentDom; + } + return; + } + + // If this is the first ever successful commit and we didn't use the hydration DOM, remove it: + if (!hasEverCommitted.current && pendingBase.current) { + if (pendingBase.current !== currentDom) pendingBase.current.remove(); + pendingBase.current = null; + } + + // Mark the component has having committed: + hasEverCommitted.current = true; + + // The route is loaded and rendered. + if (prevRoute.current !== path) { + if (props.onRouteChange) props.onRouteChange(url); + + prevRoute.current = path; + } + + if (props.onLoadEnd && isLoading.current) props.onLoadEnd(url); + isLoading.current = false; + }, [path, c]); + + // Note: cur MUST render first in order to set didSuspend & prev. + return routeChanged + ? [h(RenderRef, { r: cur }), h(RenderRef, { r: prev })] + : h(RenderRef, { r: cur }); +} + +const MODE_HYDRATE = 1 << 5; +const MODE_SUSPENDED = 1 << 7; + +// Lazily render a ref's current value: +const RenderRef = ({ r }) => r.current; + +Router.Provider = LocationProvider; + +LocationProvider.ctx = createContext( + /** @type {import('./router-navigation-api.d.ts').LocationHook} */ ({}) +); +const RouteContext = createContext( + /** @type {import('./router-navigation-api.d.ts').RouteHook & { rest: string }} */ ({}) +); + +export const Route = props => h(props.component, props); + +export const useLocation = () => useContext(LocationProvider.ctx); +export const useRoute = () => useContext(RouteContext); diff --git a/test/router-navigation-api.test.js b/test/router-navigation-api.test.js new file mode 100644 index 0000000..ddf7146 --- /dev/null +++ b/test/router-navigation-api.test.js @@ -0,0 +1,1122 @@ +import { h, Fragment, render, Component, hydrate, options } from 'preact'; +import { useState } from 'preact/hooks'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +import { LocationProvider, Router, useLocation, Route, useRoute } from '../src/router-navigation-api.js'; +import lazy, { ErrorBoundary } from '../src/lazy.js'; + +import './setup.js'; + +const expect = chai.expect; +chai.use(sinonChai); + +/** + * Usage: + * - `await sleep(1)` for nav + loc/pushState/sync component check + * - `await sleep(10)` for nav + async component check + */ +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +// delayed lazy() +const groggy = (component, ms) => lazy(() => sleep(ms).then(() => component)); + +describe('Router', () => { + let scratch, loc; + + const ShallowLocation = () => { + loc = useLocation(); + return null; + } + + beforeEach(() => { + if (scratch) { + render(null, scratch); + scratch.remove(); + } + loc = undefined; + scratch = document.createElement('scratch'); + document.body.appendChild(scratch); + history.replaceState(null, null, '/'); + }); + + + it('should throw a clear error if the LocationProvider is missing', () => { + const Home = () =>

Home

; + + try { + render( + + + , + scratch + ); + expect.fail('should have thrown'); + } catch (e) { + expect(e.message).to.include('must be used within a '); + } + }); + + it('should strip trailing slashes from path', async () => { + render( + + + , + scratch + ); + + navigation.navigate('/a/'); + await sleep(1); + + expect(loc).to.deep.include({ + url: '/a/', + path: '/a', + query: {}, + }); + }); + + it('should support class components using LocationProvider.ctx', () => { + class Foo extends Component { + static contextType = LocationProvider.ctx; + + render() { + loc = this.context; + return

{loc.url}

; + } + } + + render( + + + , + scratch + ); + + expect(scratch).to.have.property('innerHTML', '

/

'); + expect(loc).to.deep.include({ + url: '/', + path: '/', + query: {}, + }); + }); + + it('should allow passing props to a route', async () => { + const Home = sinon.fake(() =>

Home

); + + render( + + + + + + , + scratch + ); + + expect(scratch).to.have.property('textContent', 'Home'); + expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '', test: '2' }); + expect(loc).to.deep.include({ + url: '/', + path: '/', + query: {}, + }); + }); + + it('should allow updating props in a route', async () => { + const Home = sinon.fake(() =>

Home

); + + /** @type {(string) => void} */ + let set; + + const App = () => { + const [test, setTest] = useState('2'); + set = setTest; + return ( + + + + + + + ); + } + render(, scratch); + + expect(scratch).to.have.property('textContent', 'Home'); + expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '', test: '2' }); + expect(loc).to.deep.include({ + url: '/', + path: '/', + query: {}, + }); + + set('3') + await sleep(1); + + expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '', test: '3' }); + expect(loc).to.deep.include({ + url: '/', + path: '/', + query: {}, + }); + expect(scratch).to.have.property('textContent', 'Home'); + }); + + it('should switch between synchronous routes', async () => { + const Home = sinon.fake(() =>

Home

); + const Profiles = sinon.fake(() =>

Profiles

); + const Profile = sinon.fake(({ params }) =>

Profile: {params.id}

); + const Fallback = sinon.fake(() =>

Fallback

); + const stack = []; + + render( + + stack.push(url)}> + + + + + + + , + scratch + ); + + expect(scratch).to.have.property('textContent', 'Home'); + expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' }); + expect(Profiles).not.to.have.been.called; + expect(Profile).not.to.have.been.called; + expect(Fallback).not.to.have.been.called; + expect(loc).to.deep.include({ + url: '/', + path: '/', + query: {}, + }); + + Home.resetHistory(); + navigation.navigate('/profiles'); + await sleep(1); + + expect(scratch).to.have.property('textContent', 'Profiles'); + expect(Home).not.to.have.been.called; + expect(Profiles).to.have.been.calledWith({ path: '/profiles', query: {}, params: {}, rest: '' }); + expect(Profile).not.to.have.been.called; + expect(Fallback).not.to.have.been.called; + + expect(loc).to.deep.include({ + url: '/profiles', + path: '/profiles', + query: {} + }); + + Profiles.resetHistory(); + navigation.navigate('/profiles/bob'); + await sleep(1); + + expect(scratch).to.have.property('textContent', 'Profile: bob'); + expect(Home).not.to.have.been.called; + expect(Profiles).not.to.have.been.called; + expect(Profile).to.have.been.calledWith( + { path: '/profiles/bob', query: {}, params: { id: 'bob' }, id: 'bob', rest: '' }, + ); + expect(Fallback).not.to.have.been.called; + + expect(loc).to.deep.include({ + url: '/profiles/bob', + path: '/profiles/bob', + query: {} + }); + + Profile.resetHistory(); + navigation.navigate('/other?a=b&c=d'); + await sleep(1); + + expect(scratch).to.have.property('textContent', 'Fallback'); + expect(Home).not.to.have.been.called; + expect(Profiles).not.to.have.been.called; + expect(Profile).not.to.have.been.called; + expect(Fallback).to.have.been.calledWith( + { default: true, path: '/other', query: { a: 'b', c: 'd' }, params: {}, rest: '' }, + ); + + expect(loc).to.deep.include({ + url: '/other?a=b&c=d', + path: '/other', + query: { a: 'b', c: 'd' } + }); + expect(stack).to.eql(['/profiles', '/profiles/bob', '/other?a=b&c=d']); + }); + + it('should wait for asynchronous routes', async () => { + const route = name => ( + <> +

{name}

+

hello

+ + ); + const A = sinon.fake(groggy(() => route('A'), 1)); + const B = sinon.fake(groggy(() => route('B'), 1)); + const C = sinon.fake(groggy(() =>

C

, 1)); + + render( + + + + + + + + + + , + scratch + ); + + expect(scratch).to.have.property('innerHTML', ''); + expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' }); + + A.resetHistory(); + await sleep(10); + + expect(scratch).to.have.property('innerHTML', '

A

hello

'); + expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' }); + + A.resetHistory(); + navigation.navigate('/b'); + + expect(scratch).to.have.property('innerHTML', '

A

hello

'); + expect(A).not.to.have.been.called; + + await sleep(1); + + expect(scratch).to.have.property('innerHTML', '

A

hello

'); + // We should never re-invoke
while loading (that would be a remount of the old route): + expect(A).not.to.have.been.called; + expect(B).to.have.been.calledWith({ path: '/b', query: {}, params: {}, rest: '' }); + + B.resetHistory(); + await sleep(10); + + expect(scratch).to.have.property('innerHTML', '

B

hello

'); + expect(B).to.have.been.calledOnce; + expect(B).to.have.been.calledWith({ path: '/b', query: {}, params: {}, rest: '' }); + + B.resetHistory(); + navigation.navigate('/c'); + navigation.navigate('/c?1'); + navigation.navigate('/c'); + + expect(scratch).to.have.property('innerHTML', '

B

hello

'); + expect(B).not.to.have.been.called; + + await sleep(1); + + navigation.navigate('/c'); + navigation.navigate('/c?2'); + navigation.navigate('/c'); + + expect(scratch).to.have.property('innerHTML', '

B

hello

'); + // We should never re-invoke while loading (that would be a remount of the old route): + expect(B).not.to.have.been.called; + expect(C).to.have.been.calledWith({ path: '/c', query: {}, params: {}, rest: '' }); + + C.resetHistory(); + await sleep(10); + + expect(scratch).to.have.property('innerHTML', '

C

'); + expect(C).to.have.been.calledOnce; + expect(C).to.have.been.calledWith({ path: '/c', query: {}, params: {}, rest: '' }); + + // "instant" routing to already-loaded routes + + C.resetHistory(); + B.resetHistory(); + navigation.navigate('/b'); + await sleep(1); + + expect(scratch).to.have.property('innerHTML', '

B

hello

'); + expect(C).not.to.have.been.called; + expect(B).to.have.been.calledOnce; + expect(B).to.have.been.calledWith({ path: '/b', query: {}, params: {}, rest: '' }); + + A.resetHistory(); + B.resetHistory(); + navigation.navigate('/'); + await sleep(1); + + expect(scratch).to.have.property('innerHTML', '

A

hello

'); + expect(B).not.to.have.been.called; + expect(A).to.have.been.calledOnce; + expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' }); + }); + + it('rerenders same-component routes rather than swap', async () => { + const A = sinon.fake(() =>

a

); + const B = sinon.fake(groggy(({ sub }) =>

b/{sub}

, 1)); + + // Counts the wrappers around route components to determine what the Router is returning + // Count will be 2 for switching route components, and 2 more if the new route is lazily loaded + // A same-route navigation adds 1 + let renderRefCount = 0; + + const old = options.vnode; + options.vnode = (vnode) => { + if (typeof vnode.type === 'function' && vnode.props.r !== undefined) { + renderRefCount += 1; + } + + if (old) old(vnode); + } + + render( + + + +
+ +
+ + + , + scratch + ); + + expect(scratch).to.have.property('innerHTML', '

a

'); + expect(renderRefCount).to.equal(2); + + renderRefCount = 0; + navigation.navigate('/b/a'); + await sleep(10); + + expect(scratch).to.have.property('innerHTML', '

b/a

'); + expect(renderRefCount).to.equal(4); + + renderRefCount = 0; + navigation.navigate('/b/b'); + await sleep(10); + + expect(scratch).to.have.property('innerHTML', '

b/b

'); + expect(renderRefCount).to.equal(1); + + renderRefCount = 0; + navigation.navigate('/'); + await sleep(10); + + expect(scratch).to.have.property('innerHTML', '

a

'); + expect(renderRefCount).to.equal(2); + + options.vnode = old; + }); + + it('should support onLoadStart/onLoadEnd/onRouteChange w/out navigation', async () => { + const route = name => ( + <> +

{name}

+

hello

+ + ); + const A = sinon.fake(groggy(() => route('A'), 1)); + const loadStart = sinon.fake(); + const loadEnd = sinon.fake(); + const routeChange = sinon.fake(); + + render( + + + +
+ + + , + scratch + ); + + expect(scratch).to.have.property('innerHTML', ''); + expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' }); + expect(loadStart).to.have.been.calledWith('/'); + expect(loadEnd).not.to.have.been.called; + expect(routeChange).not.to.have.been.called; + + A.resetHistory(); + loadStart.resetHistory(); + loadEnd.resetHistory(); + routeChange.resetHistory(); + await sleep(1); + + expect(scratch).to.have.property('innerHTML', '

A

hello

'); + expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' }); + expect(loadStart).not.to.have.been.called; + expect(loadEnd).to.have.been.calledWith('/'); + expect(routeChange).not.to.have.been.called; + }); + + it('should support onLoadStart/onLoadEnd/onRouteChange w/ navigation', async () => { + const route = name => ( + <> +

{name}

+

hello

+ + ); + const A = sinon.fake(() => route('A')); + const B = sinon.fake(groggy(() => route('B'), 1)); + const loadStart = sinon.fake(); + const loadEnd = sinon.fake(); + const routeChange = sinon.fake(); + + render( + + + +
+ + + + + , + scratch + ); + + A.resetHistory(); + loadStart.resetHistory(); + loadEnd.resetHistory(); + routeChange.resetHistory(); + + navigation.navigate('/b'); + await sleep(1); + + expect(loadStart).to.have.been.calledWith('/b'); + expect(loadEnd).not.to.have.been.called; + expect(routeChange).not.to.have.been.called; + + A.resetHistory(); + loadStart.resetHistory(); + loadEnd.resetHistory(); + routeChange.resetHistory(); + await sleep(10); + + expect(scratch).to.have.property('innerHTML', '

B

hello

'); + expect(loadStart).not.to.have.been.called; + expect(loadEnd).to.have.been.calledWith('/b'); + expect(routeChange).to.have.been.calledWith('/b'); + }); + + it('should only call onLoadEnd once upon promise flush', async () => { + const route = name => ( + <> +

{name}

+

hello

+ + ); + const A = sinon.fake(groggy(() => route('A'), 1)); + const loadEnd = sinon.fake(); + + /** @type {(string) => void} */ + let set; + + const App = () => { + set = useState('1')[1]; + return ( + + + +
+ + + + ); + } + render(, scratch); + + await sleep(10); + + expect(loadEnd).to.have.been.calledWith('/'); + loadEnd.resetHistory(); + + set('2'); + await sleep(1); + + expect(loadEnd).not.to.have.been.called; + }); + + describe.skip('intercepted VS external links', () => { + const shouldIntercept = [null, '', '_self', 'self', '_SELF']; + const shouldNavigate = ['_top', '_parent', '_blank', 'custom', '_BLANK']; + + const clickHandler = sinon.fake(e => e.preventDefault()); + + const Route = sinon.fake( + () => + ); + + let pushState; + + before(() => { + pushState = sinon.spy(history, 'pushState'); + addEventListener('click', clickHandler); + }); + + after(() => { + pushState.restore(); + removeEventListener('click', clickHandler); + }); + + beforeEach(async () => { + render( + + + + + + , + scratch + ); + Route.resetHistory(); + clickHandler.resetHistory(); + pushState.resetHistory(); + }); + + const getName = target => (target == null ? 'no target attribute' : `target="${target}"`); + + // these should all be intercepted by the router. + for (const target of shouldIntercept) { + it(`should intercept clicks on links with ${getName(target)}`, async () => { + const sel = target == null ? `a:not([target])` : `a[target="${target}"]`; + const el = scratch.querySelector(sel); + if (!el) throw Error(`Unable to find link: ${sel}`); + const url = el.getAttribute('href'); + el.click(); + await sleep(1); + expect(loc).to.deep.include({ url }); + expect(Route).to.have.been.calledOnce; + expect(pushState).to.have.been.calledWith(null, '', url); + expect(clickHandler).to.have.been.called; + }); + } + + // these should all navigate. + for (const target of shouldNavigate) { + it(`should allow default browser navigation for links with ${getName(target)}`, async () => { + const sel = target == null ? `a:not([target])` : `a[target="${target}"]`; + const el = scratch.querySelector(sel); + if (!el) throw Error(`Unable to find link: ${sel}`); + el.click(); + await sleep(1); + expect(Route).not.to.have.been.called; + expect(pushState).not.to.have.been.called; + expect(clickHandler).to.have.been.called; + }); + } + }); + + describe.skip('intercepted VS external links with `scope`', () => { + const shouldIntercept = ['/app', '/app/deeper']; + const shouldNavigate = ['/site', '/site/deeper']; + + const clickHandler = sinon.fake(e => e.preventDefault()); + + const Links = () => ( + <> + Internal Link + Internal Deeper Link + External Link + External Deeper Link + + ); + + let pushState; + + before(() => { + pushState = sinon.spy(history, 'pushState'); + addEventListener('click', clickHandler); + }); + + after(() => { + pushState.restore(); + removeEventListener('click', clickHandler); + }); + + beforeEach(async () => { + clickHandler.resetHistory(); + pushState.resetHistory(); + }); + + it('should intercept clicks on links matching the `scope` props (string)', async () => { + render( + + + + , + scratch + ); + + for (const url of shouldIntercept) { + scratch.querySelector(`a[href="${url}"]`).click(); + await sleep(1); + expect(loc).to.deep.include({ url }); + expect(pushState).to.have.been.calledWith(null, '', url); + expect(clickHandler).to.have.been.called; + + pushState.resetHistory(); + clickHandler.resetHistory(); + } + }); + + it('should allow default browser navigation for links not matching the `scope` props (string)', async () => { + render( + + + + , + scratch + ); + + for (const url of shouldNavigate) { + scratch.querySelector(`a[href="${url}"]`).click(); + await sleep(1); + expect(pushState).not.to.have.been.called; + expect(clickHandler).to.have.been.called; + + pushState.resetHistory(); + clickHandler.resetHistory(); + } + }); + + it('should intercept clicks on links matching the `scope` props (regex)', async () => { + render( + + + + , + scratch + ); + + for (const url of shouldIntercept) { + scratch.querySelector(`a[href="${url}"]`).click(); + await sleep(1); + expect(loc).to.deep.include({ url }); + expect(pushState).to.have.been.calledWith(null, '', url); + expect(clickHandler).to.have.been.called; + + pushState.resetHistory(); + clickHandler.resetHistory(); + } + }); + + it('should allow default browser navigation for links not matching the `scope` props (regex)', async () => { + render( + + + + , + scratch + ); + + for (const url of shouldNavigate) { + scratch.querySelector(`a[href="${url}"]`).click(); + await sleep(1); + expect(pushState).not.to.have.been.called; + expect(clickHandler).to.have.been.called; + + pushState.resetHistory(); + clickHandler.resetHistory(); + } + }); + }); + + it('should ignore clicks on document fragment links', async () => { + const Route = sinon.fake( + () => + ); + + render( + + + + + + + + , + scratch + ); + + expect(Route).to.have.been.calledOnce; + Route.resetHistory(); + + scratch.querySelector('a[href="#foo"]').click(); + await sleep(1); + + // NOTE: we don't (currently) propagate in-page anchor navigations into context, to avoid useless renders. + expect(loc).to.deep.include({ url: '/' }); + expect(Route).not.to.have.been.called; + expect(location.hash).to.equal('#foo'); + + scratch.querySelector('a[href="/other#bar"]').click(); + await sleep(1); + + expect(Route).to.have.been.calledOnce; + expect(loc).to.deep.include({ url: '/other#bar', path: '/other' }); + expect(location.hash).to.equal('#bar'); + }); + + it('should ignore clicks on download links', async () => { + const downloadHref = URL.createObjectURL(new Blob(['Hello World!'], { type: 'text/plain' })); + + render( + + + Download Me + + + , + scratch + ); + + scratch.querySelector('a[download]').click(); + await sleep(1); + + // If the router attempted to navigate, the page would throw a SecurityError + // and the test would fail. + expect(true).to.equal(true); + }); + + it('should normalize children', async () => { + const Route = sinon.fake(() => foo); + + const routes = ['/foo', '/bar']; + render( + + + {routes.map(route => )} + + + + , + scratch + ); + + expect(Route).to.have.been.calledOnce; + Route.resetHistory(); + + scratch.querySelector('a[href="/foo#foo"]').click(); + await sleep(10); + + expect(Route).to.have.been.calledOnce; + expect(loc).to.deep.include({ url: '/foo#foo', path: '/foo' }); + }); + + it('should match nested routes', async () => { + let route; + const Inner = () => ( + + { + route = useRoute(); + return null; + }} + /> + + ); + + render( + + + + + + , + scratch + ); + + scratch.querySelector('a[href="/foo/bar/bob"]').click(); + await sleep(1); + expect(route).to.deep.include({ path: '/bob', params: { id: 'bar' } }); + }); + + it('should append params in nested routes', async () => { + let params; + const Inner = () => ( + + { + params = useRoute().params; + return null; + }} + /> + + ); + + render( + + + + + + , + scratch + ); + + scratch.querySelector('a[href="/foo/bar/bob"]').click(); + await sleep(1); + expect(params).to.deep.include({ id: 'bar' }); + }); + + it('should replace the current URL', async () => { + render( + + + null} /> + null} /> + null} /> + + + , + scratch + ); + + navigation.navigate('/foo'); + navigation.navigate('/bar', { history: 'replace' }); + + const entries = navigation.entries(); + + // Top of the stack + const last = new URL(entries[entries.length - 1].url); + expect(last.pathname).to.equal('/bar'); + + // Entry before + const secondLast = new URL(entries[entries.length - 2].url); + expect(secondLast.pathname).to.equal('/'); + }); + + it('should support using `Router` as an implicit suspense boundary', async () => { + let data; + function useSuspense() { + const [_, update] = useState(); + + if (!data) { + data = new Promise(r => setTimeout(r, 5, 'data')); + data.then( + (res) => update((data.res = res)), + (err) => update((data.err = err)) + ); + } + + if (data.res) return data.res; + if (data.err) throw data.err; + throw data; + } + + render( + + + { + const result = useSuspense(); + return

{result}

; + }} + /> +
+ +
, + scratch + ); + + expect(scratch).to.have.property('textContent', ''); + await sleep(10); + expect(scratch).to.have.property('textContent', 'data'); + }); + + it('should intercept clicks on links inside open shadow DOM', async () => { + const shadowlink = document.createElement('a'); + shadowlink.href = '/shadow'; + shadowlink.textContent = 'Shadow Link'; + + const attachShadow = (el) => { + if (!el || el.shadowRoot) return; + const shadowroot = el.attachShadow({ mode: 'open' }); + shadowroot.appendChild(shadowlink); + } + + const Home = sinon.fake(() =>
); + const Shadow = sinon.fake(() =>
Shadow Route
); + + render( + + + + + + + , + scratch + ); + + shadowlink.click(); + + await sleep(1); + + expect(loc).to.deep.include({ url: '/shadow' }); + expect(Shadow).to.have.been.calledOnce; + expect(scratch).to.have.property('textContent', 'Shadow Route'); + }); + + it('should not preserve param state after match failures', async () => { + const Params = () => { + const { params } = useRoute(); + return

{JSON.stringify(params)}

+ }; + + render( + + + + + + + + , + scratch + ); + + navigation.navigate('/category/123'); + await sleep(10); + + expect(scratch).to.have.property('textContent', '{"id":"123"}'); + + navigation.navigate('/category/123/products/new'); + await sleep(10); + + // If the same `params` object was reused, this would also have an `id` property + // from a failed partial match against the first route. + expect(scratch).to.have.property('textContent', '{"categoryId":"123"}'); + + navigation.navigate('/category/123/products/456/edit'); + await sleep(10); + + expect(scratch).to.have.property('textContent', '{"categoryId":"123","id":"456"}'); + }); + + it('should support navigating backwards and forwards', async () => { + render( + + + null} /> + null} /> + + + , + scratch + ); + + navigation.navigate('/foo'); + await sleep(10); + + expect(loc).to.deep.include({ url: '/foo', path: '/foo', query: {} }); + + await navigation.back().finished; + await sleep(10); + + expect(loc).to.deep.include({ url: '/', path: '/', query: {} }); + + await navigation.forward().finished; + await sleep(10); + + expect(loc).to.deep.include({ url: '/foo', path: '/foo', query: {} }); + }); +}); + +const MODE_HYDRATE = 1 << 5; +const MODE_SUSPENDED = 1 << 7; + +describe('hydration', () => { + let scratch; + + beforeEach(() => { + if (scratch) { + render(null, scratch); + scratch.remove(); + } + scratch = document.createElement('scratch'); + document.body.appendChild(scratch); + history.replaceState(null, null, '/'); + }); + + it('should wait for asynchronous routes', async () => { + scratch.innerHTML = '

A

hello

'; + const route = name => ( +
+

{name}

+

hello

+
+ ); + const A = sinon.fake(groggy(() => route('A'), 1)); + + hydrate( + + + + + + + , + scratch + ); + + const mutations = []; + const mutationObserver = new MutationObserver((x) => { + mutations.push(...x) + }); + mutationObserver.observe(scratch, { childList: true, subtree: true }); + + expect(scratch).to.have.property('innerHTML', '

A

hello

'); + expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' }); + const oldOptionsVnode = options.__b; + let hasMatched = false; + options.__b = (vnode) => { + if (vnode.type === A && !hasMatched) { + hasMatched = true; + if (vnode.__ && vnode.__.__h) { + expect(vnode.__.__h).to.equal(true) + } else if (vnode.__ && vnode.__.__u) { + expect(!!(vnode.__.__u & MODE_SUSPENDED)).to.equal(true); + expect(!!(vnode.__.__u & MODE_HYDRATE)).to.equal(true); + } else { + expect(true).to.equal(false); + } + } + + if (oldOptionsVnode) { + oldOptionsVnode(vnode); + } + } + A.resetHistory(); + await sleep(10); + + expect(scratch).to.have.property('innerHTML', '

A

hello

'); + expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' }); + expect(mutations).to.have.length(0); + + options.__b = oldOptionsVnode; + }); +})