Skip to content

Commit 9353653

Browse files
committed
Add route meta and afterNavigate hook
Expose per-route meta by adding meta to Route and RouteContext, and add an afterNavigate option to Router. Router now passes route.meta into the render context and invokes options.afterNavigate after render completes and subscribers are notified. Included tests for meta presence/absence and for afterNavigate behavior (including that it does not fire on no-match or loader errors). Updated TODO.md to mark these items complete.
1 parent 4ce119d commit 9353653

3 files changed

Lines changed: 101 additions & 2 deletions

File tree

TODO.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,15 @@ on top of it. Focus is on exposing router state reactively and supporting real a
7373

7474
### P1: Application Primitives
7575

76-
- [ ] **Per-route metadata support**
76+
- [x] **Per-route metadata support**
7777
- Files: `src/index.ts`, `tests/router.test.ts`, `README.md`
7878
- Acceptance: optional `meta` object on route definitions (typed but open-ended);
7979
`RouteContext` exposes `meta` so page modules and app code can read title, guard data,
8080
analytics keys, etc.
8181
- Why: real apps need document title management, analytics tagging, and route-level
8282
data that does not belong in the page module itself
8383

84-
- [ ] **`afterNavigate` hook**
84+
- [x] **`afterNavigate` hook**
8585
- Files: `src/index.ts`, `tests/router.test.ts`, `README.md`
8686
- Acceptance: optional `afterNavigate(context)` on constructor options; fires after
8787
render completes; complements `beforeNavigate` for analytics, focus management, etc.

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ export type RouteContext = {
88
params: Record<string, string>
99
query: URLSearchParams
1010
root: HTMLElement
11+
meta?: Record<string, unknown>
1112
}
1213

1314
export type Route = {
1415
path: string
1516
name?: string
17+
meta?: Record<string, unknown>
1618
loader: () => Promise<PageModule>
1719
}
1820

@@ -30,6 +32,7 @@ export type RouterOptions = {
3032
) => void | Promise<void>
3133
onNavigationStart?: (context: NavigationContext) => void
3234
onNavigationEnd?: (context: NavigationContext) => void
35+
afterNavigate?: (context: RouteContext) => void
3336
}
3437

3538
export type Unsubscribe = () => void
@@ -264,10 +267,12 @@ export class Router {
264267
params,
265268
query,
266269
root: this.rootEl,
270+
meta: route.meta,
267271
}
268272

269273
page.render(context)
270274
this.notifySubscribers(context)
275+
this.options.afterNavigate?.(context)
271276

272277
if (this.scrollRestoration) {
273278
if (source === 'pop') {

tests/router.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,4 +717,98 @@ describe("spectre-shell-router", () => {
717717
router.destroy()
718718
})
719719
})
720+
721+
describe("route meta", () => {
722+
it("exposes a route's meta object on the route context", async () => {
723+
const { Router } = await import("../src/index")
724+
const render = vi.fn()
725+
const routes = [
726+
{ path: "/", meta: { title: "Home" }, loader: async () => ({ render: vi.fn() }) },
727+
{ path: "/users/:id", meta: { title: "User", requiresAuth: true }, loader: async () => ({ render }) },
728+
]
729+
const root = document.createElement("div")
730+
window.history.replaceState({}, "", "/")
731+
732+
const router = new Router(routes, root)
733+
await tick()
734+
735+
router.navigate("/users/42")
736+
await tick()
737+
738+
const ctx = render.mock.calls.at(-1)?.[0]
739+
expect(ctx?.meta).toEqual({ title: "User", requiresAuth: true })
740+
router.destroy()
741+
})
742+
743+
it("leaves meta undefined for routes without a meta object", async () => {
744+
const { Router } = await import("../src/index")
745+
const render = vi.fn()
746+
const routes = [{ path: "/", loader: async () => ({ render }) }]
747+
const root = document.createElement("div")
748+
window.history.replaceState({}, "", "/")
749+
750+
const router = new Router(routes, root)
751+
await tick()
752+
753+
const ctx = render.mock.calls.at(-1)?.[0]
754+
expect(ctx?.meta).toBeUndefined()
755+
router.destroy()
756+
})
757+
})
758+
759+
describe("afterNavigate hook", () => {
760+
it("fires with the route context after render completes", async () => {
761+
const { Router } = await import("../src/index")
762+
const afterNavigate = vi.fn()
763+
const routes = [
764+
{ path: "/", meta: { title: "Home" }, loader: async () => ({ render: vi.fn() }) },
765+
{ path: "/users/:id", loader: async () => ({ render: vi.fn() }) },
766+
]
767+
const root = document.createElement("div")
768+
window.history.replaceState({}, "", "/")
769+
770+
const router = new Router(routes, root, { afterNavigate })
771+
await tick()
772+
773+
expect(afterNavigate).toHaveBeenCalledOnce()
774+
let ctx = afterNavigate.mock.calls.at(-1)?.[0]
775+
expect(ctx?.path).toBe("/")
776+
expect(ctx?.meta).toEqual({ title: "Home" })
777+
778+
afterNavigate.mockClear()
779+
router.navigate("/users/42?tab=info")
780+
await tick()
781+
782+
expect(afterNavigate).toHaveBeenCalledOnce()
783+
ctx = afterNavigate.mock.calls.at(-1)?.[0]
784+
expect(ctx?.path).toBe("/users/42")
785+
expect(ctx?.params.id).toBe("42")
786+
expect(ctx?.query.get("tab")).toBe("info")
787+
router.destroy()
788+
})
789+
790+
it("does not fire when no route matches or the loader throws", async () => {
791+
const { Router } = await import("../src/index")
792+
const afterNavigate = vi.fn()
793+
const routes = [
794+
{ path: "/", loader: async () => ({ render: vi.fn() }) },
795+
{ path: "/broken", loader: async () => { throw new Error("boom") } },
796+
]
797+
const root = document.createElement("div")
798+
window.history.replaceState({}, "", "/")
799+
800+
const router = new Router(routes, root, { afterNavigate })
801+
await tick()
802+
afterNavigate.mockClear()
803+
804+
router.navigate("/missing")
805+
await tick()
806+
expect(afterNavigate).not.toHaveBeenCalled()
807+
808+
router.navigate("/broken")
809+
await tick()
810+
expect(afterNavigate).not.toHaveBeenCalled()
811+
router.destroy()
812+
})
813+
})
720814
})

0 commit comments

Comments
 (0)