Skip to content

Commit 5489bcd

Browse files
committed
Add error routes and history helpers
Adds `errorRoute` and `onError` router options, plus `replace()`, `back()`, and `forward()` navigation helpers. Updates tests to cover loader failures, unmatched routes, and History API behavior, and marks the Phase 4 TODO items complete.
1 parent e9bf018 commit 5489bcd

4 files changed

Lines changed: 213 additions & 19 deletions

File tree

TODO.md

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ All Phase 2 items were delivered. The routing contract is stable and complete.
5252
## Phase 3 — Ecosystem Integration: P0/P1/P3 Complete — Phase 4 Unblocked
5353

5454
P0, P1, and P3 (API docs) delivered. P2 (nested routing) remains evaluation-only.
55-
Phase 4 (error routes, history helpers) can now begin.
55+
Phase 4 error routes and history helpers are now implemented — see Phase 4 below.
5656

5757
### P0: Signals Bridge
5858

@@ -99,7 +99,7 @@ Phase 6 templates can scaffold these patterns using the documented examples.
9999

100100
Unblocked:
101101

102-
- This repo: Phase 4 can start (error routes, history helpers)
102+
- This repo: Phase 4 P0/P1 delivered (error routes, history helpers)
103103
- `spectre-init`: Phase 6 template modernization (lifecycle, title, loading, plugin)
104104
- `spectre-shell`: P2.5 release readiness (templates need stable examples first)
105105

@@ -132,37 +132,37 @@ Unblocked:
132132

133133
---
134134

135-
## Phase 4 — Production Readiness: Active
135+
## Phase 4 — Production Readiness: P0/P1 Complete — README/CHANGELOG Pending (Codex)
136136

137-
Phase 3 core APIs are delivered. Phase 4 closes remaining production gaps: deterministic error
138-
handling, navigation history helpers, and nested outlet support when a concrete need is proven.
137+
Error routes and navigation history helpers are implemented and tested. Nested outlet
138+
support (P2) remains evaluation-only until a concrete need is proven.
139139

140140
### P0: Error Routes
141141

142-
- [ ] **`errorRoute` option on `RouterOptions`**
143-
- Files: `src/index.ts`, `tests/reliability.test.ts`, `README.md`
142+
- [x] **`errorRoute` option on `RouterOptions`**
143+
- Files: `src/index.ts`, `tests/reliability.test.ts``README.md` still needed (Codex)
144144
- Acceptance: when a loader throws or no route matches, the router navigates to `errorRoute`
145145
rather than silently clearing the outlet; `errorRoute` path must exist in the route list
146146
- Why: silent root-clearing gives users a blank page with no feedback; named error routes
147147
make failure states explicit and testable
148148

149-
- [ ] **`onError(error, context)` callback**
149+
- [x] **`onError(error, context)` callback**
150150
- Files: `src/index.ts`, `tests/reliability.test.ts`
151151
- Acceptance: optional callback on constructor options; fires when a loader throws;
152152
apps can log, report, or handle programmatically without requiring a redirect
153153
- Why: not all error handling is a redirect — apps need a seam for error telemetry
154154

155155
### P1: Navigation History Helpers
156156

157-
- [ ] **`router.back()` and `router.forward()`**
157+
- [x] **`router.back()` and `router.forward()`**
158158
- Files: `src/index.ts`, `tests/router.test.ts`
159159
- Acceptance: thin wrappers around `history.back()` and `history.forward()`; consistent
160160
with the existing `navigate()` contract
161161
- Why: exposes common navigation actions as first-class router methods so callers do not
162162
reach around the router to the History API directly
163163

164-
- [ ] **`router.replace(path)`**
165-
- Files: `src/index.ts`, `tests/router.test.ts`, `README.md`
164+
- [x] **`router.replace(path)`**
165+
- Files: `src/index.ts`, `tests/router.test.ts``README.md` still needed (Codex)
166166
- Acceptance: navigates to `path` without adding a new history entry (`replaceState`);
167167
race-condition guard applies as with `navigate()`
168168
- Why: redirect flows (post-login, form submission) need replace-not-push semantics
@@ -185,9 +185,10 @@ handling, navigation history helpers, and nested outlet support when a concrete
185185
3. ~~Per-route metadata~~
186186
4. ~~`afterNavigate` hook~~
187187
5. ~~Phase 3 P3 API docs~~
188-
6. **Error routes** ← current — Phase 4 P0
189-
7. Navigation history helpers — Phase 4 P1 (back/forward/replace)
190-
8. Nested routing — only when a concrete application need is proven
188+
6. ~~Error routes~~ ✓ — Phase 4 P0
189+
7. ~~Navigation history helpers~~ ✓ — Phase 4 P1 (back/forward/replace)
190+
8. **README/CHANGELOG updates for Phase 4 P0/P1** ← current (Codex)
191+
9. Nested routing — only when a concrete application need is proven
191192

192193
---
193194

@@ -217,8 +218,8 @@ they are stable and covered in README examples so spectre-init can reference the
217218

218219
### P2: Error Routes — Needed for Phase 6 template hardening
219220

220-
- [ ] `errorRoute` option on `RouterOptions` (Phase 4 P0) — when shipped, spectre-init shell-app template should add an error route
221-
- [ ] `onError(error, context)` callback — when shipped, document the pattern for template consumers
221+
- [x] `errorRoute` option on `RouterOptions` (Phase 4 P0) — implemented; spectre-init shell-app template can now add an error route
222+
- [x] `onError(error, context)` callback — implemented; document the pattern for template consumers once README lands
222223

223224
## Explicitly Out of Scope
224225

src/index.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ export type NavigationContext = {
2626
export type RouterOptions = {
2727
mode?: 'history' | 'hash'
2828
scrollRestoration?: boolean
29+
errorRoute?: string
2930
beforeNavigate?: (
3031
context: NavigationContext,
3132
next: (redirect?: string) => void,
3233
) => void | Promise<void>
3334
onNavigationStart?: (context: NavigationContext) => void
3435
onNavigationEnd?: (context: NavigationContext) => void
3536
afterNavigate?: (context: RouteContext) => void
37+
onError?: (error: unknown, context: NavigationContext) => void
3638
}
3739

3840
export type Unsubscribe = () => void
@@ -62,6 +64,12 @@ export class Router {
6264
for (const route of routes) {
6365
if (route.name) this.nameIndex.set(route.name, route.path)
6466
}
67+
if (
68+
options.errorRoute !== undefined &&
69+
!routes.some((route) => route.path === options.errorRoute)
70+
) {
71+
throw new Error(`errorRoute "${options.errorRoute}" does not match any route path.`)
72+
}
6573
this.handleNavigationBound = () => { void this.handleNavigation('pop') }
6674
this.handleHashChangeBound = this.handleHashChange.bind(this)
6775
this.handleLinkClickBound = this.handleLinkClick.bind(this)
@@ -86,6 +94,25 @@ export class Router {
8694
void this.handleNavigation('push')
8795
}
8896

97+
public replace(path: string) {
98+
if (!this.rootEl) {
99+
throw new Error('Router has been destroyed or not initialized.')
100+
}
101+
if (this.scrollRestoration) {
102+
history.replaceState({ ...history.state, scrollY: window.scrollY }, '')
103+
}
104+
history.replaceState({}, '', this.mode === 'hash' ? `#${path}` : path)
105+
void this.handleNavigation('push')
106+
}
107+
108+
public back() {
109+
history.back()
110+
}
111+
112+
public forward() {
113+
history.forward()
114+
}
115+
89116
public subscribe(callback: (context: RouteContext) => void): Unsubscribe {
90117
this.subscribers.add(callback)
91118
return () => {
@@ -246,9 +273,12 @@ export class Router {
246273
let page: PageModule
247274
try {
248275
page = await route.loader()
249-
} catch {
250-
// Loader failure: abandon this navigation and clear stale content.
251-
if (navId === this.currentNavId && this.rootEl) {
276+
} catch (error) {
277+
if (navId !== this.currentNavId || !this.rootEl) return
278+
this.options.onError?.(error, navContext)
279+
if (this.options.errorRoute !== undefined && path !== this.options.errorRoute) {
280+
this.replace(this.options.errorRoute)
281+
} else {
252282
this.destroyCurrentPage()
253283
this.rootEl.innerHTML = ''
254284
}
@@ -286,6 +316,12 @@ export class Router {
286316
return
287317
}
288318

319+
if (this.options.errorRoute !== undefined && path !== this.options.errorRoute) {
320+
this.options.onError?.(new Error(`No route matches "${path}".`), navContext)
321+
this.replace(this.options.errorRoute)
322+
return
323+
}
324+
289325
this.destroyCurrentPage()
290326
this.rootEl.innerHTML = ''
291327
this.currentPath = path

tests/reliability.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,97 @@ describe("Router Reliability Tests", () => {
114114
expect(renderCalls).toEqual(["3"])
115115
router.destroy()
116116
})
117+
118+
it("navigates to errorRoute when a loader throws", async () => {
119+
const { Router } = await import("../src/index")
120+
const onError = vi.fn()
121+
122+
const routes = [
123+
{
124+
path: "/fail",
125+
loader: async (): Promise<never> => { throw new Error("Load failed") }
126+
},
127+
{
128+
path: "/error",
129+
loader: async () => ({ render: (ctx) => { ctx.root.innerHTML = "Error" } })
130+
},
131+
{
132+
path: "/",
133+
loader: async () => ({ render: (ctx) => { ctx.root.innerHTML = "Home" } })
134+
}
135+
]
136+
137+
const root = document.createElement("div")
138+
const router = new Router(routes, root, { errorRoute: "/error", onError })
139+
await tick()
140+
141+
router.navigate("/fail")
142+
await tick()
143+
144+
expect(root.innerHTML).toBe("Error")
145+
expect(onError).toHaveBeenCalledTimes(1)
146+
expect(onError.mock.calls[0][1]).toEqual({ from: "/", to: "/fail" })
147+
148+
router.destroy()
149+
})
150+
151+
it("navigates to errorRoute when no route matches", async () => {
152+
const { Router } = await import("../src/index")
153+
const onError = vi.fn()
154+
155+
const routes = [
156+
{
157+
path: "/error",
158+
loader: async () => ({ render: (ctx) => { ctx.root.innerHTML = "Error" } })
159+
},
160+
{
161+
path: "/",
162+
loader: async () => ({ render: (ctx) => { ctx.root.innerHTML = "Home" } })
163+
}
164+
]
165+
166+
const root = document.createElement("div")
167+
const router = new Router(routes, root, { errorRoute: "/error", onError })
168+
await tick()
169+
170+
router.navigate("/does-not-exist")
171+
await tick()
172+
173+
expect(root.innerHTML).toBe("Error")
174+
expect(onError).toHaveBeenCalledTimes(1)
175+
176+
router.destroy()
177+
})
178+
179+
it("clears the root instead of looping when the errorRoute itself has no match", async () => {
180+
const { Router } = await import("../src/index")
181+
182+
const routes = [
183+
{ path: "/", loader: async () => ({ render: () => {} }) },
184+
{
185+
path: "/error",
186+
loader: async (): Promise<never> => { throw new Error("Error route itself is broken") }
187+
}
188+
]
189+
const root = document.createElement("div")
190+
const router = new Router(routes, root, { errorRoute: "/error" })
191+
await tick()
192+
193+
router.navigate("/error")
194+
await tick()
195+
196+
expect(root.innerHTML).toBe("")
197+
198+
router.destroy()
199+
})
200+
201+
it("throws in the constructor when errorRoute does not match any route path", async () => {
202+
const { Router } = await import("../src/index")
203+
const routes = [{ path: "/", loader: async () => ({ render: () => {} }) }]
204+
const root = document.createElement("div")
205+
206+
expect(() => new Router(routes, root, { errorRoute: "/missing" })).toThrow(
207+
'errorRoute "/missing" does not match any route path.'
208+
)
209+
})
117210
})

tests/router.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,4 +811,68 @@ describe("spectre-shell-router", () => {
811811
router.destroy()
812812
})
813813
})
814+
815+
describe("router.replace()", () => {
816+
it("navigates without adding a new history entry", async () => {
817+
const { Router } = await import("../src/index")
818+
const render = vi.fn()
819+
const routes = [
820+
{ path: "/", loader: async () => ({ render: vi.fn() }) },
821+
{ path: "/next", loader: async () => ({ render }) },
822+
]
823+
const root = document.createElement("div")
824+
window.history.replaceState({}, "", "/")
825+
826+
const router = new Router(routes, root)
827+
await tick()
828+
const lengthBefore = window.history.length
829+
830+
router.replace("/next")
831+
await tick()
832+
833+
expect(render).toHaveBeenCalledOnce()
834+
expect(window.location.pathname).toBe("/next")
835+
expect(window.history.length).toBe(lengthBefore)
836+
router.destroy()
837+
})
838+
839+
it("uses a hash-prefixed URL in hash mode", async () => {
840+
const { Router } = await import("../src/index")
841+
const render = vi.fn()
842+
const routes = [{ path: "/about", loader: async () => ({ render }) }]
843+
const root = document.createElement("div")
844+
window.history.replaceState({}, "", "/")
845+
846+
const router = new Router(routes, root, { mode: "hash" })
847+
router.replace("/about")
848+
await tick()
849+
850+
expect(render).toHaveBeenCalledOnce()
851+
expect(window.location.hash).toBe("#/about")
852+
router.destroy()
853+
})
854+
})
855+
856+
describe("router.back() and router.forward()", () => {
857+
it("delegates to the History API", async () => {
858+
const { Router } = await import("../src/index")
859+
const routes = [{ path: "/", loader: async () => ({ render: vi.fn() }) }]
860+
const root = document.createElement("div")
861+
window.history.replaceState({}, "", "/")
862+
863+
const back = vi.spyOn(window.history, "back").mockImplementation(() => {})
864+
const forward = vi.spyOn(window.history, "forward").mockImplementation(() => {})
865+
866+
const router = new Router(routes, root)
867+
router.back()
868+
router.forward()
869+
870+
expect(back).toHaveBeenCalledOnce()
871+
expect(forward).toHaveBeenCalledOnce()
872+
873+
router.destroy()
874+
back.mockRestore()
875+
forward.mockRestore()
876+
})
877+
})
814878
})

0 commit comments

Comments
 (0)