From 2ecb2389e91d0042fcc906cafe06e95887e784c5 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 21 Oct 2025 13:53:57 +0200 Subject: [PATCH 1/3] feat(router): allow navigating without intermediate rendering Also forward to history go, if target is a number. --- packages/esroute/src/nav-opts.ts | 6 ++- packages/esroute/src/router.spec.ts | 44 +++++++++++++++---- packages/esroute/src/router.ts | 65 ++++++++++++++++++++++++----- vitest.config.ts | 1 + 4 files changed, 97 insertions(+), 19 deletions(-) diff --git a/packages/esroute/src/nav-opts.ts b/packages/esroute/src/nav-opts.ts index 867b108..79f309b 100644 --- a/packages/esroute/src/nav-opts.ts +++ b/packages/esroute/src/nav-opts.ts @@ -15,6 +15,8 @@ export interface NavMeta { path?: string[]; /** The href to resolve. Should be relative. */ href?: string; + /** Whether the rendering should be skipped. */ + skipRender?: boolean; } export type StrictNavMeta = NavMeta & @@ -32,6 +34,7 @@ export class NavOpts implements NavMeta { readonly params: string[] = []; readonly hash?: string; readonly replace?: boolean; + readonly skipRender?: boolean; readonly path: string[]; readonly search: Record; readonly pop?: boolean; @@ -40,7 +43,7 @@ export class NavOpts implements NavMeta { constructor(target: StrictNavMeta); constructor(target: PathOrHref, opts?: NavMeta); constructor(target: PathOrHref | StrictNavMeta, opts: NavMeta = {}) { - let { path, href, hash, pop, replace, search, state } = + let { path, href, hash, pop, replace, search, state, skipRender } = typeof target === "string" || Array.isArray(target) ? opts : target; if (path) this.path = path; else if (href || typeof target === "string") { @@ -62,6 +65,7 @@ export class NavOpts implements NavMeta { if (search != null) this.search = search; if (state != null) this.state = state; if (replace != null) this.replace = replace; + if (skipRender != null) this.skipRender = skipRender; this.search ??= {}; } diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index 0d99b9f..4d2e5cf 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -1,14 +1,19 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { NavOpts } from "./nav-opts"; import { Router, createRouter } from "./router"; describe("Router", () => { - const onResolve = vi.fn(); + let onResolve: Mock; let router: Router; beforeEach(() => { + onResolve = vi.fn(); vi.spyOn(history, "replaceState"); vi.spyOn(history, "pushState"); + vi.spyOn(history, "go").mockImplementation(() => + setTimeout(() => window.dispatchEvent(new PopStateEvent("popstate")), 0) + ); router = createRouter({ + onResolve, routes: { "": ({}, next) => next ?? "index", foo: () => "foo", @@ -19,7 +24,6 @@ describe("Router", () => { describe("init()", () => { it("should subscribe to popstate and anchor click events", async () => { - router.onResolve(onResolve); router.init(); location.href = "http://localhost/foo"; @@ -34,7 +38,6 @@ describe("Router", () => { }); it("should subscribe to popstate and anchor click events", async () => { - router.onResolve(onResolve); router.init(); const anchor = document.createElement("a"); document.body.appendChild(anchor); @@ -89,14 +92,42 @@ describe("Router", () => { expect(history.replaceState).toHaveBeenCalledWith(null, "", "/foo?a=c"); }); + + it("should skip rendering, if specified by the NavMeta", async () => { + await router.go("/foo", { skipRender: true }); + + expect(history.pushState).toHaveBeenCalledWith(null, "", "/foo"); + expect(onResolve).not.toHaveBeenCalled(); + }); + + it("should render only once, if render is called with defer function", async () => { + await router.render(async () => { + await router.go("/baz"); + await router.go(-1); + await router.go("/foo"); + }); + + expect(history.pushState).toHaveBeenCalledTimes(2); + expect(history.go).toHaveBeenCalledTimes(1); + expect(onResolve).toHaveBeenCalledWith({ + opts: new NavOpts("/foo", { pop: false }), + value: "foo", + }); + }); + + it("should forward to history.go(), if target is a number", async () => { + await router.go(1); + + expect(history.go).toHaveBeenCalledWith(1); + expect(history.go).toHaveBeenCalledTimes(1); + expect(onResolve).toHaveBeenCalledTimes(1); + }); }); describe("onResolve()", () => { it("should initially call listener, if there is already a current resolution", async () => { await router.go("/foo"); - router.onResolve(onResolve); - expect(onResolve).toHaveBeenNthCalledWith(1, { value: "foo", opts: expect.objectContaining(new NavOpts("foo")), @@ -105,7 +136,6 @@ describe("Router", () => { it("should call the listener when a navigation has finished", async () => { await router.go("/foo"); - router.onResolve(onResolve); await router.go("/foo"); diff --git a/packages/esroute/src/router.ts b/packages/esroute/src/router.ts index c48a9bf..2168f6c 100644 --- a/packages/esroute/src/router.ts +++ b/packages/esroute/src/router.ts @@ -19,14 +19,17 @@ export interface Router { * Triggers a navigation. * You can modify the navigation options by passing in a second argument. * Returns a promise that resolves when the navigation is complete. - * @param target Can be one of array of path parts, a relative url, a NavOpts object or a + * @param target Can be one of number, array of path parts, a relative url, a NavOpts object or a * function that derives new NavOpts from the current NavOpts. + * If it is a number, it is forwarded to history.go(). * Use function to patch state, it uses replaceState() and keeps path, search and state * by default. * @param opts The navigation metadata. */ - go(target: StrictNavMeta | ((prev: NavOpts) => NavMeta)): Promise; - go(target: PathOrHref, opts?: NavMeta): Promise; + go( + target: number | StrictNavMeta | ((prev: NavOpts) => NavMeta) + ): Promise; + go(target: number | PathOrHref, opts?: NavMeta): Promise; /** * Use this to listen for route changes. * Returns an unsubscribe function. @@ -46,6 +49,12 @@ export interface Router { * Use this to wait for the current navigation to complete. */ resolution?: Promise>; + /** + * Use this to render the current route (history and location). + * @param defer A function that defers rendering and can be used to trigger multiple successive + * navigations without intermediate rendering. + */ + render(defer?: () => Promise): Promise; } export interface RouterConf { @@ -82,6 +91,7 @@ export const createRouter = ({ onResolve ? [onResolve] : [] ); let resolution: Promise>; + let skipRender = false; const r: Router = { routes, get current() { @@ -90,17 +100,21 @@ export const createRouter = ({ get resolution() { return resolution; }, - init() { - window.addEventListener("popstate", stateFromHref); + async init() { + window.addEventListener("popstate", popStateListener); if (!noClick) document.addEventListener("click", linkClickListener); - stateFromHref({ state: history.state }); + await resolveCurrent(); }, dispose() { - window.removeEventListener("popstate", stateFromHref); + window.removeEventListener("popstate", popStateListener); document.removeEventListener("click", linkClickListener); }, async go( - target: PathOrHref | StrictNavMeta | ((prev: NavOpts) => NavMeta), + target: + | PathOrHref + | StrictNavMeta + | ((prev: NavOpts) => NavMeta) + | number, opts?: NavMeta ): Promise { // Serialize all navigaton requests @@ -118,12 +132,20 @@ export const createRouter = ({ ...target(prevRes.opts), }; } + if (typeof target === "number") { + const waiting = waitForPopState(); + history.go(target); + await waiting; + if (skipRender || opts?.skipRender) return; + return resolveCurrent(); + } const navOpts = target instanceof NavOpts ? target : typeof target === "string" || Array.isArray(target) ? new NavOpts(target, opts) : new NavOpts(target); + if (navOpts.skipRender || skipRender) return updateState(navOpts); const res = await applyResolution(resolve(r.routes, navOpts, notFound)); updateState(res.opts); }, @@ -132,6 +154,21 @@ export const createRouter = ({ if (_current) listener(_current); return () => _listeners.delete(listener); }, + async render(defer?: () => Promise) { + if (!defer) return resolveCurrent(); + skipRender = true; + try { + await defer(); + } finally { + skipRender = false; + } + await resolveCurrent(); + }, + }; + + const popStateListener = (e: PopStateEvent) => { + if (skipRender) return; + resolveCurrent(e); }; const linkClickListener = (e: MouseEvent) => { @@ -146,12 +183,12 @@ export const createRouter = ({ } }; - const stateFromHref = async (e: { state: any } | PopStateEvent) => { + const resolveCurrent = async (e?: PopStateEvent) => { const { href, origin } = window.location; const initialOpts = new NavOpts(href.substring(origin.length), { - state: e.state, - ...(e instanceof PopStateEvent && { pop: true }), + state: e ? e.state : history.state, + pop: !!e, }); const { opts } = await applyResolution( resolve(r.routes, initialOpts, notFound) @@ -185,6 +222,12 @@ export const createRouter = ({ else history.pushState(state ?? null, "", href); }; + const waitForPopState = () => { + return new Promise((r) => + window.addEventListener("popstate", r, { once: true }) + ); + }; + return r; }; diff --git a/vitest.config.ts b/vitest.config.ts index e9012fd..4c3c1f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,6 @@ export default defineConfig({ restoreMocks: true, environment: "happy-dom", pool: "forks", + testTimeout: 1000, }, }); From bfa8a5a7d1843830aab576f1b91e8c181f361c57 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 21 Oct 2025 14:19:13 +0200 Subject: [PATCH 2/3] docs: update README --- packages/esroute/README.md | 49 +++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/esroute/README.md b/packages/esroute/README.md index 63b2aac..68a5051 100644 --- a/packages/esroute/README.md +++ b/packages/esroute/README.md @@ -18,6 +18,7 @@ Those features may be the ones you are looking for. - [🏎 Fast startup and runtime](#-fast-startup-and-runtime) - [🛡 Route guards](#-route-guards) - [🦄 Virtual routes](#-virtual-routes) +- [⏱️ Deferred rendering](#-deferred-rendering-1) ### 🌈 Framework agnostic @@ -38,6 +39,20 @@ router.go((prev) => ({ })); ``` +#### Wrapped history navigation API + +The `go` method is a wrapper around the history navigation API. +You can use it to navigate to a specific history state: + +```ts +await router.go(1); // Go one step forward and wait for the popstate event to be dispatched +await router.go(-2); // Go two steps back and wait for the popstate event to be dispatched +``` + +A difference is that the `go` method will not render the page, if the `skipRender` flag is set. + +Additionally, `go` is asynchronous, and in case of history navigation, it will wait for the popstate event to be dispatched. + ### 🕹 Simple configuration A configuration can look as simple as this: @@ -95,7 +110,7 @@ esroute comes with no dependencies and is quite small. The route resolution is done by traversing the route spec that is used to configure the app routes (no preprocessing required). The algorithm is based on simple string comparisons (no regex matching). -#### 🛡 Route guards +### 🛡 Route guards You can prevent resolving routes by redirecting to another route within a guard: @@ -165,6 +180,38 @@ const router = createRouter({ In this sczenario we have the `memberRoutes` next to the `/login` route. +### ⏱️ Deferred rendering + +You can defer rendering by passing a function to the `render` method. +This can be useful to trigger multiple successive navigations without intermediate rendering to prevent flickering. + +```ts +router.render(async () => { + await router.go("/foo"); // Will not render the page + await router.go("/bar"); // Will render the page +}); +``` + +One thing to note is that no guards and render functions will be executed for intermediate navigation. +So any specified guards or redirects within the render functions will only be executed for the last navigation. + +You can also defer rendering by passing the `skipRender` option to the `go` method. + +This is equivalent to the code above: + +```ts +await router.go("/foo", { skipRender: true }); +await router.go("/bar"); +``` + +And this one as well: + +```ts +await router.go("/foo", { skipRender: true }); +await router.go("/bar", { skipRender: true }); +await router.render(); +``` + ## Router configuration The `createRouter` factory takes a `RouterConf` object as parameter. From 1a4cdfb153baa0645c47556374507388931df677 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 21 Oct 2025 14:22:19 +0200 Subject: [PATCH 3/3] chore: bump versions --- package-lock.json | 6 +++--- packages/esroute-lit/package.json | 4 ++-- packages/esroute/package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 89c9195..80a18cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2380,7 +2380,7 @@ } }, "packages/esroute": { - "version": "0.9.1", + "version": "0.11.0", "license": "MIT", "devDependencies": { "typescript": "^5.4.5" @@ -2388,10 +2388,10 @@ }, "packages/esroute-lit": { "name": "@esroute/lit", - "version": "0.9.0", + "version": "0.11.0", "license": "MIT", "dependencies": { - "esroute": "^0.9.0", + "esroute": "^0.11.0", "lit": "^3.1.1" }, "devDependencies": { diff --git a/packages/esroute-lit/package.json b/packages/esroute-lit/package.json index 29a56a6..d4fa037 100644 --- a/packages/esroute-lit/package.json +++ b/packages/esroute-lit/package.json @@ -1,6 +1,6 @@ { "name": "@esroute/lit", - "version": "0.10.1", + "version": "0.11.0", "description": "A small efficient client-side routing library for lit, written in TypeScript.", "main": "dist/index.js", "license": "MIT", @@ -20,7 +20,7 @@ "typescript": "^5.4.5" }, "dependencies": { - "esroute": "^0.10.1", + "esroute": "^0.11.0", "lit": "^3.1.1" } } diff --git a/packages/esroute/package.json b/packages/esroute/package.json index 16f71c2..a458336 100644 --- a/packages/esroute/package.json +++ b/packages/esroute/package.json @@ -1,6 +1,6 @@ { "name": "esroute", - "version": "0.10.1", + "version": "0.11.0", "description": "A small efficient framework-agnostic client-side routing library, written in TypeScript.", "types": "dist/index.d.ts", "main": "dist/index.js",