diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..43ff7f7 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "loading-modes-demo", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "demo", "run", "dev", "--", "--port", "5273"], + "port": 5273 + } + ] +} diff --git a/.gitignore b/.gitignore index 1e6c00c..8ec6263 100755 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules coverage .DS_Store docs/public -docs/resources/_gen \ No newline at end of file +docs/resources/_gen +demo/dist diff --git a/.oxfmtignore b/.oxfmtignore index 31319e8..a192d03 100644 --- a/.oxfmtignore +++ b/.oxfmtignore @@ -3,4 +3,5 @@ dist docs/static docs/assets docs/public -examples/node_modules/* \ No newline at end of file +demo/dist +demo/node_modules/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d35e4c..d4f211b 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.7.0 + +- **Breaking**: move route state inside `` and remove the old prop-based lifecycle hooks (`useRoute`, `onNavigating`, `onNavigated`). Read the current route with `useRoute()` and observe committed route changes with regular React effects. +- **Breaking**: remove function-form `` props and `extraProps`. Use `aria-current="page"` for CSS styling, or `useLinkProps()` when active/pending state needs to affect rendered output. +- Add Suspense-aware route transitions backed by React `useTransition`, exposed through `usePending()`. +- Add route `resolver` support for code-split route segments. Resolvers are preloaded during navigation and rendered through `React.lazy`. +- Add route `prepare(ctx)` support for fetch-as-you-render data loading. Returned `PreparedHandle`s are pinned while the route is committed and released on the next commit or `` unmount. +- Add `` and `pendingDelayMs` for delayed skeleton fallbacks during in-flight route navigations. +- Add `transformRoute(route)` for synchronous pre-commit route rewrites, including URL replacement when the transformed route changes `url`. +- Inject matched path params as props onto the route segment that declares each param. +- Add per-link pending state through `useLinkProps(to).isPending`. +- Add `scrollGroup` for keeping scroll position across related routes. +- Preserve normal browser behavior for modified clicks, middle-clicks, downloads, non-self targets, and cross-origin links. + ## 0.6.6 - Upgrade all dependencies to address security alerts. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..39c02bd --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,277 @@ +# Migration Guide + +## 0.6.x → 0.7.0 + +The Router lifecycle is reframed: the router now owns route state internally +(via `useState` + `useTransition`) and applies it inside a React transition. The +old prop-based escape hatches (`onNavigating`, `onNavigated`, `useRoute`) are +gone, replaced by a single pre-commit transform and an explicit pending hook. + +This is a breaking change for any app that stored route state outside the +router or did async work in `onNavigating`. + +### Surface changes + +| Before | After | +| --- | --- | +| `` | `` | +| External Redux/atom-backed route state | Internal `useState` only | +| Manual `navigating: true/false` flag | `usePending()` | +| `route.data[i].component` only | `route.data[i].component` **or** `resolver: () => import(...)` | +| (no equivalent) | `route.data[i].prepare(ctx)` returning `PreparedHandle[]` | + +### Why the change + +`useTransition` only works if the router itself owns the state update. If route +state lives in your Redux/Zustand store, the router's commit goes through your +dispatch — which isn't wrapped in `startTransition` — and Suspense fires +fallbacks at the wrong moment. + +The same applies to delayed fallbacks: the router needs to know when a +transition is in flight so `` can hold the previous route for +`pendingDelayMs` before showing a skeleton. If state lives outside, you'd have +to expose "pending next route" + "previous route" + "has the threshold elapsed" +through your store. That's the router's job, leaking. + +### Migrating `onNavigating` + +`onNavigating` was used for two things. Both have replacements: + +**1. Awaiting `routeData.resolver()` to attach the component.** + +Before: + +```ts +const onNavigating = async (route) => { + await Promise.all( + route.data.map(async (rd) => { + if (!rd.component && rd.resolver) { + rd.component = await rd.resolver() + } + }), + ) +} +``` + +After: delete it. Declare `resolver` on the route segment and the router +preloads + renders via `React.lazy` automatically: + +```ts +{ path: '/issues/:id', resolver: () => import('./pages/IssueDetail') } +``` + +The destination's `` boundary handles the still-cold render. + +**2. Toggling a `navigating` flag for a top-of-page loading bar.** + +Before: + +```ts +const onNavigating = async (route) => { + store.set(routerAtom, (s) => ({ ...s, navigating: true })) + // ... await stuff ... +} +const onNavigated = (route) => { + store.set(routerAtom, (s) => ({ ...s, navigating: false })) +} +``` + +After: + +```ts +import { usePending } from 'react-space-router' + +function LoadingBar() { + const pending = usePending() + return pending ? : null +} +``` + +`usePending()` reads React's transition pending state directly — no manual flag +needed. + +### Migrating `onNavigated` + +Most uses are observers and become plain effects on `useRoute()`. + +**Analytics:** + +```ts +// Before +const onNavigated = (route) => trackPageView(route, prev) + +// After +const route = useRoute() +useEffect(() => trackPageView(route), [route]) +``` + +**Previous route tracking:** + +```ts +// Before +const onNavigated = (route) => store.set(routerAtom, (s) => ({ + ...s, route, previousRoute: s.route, +})) + +// After +const route = useRoute() +const prev = usePrevious(route) // your standard usePrevious hook +``` + +**Param reduction across `route.data`:** + +This was usually done because product code wanted to merge `params` declared on +ancestor segments. Move it to a `useRoute()` selector: + +```ts +function useMergedParams() { + const route = useRoute() + return useMemo( + () => + route?.data.reduce( + (acc, d) => Object.assign(acc, (d as any).params || {}), + { ...route.params }, + ) ?? {}, + [route], + ) +} +``` + +### Migrating persisted-query restoration → `transformRoute` + +This is the one case that genuinely needed pre-commit behavior. `transformRoute` +runs synchronously between match and commit; if it returns a route with a +different `url`, the router calls `history.replaceState` so the address bar +matches. + +Before (`onNavigated` did the rewrite + manual `replaceState`): + +```ts +const onNavigated = (route) => { + const persistKey = getPersistKey(route) + if (persistKey && !hasQuery(route)) { + const saved = persistedQueries.get(persistKey) + if (saved) { + const merged = { ...Object.fromEntries(new URLSearchParams(saved)), ...route.query } + const search = '?' + new URLSearchParams(merged).toString() + const url = route.pathname + search + store.set(routerAtom, (s) => ({ ...s, route: { ...route, query: merged, search, url } })) + window.history.replaceState({}, '', url) + return + } + } + store.set(routerAtom, (s) => ({ ...s, route })) +} +``` + +After: + +```ts +function transformRoute(route) { + const persistKey = getPersistKey(route) + if (!persistKey || hasQuery(route)) return // unchanged + + const saved = persistedQueries.get(persistKey) + if (!saved) return + + const merged = { ...Object.fromEntries(new URLSearchParams(saved)), ...route.query } + const search = '?' + new URLSearchParams(merged).toString() + return { ...route, query: merged, search, url: route.pathname + search } +} + +... +``` + +`transformRoute` must be pure and synchronous. Returning `undefined` (or `void`) +means "commit unchanged". + +### Removing the `useRoute` injection prop + +If you previously did: + +```ts + useSelector(() => routerAtom().route)}> +``` + +…delete it. The router holds route state internally; `useRoute()` from +`react-space-router` is the read API. Components subscribe to it directly. + +The kinfolk/Redux/Zustand atom that mirrored the router's state should be +deleted entirely — it was a relic of the "everything in global state" era and +breaks Suspense's transition contract. + +### Removing function-form `` props + +`` no longer accepts function-form `className`, function-form `style`, or +`extraProps`. Use the `aria-current="page"` attribute that `` already +sets: + +```tsx +// Before + (current ? 'nav active' : 'nav')} /> + +// After + +``` + +```css +.nav[aria-current='page'] { + font-weight: 600; +} +``` + +For active-aware logic that cannot be expressed in CSS, use `useLinkProps()`: + +```tsx +const linkProps = useLinkProps('/settings') +return {linkProps.isCurrent ? 'Settings' : 'Go to settings'} +``` + +User `onClick` handlers now compose with the router's internal click handling. +The user handler runs first; call `event.preventDefault()` to opt out of SPA +navigation for that click. + +### Replacing delayed fallback code with `` + +If you had app-level state to suppress skeletons for the first few milliseconds +of a navigation, delete it and use the built-in boundary: + +```tsx + + + + + +``` + +```tsx +}> + + +``` + +During an in-flight navigation, `` behaves like a regular +`Suspense` boundary after the router-level `pendingDelayMs` threshold. Before +that threshold, its fallback re-suspends so the already-committed route stays on +screen. + +### What's *not* changing + +- Route definition shape (`{ path, component, routes, ... }`) is unchanged. New + fields (`resolver`, `prepare`, `navigation`, `scrollGroup`) are additive. +- ``, ``, ``, `useLinkProps`, `useMakeHref`, + `useNavigate`, `qs` — unchanged. +- ESM-default components (`{ default: Component }`) still resolve via plain + `component:` — you don't have to switch to `resolver:` unless you want the + preload-and-suspend behavior. +- The `reduceRight` segment-rendering model stays. No `` — parents + receive `children` like any React component. + +### What's still not included + +These are still outside 0.7.0: + +- `` per-instance `delayMs` override (today only the + Router-level `pendingDelayMs` is configurable). +- `useBlocker(predicate)` for cancellable navigation guards (unsaved-changes + prompts). Don't try to rebuild this with `transformRoute` — different shape. diff --git a/README.md b/README.md index cc55195..fc250f8 100755 --- a/README.md +++ b/README.md @@ -6,19 +6,29 @@

Space Router bindings for React


-React Space Router is a set of hooks and components for keeping your app in sync with the url and performing page navigations. A library built by and used at [Humaans](https://humaans.io/). +React Space Router is a set of hooks and components for keeping your app in sync with the URL and performing page navigations. Suspense-aware and built around React's transition machinery. A library built by and used at [Humaans](https://humaans.io/). - React hooks based - Nested routes -- Async navigation middleware +- Code-split routes via `resolver` (`React.lazy` under the hood) +- Per-route `prepare(ctx)` for fetch-as-you-render data loading +- Pending state via `usePending()` (backed by `useTransition`) +- Delayed route fallbacks via `` +- Optional pre-commit `transformRoute` hook for URL rewrites +- Path params injected as component props - Built in query string parser -- Supports external stores for router state -- Scrolls to top after navigation +- Scrolls to top after navigation, with `scrollGroup` support - Preserves cmd/ctrl/alt/shift click and mouse middle click ## Why -"Perfection is achieved when there is nothing left to take away." React Space Router is built upon Space Router, a framework agnostic tiny core that handles url listening, route matching and navigation. React Space Router wraps that core into an idiomatic set of React components and hooks. The hope is you'll find React Space Router refreshingly simple compared to the existing alternatives, while still offering enough extensibility. +"Perfection is achieved when there is nothing left to take away." React Space Router is built upon Space Router, a framework agnostic tiny core that handles URL listening, route matching and navigation. React Space Router wraps that core into an idiomatic set of React components and hooks. The hope is you'll find React Space Router refreshingly simple compared to the existing alternatives, while still offering enough extensibility for modern Suspense-driven UIs. + +## Scope + +RSP is a client-side React router for production SPAs. SSR is intentionally out of scope. + +If you need SSR, use a framework/router designed around request-time rendering. RSP optimizes for rich authenticated apps where client routing, Suspense-aware navigation, and data cache lifecycles matter more than first-request HTML. ## Install @@ -28,4 +38,79 @@ $ npm install react-space-router ## API -See the [API Docs](https://humaans.github.io/react-space-router/) for more details. +```tsx +import { Suspense } from 'react' +import { Router, Routes, Link, DelayedSuspense } from 'react-space-router' + +const routes = [ + { path: '/', component: Home }, + { + path: '/issues/:id', + resolver: () => import('./IssueDetail'), + prepare: ({ params }) => [ + issueStore.prepare({ id: Number(params.id) }), + ], + }, +] + +export function App() { + return ( + + + + + + ) +} + +function Home() { + return Open issue +} + +function IssueSection() { + return ( + }> + + + ) +} +``` + +### Core components + +- `` owns route state internally and commits route changes inside React transitions. Props: `mode`, `qs`, `sync`, `transformRoute`, `pendingDelayMs`. +- `` matches the current URL, preloads matched `resolver()` chunks, runs matched `prepare(ctx)` functions, pins returned handles, renders nested route segments, injects each segment's own path params as props, and handles scroll-to-top. +- `` renders an anchor with SPA navigation while preserving modified clicks, middle click, downloads, external URLs, and user `onClick` cancellation. +- `` performs a navigation on mount. +- `` behaves like `Suspense`, except during an in-flight router transition it holds the previous route until `pendingDelayMs` has elapsed, then renders its fallback. + +### Hooks + +- `useRoute()` returns the current route: `{ url, pathname, params, query, search, hash, pattern, data }`. +- `useNavigate()` returns a programmatic navigation function accepting a string URL or Space Router target object. +- `usePending()` returns React transition pending state for route navigation. +- `useLinkProps(to)` returns anchor props plus non-enumerable `isCurrent` and `isPending`. +- `useMakeHref()` returns the underlying `router.href` helper. +- `useInternalRouterInstance()` exposes the underlying Space Router instance for rare escape-hatch use. + +### Utilities + +- `shouldNavigate(event)` returns whether a click should be handled by the router or left to the browser. +- `qs` re-exports Space Router's default query string parser. + +### Route data loading + +`prepare(ctx)` receives `{ pathname, url, params, query }` and may return `PreparedHandle[]`: + +```ts +interface PreparedHandle { + promise: Promise + release(): void + priority?: 'route' | 'defer' + key?: string | number +} +``` + +The router calls all matched `prepare()` functions during navigation, keeps the returned handles pinned while the route is committed, and calls `release()` when the next route commits or `` unmounts. + +See the [API Docs](https://humaans.github.io/react-space-router/) and [Migration Guide](./MIGRATION.md) for more details. diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..c8dd0de --- /dev/null +++ b/demo/index.html @@ -0,0 +1,12 @@ + + + + + + react-space-router · Loading Modes Demo + + +
+ + + diff --git a/demo/package-lock.json b/demo/package-lock.json new file mode 100644 index 0000000..1e35c4b --- /dev/null +++ b/demo/package-lock.json @@ -0,0 +1,1891 @@ +{ + "name": "react-space-router-demo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-space-router-demo", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-space-router": "file:.." + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.9.0", + "vite": "^6.0.0" + } + }, + "..": { + "version": "0.6.6", + "license": "ICS", + "dependencies": { + "space-router": "^1.0.0" + }, + "devDependencies": { + "@swc-node/register": "^1.11.1", + "@swc/core": "^1.15.30", + "@types/react": "^19.0.0", + "ava": "^7.0.0", + "c8": "^11.0.0", + "gh-pages": "^6.3.0", + "jsdom": "^29.0.2", + "oxfmt": "^0.46.0", + "oxlint": "^1.61.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-space-router": { + "resolved": "..", + "link": true + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..a58bc02 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,23 @@ +{ + "name": "react-space-router-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-space-router": "file:.." + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.9.0", + "vite": "^6.0.0" + } +} diff --git a/demo/src/Shell.tsx b/demo/src/Shell.tsx new file mode 100644 index 0000000..ca0d08c --- /dev/null +++ b/demo/src/Shell.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useRef, useState, type ReactNode } from 'react' +import { Link, usePending, useRoute } from 'react-space-router' +import { clearCache } from './data' + +/** + * Top-of-page progress bar — driven by `usePending()`. Renders nothing + * outside of an in-flight transition. + */ +function ProgressBar() { + const pending = usePending() + return pending ?
: null +} + +/** + * Stopwatch that starts when a navigation begins and freezes when it + * commits. Lets you feel the difference between modes in milliseconds. + */ +function NavTimer() { + const pending = usePending() + const [ms, setMs] = useState(0) + const startRef = useRef(null) + + useEffect(() => { + if (pending) { + startRef.current = performance.now() + setMs(0) + const id = setInterval(() => { + if (startRef.current != null) setMs(performance.now() - startRef.current) + }, 16) + return () => clearInterval(id) + } + if (startRef.current != null) { + setMs(performance.now() - startRef.current) + startRef.current = null + } + }, [pending]) + + return ( + + {pending ? 'navigating…' : 'idle'} · {(ms / 1000).toFixed(2)}s + + ) +} + +function CurrentRoute() { + const route = useRoute() + return {route?.pathname ?? '—'} +} + +export function Shell({ children }: { children: ReactNode }) { + const route = useRoute() + const isModeD = route?.pathname?.startsWith('/mode-d/') + + return ( +
+ + +
{children}
+
+ ) +} diff --git a/demo/src/data.ts b/demo/src/data.ts new file mode 100644 index 0000000..83239a3 --- /dev/null +++ b/demo/src/data.ts @@ -0,0 +1,93 @@ +// A tiny suspense-aware data layer used to simulate prepared queries. +// +// Two surfaces: +// prepare(key, ms) → returns a PreparedHandle for the router to await. +// read(key) → reads a previously-prepared value, suspends if pending. +// +// Cache lives in-module — refreshing the page resets it, exactly what the +// demo expects. + +import type { PreparedHandle } from 'react-space-router' + +type Value = { + key: string + fetchedAt: number + latency: number + payload: string +} + +type Entry = { status: 'pending'; promise: Promise } | { status: 'resolved'; value: Value } + +const cache = new Map() + +const PAYLOADS: Record = { + user: 'Karolis Narkevicius — Founder', + bio: 'Building Humaans, an HRIS for modern teams. Lives in London. Likes simple software.', + posts: '12 posts, 4 drafts, last published 3 days ago', + followers: '1,284 followers · 312 following', + feed: 'Feed item: "What I learned building react-space-router 1.0"', + comments: '8 new comments across 3 threads', + analytics: '24,910 views this week · ↑ 14% week-over-week', + related: 'You might also like: figbird, space-router, kinfolk', + 'item-atlas': 'Atlas ships with enterprise SSO, lifecycle reporting, and role-based access controls.', + 'item-beacon': 'Beacon includes approvals, compensation bands, and policy acknowledgements.', + 'item-courier': 'Courier tracks async onboarding, probation milestones, and team introductions.', + 'item-delta': 'Delta monitors payroll checks, banking details, and month-end completion.', +} + +export function clearCache() { + cache.clear() +} + +export function load(key: string, ms: number): Entry { + let entry = cache.get(key) + if (entry) return entry + + const promise = new Promise((resolve) => { + setTimeout(() => { + cache.set(key, { + status: 'resolved', + value: { + key, + fetchedAt: Date.now(), + latency: ms, + payload: PAYLOADS[key] ?? `Payload for ${key}`, + }, + }) + resolve() + }, ms) + }) + + entry = { status: 'pending', promise } + cache.set(key, entry) + return entry +} + +/** Suspense-aware read. Throws the promise if the data isn't ready. */ +export function read(key: string): Value { + const entry = cache.get(key) + if (!entry) { + throw new Error(`read("${key}") called before prepare/load — did you forget to prepare?`) + } + if (entry.status === 'pending') throw entry.promise + return entry.value +} + +/** Returns a PreparedHandle the router can pin while the route is committed. */ +export function prepare(key: string, ms: number): PreparedHandle { + const entry = load(key, ms) + const promise = entry.status === 'pending' ? entry.promise : Promise.resolve() + return { + promise, + release: () => { + // Demo: keep entries around so revisits feel snappy. A real data layer + // would refcount. Use a "Reset cache" button (or full reload) to clear. + }, + } +} + +/** "Slow chunk" simulator for lazy resolvers — wraps a real dynamic import in + * an artificial delay so we can demonstrate code-load behavior on a fast LAN. */ +export function slowImport(ms: number, factory: () => Promise): () => Promise { + return () => new Promise((resolve) => setTimeout(resolve, ms)).then(factory) +} diff --git a/demo/src/main.tsx b/demo/src/main.tsx new file mode 100644 index 0000000..b2b85bf --- /dev/null +++ b/demo/src/main.tsx @@ -0,0 +1,20 @@ +import React, { Suspense } from 'react' +import { createRoot } from 'react-dom/client' +import { Router, Routes } from 'react-space-router' +import { Shell } from './Shell' +import { routes } from './routes' +import './styles.css' + +function App() { + return ( + + + + + + + + ) +} + +createRoot(document.getElementById('root')!).render() diff --git a/demo/src/pages/Home.tsx b/demo/src/pages/Home.tsx new file mode 100644 index 0000000..c48e730 --- /dev/null +++ b/demo/src/pages/Home.tsx @@ -0,0 +1,105 @@ +import React from 'react' + +export default function Home() { + return ( + <> +
+

Loading Modes Demo

+

+ Four pages. Same router, same data layer, same latency model. The first three differ only in where each page + places its <Suspense> boundaries; the fourth adds a local same-surface fade. +

+
+ +

+ Each route delays its code chunk by ~400ms (simulating a cold lazy import) and prepares three + data sources with different latencies: +

+ +
+
+ fast read + 100ms +
+
+ medium read + 500ms +
+
+ slow read + 3000ms +
+
+ +

What to feel

+

+ Click between the three modes back-to-back. Watch the timer in the sidebar, the top-of-page progress bar, and + whether you see old content held, skeletons, or some mix. +

+ +
+

(a) Immediate + skeletons

+

+ Old page held while code chunk loads (~400ms), then new page commits with the fast data already there and + skeletons in place of slow data. Skeletons swap to real content as data arrives. +

+
+ +
+

(b) Wait for ready

+

+ Old page held until everything is ready — chunk + all data — then a clean swap to the fully-loaded + new page. No skeletons, no flicker. Slowest perceived "click → first new pixel," but no loading state to look + at. +

+
+ +
+

(c) Timed fallback

+

+ Behaves like (b) for the first ~500ms, then falls back to (a) — meaning if the slow read is still pending + after 500ms, we commit anyway and let inner skeletons take over. Best of both worlds for variable-latency + data. +

+
+ +
+

(d) Detail swap fade

+

+ A detail page with an item list. Item-to-item clicks fade the currently rendered item while the next item + loads, then swap only once the new item can render. Leaving the page does not trigger that local fade. +

+
+ +

How this is implemented

+

+ Same <Router>, same router code (every nav goes through startTransition), same{' '} + prepare() shape. The three modes differ purely in which Suspense primitive wraps each + read: +

+
    +
  • + (a) — wrap each slow read in <Suspense fallback={'{}'}>. + Skeleton renders the moment the new tree commits. +
  • +
  • + (b) — don't wrap at all. Reads suspend at the outer boundary; transition holds the previous + route until everything resolves. +
  • +
  • + (c) — wrap in <DelayedSuspense fallback={'{}'}>. Behaves like + (b) for the first pendingDelayMs (500ms), then like (a). +
  • +
  • + (d) — no inner boundary around the detail read. Same-page item links set a local fade state; + the router transition keeps the old committed detail on screen until the next item is ready. +
  • +
+ +

+ Use Reset in the sidebar (or a hard reload) to clear the in-memory cache between attempts — + otherwise the second visit to a route is instant. +

+ + ) +} diff --git a/demo/src/pages/ModeA.tsx b/demo/src/pages/ModeA.tsx new file mode 100644 index 0000000..7eb8de1 --- /dev/null +++ b/demo/src/pages/ModeA.tsx @@ -0,0 +1,111 @@ +import React, { Suspense } from 'react' +import { read } from '../data' + +/** + * Mode (a): commit immediately (well — once chunk is loaded), then render + * the page shell with inner Suspense boundaries firing skeletons for any + * data that is still pending. + * + * Where the Suspense boundaries are: wrapping each slow read individually + * in this same file. That's the whole "configuration" — no router knob. + */ +export default function ModeA() { + return ( + <> +
+

Mode (a) — Immediate + Skeletons

+

+ Page shell renders as soon as the chunk arrives. Each slow read has its own <Suspense>{' '} + boundary, so skeletons fire while data is pending and swap to content as it arrives. +

+
+ +
+ Recipe: <Suspense fallback={'{}'}> wraps each slow read. The page header and any + already-resolved reads render immediately on commit. +
+ +

User

+ {/* Fast data (100ms) — usually resolved by the time we commit; renders inline. */} + }> + + + +

Posts

+ {/* Medium (500ms) — likely pending on commit; skeleton fires. */} + }> + + + +

Analytics

+ {/* Slow (3000ms) — definitely pending; skeleton fires longest. */} + }> + + + + ) +} + +function UserCard() { + const v = read('user') + return ( +
+

{v.payload}

+

+ Resolved at +{v.latency}ms after navigation start. +

+
+ ) +} + +function Posts() { + const v = read('posts') + return ( +
+

{v.payload}

+

+ Resolved at +{v.latency}ms. +

+
+ ) +} + +function Analytics() { + const v = read('analytics') + return ( +
+

{v.payload}

+

+ Resolved at +{v.latency}ms. +

+
+ ) +} + +function UserSkeleton() { + return ( +
+ + +
+ ) +} + +function PostsSkeleton() { + return ( +
+ + +
+ ) +} + +function AnalyticsSkeleton() { + return ( +
+ + + +
+ ) +} diff --git a/demo/src/pages/ModeB.tsx b/demo/src/pages/ModeB.tsx new file mode 100644 index 0000000..90541da --- /dev/null +++ b/demo/src/pages/ModeB.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { read } from '../data' + +/** + * Mode (b): hold the previous route until *everything* is ready. + * + * Where the Suspense boundaries are: nowhere inside this page. Every read + * suspends, and the only boundary that catches them is the outer one + * (around in main.tsx). Inside a transition that boundary is + * "already-committed showing previous content" → React holds. When the + * last suspending read resolves, the entire new tree commits in one go. + * + * No skeletons. No flicker. Slowest "click → first new pixel" time. + */ +export default function ModeB() { + return ( + <> +
+

Mode (b) — Wait for Ready

+

+ The previous page stays on screen until the chunk loads and all reads resolve. Then a single, + flicker-free swap. +

+
+ +
+ Recipe: no inner <Suspense> boundaries. Reads suspend at the outer router boundary; + transition holds the previous route until everything is ready. +
+ +

User

+ + +

Bio

+ + +

Followers

+ + +

+ Notice: when this page finally appears, it's fully populated — no skeletons ever shown. The + sidebar timer reflects the full wait. +

+ + ) +} + +function UserCard() { + const v = read('user') + return ( +
+

{v.payload}

+

+ Resolved at +{v.latency}ms. +

+
+ ) +} + +function Bio() { + const v = read('bio') + return ( +
+

{v.payload}

+

+ Resolved at +{v.latency}ms. +

+
+ ) +} + +function Followers() { + const v = read('followers') + return ( +
+

{v.payload}

+

+ Resolved at +{v.latency}ms — the longest of the three; this is the gate. +

+
+ ) +} diff --git a/demo/src/pages/ModeC.tsx b/demo/src/pages/ModeC.tsx new file mode 100644 index 0000000..b85d321 --- /dev/null +++ b/demo/src/pages/ModeC.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { DelayedSuspense } from 'react-space-router' +import { read } from '../data' + +/** + * Mode (c): start as (b), fall back to (a) after a threshold. + * + * The whole pattern is just `` — a router-aware Suspense + * boundary whose fallback is delayed until the in-flight nav has been + * pending for `pendingDelayMs` (configured on ``, 500ms in this demo). + * + * Pre-commit, under threshold: fallback re-throws → outer transition + * holds the previous route. + * Past threshold or post-commit: fallback renders normally — skeleton. + * + * The phase tracking that this used to require in userspace (a Shell-level + * provider, a `PassThrough` trick, careful handling of post-commit phase + * reset) is encapsulated by the component. From here, the call site reads + * exactly like a regular ``. + */ +export default function ModeC() { + return ( + <> +
+

Mode (c) — Timed Fallback

+

+ Acts like (b) until the navigation has been pending for more than the threshold; then flips to (a) and commits + with skeletons in place of any still-pending data. +

+
+ +
+ Recipe: wrap each section in <DelayedSuspense fallback={'{}'}>. The router holds + the previous route until pendingDelayMs (500ms) has elapsed; past that, the boundaries fall back to + skeletons. +
+ +

Feed

+ }> + + + +

Comments

+ }> + + + +

Related

+ }> + + + +

+ Threshold: 500ms. If the slow read (3000ms) hasn't resolved by then, you'll see this last section commit with a + skeleton in place of Related. +

+ + ) +} + +function Reader({ name }: { name: string }) { + const v = read(name) + return ( +
+

{v.payload}

+

+ Resolved at +{v.latency}ms. +

+
+ ) +} + +function FeedSkeleton() { + return ( +
+ + +
+ ) +} + +function CommentsSkeleton() { + return ( +
+ + + +
+ ) +} + +function RelatedSkeleton() { + return ( +
+ + + +
+ ) +} diff --git a/demo/src/pages/ModeD.tsx b/demo/src/pages/ModeD.tsx new file mode 100644 index 0000000..32beea8 --- /dev/null +++ b/demo/src/pages/ModeD.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Link, shouldNavigate, usePending } from 'react-space-router' +import { read } from '../data' + +const ITEMS = [ + { + id: 'atlas', + name: 'Atlas', + meta: 'Identity and permissions', + }, + { + id: 'beacon', + name: 'Beacon', + meta: 'Approvals and policy', + }, + { + id: 'courier', + name: 'Courier', + meta: 'Onboarding workflow', + }, + { + id: 'delta', + name: 'Delta', + meta: 'Payroll readiness', + }, +] + +/** + * Mode (d): a same-surface detail swap. + * + * Item-to-item clicks set a local "exiting" state before navigating, so the + * currently committed detail fades out while the destination route suspends. + * Because the detail read is not wrapped in an inner Suspense boundary, the + * new detail only commits after its prepared data is ready. + * + * Leaving this route does not set that local state, so cross-page navigation + * falls back to whatever the destination page opted into. + */ +// Path is `/mode-d/:id` — the router injects `id` as a prop, so the page +// component declares it directly instead of reaching for `useRoute()`. +export default function ModeD({ id = ITEMS[0].id }: { id?: string }) { + const pending = usePending() + const currentId = id + const [pendingItemId, setPendingItemId] = useState(null) + + useEffect(() => { + setPendingItemId(null) + }, [currentId]) + + const currentItem = useMemo(() => ITEMS.find((item) => item.id === currentId) ?? ITEMS[0], [currentId]) + const isFading = pending && pendingItemId != null && pendingItemId !== currentId + + return ( + <> +
+

Mode (d) — Detail Swap Fade

+

+ Same page, different item: fade the old detail out, keep it mounted, then swap only when the next item is + fully ready. +

+
+ +
+ Recipe: item links set local exiting state before navigation. The detail read has no inner{' '} + <Suspense>, so the router-level transition holds the old committed detail until the new one + can render. +
+ +
+ + +
+ +
+
+ +

+ Click between items, then try leaving for (a), (b), or (c). Only item-to-item navigation fades this detail + surface; page-to-page navigation keeps the old page steady while the destination mode decides what loading UI + appears. +

+ + ) +} + +function ItemDetail({ id, name, meta }: { id: string; name: string; meta: string }) { + const value = read(`item-${id}`) + + return ( +
+
+ {meta} +

{name}

+
+

{value.payload}

+
+
+
Loaded
+
+ +{value.latency}ms +
+
+
+
Cache key
+
+ {value.key} +
+
+
+
+ ) +} diff --git a/demo/src/routes.ts b/demo/src/routes.ts new file mode 100644 index 0000000..b978777 --- /dev/null +++ b/demo/src/routes.ts @@ -0,0 +1,63 @@ +import { prepare, slowImport } from './data' + +// Latency budgets we'll reuse across routes. Tweak here to see the modes +// react. Reload the page to clear cache and re-feel cold loads. +export const LATENCIES = { + fast: 100, + medium: 500, + detail: 1100, + slow: 3000, +} + +// Slow code chunk delay — meant to feel like a cold lazy import on a real +// network. Keeps the page held during chunk download regardless of mode. +const CHUNK_MS = 400 + +export const routes = [ + { + path: '/', + resolver: slowImport(0, () => import('./pages/Home')), + }, + { + path: '/mode-a', + resolver: slowImport(CHUNK_MS, () => import('./pages/ModeA')), + prepare: () => [ + // Mode A: prepare kicks off all data so reads in inner Suspense + // boundaries can suspend on the same promises. Inner skeletons fire + // for whichever data is still pending when the route commits. + prepare('user', LATENCIES.fast), + prepare('posts', LATENCIES.medium), + prepare('analytics', LATENCIES.slow), + ], + }, + { + path: '/mode-b', + resolver: slowImport(CHUNK_MS, () => import('./pages/ModeB')), + prepare: () => [ + // Mode B: same data, different page composition (no inner Suspense + // boundaries → reads suspend at the outer boundary → transition + // holds the previous route until everything is ready). + prepare('user', LATENCIES.fast), + prepare('bio', LATENCIES.medium), + prepare('followers', LATENCIES.slow), + ], + }, + { + path: '/mode-c', + resolver: slowImport(CHUNK_MS, () => import('./pages/ModeC')), + prepare: () => [ + prepare('feed', LATENCIES.fast), + prepare('comments', LATENCIES.medium), + prepare('related', LATENCIES.slow), + ], + }, + { + path: '/mode-d/:id', + resolver: slowImport(CHUNK_MS, () => import('./pages/ModeD')), + // The router injects matching path params as props on the page + // component. ModeD's signature declares `{ id?: string }`, so it + // receives `id` for free — no `useRoute()` dance needed. + prepare: ({ params }) => [prepare(`item-${params.id}`, LATENCIES.detail)], + scrollGroup: 'mode-d', + }, +] diff --git a/demo/src/styles.css b/demo/src/styles.css new file mode 100644 index 0000000..b0f36c7 --- /dev/null +++ b/demo/src/styles.css @@ -0,0 +1,569 @@ +:root { + color-scheme: light; + --bg: #fcfcfc; + --surface: #ffffff; + --surface-muted: #f7f7f7; + --surface-selected: #efefef; + --text: #111111; + --text-muted: #6b6b6b; + --text-dim: #9b9b9b; + --border: #ededed; + --border-strong: #d8d8d8; + --accent: #111111; + --skel: #eeeeee; + --skel-shine: #f9f9f9; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + padding: 0; + height: 100%; + background: var(--surface); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Inter', ui-sans-serif, 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 13.5px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body { + overflow: hidden; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + text-decoration: none; +} + +code { + border-radius: 4px; + background: var(--surface-muted); + color: var(--text); + font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; + font-size: 0.92em; + padding: 1px 5px; +} + +/* Top progress bar */ +.progress-bar { + position: fixed; + inset: 0 0 auto 0; + height: 2px; + background: transparent; + z-index: 100; + pointer-events: none; +} + +.progress-bar::before { + content: ''; + position: absolute; + inset: 0 auto 0 0; + width: 28%; + background: var(--text); + animation: progress 1s ease-in-out infinite; +} + +@keyframes progress { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(460%); + } +} + +/* Layout */ +.app { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + height: 100%; + background: var(--surface); + overflow: hidden; +} + +.sidebar { + display: flex; + flex-direction: column; + min-width: 0; + overflow-y: auto; + border-right: 1px solid var(--border); + background: var(--surface); +} + +.sidebar h1 { + margin: 0; + padding: 18px 20px 12px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); +} + +.sidebar nav { + display: flex; + flex-direction: column; + gap: 1px; + padding: 0 8px 16px; +} + +.nav-link { + display: block; + padding: 8px 10px; + border-left: 2px solid transparent; + border-radius: 0 6px 6px 0; + color: var(--text); + font-size: 13px; + font-weight: 500; + transition: background 100ms ease; +} + +.nav-link:hover { + background: var(--surface-muted); +} + +.nav-link[aria-current='page'] { + background: var(--surface-selected); + border-left-color: var(--text); +} + +.nav-link small { + display: block; + margin-top: 1px; + color: var(--text-dim); + font-size: 11.5px; + font-weight: 400; + line-height: 1.35; +} + +.sidebar-footer { + margin-top: auto; + padding: 14px 14px 18px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 8px; +} + +.button { + width: fit-content; + border: 0; + border-radius: 4px; + background: none; + color: var(--text-muted); + cursor: pointer; + font: inherit; + font-size: 12.5px; + padding: 4px 6px; +} + +.button:hover { + background: var(--surface-muted); + color: var(--text); +} + +.button-primary { + background: var(--text); + color: var(--surface); + font-weight: 600; + padding: 6px 10px; +} + +.button-primary:hover { + background: #2a2a2a; + color: var(--surface); +} + +/* Main */ +.main { + min-width: 0; + overflow-y: auto; + padding: 36px 40px 64px; + max-width: 940px; +} + +.main h1 { + margin: 0 0 8px; + font-size: 24px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.25; +} + +.main h2 { + margin: 30px 0 10px; + color: var(--text-dim); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.main h3 { + margin: 0 0 5px; + font-size: 13px; + font-weight: 600; +} + +.main p { + margin: 0 0 12px; + color: var(--text); +} + +.main p.dim, +.dim { + color: var(--text-muted); +} + +.main ul { + margin: 0 0 16px; + padding-left: 18px; + color: var(--text-muted); +} + +.main li { + margin: 6px 0; +} + +/* Cards */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 14px 16px; + margin-bottom: 10px; +} + +.card-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 28px; +} + +.card-row + .card-row { + border-top: 1px solid var(--border); +} + +/* Skeleton */ +.skel { + background: linear-gradient(90deg, var(--skel) 0%, var(--skel-shine) 50%, var(--skel) 100%); + background-size: 200% 100%; + animation: shimmer 1.3s ease-in-out infinite; + border-radius: 4px; + height: 12px; + display: block; + margin: 10px 0; +} + +.skel.lg { + height: 18px; +} + +.skel.short { + width: 35%; +} + +.skel.med { + width: 65%; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.card.is-skeleton { + border-style: dashed; + position: relative; +} + +.card.is-skeleton::before { + content: 'loading'; + position: absolute; + top: 8px; + right: 12px; + color: var(--text-dim); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Latency badges */ +.badge { + display: inline-block; + padding: 1px 7px; + border-radius: 4px; + background: var(--surface-muted); + color: var(--text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + margin-left: 8px; +} + +.badge-fast { + color: var(--text); +} + +.badge-slow, +.badge-very-slow { + color: var(--text-muted); +} + +/* Status row */ +.status-row { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 2px 7px; + border-radius: 4px; + background: var(--surface-muted); + color: var(--text-muted); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} + +.status-pill.live { + color: var(--text); +} + +.status-pill.live::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text); + animation: blink 1s ease-in-out infinite; +} + +@keyframes blink { + 50% { + opacity: 0.3; + } +} + +/* Mode header */ +.mode-header { + margin-bottom: 24px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} + +.mode-header h1 { + margin: 0 0 8px; +} + +.mode-header p { + max-width: 720px; + margin: 0; + color: var(--text-muted); + font-size: 13px; +} + +/* Recipe blurb */ +.recipe { + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-muted); + color: var(--text-muted); + font-size: 12.5px; + padding: 11px 13px; + margin-bottom: 24px; +} + +/* Note */ +.note { + color: var(--text-dim); + font-size: 11.5px; + line-height: 1.5; +} + +.sidebar-footer .note { + margin: 0; +} + +/* Detail swap demo */ +.item-demo { + display: grid; + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); + gap: 18px; + align-items: start; + margin-bottom: 16px; +} + +.item-list { + display: flex; + flex-direction: column; + gap: 1px; + border-right: 1px solid var(--border); + padding-right: 8px; +} + +.item-link { + display: block; + padding: 8px 10px; + border-left: 2px solid transparent; + border-radius: 0 6px 6px 0; + color: var(--text); + transition: background 100ms ease; +} + +.item-link:hover { + background: var(--surface-muted); +} + +.item-link[aria-current='page'] { + background: var(--surface-selected); + border-left-color: var(--text); +} + +.item-link.requested { + background: var(--surface-muted); +} + +.item-link strong { + display: block; + font-size: 13px; + font-weight: 600; +} + +.item-link small { + display: block; + margin-top: 1px; + color: var(--text-dim); + font-size: 11.5px; + line-height: 1.35; +} + +.item-detail { + min-width: 0; + transition: + opacity 220ms ease, + transform 220ms ease, + filter 220ms ease; +} + +.item-detail.is-fading { + opacity: 0.38; + transform: translateY(4px); + filter: saturate(0); +} + +.detail-card { + min-height: 260px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 22px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 24px; +} + +.detail-card h2 { + margin: 6px 0 0; + color: var(--text); + font-size: 24px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.25; + text-transform: none; +} + +.detail-kicker { + color: var(--text-dim); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.detail-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} + +.detail-stats div { + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-muted); + padding: 9px 11px; +} + +.detail-stats dt { + margin: 0 0 4px; + color: var(--text-dim); + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.detail-stats dd { + margin: 0; + color: var(--text); +} + +@media (max-width: 760px) { + body { + overflow: auto; + } + + .app { + display: block; + min-height: 100%; + overflow: visible; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .sidebar-footer { + margin-top: 0; + } + + .main { + overflow: visible; + padding: 24px 18px 48px; + } + + .item-demo { + grid-template-columns: 1fr; + } + + .item-list { + border-right: 0; + border-bottom: 1px solid var(--border); + padding: 0 0 8px; + } +} diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 0000000..901b4ec --- /dev/null +++ b/demo/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/demo/vite.config.ts b/demo/vite.config.ts new file mode 100644 index 0000000..0ceb73b --- /dev/null +++ b/demo/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'node:path' + +// Resolve `react-space-router` to the built dist of the parent package so the +// demo always exercises real router code without symlink quirks. +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + 'react-space-router': path.resolve(__dirname, '../dist/index.js'), + }, + dedupe: ['react', 'react-dom'], + }, + server: { + port: 5173, + }, +}) diff --git a/dist/index.d.ts b/dist/index.d.ts index fb54eb9..798cc10 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,55 +1,147 @@ -import { type AnchorHTMLAttributes, type CSSProperties, type MouseEvent, type ReactNode } from 'react'; +import { type AnchorHTMLAttributes, type ComponentType, type CSSProperties, type MouseEvent, type ReactNode } from 'react'; import { type Mode, type NavigateTarget, type Qs, type Route, type RouteDefinition, type Router as SpaceRouter } from 'space-router'; export { qs } from 'space-router'; +export interface RoutePrepareContext { + pathname: string; + url: string; + params: Record; + query: Record; +} +/** + * Lifecycle handle returned by data-layer `prepare()` calls (e.g. `figbird.prepare`). + * The router pins the underlying cache entries via `release()` for the lifetime of + * the navigation; superseded navigations release their handles immediately. + */ +export interface PreparedHandle { + promise: Promise; + release(): void; + priority?: 'route' | 'defer'; + key?: string | number; +} +export type RoutePrepare = (ctx: RoutePrepareContext) => readonly PreparedHandle[] | PreparedHandle[] | void; +export type ResolverModule = { + default: ComponentType; +}; +export type RouteResolver = () => Promise; +export interface RouteNavigationOptions { + /** + * `'immediate'` (default): commit the new route synchronously inside a React + * transition; let any unresolved route-priority data suspend at the + * destination's `` boundary. + * + * `'ready'` is reserved for a follow-up release. + */ + commit?: 'immediate'; +} +export interface RouteData { + path?: string; + component?: ComponentType | { + default: ComponentType; + } | null; + resolver?: RouteResolver; + prepare?: RoutePrepare; + navigation?: RouteNavigationOptions; + scrollGroup?: string; + routes?: RouteData[]; + [extra: string]: unknown; +} +export type To = string | (NavigateTarget & { + current?: boolean; +}); interface RouterContextValue { router: SpaceRouter; - useRoute: () => Route | null; - onNavigating?: (route: Route) => void | Promise; - onNavigated: (route: Route) => void; + route: Route | null; + transformRoute: (route: Route) => Route; + syncRouteUrl: (matched: Route, transformed: Route) => void; + commit: (route: Route, matched?: Route) => void; + navigate: (to: To, curr?: Route) => void; + isPending: boolean; + pendingHref: string | null; + qs: Qs | undefined; } export declare const RouterContext: import("react").Context; -export declare const CurrRouteContext: import("react").Context> | null>; export declare function useInternalRouterInstance(): SpaceRouter; export declare function useRoute(): Route | null; +/** + * `true` while the router is between navigation start and commit. Backed by + * React's `useTransition` — flips on as soon as `navigate()` runs and flips off + * once the destination has committed (and any Suspense fallbacks at the new + * route have resolved enough to let React's transition settle). + * + * Use this for top-of-page progress bars, desaturated link states, and "your + * click did something" affordances. Don't use it for skeletons — those belong + * in destination Suspense boundaries. + */ +export declare function usePending(): boolean; export declare function useNavigate(): (to: To) => void; +/** + * Optional pre-commit transform. Runs synchronously between match and commit. + * Return a modified route to change what gets committed (e.g. to merge a + * persisted query). If the returned route's `url` differs from the matched + * route's, the browser URL is synced via `history.replaceState` so the address + * bar matches what the app is rendering. + * + * Must be pure and synchronous. + */ +export type TransformRoute = (route: Route) => Route | void; export interface RouterProps { mode?: Mode; qs?: Qs; sync?: boolean; - useRoute?: () => Route | null; - onNavigating?: (route: Route) => void | Promise; - onNavigated?: (route: Route) => void; + transformRoute?: TransformRoute; + /** + * How long to hold the previous route on screen before `` + * boundaries fall back to their fallback content. Default is `1000` ms. + * No effect on plain `` boundaries — those always show their + * fallback the moment the boundary mounts. + */ + pendingDelayMs?: number; + children?: ReactNode; +} +export declare function Router({ mode, qs, sync, transformRoute, pendingDelayMs, children, }: RouterProps): import("react/jsx-runtime").JSX.Element; +/** + * A `` boundary whose fallback is *delayed* during an in-flight + * router navigation: until the router has been pending for `pendingDelayMs` + * (configured on ``, default 1000ms), the fallback re-throws so + * suspension propagates upward — typically to the router-level transition, + * which keeps the previous route on screen. Past the threshold (or when + * the transition has already committed and a read is still pending), the + * fallback renders normally. + * + * Use this when you want "stay on the previous page for a moment, then if + * it's still loading degrade to a skeleton" — the classic browser-style + * UX for variable-latency data. Outside an in-flight nav, behaves + * identically to plain ``. + */ +export interface DelayedSuspenseProps { + fallback: ReactNode; children?: ReactNode; } -export declare function Router({ mode, qs, sync, useRoute, onNavigating, onNavigated, children }: RouterProps): import("react/jsx-runtime").JSX.Element; +export declare function DelayedSuspense({ fallback, children }: DelayedSuspenseProps): import("react/jsx-runtime").JSX.Element; export interface RoutesProps { - routes: RouteDefinition[]; + routes: RouteDefinition[]; disableScrollToTop?: boolean; } -export declare function Routes({ routes, disableScrollToTop }: RoutesProps): ReactNode; +export declare function Routes({ routes, disableScrollToTop }: RoutesProps): import("react/jsx-runtime").JSX.Element | null; export declare function useMakeHref(): (to: import("space-router").To, curr?: Route> | undefined) => string; -export type To = string | (NavigateTarget & { - onClick?: (e: MouseEvent) => void; - current?: boolean; -}); export interface LinkPropsResult { href: string; 'aria-current': 'page' | undefined; onClick: (e: MouseEvent) => void; + isCurrent: boolean; + isPending: boolean; } export declare function useLinkProps(to: To): LinkPropsResult; -type FnOr = T | ((isCurrent: boolean) => T); export interface LinkOwnProps { href?: To; replace?: boolean; current?: boolean; - className?: FnOr; - style?: FnOr; - extraProps?: (isCurrent: boolean) => Record; + className?: string; + style?: CSSProperties; children?: ReactNode; } -export type LinkProps = LinkOwnProps & Omit, keyof LinkOwnProps | 'onClick'>; -export declare function Link({ href: to, replace, current, className, style, extraProps, children, ...anchorProps }: LinkProps): import("react/jsx-runtime").JSX.Element; +export type LinkProps = LinkOwnProps & Omit, keyof LinkOwnProps>; +export declare function Link({ href: to, replace, current, className, style, onClick, children, ...anchorProps }: LinkProps): import("react/jsx-runtime").JSX.Element; export interface NavigateProps { to: To; } diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index 58be598..931edf8 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAWL,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,SAAS,EACf,MAAM,OAAO,CAAA;AACd,OAAO,EAGL,KAAK,IAAI,EACT,KAAK,cAAc,EACnB,KAAK,EAAE,EACP,KAAK,KAAK,EACV,KAAK,eAAe,EACpB,KAAK,MAAM,IAAI,WAAW,EAC3B,MAAM,cAAc,CAAA;AAErB,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAMjC,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAA;IAChB,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC/B;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,CAAA;IACzB,OAAO,IAAI,IAAI,CAAA;IACf,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,CAAA;IAC5B,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;CACtB;AAED,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE,mBAAmB,KAAK,SAAS,cAAc,EAAE,GAAG,cAAc,EAAE,GAAG,IAAI,CAAA;AAE5G,MAAM,MAAM,cAAc,GAAG;IAAE,OAAO,EAAE,aAAa,CAAC,GAAG,CAAC,CAAA;CAAE,CAAA;AAE5D,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,CAAC,cAAc,CAAC,CAAA;AAEzD,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,aAAa,CAAC,GAAG,CAAC,GAAG;QAAE,OAAO,EAAE,aAAa,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,IAAI,CAAA;IACvE,QAAQ,CAAC,EAAE,aAAa,CAAA;IACxB,OAAO,CAAC,EAAE,YAAY,CAAA;IACtB,UAAU,CAAC,EAAE,sBAAsB,CAAA;IACnC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,SAAS,EAAE,CAAA;IACpB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAA;CACzB;AAsDD,MAAM,MAAM,EAAE,GACV,MAAM,GACN,CAAC,cAAc,GAAG;IAChB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,CAAC,CAAA;AAEN,UAAU,kBAAkB;IAC1B,MAAM,EAAE,WAAW,CAAA;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,cAAc,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,KAAK,CAAA;IACvC,YAAY,EAAE,CAAC,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,KAAK,IAAI,CAAA;IAC1D,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,EAAE,KAAK,KAAK,IAAI,CAAA;IAC/C,QAAQ,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,KAAK,KAAK,IAAI,CAAA;IACxC,SAAS,EAAE,OAAO,CAAA;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,EAAE,EAAE,EAAE,GAAG,SAAS,CAAA;CACnB;AAED,eAAO,MAAM,aAAa,yDAA2D,CAAA;AAgBrF,wBAAgB,yBAAyB,IAAI,WAAW,CAEvD;AAED,wBAAgB,QAAQ,IAAI,KAAK,GAAG,IAAI,CAGvC;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,IAAI,OAAO,CAEpC;AAED,wBAAgB,WAAW,SAIlB,EAAE,UAKV;AAsBD;;;;;;;;GAQG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,KAAK,GAAG,IAAI,CAAA;AAE3D,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,EAAE,IAAI,CAAA;IACX,EAAE,CAAC,EAAE,EAAE,CAAA;IACP,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,cAAc,CAAC,EAAE,cAAc,CAAA;IAC/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB;AAID,wBAAgB,MAAM,CAAC,EACrB,IAAI,EACJ,EAAE,EACF,IAAI,EACJ,cAAc,EACd,cAAyC,EACzC,QAAQ,GACT,EAAE,WAAW,2CA2Gb;AAMD;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,SAAS,CAAA;IACnB,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB;AAED,wBAAgB,eAAe,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,oBAAoB,2CAG3E;AAkBD,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,eAAe,CAAC,SAAS,CAAC,EAAE,CAAA;IACpC,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAC7B;AAiDD,wBAAgB,MAAM,CAAC,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,WAAW,kDAwIjE;AAkDD,wBAAgB,WAAW,iGAG1B;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,cAAc,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,OAAO,EAAE,CAAC,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,KAAK,IAAI,CAAA;IACnD,SAAS,EAAE,OAAO,CAAA;IAClB,SAAS,EAAE,OAAO,CAAA;CACnB;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,EAAE,GAAG,eAAe,CAoCpD;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,EAAE,CAAA;IACT,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,aAAa,CAAA;IACrB,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB;AAED,MAAM,MAAM,SAAS,GAAG,YAAY,GAAG,IAAI,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,EAAE,MAAM,YAAY,CAAC,CAAA;AAExG,wBAAgB,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,WAAW,EAAE,EAAE,SAAS,2CAyBlH;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,EAAE,CAAA;CACP;AAED,wBAAgB,QAAQ,CAAC,EAAE,EAAE,EAAE,EAAE,aAAa,QAc7C;AAED,wBAAgB,cAAc,CAAC,CAAC,EAAE,UAAU,GAAG,OAAO,CAsBrD"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index b6bc559..f6fe343 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,100 +1,366 @@ import { jsx as _jsx } from "react/jsx-runtime"; -import { createContext, useCallback, useContext, useState, useEffect, useMemo, useRef, } from 'react'; -import { createRouter, } from 'space-router'; +import { createContext, lazy as reactLazy, Suspense, useCallback, useContext, useState, useEffect, useMemo, useRef, useTransition, } from 'react'; +import { createMatcher, createRouter, } from 'space-router'; export { qs } from 'space-router'; +const resolverPromiseCache = new WeakMap(); +const resolverComponentCache = new WeakMap(); +function preloadResolver(resolver) { + let promise = resolverPromiseCache.get(resolver); + if (!promise) { + promise = resolver(); + promise.catch(() => { + // Keep the original rejected promise cached for React.lazy/error + // boundaries, but mark preload rejections as observed. + }); + resolverPromiseCache.set(resolver, promise); + } + return promise; +} +function getResolverComponent(resolver) { + let component = resolverComponentCache.get(resolver); + if (!component) { + component = reactLazy(() => preloadResolver(resolver)); + resolverComponentCache.set(resolver, component); + } + return component; +} +function buildPrepareContext(route) { + const r = route; + return { + pathname: r.pathname ?? '', + url: r.url ?? r.pathname ?? '', + params: r.params ?? {}, + query: r.query ?? {}, + }; +} +function routeHasRedirect(route) { + return !!route?.data?.some((segment) => Boolean(segment.redirect)); +} export const RouterContext = createContext(undefined); -export const CurrRouteContext = createContext(null); -export function useInternalRouterInstance() { +const RouteContext = createContext(undefined); +// Internal context for ``. Set by `` based on +// `usePending()` + a configurable threshold. `holding` is true only during +// the pre-commit window where we want the previous route to stay on screen. +const DelayedSuspenseContext = createContext(false); +function useRouterCtx() { const ctx = useContext(RouterContext); - if (!ctx?.router) { + if (!ctx) { throw new Error('Application must be wrapped in '); } - return ctx.router; + return ctx; +} +export function useInternalRouterInstance() { + return useRouterCtx().router; } export function useRoute() { - return useContext(RouterContext).useRoute(); + const route = useContext(RouteContext); + return route === undefined ? useRouterCtx().route : route; +} +/** + * `true` while the router is between navigation start and commit. Backed by + * React's `useTransition` — flips on as soon as `navigate()` runs and flips off + * once the destination has committed (and any Suspense fallbacks at the new + * route have resolved enough to let React's transition settle). + * + * Use this for top-of-page progress bars, desaturated link states, and "your + * click did something" affordances. Don't use it for skeletons — those belong + * in destination Suspense boundaries. + */ +export function usePending() { + return useRouterCtx().isPending; } export function useNavigate() { - const currRoute = useRoute(); - const navigate = useInternalRouterInstance().navigate; + const { navigate } = useRouterCtx(); + const route = useRoute(); return useCallback((to) => { - return navigate(to, currRoute); - }, [navigate, currRoute]); -} -function defaultUseRoute() { - return useContext(CurrRouteContext); + return navigate(to, route); + }, [navigate, route]); } function makeRouter(routerOpts) { const router = createRouter({ mode: routerOpts.mode, qs: routerOpts.qs, sync: routerOpts.sync }); - router.disableScrollToTop = routerOpts.disableScrollToTop; - return { router: router, routerOpts }; + return { router, routerOpts }; } -export function Router({ mode, qs, sync, useRoute, onNavigating, onNavigated, children }) { +const DEFAULT_PENDING_DELAY_MS = 1000; +export function Router({ mode, qs, sync, transformRoute, pendingDelayMs = DEFAULT_PENDING_DELAY_MS, children, }) { const [{ router, routerOpts }, setRouter] = useState(() => makeRouter({ mode, qs, sync })); const [currRoute, setCurrRoute] = useState(null); - const connectedRouter = useMemo(() => ({ + const [pendingHref, setPendingHref] = useState(null); + const [isPending, startRouterTransition] = useTransition(); + // `holding` is true during the pre-commit window where `` + // boundaries should re-throw their fallback (so the previous route stays + // committed). It flips off either after `pendingDelayMs` elapses while + // still pending, or when the transition settles — whichever comes first. + const [holding, setHolding] = useState(false); + useEffect(() => { + if (!isPending) { + setHolding(false); + return; + } + setHolding(true); + const t = setTimeout(() => setHolding(false), pendingDelayMs); + return () => clearTimeout(t); + }, [isPending, pendingDelayMs]); + // Keep the latest transform in a ref so commit() can stay referentially + // stable while always using the freshest function. + const transformRef = useRef(transformRoute); + transformRef.current = transformRoute; + const applyTransform = useCallback((next) => { + const transform = transformRef.current; + return transform ? (transform(next) ?? next) : next; + }, []); + const syncRouteUrl = useCallback((matched, transformed) => { + const matchedUrl = matched.url; + const transformedUrl = transformed.url; + if (transformed !== matched && + transformedUrl && + transformedUrl !== matchedUrl && + typeof window !== 'undefined' && + window.history) { + window.history.replaceState({}, '', transformedUrl); + } + }, []); + const commit = useCallback((next, matched = next) => { + const matchedUrl = matched.url; + const transformedUrl = next.url; + startRouterTransition(() => { + setCurrRoute(next); + setPendingHref((current) => { + if (!current) + return current; + if (current === matchedUrl || current === transformedUrl) + return null; + return routeHasRedirect(router.match(current)) ? null : current; + }); + }); + // Sync the address bar if the transform rewrote the URL. We use + // history.replaceState directly so we don't re-trigger the router's + // listener loop. + syncRouteUrl(matched, next); + }, [router, syncRouteUrl]); + const navigate = useCallback((to, curr) => { + const href = router.href(to, curr); + setPendingHref(href); + router.navigate(to, curr); + if (!router.match(href)) { + setPendingHref(null); + } + }, [router]); + const ctx = useMemo(() => ({ router, - useRoute: useRoute || defaultUseRoute, - onNavigating, - onNavigated(route) { - if (!useRoute) { - setCurrRoute(route); - } - if (onNavigated) - onNavigated(route); - }, - }), [router, useRoute, onNavigating, onNavigated]); + route: currRoute, + transformRoute: applyTransform, + syncRouteUrl, + commit, + navigate, + isPending, + pendingHref, + qs, + }), [router, currRoute, applyTransform, syncRouteUrl, commit, navigate, isPending, pendingHref, qs]); useEffect(() => { if (routerOpts.mode !== mode || routerOpts.qs !== qs || routerOpts.sync !== sync) { setRouter(makeRouter({ mode, qs, sync })); } }, [routerOpts, mode, qs, sync]); - return (_jsx(RouterContext.Provider, { value: connectedRouter, children: _jsx(CurrRouteContext.Provider, { value: currRoute, children: children }) })); + return (_jsx(RouterContext.Provider, { value: ctx, children: _jsx(DelayedSuspenseContext.Provider, { value: holding, children: children }) })); +} +export function DelayedSuspense({ fallback, children }) { + const holding = useContext(DelayedSuspenseContext); + return _jsx(Suspense, { fallback: holding ? _jsx(DelayedSuspenseHold, {}) : fallback, children: children }); +} +const NEVER_RESOLVES = new Promise(() => { }); +/** + * Throws a never-resolving promise so the surrounding Suspense boundary's + * fallback path itself suspends — the suspension bubbles up to the next + * Suspense boundary above, which during a router transition is the + * already-committed root holding the previous route. + */ +function DelayedSuspenseHold() { + throw NEVER_RESOLVES; +} +function prepareRoute(route) { + const segments = (route.data ?? []); + const ctx = buildPrepareContext(route); + const handles = []; + for (const segment of segments) { + if (segment.resolver) + preloadResolver(segment.resolver); + if (segment.prepare) { + const result = segment.prepare(ctx); + if (result) { + for (const handle of result) { + handles.push(handle); + } + } + } + } + return handles; +} +function releaseHandles(handles) { + for (const handle of handles) { + try { + handle.release(); + } + catch { + // best-effort + } + } +} +function releaseUniqueHandles(handleGroups) { + const released = new Set(); + for (const handles of handleGroups) { + for (const handle of handles) { + if (released.has(handle)) + continue; + released.add(handle); + releaseHandles([handle]); + } + } } export function Routes({ routes, disableScrollToTop }) { - const ctx = useContext(RouterContext); - const { router, onNavigating, onNavigated } = ctx; - const route = ctx.useRoute(); - const onlyLatest = useOnlyLatest(); - useScrollToTop(route, disableScrollToTop); + const { router, route, transformRoute, syncRouteUrl, commit, qs } = useRouterCtx(); + // Pinned prepare handles for the currently committed navigation. Released + // when a new navigation commits or when unmounts. + const committed = useRef(null); + const pending = useRef(null); + const previousRoutes = useRef(routes); + const matcher = useMemo(() => createMatcher(routes, { qs }), [routes, qs]); + const prepareMatched = useCallback((matched) => { + const transformed = transformRoute(matched); + return { route: transformed, matched, handles: prepareRoute(transformed) }; + }, [transformRoute]); + const releaseAll = useCallback(() => { + releaseUniqueHandles([committed.current?.handles ?? [], pending.current?.handles ?? []]); + committed.current = null; + pending.current = null; + }, []); + const initialRoute = useMemo(() => { + if (route) + return null; + const matched = matcher.match(router.getUrl()); + if (matched) { + return { route: transformRoute(matched), matched }; + } + return null; + }, [route, router, matcher, transformRoute]); + const activeRoute = route ?? committed.current?.route ?? initialRoute?.route ?? null; + useEffect(() => { + if (!initialRoute || route || committed.current || pending.current) + return; + const prepared = { + ...initialRoute, + handles: prepareRoute(initialRoute.route), + }; + committed.current = prepared; + syncRouteUrl(prepared.matched, prepared.route); + }, [initialRoute, route, syncRouteUrl]); + useScrollToTop(activeRoute, disableScrollToTop); useEffect(() => { const transition = (next) => { - onlyLatest(async (isLatest) => { - if (isLatest() && onNavigating) { - await onNavigating(next); - } - if (isLatest()) { - onNavigated(next); + const nextUrl = next.url ?? next.pathname; + const matched = matcher.match(nextUrl) ?? next; + const matchedRoute = transformRoute(matched); + if (committed.current?.route.url === matchedRoute.url) { + if (pending.current) { + releaseHandles(pending.current.handles); + pending.current = null; } - }); + commit(committed.current.route, committed.current.matched); + return; + } + if (pending.current?.route.url === matchedRoute.url) { + commit(pending.current.route, pending.current.matched); + return; + } + if (pending.current) + releaseHandles(pending.current.handles); + pending.current = prepareMatched(matched); + commit(pending.current.route, pending.current.matched); }; return router.listen(routes, transition); - }, [router, routes, onNavigating, onNavigated]); + }, [router, routes, matcher, transformRoute, prepareMatched, commit]); + useEffect(() => { + if (previousRoutes.current === routes) + return; + previousRoutes.current = routes; + const currentUrl = route?.url ?? committed.current?.route.url ?? router.getUrl(); + if (!currentUrl) + return; + const matched = matcher.match(currentUrl); + if (!matched) + return; + if (pending.current) + releaseHandles(pending.current.handles); + pending.current = prepareMatched(matched); + commit(pending.current.route, pending.current.matched); + }, [routes, router, matcher, prepareMatched, commit, route?.url]); + useEffect(() => { + const prepared = pending.current; + if (!route || !prepared || prepared.route.url !== route.url) + return; + const previous = committed.current; + committed.current = prepared; + pending.current = null; + if (previous) + releaseHandles(previous.handles); + }, [route]); + useEffect(() => releaseAll, [releaseAll]); return useMemo(() => { - if (!route) { + if (!activeRoute) return null; - } - return route.data.reduceRight((children, segment) => { - const props = segment.props ?? {}; - const component = segment.component; - const Component = resolveComponent(component); - // segments without a component act as transparent passthroughs so descendants still render - return Component ? _jsx(Component, { ...props, children: children }) : children; + // Each segment component receives only the params *declared in its own + // `path`* — never borrowed from siblings or descendants. A wrapping + // layout without a path gets no params; a layout that owns `:userId` + // gets that one and only that one; the leaf gets whatever its own + // path declared. Components type the params they expect via their own + // function signature (e.g. `({ id }: { id: string })`); the router's + // runtime injection meets them at that boundary. + // + // Static `props` declared on the route definition win on key collision + // so consumers can intentionally override a path-injected param. + const segments = activeRoute.data; + const matchedParams = (activeRoute.params ?? {}); + const children = segments.reduceRight((children, segment) => { + const segProps = segment.props ?? {}; + const Component = resolveSegmentComponent(segment); + if (!Component) + return children; + const ownParams = paramsDeclaredBy(segment.path, matchedParams); + return (_jsx(Component, { ...ownParams, ...segProps, children: children })); }, null); - }, [router, route && route.pathname]); + return _jsx(RouteContext.Provider, { value: activeRoute, children: children }); + }, [activeRoute]); } -function resolveComponent(component) { - if (!component) +const PATH_PARAM_NAME_RE = /:([A-Za-z0-9_]+)/g; +/** + * Picks out of `matched` only the params whose names appear as `:name` + * segments in `path`. A layout segment with no path returns `{}`; a leaf + * with `/users/:userId/posts/:postId` returns `{ userId, postId }`. + */ +function paramsDeclaredBy(path, matched) { + if (!path) + return {}; + const own = {}; + for (const match of path.matchAll(PATH_PARAM_NAME_RE)) { + const name = match[1]; + if (name in matched) + own[name] = matched[name]; + } + return own; +} +function resolveSegmentComponent(segment) { + if (segment.resolver) { + return getResolverComponent(segment.resolver); + } + if (!segment.component) return null; - const c = component; + const c = segment.component; return c.default || c; } function useScrollToTop(route, disabled) { const prevScrollGroup = useRef(undefined); useEffect(() => { - if (!route || disabled) { + if (!route || disabled) return; - } const datas = route.data; const data = datas[datas.length - 1]; const scrollGroup = data.scrollGroup || route.pathname; @@ -106,53 +372,71 @@ function useScrollToTop(route, disabled) { } }, [route && route.pathname, disabled]); } +// --------------------------------------------------------------------------- +// Link / Navigate +// --------------------------------------------------------------------------- export function useMakeHref() { const { href } = useInternalRouterInstance(); return href; } export function useLinkProps(to) { const target = typeof to === 'string' ? { url: to } : to; + const { router, pendingHref } = useRouterCtx(); const currRoute = useRoute(); const navigate = useNavigate(); const makeHref = useMakeHref(); const href = target.url ? target.url : makeHref(target, currRoute); - const isCurrent = typeof target.current === 'undefined' - ? currRoute?.pathname === href.replace(/^#/, '').split('?')[0] - : target.current; + const currentPathname = currRoute?.pathname ?? router.match(router.getUrl())?.pathname; + const isCurrent = typeof target.current === 'undefined' ? currentPathname === href.replace(/^#/, '').split('?')[0] : target.current; function onClick(event) { - if (target.onClick) - target.onClick(event); if (shouldNavigate(event)) { event.preventDefault(); navigate(target); } } - return { + const result = { href, 'aria-current': isCurrent ? 'page' : undefined, onClick, }; + Object.defineProperty(result, 'isPending', { + enumerable: false, + value: pendingHref === href, + }); + Object.defineProperty(result, 'isCurrent', { + enumerable: false, + value: isCurrent, + }); + return result; } -export function Link({ href: to, replace, current, className, style, extraProps, children, ...anchorProps }) { - const linkTo = typeof to === 'string' - ? { url: to, replace, current } - : { ...to, replace, current }; +export function Link({ href: to, replace, current, className, style, onClick, children, ...anchorProps }) { + const linkTo = typeof to === 'string' ? { url: to } : { ...to }; + if (replace !== undefined) + linkTo.replace = replace; + if (current !== undefined) + linkTo.current = current; const linkProps = useLinkProps(linkTo); - const isCurrent = linkProps['aria-current'] === 'page'; - const evaluate = (valOrFn) => (typeof valOrFn === 'function' ? valOrFn(isCurrent) : valOrFn); - return (_jsx("a", { "aria-current": linkProps['aria-current'], ...anchorProps, className: evaluate(className), style: evaluate(style), ...(extraProps ? extraProps(isCurrent) : {}), href: linkProps.href, + function handleClick(event) { + if (onClick) + onClick(event); + linkProps.onClick(event); + } + return (_jsx("a", { "aria-current": linkProps['aria-current'], ...anchorProps, className: className, style: style, href: linkProps.href, // eslint-disable-next-line react/jsx-handler-names - onClick: linkProps.onClick, children: children })); + onClick: handleClick, children: children })); } export function Navigate({ to }) { - const [navigated, setNavigated] = useState(false); + const router = useInternalRouterInstance(); const navigate = useNavigate(); + const route = useRoute(); + const href = router.href(to, route); + const navigatedHref = useRef(null); useEffect(() => { - if (!navigated) { - navigate(to); - setNavigated(true); - } - }, [navigated]); + if (navigatedHref.current === href) + return; + navigatedHref.current = href; + navigate(to); + }, [href, navigate, to]); return null; } export function shouldNavigate(e) { @@ -169,18 +453,16 @@ export function shouldNavigate(e) { return false; if (a.hasAttribute('download')) return false; - if (a.origin && a.origin !== window.location.origin) + if (typeof window !== 'undefined' && a.origin && a.origin !== window.location.origin) return false; + if (typeof window !== 'undefined' && + a.hash && + a.origin === window.location.origin && + a.pathname === window.location.pathname && + a.search === window.location.search) { + return false; + } } return true; } -function useOnlyLatest() { - const seq = useRef(0); - return (fn) => { - seq.current += 1; - const curr = seq.current; - const isLatest = () => seq.current === curr; - return fn(isLatest); - }; -} //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map index e07874c..51a0585 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/docs/content/_index.md b/docs/content/_index.md index 888b8ca..9fd2653 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -8,19 +8,23 @@ toc: true > [Space Router](https://kidkarolis.github.io/space-router/) bindings for React -React Space Router is a set of hooks and components for keeping your app in sync with the url and performing page navigations. A library built by and used at [Humaans](https://humaans.io/). +React Space Router is a set of hooks and components for keeping your app in sync with the URL and performing page navigations. Suspense-aware and built around React's transition machinery. A library built by and used at [Humaans](https://humaans.io/). - React hooks based - Nested routes -- Async navigation middleware +- Code-split routes via `resolver` (`React.lazy` under the hood) +- Per-route `prepare(ctx)` for fetch-as-you-render data loading +- Pending state via `usePending()` (backed by `useTransition`) +- Delayed route fallbacks via `` +- Optional pre-commit `transformRoute` hook for URL rewrites +- Path params injected as component props - Built in query string parser -- Supports external stores for router state -- Scrolls to top after navigation +- Scrolls to top after navigation, with `scrollGroup` support - Preserves cmd/ctrl/alt/shift click and mouse middle click ## Why -"Perfection is achieved when there is nothing left to take away." React Space Router is built upon Space Router, a framework agnostic tiny core that handles url listening, route matching and navigation. React Space Router wraps that core into an idiomatic set of React components and hooks. The hope is you'll find React Space Router refreshingly simple compared to the existing alternatives, while still offering enough extensibility. +"Perfection is achieved when there is nothing left to take away." React Space Router is built upon Space Router, a framework-agnostic tiny core that handles URL listening, route matching, and navigation. React Space Router wraps that core into an idiomatic set of React components and hooks. The hope is you'll find React Space Router refreshingly simple compared to the existing alternatives, while still offering enough extensibility for modern Suspense-driven UIs. ## Install @@ -31,7 +35,7 @@ $ npm install react-space-router ## Example ```js -import React from 'react' +import React, { Suspense, useEffect } from 'react' import { Router, Routes, Link, useRoute, useNavigate } from 'react-space-router' const routes = [ @@ -40,7 +44,7 @@ const routes = [ component: SettingsContainer, routes: [ { path: '/settings', component: Settings }, - { path: '/settings/billing', component: Billing }, + { path: '/settings/billing', resolver: () => import('./Billing') }, ], }, ] @@ -48,7 +52,9 @@ const routes = [ function App() { return ( - + + + ) } @@ -63,7 +69,7 @@ function Home() { ) } -function Settings({ tag }) { +function Settings() { const navigate = useNavigate() useEffect(() => { @@ -83,35 +89,89 @@ function Settings({ tag }) { ### `` -The application needs to be wrapped in the Router component to provides the router context and state. +Wraps the application and provides router context and state. Route state lives inside the router (`useState` + `useTransition`); commits are wrapped in a transition so Suspense can keep the previous route on screen while the next one prepares. Props: -- `mode` one of `history`, `hash`, `memory`, default is `history` -- `qs` a custom query string parser, an object of shape `{ parse, stringify } -- `useRoute` a custom hook for subscribing to current route state. If this is provided, the router will assume you're storing the latest router state passed to you via `onNavigated` callback and will allow subscribing to this state via this custom hook -- `onNavigating(nextRoute)` called when navigation starts, can be an async function which case the router will await before proceeding to finalise the transition and call `onNavigated`, note if a new navigation is started while this function is processing, `onNavigated` will no longer be called for this specific navigation, instead the next navigation kicks on and repeats the same sequence -- `onNavigated(route)` called when navigation completed +- `mode` one of `history`, `hash`, `memory` — default is `history`. +- `qs` a custom query string parser of shape `{ parse, stringify }`. +- `sync` if `true`, the underlying space-router fires synchronous transitions (useful in tests). +- `transformRoute(route)` an optional pure, synchronous function that runs between match and commit. Return a modified `Route` to change what gets committed; if its `url` differs from the matched URL, the router calls `history.replaceState` so the address bar matches. Use this for things like persisted-query restoration. Must not be async. +- `pendingDelayMs` how long `` holds the previous route before rendering its fallback during an in-flight navigation. Default: `1000`. ### `` ```js - + ``` -Render the components that match the current route based on the route config. +Renders the components that match the current route based on the route config. Nested ancestor segments wrap their descendants automatically — parents render `{children}` to position the matched child. Segments without a `component` or `resolver` are transparent wrappers for their descendants. + +When a navigation happens, every matched segment's `resolver()` is preloaded and every matched segment's `prepare()` is called, so chunk download and data loading can overlap. The router does not await the returned prepare promises before committing; the destination's nearest `` boundary handles any still-cold reads. Props: -- `routes` an array of arrays of route definitions, where each route is an object of shape `{ path, component, props, redirect, scrollGroup, routes, ...metadata }` - - `path` is the URL pattern to match that can include named parameters as segments - - `component` a react component to render, can be a component wrapped in React.lazy - - `props` props to be passed to the component - - `redirect` can be a string or a function that redirects upon entering that route - - `scrollGroup` a string that can group a set of routes, such that navigating between them does not scroll to top, by default each route is in it's own scroll group - - `routes` is an array of nested route definitions - - `...metadata` all other other keys can be chosen by you -- `disableScrollToTop` disable the scroll to top behaviour after each navigation +- `routes` an array of route definitions, where each route is an object of shape `{ path, component, resolver, prepare, navigation, props, scrollGroup, routes, ...metadata }`: + - `path` URL pattern, may include `:named` segments. + - `component` a React component to render. Accepts an ESM-default module shape (`{ default: Component }`) too. + - `resolver` `() => import('./Screen')` — a dynamic import. The router preloads this at navigation time and renders via `React.lazy`. Cold imports suspend at the destination's Suspense boundary. + - `prepare(ctx)` a function called at navigation time with `{ pathname, url, params, query }`. Returns an array of `PreparedHandle` objects (e.g. from a data layer's `prepare()` call). The router pins them for the lifetime of the committed navigation and releases them when the next navigation commits. + - `navigation` currently only accepts `commit: 'immediate'`, which is the default behavior. Alternate commit policies are not implemented. + - `props` props to pass to the segment's component. + - `scrollGroup` a string that groups routes; navigations within a group don't scroll to top. + - `routes` nested route definitions. + - `...metadata` any other keys you want — they're available on `route.data[i]`. +- `disableScrollToTop` disables the scroll-to-top behavior after each navigation. + +### Path params as component props + +When the router commits a route, it spreads matched path params onto route segment components as own props. Each segment receives only the params declared in its own `path` — wrapping layouts that didn't declare those params get nothing extra, while a parent layout that declares `:orgId` receives `orgId` and a child leaf that declares `:issueId` receives `issueId`. + +```js +const routes = [ + { + path: '/issues/:id', + resolver: () => import('./pages/IssueDetail'), + prepare: ({ params }) => [ + figbird.prepare(issueDetail, { id: Number(params.id) }), + ], + }, +] + +// In ./pages/IssueDetail +export default function IssueDetail({ id }) { + // `id` is injected from the route's `:id` segment. +} +``` + +If you also need cross-cutting access from a parent layout, reach for `useRoute()` from there. + +### `PreparedHandle` + +The shape returned by `prepare()` functions. The router collects these from every matched segment, pins them while the route is committed, and calls `release()` when the next navigation commits or `` unmounts. + +```ts +interface PreparedHandle { + promise: Promise + release(): void + priority?: 'route' | 'defer' + key?: string | number +} +``` + +The router stores the handles and calls `release()`. Your data layer decides what `promise`, `priority`, and `key` mean; the router does not inspect `priority` or `key`. + +### `` + +```js +}> + + +``` + +A router-aware `Suspense` boundary. During an in-flight route transition it re-throws its fallback for the first `pendingDelayMs` milliseconds, which lets the already-committed outer route stay on screen. After the threshold, or outside a pending navigation, it behaves like regular `Suspense` and renders its fallback. + +Use it for routes where you want to avoid flashing a skeleton for fast navigations but still show a loading state for slower data. ### `` @@ -119,35 +179,41 @@ Props: ``` -Renders an `` link with a correct `href` and `onClick` handler that will intercept the click and push a history entry to avoid full page reload. Preserves cmd + click behaviour. +Renders an `` with a correct `href` and `onClick` handler that intercepts the click and pushes a history entry instead of triggering a full page reload. Preserves cmd/ctrl/shift/alt + click and middle-click for new-tab/window/download behavior. Props: -- `href` navigation target, can be a `string` or an `object` with: - - `pathname` the pathname portion of the target url, which can include named segments - - `params` params to interpolate into the named pathname segments - - `query` the query object that will be passed through `qs.stringify` - - `hash` the hash fragment to append to the url of the url - - `merge` merge partial `to` object into the current route -- `replace` set to true to replace the current entry in the navigation stack instead of pushing -- `current` set to true to render link as current page, or false to disable auto current page detection based on the current URL -- `className` can be a function that takes `isCurrent` if the current route is active -- `style` can be a function that takes `isCurrent` if the current route is active -- `extraProps` a function that takes `isCurrent` if the current route is active +- `href` navigation target — a `string` or an object with: + - `pathname` the pathname portion, may include named segments. + - `params` params to interpolate into the pathname. + - `query` query object passed through `qs.stringify`. + - `hash` hash fragment. + - `merge` merge partial `to` object into the current route. +- `replace` replace the current entry in the navigation stack instead of pushing. +- `current` set to true/false to override automatic current-page detection. +- `onClick` user click handler. Runs before the router's internal click handling; call `event.preventDefault()` to stop SPA navigation. The rest of the props are spread onto the `` element. +Active links receive `aria-current="page"`, so active styling should usually be plain CSS: + +```css +.nav-link[aria-current='page'] { + font-weight: 600; +} +``` + ### `` ```js ``` -Redirect to the target url upon rendering this component. +Redirects to the target URL on mount. Props: -- `to` can be a `string` or an `object` (refer to `navigate` below) +- `to` `string` or object — same shape as `useNavigate`'s argument. ### `useInternalRouterInstance` @@ -155,7 +221,7 @@ Props: const router = useInternalRouterInstance() ``` -Get the Space Router instance. See [space-router docs](https://kidkarolis.github.io/space-router/) for details. Should typically not be necessary to use it directly. All relevant functionality is available via the other hooks. +Get the underlying Space Router instance. See [space-router docs](https://kidkarolis.github.io/space-router/) for details. Rarely needed — the other hooks cover the common cases. ### `useRoute` @@ -163,23 +229,43 @@ Get the Space Router instance. See [space-router docs](https://kidkarolis.github const route = useRoute() ``` -Subscribe to the current route. Route is an object of shape `{ url, pathname, params, query, search, hash, pattern, data }`. +Subscribe to the current route. Route is `null` before a route has been committed outside ``, otherwise an object of shape `{ url, pathname, params, query, search, hash, pattern, data }`: + +- `url` full relative URL string including query string and hash if any. +- `pathname` the pathname portion. +- `params` params extracted from named pathname segments. +- `query` query object parsed via `qs.parse`. +- `search` full unparsed query string. +- `hash` hash fragment. +- `pattern` the matched route pattern from the route config. +- `data` array of nested matched route objects (with components and any custom metadata). + +Route components rendered by `` receive the initial route synchronously. Components outside `` can still see `null` before the route table has mounted. + +### `usePending` + +```js +const pending = usePending() +``` + +`true` while the router is between navigation start and commit. Backed by React's `useTransition` — flips on as soon as `navigate()` runs and flips off once the destination has committed and the transition has settled. -- `url` full relative url string including query string and hash if any -- `pathname` the pathname portion of the target url, which can include named segments -- `params` params extracted from the named pathname segments -- `query` query object that was parsed with `qs.parse` -- `search` full unparsed query string -- `hash` hash fragment -- `pattern` the matched route pattern as defined in the route config -- `data` an array of nested matched route objects with componentns and any additional metadata found in the route config +Use this for top-of-page progress bars and "your click did something" affordances: + +```js +function LoadingBar() { + const pending = usePending() + return pending ? : null +} +``` + +Don't use it for skeletons — those belong in destination Suspense boundaries. ### `useNavigate` ```js const navigate = useNavigate() -// examples navigate('/shows') navigate({ url: '/show/1' }) navigate({ url: '/show/2', replace: true }) @@ -188,15 +274,15 @@ navigate({ query: { 'top-rated': 1 }, merge: true }) navigate({ query: { 'top-rated': undefined }, merge: true }) ``` -Get the `navigate` function for performing navigations. Navigate takes a `string` url or an `object` of shape: +Get the `navigate` function for performing programmatic navigations. Accepts a `string` URL or an object: -- `url` url string -- `pathname` the pathname portion of the target url, which can include named segments -- `params` params to interpolate into the named pathname segments -- `query` the query object that will be passed through `qs.stringify` -- `hash` the hash fragment to append to the url of the url -- `merge` merge partial `to` object into the current route -- `replace` set to true to replace the current entry in the navigation stack instead of pushing +- `url` URL string. +- `pathname` pathname portion, may include named segments. +- `params` params to interpolate. +- `query` query object passed through `qs.stringify`. +- `hash` hash fragment. +- `merge` merge partial `to` into the current route. +- `replace` replace the current history entry instead of pushing. ### `useLinkProps` @@ -205,18 +291,23 @@ const linkProps = useLinkProps(to) ``` -Get linkProps that you can spread onto your own links to make them render both `href`, but also handle clicks to perform navigations using the router. Link props is an object of shape `{ href, aria-current, onClick}`. +Returns `{ href, aria-current, onClick, isCurrent, isPending }` so you can build your own anchor and get full router behavior without using ``. `isCurrent` and `isPending` are non-enumerable, so `` stays safe, but you can still read them for active and per-link loading UI. + +Takes a `string` URL or an object — same fields as `useNavigate`, plus: -Takes a `string` url or an `object` of shape: +- `current` override automatic current-page detection. -- `pathname` the pathname portion of the target url, which can include named segments -- `params` params to interpolate into the named pathname segments -- `query` the query object that will be passed through `qs.stringify` -- `hash` the hash fragment to append to the url of the url -- `merge` merge partial `to` object into the current route -- `replace` set to true to replace the current entry in the navigation stack instead of pushing -- `current` set to true to render link as current page, or false to disable auto current page detection based on the current URL -- `onClick` a click handler to be called before the navigation takes place +For programmatic active-aware UI, read `isCurrent` from the returned props: + +```tsx +const linkProps = useLinkProps('/settings') + +return ( + + Settings + +) +``` ### `useMakeHref` @@ -225,11 +316,11 @@ const makeHref = useMakeHref() makeHref(to) ``` -Create a relative url string to use in `` attribute. +Create a relative URL string to use in ``. -- `to` object of shape `{ pathname, params, query, hash }`. The `params` will be interpolated into the pathname if the pathname contains any parametrised segments. The `query` is an object that will be passed through `qs.stringify`. +- `to` object of shape `{ pathname, params, query, hash }`. The `params` interpolate into named pathname segments; `query` is stringified via `qs.stringify`. -Note: `to` can be a string, in which case `href` simply returns the input. Similarly, `to` can contain `{ url }` key in which case `href` returns that url. This is to align the function signature with that of `navigate` so that two can be used interchangeably. +If `to` is a string, `makeHref` returns it as-is. Same for `{ url }` — this matches `navigate`'s signature so the two are interchangeable. ### `shouldNavigate` @@ -237,8 +328,22 @@ Note: `to` can be a string, in which case `href` simply returns the input. Simil shouldNavigate(e) ``` -Check if the current click event should cause a history push, or should be handled by the browser. Used internally by the `` component when intercepting `click` events to let browser handle: +Check whether a click event should result in a router navigation or be left to the browser. Used internally by ``. Returns `false` for: - cmd/ctrl/alt/shift + click - middle mouse click -- stop navigation if `e.defaultPrevented` is true +- `e.defaultPrevented` +- target=\_blank or other non-self targets +- `download` attribute +- cross-origin or non-http(s) URLs + +## Migrating from 0.6.x + +See [MIGRATION.md](https://github.com/humaans/react-space-router/blob/master/MIGRATION.md) for the full migration guide. Headlines: + +- `onNavigating`/`onNavigated`/`useRoute` props have been removed from ``. Route state lives inside the router now. +- Use `resolver` on a route segment instead of awaiting `import()` in `onNavigating`. +- Use `usePending()` instead of a manual `navigating: true/false` flag. +- Use `` for delayed skeleton fallbacks during route transitions. +- Use `transformRoute` for the one case that actually needs a pre-commit hook (e.g. persisted-query restoration). +- Replace external Redux/Zustand/atom-backed route state with direct `useRoute()` reads. diff --git a/examples/kitchen-sink/.prettierignore b/examples/kitchen-sink/.prettierignore deleted file mode 100644 index 14d6e2d..0000000 --- a/examples/kitchen-sink/.prettierignore +++ /dev/null @@ -1,5 +0,0 @@ -coverage -dist -docs/static -docs/assets -docs/public \ No newline at end of file diff --git a/examples/kitchen-sink/.prettierrc b/examples/kitchen-sink/.prettierrc deleted file mode 100644 index ac3a5e8..0000000 --- a/examples/kitchen-sink/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "semi": false, - "singleQuote": true, - "jsxSingleQuote": true, - "printWidth": 120 -} diff --git a/examples/kitchen-sink/index.js b/examples/kitchen-sink/index.js deleted file mode 100755 index ba3829a..0000000 --- a/examples/kitchen-sink/index.js +++ /dev/null @@ -1,126 +0,0 @@ -/* eslint-disable react/jsx-no-bind */ - -import React, { useState, useEffect, useCallback } from 'react' -import ReactDOM from 'react-dom' -import './styles.css' - -import { Router, Routes, Link, Navigate, useRouter, useRoute, useLink, useNavigate } from '../..' - -const routes = [ - { - component: Chrome, - a: 1, - routes: [ - { path: '/', component: Home, b: 2 }, - { path: '/kitchen', component: Kitchen, c: 3 }, - { path: '/sink', component: Sink, d: 4 }, - { path: '/asink', resolver: async () => new Promise((resolve) => setTimeout(() => resolve(Asink), 1000)), e: 5 }, - ], - }, -] - -function App() { - return ( - { - if (route.data.find((r) => !r.component)) { - await Promise.all( - route.data.map(async (routeData) => { - if (!routeData.component && routeData.resolver) { - routeData.component = await routeData.resolver() - } - }), - ) - } - }, [])} - > - - - ) -} - -function Chrome({ children }) { - return ( -
- -
-
{children}
- - - -
-
- ) -} - -function Home() { - return
Home
-} - -function Kitchen() { - return
Kitchen
-} - -function Sink() { - return
Sink
-} - -function Asink() { - return
Asink resolves after 1 second
-} - -function Toys() { - const { pathname } = useRoute() - const router = useRouter() - const navigate = useNavigate() - const linkProps = useLink({ query: { rnd: Math.random(Math.random() * 1000) }, merge: true }) - - const [navigateWithComponent, setNavigateWithComponent] = useState() - - function delayedNavToKitchen() { - navigate({ url: '/' }) - } - - function delayedHomeUsingRouter() { - router.navigate({ url: '/' }) - } - - function navigateUsingNavigate() { - setNavigateWithComponent(true) - } - - function navigateWithMerge() { - navigate({ pathname: '/', merge: true }) - } - - useEffect(() => { - setNavigateWithComponent(false) - }, [pathname]) - - return ( -
- Navigate to random query param - Merges in ?mm=foo - - - - - - {navigateWithComponent && } -
- ) -} - -function RouteDetails() { - const route = useRoute() - - return
{JSON.stringify(route, null, 2)}
-} - -ReactDOM.render(, document.querySelector('#root')) diff --git a/examples/kitchen-sink/package-lock.json b/examples/kitchen-sink/package-lock.json deleted file mode 100644 index bb4f076..0000000 --- a/examples/kitchen-sink/package-lock.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "name": "kitchen-sink", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "kitchen-sink", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "react": "^17.0.2", - "react-dom": "^17.0.2" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - }, - "peerDependencies": { - "react": "17.0.2" - } - }, - "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - } - }, - "dependencies": { - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" - }, - "react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - } - }, - "scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - } - } -} diff --git a/examples/kitchen-sink/package.json b/examples/kitchen-sink/package.json deleted file mode 100644 index 08d64e7..0000000 --- a/examples/kitchen-sink/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "kitchen-sink", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "", - "license": "ISC", - "dependencies": { - "react": "^17.0.2", - "react-dom": "^17.0.2" - } -} diff --git a/examples/kitchen-sink/styles.css b/examples/kitchen-sink/styles.css deleted file mode 100644 index 58c3548..0000000 --- a/examples/kitchen-sink/styles.css +++ /dev/null @@ -1,49 +0,0 @@ -html, -body { - padding: 0; - margin: 0; - font-family: sans-serif; -} - -nav { - border-bottom: 1px solid #eee; - margin-bottom: 20px; - padding: 20px; -} - -nav a { - padding: 8px 16px; - background: #f2f2f2; - border-radius: 4px; - margin: 0 4px; - text-decoration: none; -} - -nav a:hover { - background: #e2e2e2; -} - -nav a[aria-current='page'] { - background: orange; -} - -.Chrome-container { - padding: 20px; -} - -.Chrome-content { - padding: 20px 0; - border-bottom: 1px solid #eee; -} - -.RouteDetails { - margin: 20px; -} - -.Toys { - padding: 20px 0; - display: grid; - grid-template-columns: 1fr; - grid-gap: 8px; - max-width: 320px; -} diff --git a/package.json b/package.json index 8603e94..8a4c52b 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,10 @@ ], "author": "Karolis Narkevicius", "scripts": { - "test": "npm run build && oxlint --ignore-pattern examples --ignore-pattern dist --ignore-pattern docs && oxfmt --check --ignore-path=.gitignore --ignore-path=.oxfmtignore '**/*.{ts,tsx,js,css,yml}' && c8 ava", + "test": "npm run build && oxlint --ignore-pattern demo --ignore-pattern dist --ignore-pattern docs && oxfmt --check --ignore-path=.gitignore --ignore-path=.oxfmtignore '**/*.{ts,tsx,js,css,yml}' && c8 ava", "format": "oxfmt --ignore-path=.gitignore --ignore-path=.oxfmtignore '**/*.{ts,tsx,js,css,yml}'", + "demo": "npm run build && npm --prefix demo run dev", + "demo:build": "npm run build && npm --prefix demo run build", "coverage": "c8 --reporter=html ava", "build": "rm -rf dist && tsc", "watch": "tsc -w", diff --git a/src/index.tsx b/src/index.tsx index 7e505a2..ea71bf0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,14 @@ import { createContext, + lazy as reactLazy, + Suspense, useCallback, useContext, useState, useEffect, useMemo, useRef, + useTransition, type AnchorHTMLAttributes, type ComponentType, type CSSProperties, @@ -13,6 +16,7 @@ import { type ReactNode, } from 'react' import { + createMatcher, createRouter, type Mode, type NavigateTarget, @@ -24,89 +28,326 @@ import { export { qs } from 'space-router' +// --------------------------------------------------------------------------- +// Route preparation +// --------------------------------------------------------------------------- + +export interface RoutePrepareContext { + pathname: string + url: string + params: Record + query: Record +} + +/** + * Lifecycle handle returned by data-layer `prepare()` calls (e.g. `figbird.prepare`). + * The router pins the underlying cache entries via `release()` for the lifetime of + * the navigation; superseded navigations release their handles immediately. + */ +export interface PreparedHandle { + promise: Promise + release(): void + priority?: 'route' | 'defer' + key?: string | number +} + +export type RoutePrepare = (ctx: RoutePrepareContext) => readonly PreparedHandle[] | PreparedHandle[] | void + +export type ResolverModule = { default: ComponentType } + +export type RouteResolver = () => Promise + +export interface RouteNavigationOptions { + /** + * `'immediate'` (default): commit the new route synchronously inside a React + * transition; let any unresolved route-priority data suspend at the + * destination's `` boundary. + * + * `'ready'` is reserved for a follow-up release. + */ + commit?: 'immediate' +} + +export interface RouteData { + path?: string + component?: ComponentType | { default: ComponentType } | null + resolver?: RouteResolver + prepare?: RoutePrepare + navigation?: RouteNavigationOptions + scrollGroup?: string + routes?: RouteData[] + [extra: string]: unknown +} + +// Internal caches keyed by the resolver function reference. Generic params are +// erased here — the cache is structurally a map of unknown resolvers to their +// lazily-imported component types. +type AnyResolver = () => Promise<{ default: ComponentType }> + +const resolverPromiseCache = new WeakMap }>>() +const resolverComponentCache = new WeakMap>() + +function preloadResolver(resolver: AnyResolver): Promise<{ default: ComponentType }> { + let promise = resolverPromiseCache.get(resolver) + if (!promise) { + promise = resolver() + promise.catch(() => { + // Keep the original rejected promise cached for React.lazy/error + // boundaries, but mark preload rejections as observed. + }) + resolverPromiseCache.set(resolver, promise) + } + return promise +} + +function getResolverComponent(resolver: AnyResolver): ComponentType { + let component = resolverComponentCache.get(resolver) + if (!component) { + component = reactLazy(() => preloadResolver(resolver)) + resolverComponentCache.set(resolver, component) + } + return component +} + +function buildPrepareContext(route: Route): RoutePrepareContext { + const r = route as Route & { + pathname?: string + params?: Record + query?: Record + } + return { + pathname: r.pathname ?? '', + url: (r as Route & { url?: string }).url ?? r.pathname ?? '', + params: r.params ?? {}, + query: r.query ?? {}, + } +} + +function routeHasRedirect(route: Route | undefined): boolean { + return !!route?.data?.some((segment) => Boolean((segment as { redirect?: unknown }).redirect)) +} + +// --------------------------------------------------------------------------- +// Router context +// --------------------------------------------------------------------------- + +export type To = + | string + | (NavigateTarget & { + current?: boolean + }) + interface RouterContextValue { router: SpaceRouter - useRoute: () => Route | null - onNavigating?: (route: Route) => void | Promise - onNavigated: (route: Route) => void + route: Route | null + transformRoute: (route: Route) => Route + syncRouteUrl: (matched: Route, transformed: Route) => void + commit: (route: Route, matched?: Route) => void + navigate: (to: To, curr?: Route) => void + isPending: boolean + pendingHref: string | null + qs: Qs | undefined } export const RouterContext = createContext(undefined) -export const CurrRouteContext = createContext(null) +const RouteContext = createContext(undefined) -export function useInternalRouterInstance(): SpaceRouter { +// Internal context for ``. Set by `` based on +// `usePending()` + a configurable threshold. `holding` is true only during +// the pre-commit window where we want the previous route to stay on screen. +const DelayedSuspenseContext = createContext(false) + +function useRouterCtx(): RouterContextValue { const ctx = useContext(RouterContext) - if (!ctx?.router) { + if (!ctx) { throw new Error('Application must be wrapped in ') } - return ctx.router + return ctx +} + +export function useInternalRouterInstance(): SpaceRouter { + return useRouterCtx().router } export function useRoute(): Route | null { - return useContext(RouterContext)!.useRoute() + const route = useContext(RouteContext) + return route === undefined ? useRouterCtx().route : route +} + +/** + * `true` while the router is between navigation start and commit. Backed by + * React's `useTransition` — flips on as soon as `navigate()` runs and flips off + * once the destination has committed (and any Suspense fallbacks at the new + * route have resolved enough to let React's transition settle). + * + * Use this for top-of-page progress bars, desaturated link states, and "your + * click did something" affordances. Don't use it for skeletons — those belong + * in destination Suspense boundaries. + */ +export function usePending(): boolean { + return useRouterCtx().isPending } export function useNavigate() { - const currRoute = useRoute() - const navigate = useInternalRouterInstance().navigate + const { navigate } = useRouterCtx() + const route = useRoute() return useCallback( (to: To) => { - return navigate(to, currRoute as Route | undefined) + return navigate(to, route as Route | undefined) }, - [navigate, currRoute], + [navigate, route], ) } -function defaultUseRoute(): Route | null { - return useContext(CurrRouteContext) -} +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- interface RouterOpts { mode: Mode | undefined qs: Qs | undefined sync: boolean | undefined - disableScrollToTop?: boolean } interface InternalRouter { - router: SpaceRouter & { disableScrollToTop?: boolean } + router: SpaceRouter routerOpts: RouterOpts } function makeRouter(routerOpts: RouterOpts): InternalRouter { const router = createRouter({ mode: routerOpts.mode, qs: routerOpts.qs, sync: routerOpts.sync }) - ;(router as InternalRouter['router']).disableScrollToTop = routerOpts.disableScrollToTop - return { router: router as InternalRouter['router'], routerOpts } + return { router, routerOpts } } +/** + * Optional pre-commit transform. Runs synchronously between match and commit. + * Return a modified route to change what gets committed (e.g. to merge a + * persisted query). If the returned route's `url` differs from the matched + * route's, the browser URL is synced via `history.replaceState` so the address + * bar matches what the app is rendering. + * + * Must be pure and synchronous. + */ +export type TransformRoute = (route: Route) => Route | void + export interface RouterProps { mode?: Mode qs?: Qs sync?: boolean - useRoute?: () => Route | null - onNavigating?: (route: Route) => void | Promise - onNavigated?: (route: Route) => void + transformRoute?: TransformRoute + /** + * How long to hold the previous route on screen before `` + * boundaries fall back to their fallback content. Default is `1000` ms. + * No effect on plain `` boundaries — those always show their + * fallback the moment the boundary mounts. + */ + pendingDelayMs?: number children?: ReactNode } -export function Router({ mode, qs, sync, useRoute, onNavigating, onNavigated, children }: RouterProps) { +const DEFAULT_PENDING_DELAY_MS = 1000 + +export function Router({ + mode, + qs, + sync, + transformRoute, + pendingDelayMs = DEFAULT_PENDING_DELAY_MS, + children, +}: RouterProps) { const [{ router, routerOpts }, setRouter] = useState(() => makeRouter({ mode, qs, sync })) const [currRoute, setCurrRoute] = useState(null) + const [pendingHref, setPendingHref] = useState(null) + const [isPending, startRouterTransition] = useTransition() + + // `holding` is true during the pre-commit window where `` + // boundaries should re-throw their fallback (so the previous route stays + // committed). It flips off either after `pendingDelayMs` elapses while + // still pending, or when the transition settles — whichever comes first. + const [holding, setHolding] = useState(false) + useEffect(() => { + if (!isPending) { + setHolding(false) + return + } + setHolding(true) + const t = setTimeout(() => setHolding(false), pendingDelayMs) + return () => clearTimeout(t) + }, [isPending, pendingDelayMs]) + + // Keep the latest transform in a ref so commit() can stay referentially + // stable while always using the freshest function. + const transformRef = useRef(transformRoute) + transformRef.current = transformRoute + + const applyTransform = useCallback((next: Route) => { + const transform = transformRef.current + return transform ? (transform(next) ?? next) : next + }, []) + + const syncRouteUrl = useCallback((matched: Route, transformed: Route) => { + const matchedUrl = (matched as Route & { url?: string }).url + const transformedUrl = (transformed as Route & { url?: string }).url + + if ( + transformed !== matched && + transformedUrl && + transformedUrl !== matchedUrl && + typeof window !== 'undefined' && + window.history + ) { + window.history.replaceState({}, '', transformedUrl) + } + }, []) + + const commit = useCallback( + (next: Route, matched: Route = next) => { + const matchedUrl = (matched as Route & { url?: string }).url + const transformedUrl = (next as Route & { url?: string }).url + + startRouterTransition(() => { + setCurrRoute(next) + setPendingHref((current) => { + if (!current) return current + if (current === matchedUrl || current === transformedUrl) return null + return routeHasRedirect(router.match(current)) ? null : current + }) + }) + + // Sync the address bar if the transform rewrote the URL. We use + // history.replaceState directly so we don't re-trigger the router's + // listener loop. + syncRouteUrl(matched, next) + }, + [router, syncRouteUrl], + ) - const connectedRouter = useMemo( + const navigate = useCallback( + (to: To, curr?: Route) => { + const href = router.href(to, curr) + setPendingHref(href) + router.navigate(to, curr) + if (!router.match(href)) { + setPendingHref(null) + } + }, + [router], + ) + + const ctx = useMemo( () => ({ router, - useRoute: useRoute || defaultUseRoute, - onNavigating, - onNavigated(route: Route) { - if (!useRoute) { - setCurrRoute(route) - } - if (onNavigated) onNavigated(route) - }, + route: currRoute, + transformRoute: applyTransform, + syncRouteUrl, + commit, + navigate, + isPending, + pendingHref, + qs, }), - [router, useRoute, onNavigating, onNavigated], + [router, currRoute, applyTransform, syncRouteUrl, commit, navigate, isPending, pendingHref, qs], ) useEffect(() => { @@ -116,56 +357,269 @@ export function Router({ mode, qs, sync, useRoute, onNavigating, onNavigated, ch }, [routerOpts, mode, qs, sync]) return ( - - {children} + + {children} ) } +// --------------------------------------------------------------------------- +// DelayedSuspense +// --------------------------------------------------------------------------- + +/** + * A `` boundary whose fallback is *delayed* during an in-flight + * router navigation: until the router has been pending for `pendingDelayMs` + * (configured on ``, default 1000ms), the fallback re-throws so + * suspension propagates upward — typically to the router-level transition, + * which keeps the previous route on screen. Past the threshold (or when + * the transition has already committed and a read is still pending), the + * fallback renders normally. + * + * Use this when you want "stay on the previous page for a moment, then if + * it's still loading degrade to a skeleton" — the classic browser-style + * UX for variable-latency data. Outside an in-flight nav, behaves + * identically to plain ``. + */ +export interface DelayedSuspenseProps { + fallback: ReactNode + children?: ReactNode +} + +export function DelayedSuspense({ fallback, children }: DelayedSuspenseProps) { + const holding = useContext(DelayedSuspenseContext) + return : fallback}>{children} +} + +const NEVER_RESOLVES: Promise = new Promise(() => {}) + +/** + * Throws a never-resolving promise so the surrounding Suspense boundary's + * fallback path itself suspends — the suspension bubbles up to the next + * Suspense boundary above, which during a router transition is the + * already-committed root holding the previous route. + */ +function DelayedSuspenseHold(): null { + throw NEVER_RESOLVES +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + export interface RoutesProps { - routes: RouteDefinition[] + routes: RouteDefinition[] disableScrollToTop?: boolean } +interface PreparedRoute { + route: Route + matched: Route + handles: PreparedHandle[] +} + +function prepareRoute(route: Route): PreparedHandle[] { + const segments = (route.data ?? []) as RouteData[] + const ctx = buildPrepareContext(route) + const handles: PreparedHandle[] = [] + + for (const segment of segments) { + if (segment.resolver) preloadResolver(segment.resolver) + if (segment.prepare) { + const result = segment.prepare(ctx) + if (result) { + for (const handle of result) { + handles.push(handle) + } + } + } + } + + return handles +} + +function releaseHandles(handles: PreparedHandle[]) { + for (const handle of handles) { + try { + handle.release() + } catch { + // best-effort + } + } +} + +function releaseUniqueHandles(handleGroups: PreparedHandle[][]) { + const released = new Set() + for (const handles of handleGroups) { + for (const handle of handles) { + if (released.has(handle)) continue + released.add(handle) + releaseHandles([handle]) + } + } +} + export function Routes({ routes, disableScrollToTop }: RoutesProps) { - const ctx = useContext(RouterContext)! - const { router, onNavigating, onNavigated } = ctx - const route = ctx.useRoute() - const onlyLatest = useOnlyLatest() - useScrollToTop(route, disableScrollToTop) + const { router, route, transformRoute, syncRouteUrl, commit, qs } = useRouterCtx() + + // Pinned prepare handles for the currently committed navigation. Released + // when a new navigation commits or when unmounts. + const committed = useRef(null) + const pending = useRef(null) + const previousRoutes = useRef(routes) + const matcher = useMemo(() => createMatcher(routes, { qs }), [routes, qs]) + + const prepareMatched = useCallback( + (matched: Route): PreparedRoute => { + const transformed = transformRoute(matched) + return { route: transformed, matched, handles: prepareRoute(transformed) } + }, + [transformRoute], + ) + + const releaseAll = useCallback(() => { + releaseUniqueHandles([committed.current?.handles ?? [], pending.current?.handles ?? []]) + committed.current = null + pending.current = null + }, []) + + const initialRoute = useMemo | null>(() => { + if (route) return null + const matched = matcher.match(router.getUrl()) + if (matched) { + return { route: transformRoute(matched), matched } + } + return null + }, [route, router, matcher, transformRoute]) + + const activeRoute = route ?? committed.current?.route ?? initialRoute?.route ?? null + + useEffect(() => { + if (!initialRoute || route || committed.current || pending.current) return + + const prepared = { + ...initialRoute, + handles: prepareRoute(initialRoute.route), + } + committed.current = prepared + syncRouteUrl(prepared.matched, prepared.route) + }, [initialRoute, route, syncRouteUrl]) + + useScrollToTop(activeRoute, disableScrollToTop) useEffect(() => { const transition = (next: Route) => { - onlyLatest(async (isLatest) => { - if (isLatest() && onNavigating) { - await onNavigating(next) + const nextUrl = (next as Route & { url?: string; pathname?: string }).url ?? next.pathname + const matched = matcher.match(nextUrl) ?? next + const matchedRoute = transformRoute(matched) + + if (committed.current?.route.url === matchedRoute.url) { + if (pending.current) { + releaseHandles(pending.current.handles) + pending.current = null } - if (isLatest()) { - onNavigated(next) - } - }) + commit(committed.current.route, committed.current.matched) + return + } + + if (pending.current?.route.url === matchedRoute.url) { + commit(pending.current.route, pending.current.matched) + return + } + + if (pending.current) releaseHandles(pending.current.handles) + + pending.current = prepareMatched(matched) + commit(pending.current.route, pending.current.matched) } return router.listen(routes, transition) - }, [router, routes, onNavigating, onNavigated]) + }, [router, routes, matcher, transformRoute, prepareMatched, commit]) + + useEffect(() => { + if (previousRoutes.current === routes) return + previousRoutes.current = routes + + const currentUrl = route?.url ?? committed.current?.route.url ?? router.getUrl() + if (!currentUrl) return + + const matched = matcher.match(currentUrl) + if (!matched) return + + if (pending.current) releaseHandles(pending.current.handles) + pending.current = prepareMatched(matched) + commit(pending.current.route, pending.current.matched) + }, [routes, router, matcher, prepareMatched, commit, route?.url]) + + useEffect(() => { + const prepared = pending.current + if (!route || !prepared || prepared.route.url !== route.url) return + + const previous = committed.current + committed.current = prepared + pending.current = null + if (previous) releaseHandles(previous.handles) + }, [route]) + + useEffect(() => releaseAll, [releaseAll]) return useMemo(() => { - if (!route) { - return null - } + if (!activeRoute) return null + + // Each segment component receives only the params *declared in its own + // `path`* — never borrowed from siblings or descendants. A wrapping + // layout without a path gets no params; a layout that owns `:userId` + // gets that one and only that one; the leaf gets whatever its own + // path declared. Components type the params they expect via their own + // function signature (e.g. `({ id }: { id: string })`); the router's + // runtime injection meets them at that boundary. + // + // Static `props` declared on the route definition win on key collision + // so consumers can intentionally override a path-injected param. + const segments = activeRoute.data as RouteData[] + const matchedParams = ((activeRoute as Route & { params?: Record }).params ?? {}) as Record< + string, + string + > - return route.data.reduceRight((children, segment) => { - const props = (segment as { props?: Record }).props ?? {} - const component = (segment as { component?: unknown }).component - const Component = resolveComponent(component) - // segments without a component act as transparent passthroughs so descendants still render - return Component ? {children} : children + const children = segments.reduceRight((children, segment) => { + const segProps = (segment as { props?: Record }).props ?? {} + const Component = resolveSegmentComponent(segment) + if (!Component) return children + const ownParams = paramsDeclaredBy(segment.path, matchedParams) + return ( + + {children} + + ) }, null) - }, [router, route && route.pathname]) + + return {children} + }, [activeRoute]) +} + +const PATH_PARAM_NAME_RE = /:([A-Za-z0-9_]+)/g + +/** + * Picks out of `matched` only the params whose names appear as `:name` + * segments in `path`. A layout segment with no path returns `{}`; a leaf + * with `/users/:userId/posts/:postId` returns `{ userId, postId }`. + */ +function paramsDeclaredBy(path: string | undefined, matched: Record): Record { + if (!path) return {} + const own: Record = {} + for (const match of path.matchAll(PATH_PARAM_NAME_RE)) { + const name = match[1] + if (name in matched) own[name] = matched[name] + } + return own } -function resolveComponent(component: unknown): ComponentType | null { - if (!component) return null - const c = component as { default?: ComponentType } & ComponentType +function resolveSegmentComponent(segment: RouteData): ComponentType | null { + if (segment.resolver) { + return getResolverComponent(segment.resolver) + } + if (!segment.component) return null + const c = segment.component as { default?: ComponentType } & ComponentType return c.default || c } @@ -173,9 +627,7 @@ function useScrollToTop(route: Route | null, disabled?: boolean) { const prevScrollGroup = useRef(undefined) useEffect(() => { - if (!route || disabled) { - return - } + if (!route || disabled) return const datas = route.data const data = datas[datas.length - 1] as { scrollGroup?: string } @@ -189,95 +641,93 @@ function useScrollToTop(route: Route | null, disabled?: boolean) { }, [route && route.pathname, disabled]) } +// --------------------------------------------------------------------------- +// Link / Navigate +// --------------------------------------------------------------------------- + export function useMakeHref() { const { href } = useInternalRouterInstance() return href } -export type To = - | string - | (NavigateTarget & { - onClick?: (e: MouseEvent) => void - current?: boolean - }) - export interface LinkPropsResult { href: string 'aria-current': 'page' | undefined onClick: (e: MouseEvent) => void + isCurrent: boolean + isPending: boolean } export function useLinkProps(to: To): LinkPropsResult { - const target: NavigateTarget & { onClick?: (e: MouseEvent) => void; current?: boolean } = - typeof to === 'string' ? { url: to } : to + const target: NavigateTarget & { current?: boolean } = typeof to === 'string' ? { url: to } : to + const { router, pendingHref } = useRouterCtx() const currRoute = useRoute() const navigate = useNavigate() const makeHref = useMakeHref() const href = target.url ? target.url : makeHref(target, currRoute as Route | undefined) + const currentPathname = currRoute?.pathname ?? router.match(router.getUrl())?.pathname const isCurrent = - typeof target.current === 'undefined' - ? currRoute?.pathname === href.replace(/^#/, '').split('?')[0] - : target.current + typeof target.current === 'undefined' ? currentPathname === href.replace(/^#/, '').split('?')[0] : target.current function onClick(event: MouseEvent) { - if (target.onClick) target.onClick(event) - if (shouldNavigate(event)) { event.preventDefault() navigate(target) } } - return { + const result = { href, 'aria-current': isCurrent ? 'page' : undefined, onClick, - } + } as LinkPropsResult + + Object.defineProperty(result, 'isPending', { + enumerable: false, + value: pendingHref === href, + }) + Object.defineProperty(result, 'isCurrent', { + enumerable: false, + value: isCurrent, + }) + + return result } -type FnOr = T | ((isCurrent: boolean) => T) - export interface LinkOwnProps { href?: To replace?: boolean current?: boolean - className?: FnOr - style?: FnOr - extraProps?: (isCurrent: boolean) => Record + className?: string + style?: CSSProperties children?: ReactNode } -export type LinkProps = LinkOwnProps & Omit, keyof LinkOwnProps | 'onClick'> +export type LinkProps = LinkOwnProps & Omit, keyof LinkOwnProps> -export function Link({ - href: to, - replace, - current, - className, - style, - extraProps, - children, - ...anchorProps -}: LinkProps) { - const linkTo: To = - typeof to === 'string' - ? { url: to, replace, current } - : { ...(to as NavigateTarget & { onClick?: any; current?: boolean }), replace, current } +export function Link({ href: to, replace, current, className, style, onClick, children, ...anchorProps }: LinkProps) { + const linkTo: NavigateTarget & { current?: boolean } = + typeof to === 'string' ? { url: to } : { ...(to as NavigateTarget & { current?: boolean }) } + if (replace !== undefined) linkTo.replace = replace + if (current !== undefined) linkTo.current = current const linkProps = useLinkProps(linkTo) - const isCurrent = linkProps['aria-current'] === 'page' - const evaluate = (valOrFn: FnOr): T => (typeof valOrFn === 'function' ? (valOrFn as any)(isCurrent) : valOrFn) + + function handleClick(event: MouseEvent) { + if (onClick) onClick(event) + linkProps.onClick(event) + } + return ( {children} @@ -289,15 +739,17 @@ export interface NavigateProps { } export function Navigate({ to }: NavigateProps) { - const [navigated, setNavigated] = useState(false) + const router = useInternalRouterInstance() const navigate = useNavigate() + const route = useRoute() + const href = router.href(to, route as Route | undefined) + const navigatedHref = useRef(null) useEffect(() => { - if (!navigated) { - navigate(to) - setNavigated(true) - } - }, [navigated]) + if (navigatedHref.current === href) return + navigatedHref.current = href + navigate(to) + }, [href, navigate, to]) return null } @@ -312,18 +764,16 @@ export function shouldNavigate(e: MouseEvent): boolean { // and cross-origin or non-http(s) protocols (mailto:, tel:, ...) if (a.target && a.target !== '_self') return false if (a.hasAttribute('download')) return false - if (a.origin && a.origin !== window.location.origin) return false + if (typeof window !== 'undefined' && a.origin && a.origin !== window.location.origin) return false + if ( + typeof window !== 'undefined' && + a.hash && + a.origin === window.location.origin && + a.pathname === window.location.pathname && + a.search === window.location.search + ) { + return false + } } return true } - -function useOnlyLatest() { - const seq = useRef(0) - - return (fn: (isLatest: () => boolean) => void) => { - seq.current += 1 - const curr = seq.current - const isLatest = () => seq.current === curr - return fn(isLatest) - } -} diff --git a/test/router.test.tsx b/test/router.test.tsx index 96a9f50..78beeae 100755 --- a/test/router.test.tsx +++ b/test/router.test.tsx @@ -1,16 +1,22 @@ import test from 'ava' -import { act, useEffect, useState } from 'react' +import { act, Component, Suspense, useEffect, useState, type ReactNode } from 'react' import ReactDOM from 'react-dom/client' -import { JSDOM } from 'jsdom' +import { renderToString } from 'react-dom/server' +import { JSDOM, VirtualConsole } from 'jsdom' import { Router, RouterContext, Routes, Link, Navigate, + DelayedSuspense, useInternalRouterInstance, useLinkProps, + usePending, + useRoute, qs, + type PreparedHandle, + type Route, } from '../src/index.tsx' ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true @@ -18,7 +24,23 @@ import { const g = globalThis as any function setup() { - const dom = new JSDOM('
') + const virtualConsole = new VirtualConsole() + virtualConsole.forwardTo(console, { jsdomErrors: 'none' }) + virtualConsole.on('jsdomError', (error) => { + if (error.type === 'not-implemented' && error.message === 'Not implemented: navigation to another Document') { + return + } + if (error.type === 'unhandled-exception') { + console.error(error.cause.stack) + return + } + console.error(error.message) + }) + + const dom = new JSDOM('
', { + url: 'http://localhost/', + virtualConsole, + }) g.window = dom.window g.window.scrollTo = () => {} g.document = dom.window.document @@ -30,6 +52,9 @@ function setup() { const popstate = new dom.window.PopStateEvent('popstate') dom.window.dispatchEvent(popstate) }, + replaceState() { + // no-op for tests; real replaceState would update the address bar + }, } g.location = { href: '/', @@ -39,6 +64,12 @@ function setup() { } } +function dispatchClick(el: Element): MouseEvent { + const event = new window.MouseEvent('click', { bubbles: true, button: 0, cancelable: true }) + el.dispatchEvent(event) + return event +} + test.serial('usage', async function (t) { setup() @@ -54,7 +85,7 @@ test.serial('usage', async function (t) { function Home() { return (
- StuffHello + StuffHello
) } @@ -83,10 +114,7 @@ test.serial('usage', async function (t) { r.render() }) - t.is( - window.document.body.innerHTML, - '
StuffHello
', - ) + t.is(window.document.body.innerHTML, '
StuffHello
') act(() => { router.navigate('/stuff') @@ -95,6 +123,92 @@ test.serial('usage', async function (t) { t.is(window.document.body.innerHTML, '
Stuff
') }) +test.serial('Router and Link render without browser globals', (t) => { + const previous = { + window: g.window, + document: g.document, + history: g.history, + location: g.location, + } + + delete g.window + delete g.document + delete g.history + delete g.location + + try { + const html = renderToString( + + X +
Home
}]} /> +
, + ) + + t.is(html, 'X') + } finally { + g.window = previous.window + g.document = previous.document + g.history = previous.history + g.location = previous.location + } +}) + +test.serial('Routes does not prepare the initial route during render', (t) => { + let prepareCalls = 0 + + const routes = [ + { + path: '/', + prepare: () => { + prepareCalls++ + }, + component: () =>
Home
, + }, + ] + + const matched = { + pattern: '/', + url: '/', + pathname: '/', + params: {}, + query: {}, + search: '', + hash: '', + data: routes, + } as Route + + const router = { + getUrl: () => '/', + match: () => matched, + listen: () => () => {}, + href: () => '/', + navigate: () => {}, + } + + const html = renderToString( + route, + syncRouteUrl: () => {}, + commit: () => {}, + navigate: () => {}, + isPending: false, + pendingHref: null, + qs: undefined, + } as any + } + > + + , + ) + + t.is(html, '
Home
') + t.is(prepareCalls, 0) +}) + test.serial('useLinkProps()', async function (t) { setup() @@ -135,6 +249,74 @@ test.serial('useLinkProps()', async function (t) { onClick: linkProps.onClick, }) t.is(typeof linkProps.onClick, 'function') + t.true(linkProps.isCurrent) + t.false(linkProps.isPending) +}) + +test.serial('Navigate follows to prop changes while mounted', async (t) => { + setup() + + const root = document.getElementById('root') + let setTarget + + const routes = [ + { path: '/a', component: () =>
A
}, + { path: '/b', component: () =>
B
}, + ] + + function App() { + const [target, _setTarget] = useState('/a') + setTarget = _setTarget + return ( + + + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + t.is(window.document.body.innerHTML, '
A
') + + await act(async () => { + setTarget('/b') + }) + + t.is(window.document.body.innerHTML, '
B
') +}) + +test.serial('Routes seeds the initial route synchronously for route components', async (t) => { + setup() + + const root = document.getElementById('root') + + function Home() { + const route = useRoute() + return
route={route?.pathname ?? 'null'}
+ } + + function App() { + return ( + + + + ) + } + + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + t.is(window.document.body.innerHTML, '
route=/
') + + await act(async () => { + await Promise.resolve() + }) }) test('qs', async (t) => { @@ -190,7 +372,9 @@ test.serial('Link click navigates and invokes to.onClick', (t) => { component: () => (
StringHref - onClickCalls++ }}>ObjectHref + onClickCalls++}> + ObjectHref +
), }, @@ -235,79 +419,74 @@ test.serial('Link click navigates and invokes to.onClick', (t) => { t.truthy(stringLink) }) -test.serial('onNavigating is awaited before onNavigated', async (t) => { +test.serial('Link composes user onClick before internal navigation', (t) => { setup() const root = document.getElementById('root') - const events = [] + let onClickCalls = 0 const routes = [ - { path: '/', component: () =>
Home
}, + { + path: '/', + component: () => ( + { + onClickCalls++ + event.preventDefault() + }} + > + Blocked + + ), + }, { path: '/stuff', component: () =>
Stuff
}, ] - let router - - function Capture() { - const r = useInternalRouterInstance() - useEffect(() => { - router = r - }, [r]) - return null - } - - async function onNavigating(route) { - events.push(`navigating:${route.pathname}`) - await Promise.resolve() - } - - function onNavigated(route) { - events.push(`navigated:${route.pathname}`) - } - function App() { return ( - - + ) } - await act(async () => { + act(() => { const r = ReactDOM.createRoot(root) r.render() }) - await act(async () => { - router.navigate('/stuff') + act(() => { + window.document.querySelector('a')!.click() }) - t.true(events.includes('navigating:/stuff')) - t.true(events.includes('navigated:/stuff')) - t.is(events.indexOf('navigating:/stuff') < events.indexOf('navigated:/stuff'), true) + t.is(onClickCalls, 1) + t.is(location.pathname, '/') }) -test.serial('Routes resolves ESM-default components and skips null components', (t) => { +test.serial('transformRoute rewrites the route before commit and syncs the URL', async (t) => { setup() const root = document.getElementById('root') + let preparedStatus + let preparedUrl const routes = [ + { path: '/', component: () =>
Home
}, { - path: '/', - component: ({ children }) =>
{children}
, - routes: [ - // simulates a dynamically imported module: { default: Component } - { path: '/esm', component: { default: () =>
ESM
} }, - // null component renders nothing for this segment - { path: '/empty', component: null }, - ], + path: '/people', + prepare: ({ query, url }) => { + preparedStatus = query.status + preparedUrl = url + }, + component: () => { + const r = useRoute() + return
status={String(r?.query?.status ?? 'none')}
+ }, }, ] let router - function Capture() { const r = useInternalRouterInstance() useEffect(() => { @@ -316,91 +495,114 @@ test.serial('Routes resolves ESM-default components and skips null components', return null } + // Simulate persisted-query restoration: if /people has no `status`, inject one. + function transformRoute(route: Route): Route | void { + if (route.pathname === '/people' && !(route as any).query?.status) { + const query = { ...(route as any).query, status: 'active' } + const search = '?status=active' + return { ...route, query, search, url: '/people' + search } as Route + } + } + function App() { return ( - + ) } - act(() => { + await act(async () => { const r = ReactDOM.createRoot(root) r.render() }) - act(() => { - router.navigate('/esm') + await act(async () => { + router.navigate('/people') }) - t.is(window.document.body.innerHTML, '
ESM
') - act(() => { - router.navigate('/empty') - }) - t.is(window.document.body.innerHTML, '
') + t.regex(window.document.body.innerHTML, /status=active/) + t.is(preparedStatus, 'active') + t.is(preparedUrl, '/people?status=active') }) -test.serial('Link honours current override and function className/style/extraProps', (t) => { +test.serial('transformRoute applies before initial route prepare', async (t) => { setup() + history.pushState({}, '', '/people') + + const root = document.getElementById('root') + let preparedStatus + let preparedUrl const routes = [ { - path: '/', - component: () => ( -
- (isCurrent ? 'on' : 'off')} - style={(isCurrent) => ({ color: isCurrent ? 'red' : 'blue' })} - extraProps={(isCurrent) => ({ 'data-active': isCurrent ? 'yes' : 'no' })} - > - Forced - - - Disabled - -
- ), + path: '/people', + prepare: ({ query, url }) => { + preparedStatus = query.status + preparedUrl = url + }, + component: () => { + const r = useRoute() + return
status={String(r?.query?.status ?? 'none')}
+ }, }, - { path: '/stuff', component: () =>
Stuff
}, ] + function transformRoute(route: Route): Route | void { + if (route.pathname === '/people' && !(route as any).query?.status) { + const query = { ...(route as any).query, status: 'active' } + const search = '?status=active' + return { ...route, query, search, url: '/people' + search } as Route + } + } + function App() { return ( - + ) } - const root = document.getElementById('root') - act(() => { + await act(async () => { const r = ReactDOM.createRoot(root) r.render() }) - const [forced, disabled] = window.document.querySelectorAll('a') - t.is(forced.getAttribute('aria-current'), 'page') - t.is(forced.getAttribute('class'), 'on') - t.is(forced.getAttribute('style'), 'color: red;') - t.is(forced.getAttribute('data-active'), 'yes') - t.is(disabled.getAttribute('aria-current'), null) + t.regex(window.document.body.innerHTML, /status=active/) + t.is(preparedStatus, 'active') + t.is(preparedUrl, '/people?status=active') }) -test.serial('Link rendered alongside Routes in async mode does not crash', async (t) => { +test.serial('prepare context falls back when transformRoute omits optional route fields', async (t) => { setup() + history.pushState({}, '', '/bare') const root = document.getElementById('root') + const prepared: unknown[] = [] - const routes = [{ path: '/', component: () =>
Home
}] + const routes = [ + { + path: '/bare', + prepare: (ctx) => { + prepared.push(ctx) + }, + component: () =>
Bare
, + }, + ] function App() { return ( - - Nav - + + ({ + data: route.data, + }) as Route + } + > + ) } @@ -410,34 +612,41 @@ test.serial('Link rendered alongside Routes in async mode does not crash', async r.render() }) - // before the bug fix this crashed during render with "Cannot read properties of null (reading 'pathname')" - const link = window.document.querySelector('a') - t.is(link?.getAttribute('href'), '/somewhere') - t.is(link?.getAttribute('aria-current'), null) + t.is(window.document.body.innerHTML, '
Bare
') + t.deepEqual(prepared, [{ pathname: '', url: '', params: {}, query: {} }]) }) -test.serial('Routes passes children through when a middle segment has no component', (t) => { +test.serial('usePending flips while a transition is in flight', async (t) => { setup() const root = document.getElementById('root') + let resolveSlow: (() => void) | null = null + const slowGate = new Promise((r) => { + resolveSlow = r + }) + + function Slow() { + // The first render of /slow throws this promise to suspend until the gate resolves. + if (!(Slow as any).ready) { + throw slowGate.then(() => { + ;(Slow as any).ready = true + }) + } + return
Slow
+ } + const routes = [ - { - path: '/', - component: ({ children }) =>
{children}
, - routes: [ - { - // middle segment with no component — should be transparent, not block descendants - routes: [{ path: '/inner', component: () =>
Inner
}], - }, - ], - }, + { path: '/', component: () =>
Home
}, + { path: '/slow', component: Slow }, ] + const pendingSamples: boolean[] = [] let router - function Capture() { const r = useInternalRouterInstance() + const pending = usePending() + pendingSamples.push(pending) useEffect(() => { router = r }, [r]) @@ -453,19 +662,31 @@ test.serial('Routes passes children through when a middle segment has no compone ) } - act(() => { + await act(async () => { const r = ReactDOM.createRoot(root) r.render() }) - act(() => { - router.navigate('/inner') + pendingSamples.length = 0 + + await act(async () => { + router.navigate('/slow') }) - t.is(window.document.body.innerHTML, '
Inner
') + // While suspended, React is mid-transition: pending should have flipped true. + t.true(pendingSamples.includes(true), 'usePending was true during the suspended transition') + + await act(async () => { + resolveSlow!() + await Promise.resolve() + await Promise.resolve() + }) + + // After commit, the latest pending sample is false. + t.is(pendingSamples[pendingSamples.length - 1], false, 'usePending settles to false after commit') }) -test.serial('Link with target=_blank lets the browser open in a new tab', (t) => { +test.serial('Routes resolves ESM-default components and skips null components', (t) => { setup() const root = document.getElementById('root') @@ -473,18 +694,30 @@ test.serial('Link with target=_blank lets the browser open in a new tab', (t) => const routes = [ { path: '/', - component: () => ( - - NewTab - - ), + component: ({ children }) =>
{children}
, + routes: [ + // simulates a dynamically imported module: { default: Component } + { path: '/esm', component: { default: () =>
ESM
} }, + // null component renders nothing for this segment + { path: '/empty', component: null }, + ], }, - { path: '/foo', component: () =>
Foo
}, ] + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + function App() { return ( + ) @@ -495,25 +728,906 @@ test.serial('Link with target=_blank lets the browser open in a new tab', (t) => r.render() }) - const link = window.document.querySelector('a')! act(() => { - link.click() + router.navigate('/esm') }) + t.is(window.document.body.innerHTML, '
ESM
') - // navigation should NOT have happened — the browser handles target=_blank - t.is(location.pathname, '/') + act(() => { + router.navigate('/empty') + }) + t.is(window.document.body.innerHTML, '
') }) -test.serial('Link with cross-origin URL lets the browser handle it', (t) => { +test.serial('Routes resolves lazy resolver components', async (t) => { setup() const root = document.getElementById('root') + let resolverCalls = 0 const routes = [ + { path: '/', component: () =>
Home
}, { - path: '/', - component: () => External, + path: '/lazy', + resolver: () => { + resolverCalls++ + return Promise.resolve({ default: () =>
Lazy
}) + }, + }, + ] + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + Loading
}> + +
+
+ ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + await act(async () => { + router.navigate('/lazy') + await Promise.resolve() + await Promise.resolve() + }) + + t.is(window.document.body.innerHTML, '
Lazy
') + t.is(resolverCalls, 1) + + await act(async () => { + router.navigate('/') + }) + await act(async () => { + router.navigate('/lazy') + await Promise.resolve() + }) + + t.is(resolverCalls, 1, 'resolver result is cached by function reference') +}) + +test.serial('Routes observes rejected resolver preload promises', async (t) => { + setup() + + const root = document.getElementById('root') + const originalConsoleError = console.error + console.error = () => {} + + class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> { + state = { hasError: false } + + static getDerivedStateFromError() { + return { hasError: true } + } + + render() { + return this.state.hasError ?
Error
: this.props.children + } + } + + const routes = [ + { path: '/', component: () =>
Home
}, + { + path: '/broken', + resolver: () => Promise.reject(new Error('broken import')), + }, + ] + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + + Loading}> + + + + + ) + } + + try { + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + await act(async () => { + router.navigate('/broken') + await Promise.resolve() + await Promise.resolve() + }) + + t.is(window.document.body.innerHTML, '
Error
') + } finally { + console.error = originalConsoleError + } +}) + +test.serial('DelayedSuspense renders fallback normally outside delayed navigation hold', async (t) => { + setup() + + const root = document.getElementById('root') + let resolveChild: (() => void) | null = null + const childGate = new Promise((r) => { + resolveChild = r + }) + + function Child() { + if (!(Child as any).ready) { + throw childGate.then(() => { + ;(Child as any).ready = true + }) + } + return Ready + } + + function App() { + return ( + + Fallback}> + + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + t.is(window.document.body.innerHTML, '
Fallback
') + + await act(async () => { + resolveChild!() + await Promise.resolve() + }) + + t.is(window.document.body.innerHTML, '
Ready
') +}) + +test.serial('DelayedSuspense holds fallback during route transition delay', async (t) => { + setup() + + const root = document.getElementById('root') + let resolveSlow: (() => void) | null = null + const slowGate = new Promise((r) => { + resolveSlow = r + }) + + function SlowChild() { + if (!(SlowChild as any).ready) { + throw slowGate.then(() => { + ;(SlowChild as any).ready = true + }) + } + return
Slow
+ } + + function SlowPage() { + return ( + Inner fallback}> + + + ) + } + + const routes = [ + { path: '/', component: () =>
Home
}, + { path: '/slow', component: SlowPage }, + ] + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + Outer fallback}> + + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + await act(async () => { + router.navigate('/slow') + }) + + t.is(window.document.body.innerHTML, '
Home
') + + await act(async () => { + resolveSlow!() + await Promise.resolve() + await Promise.resolve() + }) + + t.is(window.document.body.innerHTML, '
Slow
') +}) + +test.serial('Link honours current override and regular className/style props', (t) => { + setup() + + const routes = [ + { + path: '/', + component: () => ( +
+ + Forced + + + Disabled + +
+ ), + }, + { path: '/stuff', component: () =>
Stuff
}, + ] + + function App() { + return ( + + + + ) + } + + const root = document.getElementById('root') + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + const [forced, disabled] = window.document.querySelectorAll('a') + t.is(forced.getAttribute('aria-current'), 'page') + t.is(forced.getAttribute('class'), 'on') + t.is(forced.getAttribute('style'), 'color: red;') + t.is(forced.getAttribute('data-active'), 'yes') + t.is(disabled.getAttribute('aria-current'), null) +}) + +test.serial('Link preserves replace and current from object href when props are omitted', (t) => { + setup() + + const root = document.getElementById('root') + let pushCalls = 0 + let replaceCalls = 0 + const originalPushState = history.pushState + const originalReplaceState = history.replaceState + + history.pushState = (state: unknown, title: string, url: string) => { + pushCalls++ + originalPushState.call(history, state, title, url) + } + history.replaceState = (_state: unknown, _title: string, url: string) => { + replaceCalls++ + location.href = url + location.pathname = url + const popstate = new window.PopStateEvent('popstate') + window.dispatchEvent(popstate) + } + + const routes = [ + { + path: '/', + component: () => ( +
+ Next + Forced + + PropReplace + +
+ ), + }, + { path: '/next', component: () =>
Next
}, + ] + + function App() { + return ( + + + + ) + } + + try { + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + const [next, forced, propReplace] = window.document.querySelectorAll('a') + t.is(forced.getAttribute('aria-current'), 'page') + t.is(propReplace.getAttribute('href'), '/next') + + act(() => { + next.click() + }) + + t.is(replaceCalls, 1) + t.is(pushCalls, 0) + t.is(window.document.body.innerHTML, '
Next
') + } finally { + history.pushState = originalPushState + history.replaceState = originalReplaceState + } +}) + +test.serial('useLinkProps exposes per-link pending state', async (t) => { + setup() + + const root = document.getElementById('root') + let resolveSlow: (() => void) | null = null + const slowGate = new Promise((r) => { + resolveSlow = r + }) + + function Home() { + const slowLink = useLinkProps('/slow') + const otherLink = useLinkProps('/other') + return ( +
+ + Slow + + + +
+ ) + } + + function Slow() { + if (!(Slow as any).ready) { + throw slowGate.then(() => { + ;(Slow as any).ready = true + }) + } + return
Slow
+ } + + const routes = [ + { path: '/', component: Home }, + { path: '/slow', component: Slow }, + { path: '/other', component: () =>
Other
}, + ] + + function App() { + return ( + + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + await act(async () => { + window.document.querySelector('a')!.click() + }) + + t.is(window.document.querySelector('a')?.getAttribute('data-pending'), 'true') + t.is(window.document.querySelector('a')?.getAttribute('data-current'), 'false') + t.is(window.document.querySelector('[data-current-other]')?.getAttribute('data-current-other'), 'false') + t.is(window.document.querySelector('[data-pending-other]')?.getAttribute('data-pending-other'), 'false') + + await act(async () => { + resolveSlow!() + await Promise.resolve() + await Promise.resolve() + }) + + t.is(window.document.body.innerHTML, '
Slow
') +}) + +test.serial('Link rendered alongside Routes in async mode does not crash', async (t) => { + setup() + + const root = document.getElementById('root') + + const routes = [{ path: '/', component: () =>
Home
}] + + function App() { + return ( + + Nav + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + // before the bug fix this crashed during render with "Cannot read properties of null (reading 'pathname')" + const link = window.document.querySelector('a') + t.is(link?.getAttribute('href'), '/somewhere') + t.is(link?.getAttribute('aria-current'), null) +}) + +test.serial('Link clears pending href when async navigation commits', async (t) => { + setup() + + const root = document.getElementById('root') + + const routes = [ + { + path: '/', + component: () => Next, + }, + { path: '/next', component: () =>
Next
}, + ] + + function PendingProbe() { + const props = useLinkProps('/next') + return + } + + function App() { + return ( + + + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + await act(async () => { + window.document.querySelector('a')!.click() + await Promise.resolve() + await Promise.resolve() + }) + + t.is(window.document.querySelector('[data-pending]')?.getAttribute('data-pending'), 'false') + t.is(window.document.body.innerHTML, '
Next
') +}) + +test.serial('Link clears pending href after a route-level redirect commits', async (t) => { + setup() + + const root = document.getElementById('root') + + const routes = [ + { + path: '/', + component: () => Old, + }, + { path: '/old', redirect: '/new' } as RouteDefinition, + { path: '/new', component: () =>
New
}, + ] + + function PendingProbe() { + const props = useLinkProps('/old') + return + } + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + await act(async () => { + router.navigate('/') + }) + + await act(async () => { + window.document.querySelector('a')!.click() + await Promise.resolve() + }) + + t.is(window.document.body.innerHTML, '
New
') +}) + +test.serial('Routes passes children through when a middle segment has no component', (t) => { + setup() + + const root = document.getElementById('root') + + const routes = [ + { + component: ({ children }) =>
{children}
, + routes: [ + { + // middle segment with no component — should be transparent, not block descendants + routes: [{ path: '/inner', component: () =>
Inner
}], + }, + ], + }, + ] + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + + + ) + } + + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + act(() => { + router.navigate('/inner') + }) + + t.is(window.document.body.innerHTML, '
Inner
') +}) + +test.serial('Routes injects path params as component props', (t) => { + setup() + + const root = document.getElementById('root') + + function Item({ id }: { id?: string }) { + return
item={id ?? 'missing'}
+ } + + const routes = [{ path: '/items/:id', component: Item }] + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + + + ) + } + + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + act(() => { + router.navigate('/items/beacon') + }) + + t.is(window.document.body.innerHTML, '
item=beacon
') +}) + +test.serial('Routes parses query hash splat optional params and wildcard routes', (t) => { + setup() + + const root = document.getElementById('root') + + function Inspector() { + const route = useRoute() + return ( +
+ path={route?.pathname}; params={JSON.stringify(route?.params)}; query={JSON.stringify(route?.query)}; hash= + {route?.hash} +
+ ) + } + + const routes = [ + { path: '/files/:path+', component: Inspector }, + { path: '/needs/:id+', component: Inspector }, + { path: '/optional/:id?', component: Inspector }, + { path: '*', component: Inspector }, + ] + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + + + ) + } + + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + act(() => { + router.navigate('/files/a/b?q=1#top') + }) + + t.regex(window.document.body.innerHTML, /path=\/files\/a\/b/) + t.regex(window.document.body.innerHTML, /"path":"a\/b"/) + t.regex(window.document.body.innerHTML, /"q":"1"/) + t.regex(window.document.body.innerHTML, /hash=#top/) + + act(() => { + router.navigate('/needs') + }) + + t.regex(window.document.body.innerHTML, /path=\/needs/) + t.notRegex(window.document.body.innerHTML, /"id":/) + + act(() => { + router.navigate('/optional') + }) + + t.regex(window.document.body.innerHTML, /"id":""/) + + act(() => { + router.navigate('/anything-else') + }) + + t.regex(window.document.body.innerHTML, /path=\/anything-else/) +}) + +test.serial('Link with target=_blank lets the browser open in a new tab', (t) => { + setup() + + const root = document.getElementById('root') + + const routes = [ + { + path: '/', + component: () => ( + + NewTab + + ), + }, + { path: '/foo', component: () =>
Foo
}, + ] + + function App() { + return ( + + + + ) + } + + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + const link = window.document.querySelector('a')! + let event: MouseEvent + act(() => { + event = dispatchClick(link) + }) + + // navigation should NOT have happened — the browser handles target=_blank + t.false(event!.defaultPrevented) + t.is(location.pathname, '/') +}) + +test.serial('Link with modifier key lets the browser handle it', (t) => { + setup() + + const root = document.getElementById('root') + + const routes = [ + { + path: '/', + component: () => Modified, + }, + { path: '/foo', component: () =>
Foo
}, + ] + + function App() { + return ( + + + + ) + } + + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + const link = window.document.querySelector('a')! + let event: MouseEvent + act(() => { + event = new window.MouseEvent('click', { bubbles: true, button: 0, cancelable: true, metaKey: true }) + link.dispatchEvent(event) + }) + + t.false(event!.defaultPrevented) + t.is(location.pathname, '/') +}) + +test.serial('Link with cross-origin URL lets the browser handle it', (t) => { + setup() + + const root = document.getElementById('root') + + const routes = [ + { + path: '/', + component: () => External, + }, + ] + + function App() { + return ( + + + + ) + } + + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + const link = window.document.querySelector('a')! + let event: MouseEvent + act(() => { + event = dispatchClick(link) + }) + + // SPA navigation should be skipped for cross-origin URLs + t.false(event!.defaultPrevented) + t.is(location.pathname, '/') +}) + +test.serial('Link with download attribute lets the browser handle it', (t) => { + setup() + + const root = document.getElementById('root') + + const routes = [ + { + path: '/', + component: () => ( + + Download + + ), + }, + ] + + function App() { + return ( + + + + ) + } + + act(() => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + const link = window.document.querySelector('a')! + let event: MouseEvent + act(() => { + event = dispatchClick(link) + }) + + t.false(event!.defaultPrevented) + t.is(location.pathname, '/') +}) + +test.serial('Link with same-page hash lets the browser handle it', (t) => { + setup() + + const root = document.getElementById('root') + + const routes = [ + { + path: '/', + component: () => ( +
+ Jump +
Target
+
+ ), }, + { path: '/target', component: () =>
Wrong route
}, ] function App() { @@ -530,49 +1644,407 @@ test.serial('Link with cross-origin URL lets the browser handle it', (t) => { }) const link = window.document.querySelector('a')! + let event: MouseEvent act(() => { - link.click() + event = dispatchClick(link) }) - // SPA navigation should be skipped for cross-origin URLs + t.false(event!.defaultPrevented) t.is(location.pathname, '/') + t.regex(window.document.body.innerHTML, /Target/) + t.notRegex(window.document.body.innerHTML, /Wrong route/) }) -test.serial('Link with download attribute lets the browser handle it', (t) => { +test.serial('Routes pins prepare handles for the committed nav and releases on the next', async (t) => { + setup() + + const root = document.getElementById('root') + + const released: string[] = [] + let nextHandleId = 0 + + function makeHandle(label: string): PreparedHandle { + const key = `${label}#${++nextHandleId}` + return { + key, + promise: new Promise(() => {}), + release() { + released.push(key) + }, + } + } + + const routes = [ + { path: '/', component: () =>
Home
}, + { path: '/a', component: () =>
A
, prepare: () => [makeHandle('a')] }, + { path: '/b', component: () =>
B
, prepare: () => [makeHandle('b')] }, + { path: '/c', component: () =>
C
, prepare: () => [makeHandle('c')] }, + ] + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + + + ) + } + + let rootHandle + await act(async () => { + rootHandle = ReactDOM.createRoot(root) + rootHandle.render() + }) + + await act(async () => { + router.navigate('/a') + }) + + t.deepEqual(released, [], '/a pinned, nothing released yet') + + await act(async () => { + router.navigate('/b') + }) + + t.true( + released.some((k) => k.startsWith('a#')), + '/a released when /b committed', + ) + t.false( + released.some((k) => k.startsWith('b#')), + '/b still pinned', + ) + + await act(async () => { + router.navigate('/c') + }) + + t.true( + released.some((k) => k.startsWith('b#')), + '/b released when /c committed', + ) + + await act(async () => { + rootHandle.unmount() + }) + + t.true( + released.some((k) => k.startsWith('c#')), + '/c released on unmount', + ) +}) + +test.serial('Routes keeps current prepare handles pinned until a suspended navigation commits', async (t) => { + setup() + + const root = document.getElementById('root') + + const released: string[] = [] + let resolveSlow: (() => void) | null = null + const slowGate = new Promise((r) => { + resolveSlow = r + }) + + function makeHandle(label: string): PreparedHandle { + return { + key: label, + promise: Promise.resolve(), + release() { + released.push(label) + }, + } + } + + function Slow() { + if (!(Slow as any).ready) { + throw slowGate.then(() => { + ;(Slow as any).ready = true + }) + } + return
Slow
+ } + + const routes = [ + { path: '/', component: () =>
Home
, prepare: () => [makeHandle('home')] }, + { path: '/slow', component: Slow, prepare: () => [makeHandle('slow')] }, + ] + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + await act(async () => { + router.navigate('/slow') + }) + + t.is(window.document.body.innerHTML, '
Home
') + t.false(released.includes('home'), 'home handle stays pinned while old route remains committed') + + await act(async () => { + resolveSlow!() + await Promise.resolve() + await Promise.resolve() + }) + + t.is(window.document.body.innerHTML, '
Slow
') + t.true(released.includes('home'), 'home handle releases after /slow commits') + t.false(released.includes('slow'), 'slow handle remains pinned after /slow commits') +}) + +test.serial('Routes releases superseded pending handles and ignores release errors', async (t) => { + setup() + + const root = document.getElementById('root') + + const released: string[] = [] + let resolveA: (() => void) | null = null + const gateA = new Promise((r) => { + resolveA = r + }) + let resolveB: (() => void) | null = null + const gateB = new Promise((r) => { + resolveB = r + }) + + function makeSlow(label: string, gate: Promise) { + function Slow() { + if (!(Slow as any).ready) { + throw gate.then(() => { + ;(Slow as any).ready = true + }) + } + return
{label}
+ } + return Slow + } + + const SlowA = makeSlow('A', gateA) + const SlowB = makeSlow('B', gateB) + + const routes = [ + { path: '/', component: () =>
Home
}, + { + path: '/a', + component: SlowA, + prepare: () => [ + { + promise: Promise.resolve(), + release() { + released.push('a') + throw new Error('ignored') + }, + }, + ], + }, + { + path: '/b', + component: SlowB, + prepare: () => [ + { + promise: Promise.resolve(), + release() { + released.push('b') + }, + }, + ], + }, + ] + + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + return ( + + + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + await act(async () => { + router.navigate('/a') + }) + await act(async () => { + router.navigate('/b') + }) + + t.deepEqual(released, ['a']) + + await act(async () => { + resolveA!() + resolveB!() + await Promise.resolve() + await Promise.resolve() + }) + + t.is(window.document.body.innerHTML, '
B
') + t.false(released.includes('b')) +}) + +test.serial('Routes releases pending handles when navigation returns to the committed route', async (t) => { setup() const root = document.getElementById('root') + const released: string[] = [] + let resolveSlow: (() => void) | null = null + const slowGate = new Promise((r) => { + resolveSlow = r + }) + + function Slow() { + if (!(Slow as any).ready) { + throw slowGate.then(() => { + ;(Slow as any).ready = true + }) + } + return
Slow
+ } const routes = [ + { path: '/', component: () =>
Home
}, { - path: '/', - component: () => ( - - Download - - ), + path: '/slow', + component: Slow, + prepare: () => [ + { + promise: Promise.resolve(), + release() { + released.push('slow') + }, + }, + ], }, ] + let router + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + function App() { return ( + ) } - act(() => { + await act(async () => { const r = ReactDOM.createRoot(root) r.render() }) - const link = window.document.querySelector('a')! - act(() => { - link.click() + await act(async () => { + router.navigate('/slow') }) - t.is(location.pathname, '/') + t.is(window.document.body.innerHTML, '
Home
') + + await act(async () => { + router.navigate('/') + }) + + t.deepEqual(released, ['slow']) + + await act(async () => { + resolveSlow!() + await Promise.resolve() + }) + + t.is(window.document.body.innerHTML, '
Home
') +}) + +test.serial('Routes rematches the current URL when the route map changes in memory mode', async (t) => { + setup() + + const root = document.getElementById('root') + let router + let setRoutes + + function Capture() { + const r = useInternalRouterInstance() + useEffect(() => { + router = r + }, [r]) + return null + } + + function App() { + const [routes, _setRoutes] = useState([{ path: '/swap', component: () =>
A
}]) + setRoutes = _setRoutes + return ( + + + + + ) + } + + await act(async () => { + const r = ReactDOM.createRoot(root) + r.render() + }) + + await act(async () => { + router.navigate('/swap') + }) + + t.is(window.document.body.innerHTML, '
A
') + + await act(async () => { + setRoutes([{ path: '/swap', component: () =>
B
}]) + }) + + t.is(window.document.body.innerHTML, '
B
') }) test.serial('Router recreates router when mode prop changes', (t) => {