Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/esroute-lit/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -20,7 +20,7 @@
"typescript": "^5.4.5"
},
"dependencies": {
"esroute": "^0.10.1",
"esroute": "^0.11.0",
"lit": "^3.1.1"
}
}
49 changes: 48 additions & 1 deletion packages/esroute/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/esroute/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/esroute/src/nav-opts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &
Expand All @@ -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<string, string>;
readonly pop?: boolean;
Expand All @@ -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") {
Expand All @@ -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 ??= {};
}

Expand Down
44 changes: 37 additions & 7 deletions packages/esroute/src/router.spec.ts
Original file line number Diff line number Diff line change
@@ -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<any>;
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",
Expand All @@ -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";

Expand All @@ -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);
Expand Down Expand Up @@ -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")),
Expand All @@ -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");

Expand Down
65 changes: 54 additions & 11 deletions packages/esroute/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ export interface Router<T = any> {
* 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<void>;
go(target: PathOrHref, opts?: NavMeta): Promise<void>;
go(
target: number | StrictNavMeta | ((prev: NavOpts) => NavMeta)
): Promise<void>;
go(target: number | PathOrHref, opts?: NavMeta): Promise<void>;
/**
* Use this to listen for route changes.
* Returns an unsubscribe function.
Expand All @@ -46,6 +49,12 @@ export interface Router<T = any> {
* Use this to wait for the current navigation to complete.
*/
resolution?: Promise<Resolved<T>>;
/**
* 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<void>): Promise<void>;
}

export interface RouterConf<T = any> {
Expand Down Expand Up @@ -82,6 +91,7 @@ export const createRouter = <T = any>({
onResolve ? [onResolve] : []
);
let resolution: Promise<Resolved<T>>;
let skipRender = false;
const r: Router<T> = {
routes,
get current() {
Expand All @@ -90,17 +100,21 @@ export const createRouter = <T = any>({
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<void> {
// Serialize all navigaton requests
Expand All @@ -118,12 +132,20 @@ export const createRouter = <T = any>({
...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);
},
Expand All @@ -132,6 +154,21 @@ export const createRouter = <T = any>({
if (_current) listener(_current);
return () => _listeners.delete(listener);
},
async render(defer?: () => Promise<void>) {
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) => {
Expand All @@ -146,12 +183,12 @@ export const createRouter = <T = any>({
}
};

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)
Expand Down Expand Up @@ -185,6 +222,12 @@ export const createRouter = <T = any>({
else history.pushState(state ?? null, "", href);
};

const waitForPopState = () => {
return new Promise<PopStateEvent>((r) =>
window.addEventListener("popstate", r, { once: true })
);
};

return r;
};

Expand Down
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export default defineConfig({
restoreMocks: true,
environment: "happy-dom",
pool: "forks",
testTimeout: 1000,
},
});