Skip to content

Commit b60dc22

Browse files
authored
Merge pull request #20 from sv2dev/19-allow-successive-navigation-without-intermediate-rendering
19 allow successive navigation without intermediate rendering
2 parents 960d187 + 1a4cdfb commit b60dc22

8 files changed

Lines changed: 151 additions & 26 deletions

File tree

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/esroute-lit/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@esroute/lit",
3-
"version": "0.10.1",
3+
"version": "0.11.0",
44
"description": "A small efficient client-side routing library for lit, written in TypeScript.",
55
"main": "dist/index.js",
66
"license": "MIT",
@@ -20,7 +20,7 @@
2020
"typescript": "^5.4.5"
2121
},
2222
"dependencies": {
23-
"esroute": "^0.10.1",
23+
"esroute": "^0.11.0",
2424
"lit": "^3.1.1"
2525
}
2626
}

packages/esroute/README.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Those features may be the ones you are looking for.
1818
- [🏎 Fast startup and runtime](#-fast-startup-and-runtime)
1919
- [🛡 Route guards](#-route-guards)
2020
- [🦄 Virtual routes](#-virtual-routes)
21+
- [⏱️ Deferred rendering](#-deferred-rendering-1)
2122

2223
### 🌈 Framework agnostic
2324

@@ -38,6 +39,20 @@ router.go((prev) => ({
3839
}));
3940
```
4041

42+
#### Wrapped history navigation API
43+
44+
The `go` method is a wrapper around the history navigation API.
45+
You can use it to navigate to a specific history state:
46+
47+
```ts
48+
await router.go(1); // Go one step forward and wait for the popstate event to be dispatched
49+
await router.go(-2); // Go two steps back and wait for the popstate event to be dispatched
50+
```
51+
52+
A difference is that the `go` method will not render the page, if the `skipRender` flag is set.
53+
54+
Additionally, `go` is asynchronous, and in case of history navigation, it will wait for the popstate event to be dispatched.
55+
4156
### 🕹 Simple configuration
4257

4358
A configuration can look as simple as this:
@@ -95,7 +110,7 @@ esroute comes with no dependencies and is quite small.
95110

96111
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).
97112

98-
#### 🛡 Route guards
113+
### 🛡 Route guards
99114

100115
You can prevent resolving routes by redirecting to another route within a guard:
101116

@@ -165,6 +180,38 @@ const router = createRouter({
165180

166181
In this sczenario we have the `memberRoutes` next to the `/login` route.
167182

183+
### ⏱️ Deferred rendering
184+
185+
You can defer rendering by passing a function to the `render` method.
186+
This can be useful to trigger multiple successive navigations without intermediate rendering to prevent flickering.
187+
188+
```ts
189+
router.render(async () => {
190+
await router.go("/foo"); // Will not render the page
191+
await router.go("/bar"); // Will render the page
192+
});
193+
```
194+
195+
One thing to note is that no guards and render functions will be executed for intermediate navigation.
196+
So any specified guards or redirects within the render functions will only be executed for the last navigation.
197+
198+
You can also defer rendering by passing the `skipRender` option to the `go` method.
199+
200+
This is equivalent to the code above:
201+
202+
```ts
203+
await router.go("/foo", { skipRender: true });
204+
await router.go("/bar");
205+
```
206+
207+
And this one as well:
208+
209+
```ts
210+
await router.go("/foo", { skipRender: true });
211+
await router.go("/bar", { skipRender: true });
212+
await router.render();
213+
```
214+
168215
## Router configuration
169216

170217
The `createRouter` factory takes a `RouterConf` object as parameter.

packages/esroute/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "esroute",
3-
"version": "0.10.1",
3+
"version": "0.11.0",
44
"description": "A small efficient framework-agnostic client-side routing library, written in TypeScript.",
55
"types": "dist/index.d.ts",
66
"main": "dist/index.js",

packages/esroute/src/nav-opts.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface NavMeta {
1515
path?: string[];
1616
/** The href to resolve. Should be relative. */
1717
href?: string;
18+
/** Whether the rendering should be skipped. */
19+
skipRender?: boolean;
1820
}
1921

2022
export type StrictNavMeta = NavMeta &
@@ -32,6 +34,7 @@ export class NavOpts implements NavMeta {
3234
readonly params: string[] = [];
3335
readonly hash?: string;
3436
readonly replace?: boolean;
37+
readonly skipRender?: boolean;
3538
readonly path: string[];
3639
readonly search: Record<string, string>;
3740
readonly pop?: boolean;
@@ -40,7 +43,7 @@ export class NavOpts implements NavMeta {
4043
constructor(target: StrictNavMeta);
4144
constructor(target: PathOrHref, opts?: NavMeta);
4245
constructor(target: PathOrHref | StrictNavMeta, opts: NavMeta = {}) {
43-
let { path, href, hash, pop, replace, search, state } =
46+
let { path, href, hash, pop, replace, search, state, skipRender } =
4447
typeof target === "string" || Array.isArray(target) ? opts : target;
4548
if (path) this.path = path;
4649
else if (href || typeof target === "string") {
@@ -62,6 +65,7 @@ export class NavOpts implements NavMeta {
6265
if (search != null) this.search = search;
6366
if (state != null) this.state = state;
6467
if (replace != null) this.replace = replace;
68+
if (skipRender != null) this.skipRender = skipRender;
6569
this.search ??= {};
6670
}
6771

packages/esroute/src/router.spec.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
22
import { NavOpts } from "./nav-opts";
33
import { Router, createRouter } from "./router";
44

55
describe("Router", () => {
6-
const onResolve = vi.fn();
6+
let onResolve: Mock;
77
let router: Router<any>;
88
beforeEach(() => {
9+
onResolve = vi.fn();
910
vi.spyOn(history, "replaceState");
1011
vi.spyOn(history, "pushState");
12+
vi.spyOn(history, "go").mockImplementation(() =>
13+
setTimeout(() => window.dispatchEvent(new PopStateEvent("popstate")), 0)
14+
);
1115
router = createRouter({
16+
onResolve,
1217
routes: {
1318
"": ({}, next) => next ?? "index",
1419
foo: () => "foo",
@@ -19,7 +24,6 @@ describe("Router", () => {
1924

2025
describe("init()", () => {
2126
it("should subscribe to popstate and anchor click events", async () => {
22-
router.onResolve(onResolve);
2327
router.init();
2428
location.href = "http://localhost/foo";
2529

@@ -34,7 +38,6 @@ describe("Router", () => {
3438
});
3539

3640
it("should subscribe to popstate and anchor click events", async () => {
37-
router.onResolve(onResolve);
3841
router.init();
3942
const anchor = document.createElement("a");
4043
document.body.appendChild(anchor);
@@ -89,14 +92,42 @@ describe("Router", () => {
8992

9093
expect(history.replaceState).toHaveBeenCalledWith(null, "", "/foo?a=c");
9194
});
95+
96+
it("should skip rendering, if specified by the NavMeta", async () => {
97+
await router.go("/foo", { skipRender: true });
98+
99+
expect(history.pushState).toHaveBeenCalledWith(null, "", "/foo");
100+
expect(onResolve).not.toHaveBeenCalled();
101+
});
102+
103+
it("should render only once, if render is called with defer function", async () => {
104+
await router.render(async () => {
105+
await router.go("/baz");
106+
await router.go(-1);
107+
await router.go("/foo");
108+
});
109+
110+
expect(history.pushState).toHaveBeenCalledTimes(2);
111+
expect(history.go).toHaveBeenCalledTimes(1);
112+
expect(onResolve).toHaveBeenCalledWith({
113+
opts: new NavOpts("/foo", { pop: false }),
114+
value: "foo",
115+
});
116+
});
117+
118+
it("should forward to history.go(), if target is a number", async () => {
119+
await router.go(1);
120+
121+
expect(history.go).toHaveBeenCalledWith(1);
122+
expect(history.go).toHaveBeenCalledTimes(1);
123+
expect(onResolve).toHaveBeenCalledTimes(1);
124+
});
92125
});
93126

94127
describe("onResolve()", () => {
95128
it("should initially call listener, if there is already a current resolution", async () => {
96129
await router.go("/foo");
97130

98-
router.onResolve(onResolve);
99-
100131
expect(onResolve).toHaveBeenNthCalledWith(1, {
101132
value: "foo",
102133
opts: expect.objectContaining(new NavOpts("foo")),
@@ -105,7 +136,6 @@ describe("Router", () => {
105136

106137
it("should call the listener when a navigation has finished", async () => {
107138
await router.go("/foo");
108-
router.onResolve(onResolve);
109139

110140
await router.go("/foo");
111141

packages/esroute/src/router.ts

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,17 @@ export interface Router<T = any> {
1919
* Triggers a navigation.
2020
* You can modify the navigation options by passing in a second argument.
2121
* Returns a promise that resolves when the navigation is complete.
22-
* @param target Can be one of array of path parts, a relative url, a NavOpts object or a
22+
* @param target Can be one of number, array of path parts, a relative url, a NavOpts object or a
2323
* function that derives new NavOpts from the current NavOpts.
24+
* If it is a number, it is forwarded to history.go().
2425
* Use function to patch state, it uses replaceState() and keeps path, search and state
2526
* by default.
2627
* @param opts The navigation metadata.
2728
*/
28-
go(target: StrictNavMeta | ((prev: NavOpts) => NavMeta)): Promise<void>;
29-
go(target: PathOrHref, opts?: NavMeta): Promise<void>;
29+
go(
30+
target: number | StrictNavMeta | ((prev: NavOpts) => NavMeta)
31+
): Promise<void>;
32+
go(target: number | PathOrHref, opts?: NavMeta): Promise<void>;
3033
/**
3134
* Use this to listen for route changes.
3235
* Returns an unsubscribe function.
@@ -46,6 +49,12 @@ export interface Router<T = any> {
4649
* Use this to wait for the current navigation to complete.
4750
*/
4851
resolution?: Promise<Resolved<T>>;
52+
/**
53+
* Use this to render the current route (history and location).
54+
* @param defer A function that defers rendering and can be used to trigger multiple successive
55+
* navigations without intermediate rendering.
56+
*/
57+
render(defer?: () => Promise<void>): Promise<void>;
4958
}
5059

5160
export interface RouterConf<T = any> {
@@ -82,6 +91,7 @@ export const createRouter = <T = any>({
8291
onResolve ? [onResolve] : []
8392
);
8493
let resolution: Promise<Resolved<T>>;
94+
let skipRender = false;
8595
const r: Router<T> = {
8696
routes,
8797
get current() {
@@ -90,17 +100,21 @@ export const createRouter = <T = any>({
90100
get resolution() {
91101
return resolution;
92102
},
93-
init() {
94-
window.addEventListener("popstate", stateFromHref);
103+
async init() {
104+
window.addEventListener("popstate", popStateListener);
95105
if (!noClick) document.addEventListener("click", linkClickListener);
96-
stateFromHref({ state: history.state });
106+
await resolveCurrent();
97107
},
98108
dispose() {
99-
window.removeEventListener("popstate", stateFromHref);
109+
window.removeEventListener("popstate", popStateListener);
100110
document.removeEventListener("click", linkClickListener);
101111
},
102112
async go(
103-
target: PathOrHref | StrictNavMeta | ((prev: NavOpts) => NavMeta),
113+
target:
114+
| PathOrHref
115+
| StrictNavMeta
116+
| ((prev: NavOpts) => NavMeta)
117+
| number,
104118
opts?: NavMeta
105119
): Promise<void> {
106120
// Serialize all navigaton requests
@@ -118,12 +132,20 @@ export const createRouter = <T = any>({
118132
...target(prevRes.opts),
119133
};
120134
}
135+
if (typeof target === "number") {
136+
const waiting = waitForPopState();
137+
history.go(target);
138+
await waiting;
139+
if (skipRender || opts?.skipRender) return;
140+
return resolveCurrent();
141+
}
121142
const navOpts =
122143
target instanceof NavOpts
123144
? target
124145
: typeof target === "string" || Array.isArray(target)
125146
? new NavOpts(target, opts)
126147
: new NavOpts(target);
148+
if (navOpts.skipRender || skipRender) return updateState(navOpts);
127149
const res = await applyResolution(resolve(r.routes, navOpts, notFound));
128150
updateState(res.opts);
129151
},
@@ -132,6 +154,21 @@ export const createRouter = <T = any>({
132154
if (_current) listener(_current);
133155
return () => _listeners.delete(listener);
134156
},
157+
async render(defer?: () => Promise<void>) {
158+
if (!defer) return resolveCurrent();
159+
skipRender = true;
160+
try {
161+
await defer();
162+
} finally {
163+
skipRender = false;
164+
}
165+
await resolveCurrent();
166+
},
167+
};
168+
169+
const popStateListener = (e: PopStateEvent) => {
170+
if (skipRender) return;
171+
resolveCurrent(e);
135172
};
136173

137174
const linkClickListener = (e: MouseEvent) => {
@@ -146,12 +183,12 @@ export const createRouter = <T = any>({
146183
}
147184
};
148185

149-
const stateFromHref = async (e: { state: any } | PopStateEvent) => {
186+
const resolveCurrent = async (e?: PopStateEvent) => {
150187
const { href, origin } = window.location;
151188

152189
const initialOpts = new NavOpts(href.substring(origin.length), {
153-
state: e.state,
154-
...(e instanceof PopStateEvent && { pop: true }),
190+
state: e ? e.state : history.state,
191+
pop: !!e,
155192
});
156193
const { opts } = await applyResolution(
157194
resolve(r.routes, initialOpts, notFound)
@@ -185,6 +222,12 @@ export const createRouter = <T = any>({
185222
else history.pushState(state ?? null, "", href);
186223
};
187224

225+
const waitForPopState = () => {
226+
return new Promise<PopStateEvent>((r) =>
227+
window.addEventListener("popstate", r, { once: true })
228+
);
229+
};
230+
188231
return r;
189232
};
190233

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export default defineConfig({
66
restoreMocks: true,
77
environment: "happy-dom",
88
pool: "forks",
9+
testTimeout: 1000,
910
},
1011
});

0 commit comments

Comments
 (0)