diff --git a/.changeset/neat-wasps-mix.md b/.changeset/neat-wasps-mix.md new file mode 100644 index 000000000..1a61564f3 --- /dev/null +++ b/.changeset/neat-wasps-mix.md @@ -0,0 +1,5 @@ +--- +'preact-iso': minor +--- + +Support route params and inject them into the rendered route, add the `useRoute` hook so we can retrieve route parameters from anywhere in the subtree. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 967fc3e9b..930af9710 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,9 +6,11 @@ on: - main paths: - packages/wmr/** + - packages/preact-iso/** pull_request: paths: - packages/wmr/** + - packages/preact-iso/** jobs: build: @@ -28,8 +30,11 @@ jobs: run: yarn --frozen-lockfile - name: Build run: yarn workspace wmr build - - name: Test + - name: Test wmr run: yarn workspace wmr test + - name: Test preact-iso + run: yarn workspace preact-iso test + lhci: runs-on: ubuntu-latest steps: diff --git a/package.json b/package.json index d8f7be3f7..c6d1afb12 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "eslintIgnore": [ "*.cjs", + "*.ts", "packages/wmr/test/fixtures/**/*.expected.*", "packages/wmr/test/fixtures/*/dist", "packages/wmr/test/fixtures/*/.cache" diff --git a/packages/preact-iso/README.md b/packages/preact-iso/README.md index e165f57ff..cd1fbd1b9 100644 --- a/packages/preact-iso/README.md +++ b/packages/preact-iso/README.md @@ -85,20 +85,24 @@ import { LocationProvider, Router, useLoc } from 'preact-iso/router'; // Asynchronous (throws a promise) const Home = lazy(() => import('./routes/home.js')); const Profile = lazy(() => import('./routes/profile.js')); +const Profiles = lazy(() => import('./routes/profiles.js')); const App = () => ( - + + ); ``` -During prerendering, the generated HTML includes our full `` and `` component output because it waits for the `lazy()`-wrapped `import()` to resolve. +During prerendering, the generated HTML includes our full `` and `` component output because it waits for the `lazy()`-wrapped `import()` to resolve. + +You can use the `useRoute` hook to get information of the route you are currently on. **Progressive Hydration:** When the app is hydrated on the client, the route (`Home` or `Profile` in this case) suspends. This causes hydration for that part of the page to be deferred until the route's `import()` is resolved, at which point that part of the page automatically finishes hydrating. diff --git a/packages/preact-iso/index.d.ts b/packages/preact-iso/index.d.ts index f0915719f..e44a1801c 100644 --- a/packages/preact-iso/index.d.ts +++ b/packages/preact-iso/index.d.ts @@ -1,11 +1,4 @@ -import { VNode } from 'preact'; -import { PrerenderOptions } from './prerender'; - +export { default as prerender } from './prerender'; export { Router, LocationProvider, useLoc, useLocation } from './router'; export { default as lazy, ErrorBoundary } from './lazy'; export { default as hydrate } from './hydrate'; - -export default function prerender( - vnode: VNode, - options?: PrerenderOptions -): Promise<{ html: string; links: Set }>; diff --git a/packages/preact-iso/package.json b/packages/preact-iso/package.json index f34f1f0d1..df5b1f658 100644 --- a/packages/preact-iso/package.json +++ b/packages/preact-iso/package.json @@ -6,6 +6,9 @@ "module": "./index.js", "types": "./index.d.ts", "type": "module", + "scripts": { + "test": "node --experimental-vm-modules node_modules/.bin/jest" + }, "exports": { ".": "./index.js", "./router": "./router.js", @@ -21,6 +24,7 @@ }, "license": "MIT", "devDependencies": { + "jest": "26.6.3", "preact": "^10.5.7", "preact-render-to-string": "^5.1.12" }, diff --git a/packages/preact-iso/prerender.d.ts b/packages/preact-iso/prerender.d.ts index 794b91626..4e9673367 100644 --- a/packages/preact-iso/prerender.d.ts +++ b/packages/preact-iso/prerender.d.ts @@ -5,7 +5,12 @@ export interface PrerenderOptions { props?: Record; } +export interface PrerenderResult { + html: string; + links?: Set +} + export default function prerender( vnode: VNode, options?: PrerenderOptions -): Promise<{ html: string; links: Set }>; +): Promise; diff --git a/packages/preact-iso/router.d.ts b/packages/preact-iso/router.d.ts index 0ee0ffc73..466d2a22e 100644 --- a/packages/preact-iso/router.d.ts +++ b/packages/preact-iso/router.d.ts @@ -4,9 +4,17 @@ export const LocationProvider: FunctionComponent; export function Router(props: { onLoadEnd?: () => void; onLoadStart?: () => void; children?: VNode[] }): VNode; -type LocationHook = { url: string; path: string; query: Record; route: (url: string) => void }; -export const useLoc: () => LocationHook; +interface LocationHook { + url: string; + path: string; + query: Record; + route: (url: string) => void; +}; export const useLocation: () => LocationHook; +/** @deprecated renamed to useLocation() */ +export const useLoc: () => LocationHook; + +export const useRoute: () => { [key: string]: string }; interface RoutableProps { path?: string; diff --git a/packages/preact-iso/router.js b/packages/preact-iso/router.js index f943a0730..a000ddb8d 100644 --- a/packages/preact-iso/router.js +++ b/packages/preact-iso/router.js @@ -18,6 +18,28 @@ const UPDATE = (state, url, push) => { return url; }; +export const exec = (url, route, matches) => { + url = url.trim('/').split('/'); + route = (route || '').trim('/').split('/'); + for (let i = 0, val; 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; + // segment mismatch / missing required field: + if (!m || (!val && flag != '?' && flag != '*')) return; + // field match: + matches[param] = val && decodeURIComponent(val); + // normal/optional field: + if (flag >= '?') continue; + // rest (+/*) match: + matches[param] = url.slice(i).map(decodeURIComponent).join('/'); + break; + } + + return matches; +}; + export function LocationProvider(props) { const [url, route] = useReducer(UPDATE, location.pathname + location.search); @@ -82,20 +104,24 @@ export function Router(props) { } else commit(); }, [url]); - const children = [].concat(...props.children); + let p, d, m; + [].concat(props.children || []).some(vnode => { + const matches = exec(path, vnode.props.path, (m = { path, query })); + if (matches) { + return (p = h(RouteContext.Provider, { value: m }, cloneElement(vnode, m))); + } - let a = children.filter(c => c.props.path === path); - - if (a.length == 0) a = children.filter(c => c.props.default); - - curChildren.current = a.map((p, i) => cloneElement(p, { path, query })); + if (vnode.props.default) d = cloneElement(vnode, m); + }); - return curChildren.current.concat(prevChildren.current || []); + return [(curChildren.current = p || d), prevChildren.current]; } Router.Provider = LocationProvider; LocationProvider.ctx = createContext(/** @type {{ url: string, path: string, query: object, route }} */ ({})); +const RouteContext = createContext({}); export const useLoc = () => useContext(LocationProvider.ctx); export const useLocation = useLoc; +export const useRoute = () => useContext(RouteContext); diff --git a/packages/preact-iso/test/match.test.js b/packages/preact-iso/test/match.test.js new file mode 100644 index 000000000..124b7f571 --- /dev/null +++ b/packages/preact-iso/test/match.test.js @@ -0,0 +1,27 @@ +import { exec } from '../router.js'; + +describe('match', () => { + it('Base route', () => { + const accurateResult = exec('/', '/', { path: '/' }); + expect(accurateResult).toEqual({ path: '/' }); + + const inaccurateResult = exec('/user/1', '/', { path: '/' }); + expect(inaccurateResult).toEqual(undefined); + }); + + it('Param route', () => { + const accurateResult = exec('/user/2', '/user/:id', { path: '/' }); + expect(accurateResult).toEqual({ path: '/', id: '2' }); + + const inaccurateResult = exec('/', '/user/:id', { path: '/' }); + expect(inaccurateResult).toEqual(undefined); + }); + + it('Optional param route', () => { + const accurateResult = exec('/user', '/user/:id?', { path: '/' }); + expect(accurateResult).toEqual({ path: '/' }); + + const inaccurateResult = exec('/', '/user/:id?', { path: '/' }); + expect(inaccurateResult).toEqual(undefined); + }); +}); diff --git a/packages/wmr/demo/public/index.tsx b/packages/wmr/demo/public/index.tsx index 08d5380fb..0152f23cc 100644 --- a/packages/wmr/demo/public/index.tsx +++ b/packages/wmr/demo/public/index.tsx @@ -1,5 +1,5 @@ import { h, render } from 'preact'; -import { Loc, Router } from './lib/loc.js'; +import { LocationProvider, Router } from './lib/loc.js'; import lazy, { ErrorBoundary } from './lib/lazy.js'; import Home from './pages/home.js'; // import About from './pages/about/index.js'; @@ -15,7 +15,7 @@ const Environment = lazy(async () => (await import('./pages/environment/index.js export function App() { return ( - +
@@ -30,7 +30,7 @@ export function App() {
-
+ ); } diff --git a/packages/wmr/demo/public/lib/loc.js b/packages/wmr/demo/public/lib/loc.js index 2f159a4a4..5278bcb62 100644 --- a/packages/wmr/demo/public/lib/loc.js +++ b/packages/wmr/demo/public/lib/loc.js @@ -5,51 +5,89 @@ const UPDATE = (state, url, push) => { if (url && url.type === 'click') { const link = url.target.closest('a[href]'); if (!link || link.origin != location.origin) return state; + url.preventDefault(); push = true; url = link.href.replace(location.origin, ''); - } else if (typeof url !== 'string') url = location.pathname + location.search; + } else if (typeof url !== 'string') { + url = location.pathname + location.search; + } + if (push === true) history.pushState(null, '', url); else if (push === false) history.replaceState(null, '', url); return url; }; -export function Loc(props) { +const exec = (url, route, matches) => { + url = url.trim('/').split('/'); + route = (route || '').trim('/').split('/'); + for (let i = 0, val; 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; + // segment mismatch / missing required field: + if (!m || (!val && flag != '?' && flag != '*')) return; + // field match: + matches[param] = val && decodeURIComponent(val); + // normal/optional field: + if (flag >= '?') continue; + // rest (+/*) match: + matches[param] = url.slice(i).map(decodeURIComponent).join('/'); + break; + } + + return matches; +}; + +export function LocationProvider(props) { const [url, route] = useReducer(UPDATE, location.pathname + location.search); + const value = useMemo(() => { const u = new URL(url, location.origin); const path = u.pathname.replace(/(.)\/$/g, '$1'); + // @ts-ignore-next return { url, path, query: Object.fromEntries(u.searchParams), route }; }, [url]); + useEffect(() => { addEventListener('click', route); addEventListener('popstate', route); + return () => { removeEventListener('click', route); removeEventListener('popstate', route); }; }); - return h(Loc.ctx.Provider, { value }, props.children); + + // @ts-ignore + return h(LocationProvider.ctx.Provider, { value }, props.children); } export function Router(props) { const [, update] = useReducer(c => c + 1, 0); + const loc = useLoc(); + const { url, path, query } = loc; + const cur = useRef(loc); const prev = useRef(); const curChildren = useRef(); const prevChildren = useRef(); const pending = useRef(); + if (url !== cur.current.url) { pending.current = null; prev.current = cur.current; prevChildren.current = curChildren.current; cur.current = loc; } + this.componentDidCatch = err => { if (err && err.then) pending.current = err; }; + useEffect(() => { let p = pending.current; const commit = () => { @@ -58,20 +96,37 @@ export function Router(props) { prev.current = prevChildren.current = null; update(0); }; + if (p) { if (props.onLoadStart) props.onLoadStart(url); p.then(commit); } else commit(); }, [url]); - const children = [].concat(...props.children); - let a = children.filter(c => c.props.path === path); - if (a.length == 0) a = children.filter(c => c.props.default); - curChildren.current = a.map((p, i) => cloneElement(p, { path, query })); - return curChildren.current.concat(prevChildren.current || []); + + let p, d, m; + [].concat(props.children || []).some(vnode => { + const matches = exec(path, vnode.props.path, (m = { path, query })); + if (matches) { + return p = ( + + {cloneElement(vnode, { ...m, ...matches })} + + ); + } else { + if (vnode.props.default) d = cloneElement(vnode, m); + return undefined; + } + }); + + return [(curChildren.current = p || d), prevChildren.current]; } -Loc.Router = Router; +Router.Provider = LocationProvider; + +LocationProvider.ctx = createContext(/** @type {{ url: string, path: string, query: object, route }} */ ({})); -Loc.ctx = createContext(/** @type {{ url: string, path: string, query: object, route }} */ ({})); +const RouteContext = createContext({}); -export const useLoc = () => useContext(Loc.ctx); +export const useLoc = () => useContext(LocationProvider.ctx); +export const useLocation = useLoc; +export const useRoute = () => useContext(RouteContext); diff --git a/yarn.lock b/yarn.lock index ef577b09c..3d6d634d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5627,7 +5627,7 @@ jest-worker@^26.2.1, jest-worker@^26.6.2: merge-stream "^2.0.0" supports-color "^7.0.0" -jest@^26.1.0: +jest@26.6.3, jest@^26.1.0: version "26.6.3" resolved "https://registry.yarnpkg.com/jest/-/jest-26.6.3.tgz#40e8fdbe48f00dfa1f0ce8121ca74b88ac9148ef" integrity sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q==