Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Navigation #31

Merged
merged 7 commits into from
Sep 7, 2023
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"babel-jest": "^26.3.0",
"date-fns": "^2.16.1",
"event-propagation-path": "^1.0.5",
"fast-equals": "^5.0.1",
"indent-string": "^4.0.0",
"jest": "^26.4.2",
"preact": "10.4.4",
Expand Down
167 changes: 99 additions & 68 deletions source/Program.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Component, h, render } from "preact";
import RouteParser from "route-parser";
import { deepEqual } from "fast-equals";
import "event-propagation-path";

import { navigate } from "./Utils";
Expand All @@ -18,8 +19,32 @@ const queueTask = (callback) => {
}
};

class DecodingError extends Error {}

const equals = (a, b) => {
if (a instanceof Object) {
return b instanceof Object && deepEqual(a, b);
} else {
return !b instanceof Object && a === b;
}
};

const getRouteInfo = (url, routes) => {
for (let route of routes) {
if (route.path === "*") {
return { route: route, vars: false };
} else {
let vars = new RouteParser(route.path).match(url);
if (vars) {
return { route: route, vars: vars };
}
}
}
return null;
};

class Root extends Component {
handleClick(event, routes) {
handleClick(event) {
// If someone prevented default we honor that.
if (event.defaultPrevented) {
return;
Expand All @@ -39,23 +64,20 @@ class Root extends Component {
return;
}

let pathname = element.pathname;
let origin = element.origin;
let search = element.search;
let hash = element.hash;

if (origin === window.location.origin) {
for (let item of this.props.routes) {
let partialPath = pathname + search;
let fullPath = partialPath + hash;
let path = new RouteParser(item.path);
let match = item.path == "*" ? true : path.match(fullPath);

if (match) {
event.preventDefault();
navigate(fullPath);
return;
}
if (element.origin === window.location.origin) {
const fullPath = element.pathname + element.search + element.hash;
const routes = this.props.routes;
const routeInfo = getRouteInfo(fullPath, routes);

if (routeInfo) {
event.preventDefault();
navigate(
fullPath,
/* dispatch */ true,
/* triggerJump */ true,
routeInfo
);
return;
}
}
}
Expand Down Expand Up @@ -89,83 +111,92 @@ export default (enums) => {
this.root = document.createElement("div");
document.body.appendChild(this.root);

this.firstPageLoad = true;
this.routes = [];
this.url = null;
this.routeInfo = null;

window.addEventListener("popstate", () => {
this.handlePopState();
window.addEventListener("popstate", (event) => {
this.handlePopState(event);
});
}

resolvePagePosition() {
resolvePagePosition(triggerJump) {
// Queue a microTask, this will run after Preact does a render.
queueTask(() => {
// On the next frame, the DOM should be updated already.
requestAnimationFrame(() => {
let hashAnchor;
const hash = window.location.hash;

try {
hashAnchor = this.root.querySelector(
`a[name="${window.location.hash.slice(1)}"]`
);
} finally {
}
if (hash) {
let elem = null;
try {
elem =
this.root.querySelector(hash) ||
this.root.querySelector(`a[name="${hash.slice(1)}"]`);
} finally {
}

if (window.location.hash && hashAnchor) {
// This triggers a jump to the hash.
window.location.href = window.location.hash;
} else if (!this.firstPageLoad) {
// Otherwise if its not the first page load scroll to the top of the page.
window.scrollTo(document.body.scrollTop, 0);
if (elem) {
if (triggerJump) {
elem.scrollIntoView();
}
} else {
console.warn(
`${hash} matches no element with an id and no link with a name`
);
}
} else if (triggerJump) {
window.scrollTo(0, 0);
}

this.firstPageLoad = false;
});
});
}

handlePopState() {
handlePopState(event) {
const url =
window.location.pathname +
window.location.search +
window.location.hash;
const routeInfo = event?.routeInfo || getRouteInfo(url, this.routes);

if (routeInfo) {
if (
this.routeInfo === null ||
routeInfo.route.path !== this.routeInfo.route.path ||
!equals(routeInfo.vars, this.routeInfo.vars)
) {
this.runRouteHandler(routeInfo);
}

if (url === this.url) {
return;
this.resolvePagePosition(!!event?.triggerJump);
}

for (let item of this.routes) {
if (item.path === "*") {
item.handler();
this.resolvePagePosition();
} else {
let path = new RouteParser(item.path);
let match = path.match(url);
this.routeInfo = routeInfo;
}

if (match) {
try {
let args = item.mapping.map((name, index) => {
const value = match[name];
const result = item.decoders[index](value);

if (result instanceof enums.ok) {
return result._0;
} else {
throw "";
}
});

item.handler.apply(null, args);
this.resolvePagePosition();

break;
} catch (_) {}
runRouteHandler(routeInfo) {
const { route } = routeInfo;
if (route.path === "*") {
route.handler();
} else {
const { vars } = routeInfo;
try {
let args = route.mapping.map((name, index) => {
const value = vars[name];
const result = route.decoders[index](value);

if (result instanceof enums.ok) {
return result._0;
} else {
throw new DecodingError();
}
});
route.handler.apply(null, args);
} catch (error) {
if (error.constructor !== DecodingError) {
throw error;
}
}
}

this.url = url;
}

render(main, globals) {
Expand Down
16 changes: 12 additions & 4 deletions source/Utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Equals } from "./Symbols";
import Record from "./Record";

export const update = (data, newData) => {
Expand All @@ -11,21 +10,30 @@ export const update = (data, newData) => {
}
};

export const navigate = (url, dispatch = true) => {
export const navigate = (
url,
dispatch = true,
triggerJump = true,
routeInfo = null
) => {
let pathname = window.location.pathname;
let search = window.location.search;
let hash = window.location.hash;

let fullPath = pathname + search + hash;

if (fullPath !== url) {
if (dispatch) {
window.history.pushState({}, "", url);
dispatchEvent(new PopStateEvent("popstate"));
} else {
window.history.replaceState({}, "", url);
}
}
if (dispatch) {
let event = new PopStateEvent("popstate");
event.triggerJump = triggerJump;
event.routeInfo = routeInfo;
dispatchEvent(event);
}
};

export const insertStyles = (styles) => {
Expand Down
8 changes: 6 additions & 2 deletions tests/Program.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ const indexRoute = {
mapping: [],
};

afterEach(() => {
jest.resetAllMocks();
});

describe("handling links", () => {
test("it does not navigate to local link that does not have a route", () => {
let event = new window.Event("click", { bubbles: true });
Expand Down Expand Up @@ -134,7 +138,7 @@ describe("handling links", () => {
program.render($Link);
program.root.querySelector("a:nth-child(6)").dispatchEvent(event);

expect(linkRoute.handler.mock.calls.length).toBe(1);
expect(linkRoute.handler.mock.calls.length).toBe(0);
});
});

Expand All @@ -158,7 +162,7 @@ describe("handling navigation", () => {
test("handles index route", () => {
program.routes = [indexRoute];
navigate("/user/2");
expect(indexRoute.handler.mock.calls.length).toBe(2);
expect(indexRoute.handler.mock.calls.length).toBe(1);
});
});

Expand Down
33 changes: 21 additions & 12 deletions tests/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ beforeEach(() => {
console.warn = jest.fn();
});

afterEach(() => {
jest.resetAllMocks();
});

describe("compareObjects", () => {
test("compares null and undefined with ===", () => {
expect(compareObjects(undefined, undefined)).toBe(true);
Expand Down Expand Up @@ -76,24 +80,29 @@ describe("update", () => {
describe("navigate", () => {
beforeEach(() => {
window.dispatchEvent = jest.fn();
window.window.scrollTo = jest.fn();
});

test("navigates to the new URL", () => {
navigate("/test");
expect(window.location.pathname).toBe("/test");
expect(window.dispatchEvent.mock.calls.length).toBe(1);
test("`Window.setUrl()` does not dispatch and does not jump", () => {
navigate("/foo", /* dispatch */ false);
expect(window.location.pathname).toBe("/foo");
expect(window.dispatchEvent.mock.calls.length).toBe(0);
expect(window.scrollTo.mock.calls.length).toBe(0);
});

test("navigates to the same URL does nothing", () => {
navigate("/test");
expect(window.location.pathname).toBe("/test");
expect(window.dispatchEvent.mock.calls.length).toBe(0);
test("`Window.navigate()` sets the url and dispatches and does not jump", () => {
navigate("/bar", /* dispatch */ true, /* triggerJump */ false);
expect(window.location.pathname).toBe("/bar");
expect(window.dispatchEvent.mock.calls.length).toBe(1);
expect(window.scrollTo.mock.calls.length).toBe(0);
});

test("navigates to the new URL does not dispatch", () => {
navigate("/testbaha", false);
expect(window.location.pathname).toBe("/testbaha");
expect(window.dispatchEvent.mock.calls.length).toBe(0);
test("`Window.jump()` sets the url and dispatches and jumps if it has a defined route", () => {
navigate("/baz", /* dispatch */ true, /* triggerJump */ true);
expect(window.location.pathname).toBe("/baz");
expect(window.dispatchEvent.mock.calls.length).toBe(1);
// Scrolling only happens when the url matches a defined route. This is
// currently not tested.
});
});

Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2155,6 +2155,11 @@ extglob@^2.0.4:
snapdragon "^0.8.1"
to-regex "^3.0.1"

fast-equals@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.0.1.tgz#a4eefe3c5d1c0d021aeed0bc10ba5e0c12ee405d"
integrity sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==

fast-json-stable-stringify@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
Expand Down