{"id":51304808,"url":"https://github.com/openclaw/uirouter","last_synced_at":"2026-07-06T04:00:27.649Z","repository":{"id":368256931,"uuid":"1284277940","full_name":"openclaw/uirouter","owner":"openclaw","description":null,"archived":false,"fork":false,"pushed_at":"2026-07-02T18:55:26.000Z","size":247,"stargazers_count":2,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-03T01:27:10.617Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/openclaw.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null},"funding":{"github":["moltbot"]}},"created_at":"2026-06-29T17:44:58.000Z","updated_at":"2026-07-02T12:58:32.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/openclaw/uirouter","commit_stats":null,"previous_names":["openclaw/uirouter"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/openclaw/uirouter","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openclaw%2Fuirouter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openclaw%2Fuirouter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openclaw%2Fuirouter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openclaw%2Fuirouter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/openclaw","download_url":"https://codeload.github.com/openclaw/uirouter/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openclaw%2Fuirouter/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35107463,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-04T02:00:05.987Z","response_time":113,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-30T23:00:32.395Z","updated_at":"2026-07-04T02:01:04.661Z","avatar_url":"https://github.com/openclaw.png","language":"TypeScript","funding_links":["https://github.com/sponsors/moltbot"],"categories":[],"sub_categories":[],"readme":"# @openclaw/uirouter\n\nSmall, framework-agnostic router for OpenClaw UI surfaces. It handles route\nmatching, lazy component loading, data loading with caching and revalidation,\nand reactive navigation state.\n\n- ESM-only, TypeScript-first, zero runtime dependencies\n- Bring-your-own history (browser, memory, or otherwise)\n- Per-route loaders with preload, invalidation, and stale reload\n- `notFound` / `redirect` loader control flow\n- Fine-grained subscriptions (whole state, selector, or single match)\n\n## Install\n\n```sh\npnpm add @openclaw/uirouter\n# or\nnpm install @openclaw/uirouter\n# or\nyarn add @openclaw/uirouter\n```\n\nRequires Node `^22.18.0 || \u003e=24.11.0`. The package ships ESM and TypeScript\ndeclarations only — there is no CommonJS build.\n\n## Quick start\n\n```ts\nimport { createRouter, definePage } from \"@openclaw/uirouter\";\n\nconst home = definePage({\n  id: \"home\",\n  path: \"/\",\n  component: () =\u003e import(\"./pages/home.js\"),\n});\n\nconst chat = definePage({\n  id: \"chat\",\n  path: \"/chat\",\n  component: () =\u003e import(\"./pages/chat.js\"),\n  loader: async (context, { signal }) =\u003e {\n    const response = await fetch(`/api/threads/${context.userId}`, { signal });\n    return response.json();\n  },\n});\n\nconst router = createRouter\u003c\"home\" | \"chat\", { userId: string }\u003e({\n  routes: [home, chat],\n});\n\nawait router.navigate(\"chat\", { userId: \"u_1\" });\nconst { matches } = router.getState();\n```\n\nThe first generic is the union of route ids; the second is the loader\n**context** type — an arbitrary value you pass into every navigation so loaders\nand hooks can read from session, auth, or DI without reaching for globals.\n\n## History integration\n\nThe router does not bind to `window.history` directly. Provide a `RouterHistory`\nadapter and call `router.start(history, basePath, context)`:\n\n```ts\nimport type { RouterHistory, RouteLocation } from \"@openclaw/uirouter\";\n\nconst browserHistory: RouterHistory = {\n  location: () =\u003e ({\n    pathname: window.location.pathname,\n    search: window.location.search,\n    hash: window.location.hash,\n  }),\n  push: (loc) =\u003e window.history.pushState(null, \"\", serialize(loc)),\n  replace: (loc) =\u003e window.history.replaceState(null, \"\", serialize(loc)),\n  listen: (listener) =\u003e {\n    const onPop = () =\u003e listener(browserHistory.location());\n    window.addEventListener(\"popstate\", onPop);\n    return () =\u003e window.removeEventListener(\"popstate\", onPop);\n  },\n};\n\nfunction serialize(loc: RouteLocation): string {\n  return `${loc.pathname}${loc.search}${loc.hash}`;\n}\n\nawait router.start(browserHistory, \"/app\", { userId: \"u_1\" });\n```\n\n`start` matches the current location, runs its loader, and subscribes to\nhistory changes. Call `router.stop()` to detach and clear caches.\n\nFor programmatic navigation, use `navigate(routeId, context, options)` or\n`navigateLocation(location, context)`. Pass `{ history: \"push\" | \"replace\" }`\nto have the router update the underlying history.\n\n## Loaders, deps, and caching\n\nA `loader` returns the route data; `loaderDeps` derives a string key from\ncontext and location. Two navigations that produce the same `(routeId, deps)`\nshare a match, so dependency-driven re-fetching is just a matter of returning\na different deps string.\n\n```ts\ndefinePage({\n  id: \"thread\",\n  path: \"/thread\",\n  component: () =\u003e import(\"./pages/thread.js\"),\n  loaderDeps: (_, location) =\u003e new URLSearchParams(location.search).get(\"id\") ?? \"\",\n  loader: async (context, { signal, deps }) =\u003e {\n    const response = await fetch(`/api/threads/${deps}`, { signal });\n    return response.json();\n  },\n  staleTime: 30_000,\n  gcTime: 5 * 60_000,\n});\n```\n\nPer-route cache knobs (all optional, all in milliseconds):\n\n| Option             | Default      | Meaning                                                             |\n| ------------------ | ------------ | ------------------------------------------------------------------- |\n| `staleTime`        | `0`          | How long a successful match is considered fresh.                    |\n| `staleReloadMode`  | `background` | `background`: show cached data and refetch; `blocking`: wait.       |\n| `preloadStaleTime` | `30_000`     | Freshness for matches produced by `preloadRoute`/`preloadLocation`. |\n| `gcTime`           | `30 min`     | How long an unused cached match is kept.                            |\n| `preloadGcTime`    | `30 min`     | GC for preloaded matches.                                           |\n\nThe same defaults can be set router-wide via `createRouter({ staleTime, defaultStaleReloadMode, preloadStaleTime, preloadGcTime, gcTime })`.\n\n## Redirects and not-found\n\nLoaders signal control flow by **throwing** the result of `redirect()` or\n`notFound()`. Returning them works too; the router treats both equivalently.\n\n```ts\nimport { definePage, notFound, redirect } from \"@openclaw/uirouter\";\n\ndefinePage({\n  id: \"thread\",\n  path: \"/thread\",\n  component: () =\u003e import(\"./pages/thread.js\"),\n  loader: async (context, { signal }) =\u003e {\n    if (!context.session) {\n      throw redirect({ pathname: \"/login\", search: \"\", hash: \"\" });\n    }\n    const response = await fetch(`/api/thread`, { signal });\n    if (response.status === 404) throw notFound({ reason: \"thread-missing\" });\n    return response.json();\n  },\n});\n```\n\nWhen a redirect is thrown during a real navigation (not a preload), the router\nchases it with `history: \"replace\"`. A `notFound` sets the router status to\n`\"notFound\"` and exposes the payload on the match's `error`.\n\nUnmatched locations also produce `notFound` router state. Applications decide\nhow to present or redirect that state; the router does not choose a default\nroute.\n\n## Subscriptions\n\n`getState()` returns the current `RouterState`. To react to changes:\n\n```ts\nconst unsubscribe = router.subscribe((state) =\u003e {\n  render(state.matches[0]);\n});\n\n// Only fire when status changes\nrouter.subscribeSelector(\n  (state) =\u003e state.status,\n  (status) =\u003e console.log(status),\n);\n\n// Watch a single match by id (e.g. for a preloaded route)\nrouter.subscribeMatch(matchId, (match) =\u003e {\n  if (match?.status === \"success\") prefetchAssets(match.module);\n});\n```\n\nSelector subscriptions use `Object.is` by default; pass a custom `equal`\ncomparator for structural checks.\n\n## Preloading, invalidation, and revalidation\n\n```ts\nawait router.preloadRoute(\"chat\", context);\nawait router.preloadLocation({ pathname: \"/chat\", search: \"?t=1\", hash: \"\" }, context);\n\nawait router.invalidate(); // mark all matches stale\nawait router.invalidate(\"chat\"); // single route\nawait router.revalidate(context); // force refetch of the active match\n```\n\n`preload*` populates the cache without making the route active. If a cached\nmatch is fresh on the next navigation, it's promoted instantly; otherwise the\nrouter refetches in the background or blocks per `staleReloadMode`.\n\n## Lifecycle hooks\n\n`onEnter` runs after a successful navigation; `onLeave` runs when the previous\nmatch is being replaced by a different route. Both receive the load context,\nthe resolved data, and the standard `RouteHookOptions` (signal, location,\ndeps, cause, `shouldRun`).\n\n```ts\ndefinePage({\n  id: \"chat\",\n  path: \"/chat\",\n  component: () =\u003e import(\"./pages/chat.js\"),\n  onEnter: (context, data) =\u003e analytics.pageview(\"chat\", data),\n  onLeave: () =\u003e analytics.flush(),\n});\n```\n\nIf a hook throws, the match transitions to `\"error\"` and the error propagates\nout of the originating `navigate` call.\n\n## API reference\n\n### `createRouter(options)`\n\nReturns a `Router`. Options:\n\n- `routes: PageDefinition[]` — required.\n- `staleTime`, `defaultStaleReloadMode`, `preloadStaleTime`,\n  `preloadGcTime`, `gcTime` — router-wide defaults.\n\n### `Router`\n\n| Member                                            | Purpose                                              |\n| ------------------------------------------------- | ---------------------------------------------------- |\n| `routes`                                          | Compiled, normalized route definitions.              |\n| `getRoute(id)`                                    | Lookup a `PageDefinition` by id.                     |\n| `getMatch(matchId)`                               | Lookup a match across active/pending/cached pools.   |\n| `getState()`                                      | Current `RouterState`.                               |\n| `subscribe(listener)`                             | Subscribe to state changes.                          |\n| `subscribeSelector(selector, listener, equal?)`   | Subscribe to a derived slice.                        |\n| `subscribeMatch(matchId, listener)`               | Subscribe to a single match.                         |\n| `pathForRoute(id, basePath?)`                     | Build a URL pathname for a route.                    |\n| `routeIdFromPath(pathname, basePath?)`            | Resolve a path to a route id, or `null`.             |\n| `start(history, basePath, context)`               | Attach to history and load the current location.     |\n| `navigate(routeId, context, options?, location?)` | Navigate to a route.                                 |\n| `navigateLocation(location, context)`             | Navigate to an arbitrary location.                   |\n| `preloadRoute(routeId, context)`                  | Warm the cache for a route.                          |\n| `preloadLocation(location, context)`              | Warm the cache for a location.                       |\n| `revalidate(context, routeId?)`                   | Force refetch of the active or named route.          |\n| `invalidate(routeId?)`                            | Mark all (or one) match(es) stale.                   |\n| `stop()`                                          | Detach history, abort in-flight loads, clear caches. |\n\n### `definePage(page)`\n\nIdentity helper that returns its argument while inferring the strongest\ngeneric types. Use it instead of plain object literals so route ids stay\nnarrowed.\n\n### `notFound(data?)` / `redirect(location)`\n\nConstruct control-flow values for loaders. Throw them (or return them) from a\n`loader` to short-circuit a navigation.\n\n### Types\n\nThe package exports types for every shape it consumes or produces:\n\n`PageDefinition`, `Router`, `RouterOptions`, `RouterState`, `RouterHistory`,\n`RouterNavigationOptions`, `RouterStateSelector`, `RouteMatch`,\n`RouteMatchStatus`, `RouteMatchFetching`, `RouteLocation`, `RouteLoadCause`,\n`RouteLoaderOptions`, `RouteLoaderResult`, `RouteHookOptions`, `RouteNotFound`,\n`RouteRedirect`, `MaybePromise`.\n\nPath helpers: `normalizeRoutePath`, `normalizeRouteBasePath`.\n\n## Scripts\n\n```sh\npnpm install\npnpm run build\npnpm run typecheck\npnpm run lint\npnpm run test\npnpm run check\n```\n\n## License\n\n[MIT](LICENSE) © OpenClaw\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fopenclaw%2Fuirouter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fopenclaw%2Fuirouter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fopenclaw%2Fuirouter/lists"}