From 1def45303b01c3b2839e3e6ba44607a12d8159ce Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Thu, 14 Jan 2021 18:57:19 -0800 Subject: [PATCH 01/10] Add support for bypassing router for unhandled links Allow links to be handled by the browser if the router does not support the route. In the case where you may be proxy-passing from a server-side routing web app to a client-side routing web app, many of the URLs may be unsupported client-side. This allows the links to pass through to the parent router. --- src/lib/model.ts | 4 +++- src/lib/router-slot.ts | 39 ++++++++++++++++++++++++++++++++++----- src/lib/util/anchor.ts | 41 ++++++++++++++++++++++++++--------------- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/lib/model.ts b/src/lib/model.ts index bed6efe..30c2e8d 100644 --- a/src/lib/model.ts +++ b/src/lib/model.ts @@ -11,6 +11,8 @@ export interface IRouterSlot extends HTMLElement { constructAbsolutePath: ((path: PathFragment) => string); parent: IRouterSlot

| null | undefined; queryParentRouterSlot: (() => IRouterSlot

| null); + // Return the matched route if one found for the given path + getRouteMatch(path: string | PathFragment): IRouteMatch | null; } export type IRoutingInfo = { @@ -174,4 +176,4 @@ declare global { "navigationerror": NavigationErrorEvent, "willchangestate": WillChangeStateEvent } -} \ No newline at end of file +} diff --git a/src/lib/router-slot.ts b/src/lib/router-slot.ts index db07ac7..b551368 100644 --- a/src/lib/router-slot.ts +++ b/src/lib/router-slot.ts @@ -1,6 +1,6 @@ import { GLOBAL_ROUTER_EVENTS_TARGET, ROUTER_SLOT_TAG_NAME } from "./config"; import { Cancel, EventListenerSubscription, GlobalRouterEvent, IPathFragments, IRoute, IRouteMatch, IRouterSlot, IRoutingInfo, Params, PathFragment, RouterSlotEvent } from "./model"; -import { addListener, constructAbsolutePath, dispatchGlobalRouterEvent, dispatchRouteChangeEvent, ensureAnchorHistory, ensureHistoryEvents, handleRedirect, isRedirectRoute, isResolverRoute, matchRoutes, pathWithoutBasePath, queryParentRouterSlot, removeListeners, resolvePageComponent, shouldNavigate } from "./util"; +import { addListener, AnchorHandler, constructAbsolutePath, dispatchGlobalRouterEvent, dispatchRouteChangeEvent, ensureHistoryEvents, handleRedirect, isRedirectRoute, isResolverRoute, matchRoutes, pathWithoutBasePath, queryParentRouterSlot, removeListeners, resolvePageComponent, shouldNavigate } from "./util"; const template = document.createElement("template"); template.innerHTML = ``; @@ -8,9 +8,6 @@ template.innerHTML = ``; // Patches the history object and ensures the correct events. ensureHistoryEvents(); -// Ensure the anchor tags uses the history API -ensureAnchorHistory(); - /** * Slot for a node in the router tree. * @slot - Default content. @@ -89,6 +86,11 @@ export class RouterSlot extends HTMLElement implements IRouter return this.match != null ? this.match.params : null; } + /** + * The anchor link handler for the router slot + */ + private anchorHandler?: AnchorHandler; + /** * Hooks up the element. */ @@ -107,6 +109,7 @@ export class RouterSlot extends HTMLElement implements IRouter */ connectedCallback () { this.parent = this.queryParentRouterSlot(); + this.setupAnchorListener(); } /** @@ -114,6 +117,7 @@ export class RouterSlot extends HTMLElement implements IRouter */ disconnectedCallback () { this.detachListeners(); + this.detachAnchorListener() } /** @@ -179,6 +183,26 @@ export class RouterSlot extends HTMLElement implements IRouter await this.renderPath(pathFragment); } + /** + * Attach the anchor listener + */ + protected setupAnchorListener(): void { + this.anchorHandler = new AnchorHandler(this); + window.addEventListener( + 'click', + this.anchorHandler?.handleEvent.bind(this) + ); + } + + protected detachAnchorListener(): void { + if (this.anchorHandler) { + window.removeEventListener( + 'click', + this.anchorHandler.handleEvent + ); + } + } + /** * Attaches listeners, either globally or on the parent router. */ @@ -212,6 +236,11 @@ export class RouterSlot extends HTMLElement implements IRouter removeListeners(this.listeners); } + getRouteMatch(path: string | PathFragment): IRouteMatch | null { + const match = matchRoutes(this._routes, path); + return match; + } + /** * Loads a new path based on the routes. * Returns true if a navigation was made to a new page. @@ -219,7 +248,7 @@ export class RouterSlot extends HTMLElement implements IRouter protected async renderPath (path: string | PathFragment): Promise { // Find the corresponding route. - const match = matchRoutes(this._routes, path); + const match = this.getRouteMatch(path); // Ensure that a route was found, otherwise we just clear the current state of the route. if (match == null) { diff --git a/src/lib/util/anchor.ts b/src/lib/util/anchor.ts index 0a36683..4dbd57a 100644 --- a/src/lib/util/anchor.ts +++ b/src/lib/util/anchor.ts @@ -1,10 +1,17 @@ +import { IRouterSlot } from "../model"; + /** - * Hook up a click listener to the window that, for all anchor tags - * that has a relative HREF, uses the history API instead. + * The AnchorHandler allows the RouterSlot to observe all anchor clicks + * and either handle the click or let the browser handle it. */ -export function ensureAnchorHistory () { - window.addEventListener("click", (e: MouseEvent) => { +export class AnchorHandler { + routerSlot?: IRouterSlot; + + constructor(routerSlot?: IRouterSlot) { + this.routerSlot = routerSlot; + } + handleEvent(e: MouseEvent) { // Find the target by using the composed path to get the element through the shadow boundaries. const $anchor = ("composedPath" in e as any) ? e.composedPath().find($elem => $elem instanceof HTMLAnchorElement) : e.target; @@ -13,16 +20,20 @@ export function ensureAnchorHistory () { return; } - // Get the HREF value from the anchor tag - const href = $anchor.href; - // Only handle the anchor tag if the follow holds true: - // - The HREF is relative to the origin of the current location. - // - The target is targeting the current frame. - // - The anchor doesn't have the attribute [data-router-slot]="disabled" - if (!href.startsWith(location.origin) || - ($anchor.target !== "" && $anchor.target !== "_self") || - $anchor.dataset["routerSlot"] === "disabled") { + // - 1. The HREF is relative to the origin of the current location. + const hrefIsRelative = $anchor.href.startsWith(location.origin); + + // - 2. The target is targeting the current frame. + const differentFrameTargetted = $anchor.target !== "" && $anchor.target !== "_self"; + + // - 3. The anchor doesn't have the attribute [data-router-slot]="disabled" + const isDisabled = $anchor.dataset["routerSlot"] === "disabled"; + + // - 4. The router can handle the route + const routeMatched = this.routerSlot?.getRouteMatch($anchor.pathname); + + if (!hrefIsRelative || differentFrameTargetted || isDisabled || !routeMatched) { return; } @@ -34,5 +45,5 @@ export function ensureAnchorHistory () { // Change the history! history.pushState(null, "", path); - }); -} \ No newline at end of file + } +} From 9f5b4678c3770df9a3ce62ccedb93471571d9b1a Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Sat, 16 Jan 2021 14:22:20 -0800 Subject: [PATCH 02/10] Cleanup AnchorHandler and add tests --- src/lib/router-slot.ts | 16 ++++---------- src/lib/util/anchor.ts | 27 +++++++++++++++++++---- src/test/anchor.test.ts | 48 +++++++++++++++++++++++++++++++++++------ 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/src/lib/router-slot.ts b/src/lib/router-slot.ts index b551368..a6263b2 100644 --- a/src/lib/router-slot.ts +++ b/src/lib/router-slot.ts @@ -1,6 +1,6 @@ import { GLOBAL_ROUTER_EVENTS_TARGET, ROUTER_SLOT_TAG_NAME } from "./config"; import { Cancel, EventListenerSubscription, GlobalRouterEvent, IPathFragments, IRoute, IRouteMatch, IRouterSlot, IRoutingInfo, Params, PathFragment, RouterSlotEvent } from "./model"; -import { addListener, AnchorHandler, constructAbsolutePath, dispatchGlobalRouterEvent, dispatchRouteChangeEvent, ensureHistoryEvents, handleRedirect, isRedirectRoute, isResolverRoute, matchRoutes, pathWithoutBasePath, queryParentRouterSlot, removeListeners, resolvePageComponent, shouldNavigate } from "./util"; +import { addListener, AnchorHandler, constructAbsolutePath, dispatchGlobalRouterEvent, dispatchRouteChangeEvent, ensureHistoryEvents, handleRedirect, IAnchorHandler, isRedirectRoute, isResolverRoute, matchRoutes, pathWithoutBasePath, queryParentRouterSlot, removeListeners, resolvePageComponent, shouldNavigate } from "./util"; const template = document.createElement("template"); template.innerHTML = ``; @@ -89,7 +89,7 @@ export class RouterSlot extends HTMLElement implements IRouter /** * The anchor link handler for the router slot */ - private anchorHandler?: AnchorHandler; + private anchorHandler?: IAnchorHandler; /** * Hooks up the element. @@ -188,19 +188,11 @@ export class RouterSlot extends HTMLElement implements IRouter */ protected setupAnchorListener(): void { this.anchorHandler = new AnchorHandler(this); - window.addEventListener( - 'click', - this.anchorHandler?.handleEvent.bind(this) - ); + this.anchorHandler?.setup(); } protected detachAnchorListener(): void { - if (this.anchorHandler) { - window.removeEventListener( - 'click', - this.anchorHandler.handleEvent - ); - } + this.anchorHandler?.teardown(); } /** diff --git a/src/lib/util/anchor.ts b/src/lib/util/anchor.ts index 4dbd57a..5032990 100644 --- a/src/lib/util/anchor.ts +++ b/src/lib/util/anchor.ts @@ -1,17 +1,36 @@ -import { IRouterSlot } from "../model"; +import type { IRouterSlot } from "../model"; + +export interface IAnchorHandler { + setup(): void; + teardown(): void; +} /** * The AnchorHandler allows the RouterSlot to observe all anchor clicks * and either handle the click or let the browser handle it. */ -export class AnchorHandler { +export class AnchorHandler implements IAnchorHandler { routerSlot?: IRouterSlot; constructor(routerSlot?: IRouterSlot) { this.routerSlot = routerSlot; } - handleEvent(e: MouseEvent) { + setup(): void { + window.addEventListener( + 'click', + (e) => this.handleEvent(e) + ); + } + + teardown(): void { + window.removeEventListener( + 'click', + (e) => this.handleEvent(e) + ); + } + + private handleEvent(e: MouseEvent) { // Find the target by using the composed path to get the element through the shadow boundaries. const $anchor = ("composedPath" in e as any) ? e.composedPath().find($elem => $elem instanceof HTMLAnchorElement) : e.target; @@ -38,7 +57,7 @@ export class AnchorHandler { } // Remove the origin from the start of the HREF to get the path - const path = $anchor.pathname; + const path = `${$anchor.pathname}${$anchor.search}`; // Prevent the default behavior e.preventDefault(); diff --git a/src/test/anchor.test.ts b/src/test/anchor.test.ts index 5122b96..4a9bb54 100644 --- a/src/test/anchor.test.ts +++ b/src/test/anchor.test.ts @@ -1,31 +1,49 @@ -import { ensureAnchorHistory } from "../lib/util/anchor"; +import { AnchorHandler, RouterSlot } from "../lib"; import { ensureHistoryEvents } from "../lib/util/history"; import { path } from "../lib/util/url"; import { addBaseTag, clearHistory } from "./test-helpers"; const testPath = `/about`; -describe("anchor", () => { +describe("AnchorHandler", () => { const {expect} = chai; let $anchor!: HTMLAnchorElement; + let $slot = new RouterSlot(); + let $anchorHandler = new AnchorHandler($slot); + + const addTestRoute = () => { + $slot.add([ + { + path: testPath, + pathMatch: "suffix", + component: () => document.createElement("div") + } + ]) + } before(() => { ensureHistoryEvents(); - ensureAnchorHistory(); addBaseTag(); + document.body.appendChild($slot); + $anchorHandler.setup(); }); beforeEach(() => { document.body.innerHTML = ` Anchor `; - $anchor = document.body.querySelector("#anchor")!; }); + afterEach(() => { + $slot.clear(); + }); after(() => { clearHistory(); + $anchorHandler.teardown(); }); - it("[ensureAnchorHistory] should change anchors to use history API", done => { + it("[AnchorHandler] should change anchors to use history API", done => { + addTestRoute(); + window.addEventListener("pushstate", () => { expect(path({end: false})).to.equal(testPath); done(); @@ -34,7 +52,9 @@ describe("anchor", () => { $anchor.click(); }); - it("[ensureAnchorHistory] should not change anchors with target _blank", done => { + it("[AnchorHandler] should not change anchors with target _blank", done => { + addTestRoute(); + window.addEventListener("pushstate", () => { expect(true).to.equal(false); }); @@ -44,7 +64,9 @@ describe("anchor", () => { done(); }); - it("[ensureAnchorHistory] should not change anchors with [data-router-slot]='disabled'", done => { + it("[AnchorHandler] should not change anchors with [data-router-slot]='disabled'", done => { + addTestRoute(); + window.addEventListener("pushstate", () => { expect(true).to.equal(false); }); @@ -53,4 +75,16 @@ describe("anchor", () => { $anchor.click(); done(); }); + + it("[AnchorHandler] should not change anchors that are not supported by the router", done => { + // there are no routes added to the $slot in this test + // so the router will not attempt to handle it + + window.addEventListener("pushstate", () => { + expect(true).to.equal(false); + }); + + $anchor.click(); + done(); + }); }); From e9fb5b0dca9a48afc163aa1aca9077868142143d Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Sat, 16 Jan 2021 14:26:00 -0800 Subject: [PATCH 03/10] Handle metaKey case --- src/lib/util/anchor.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/util/anchor.ts b/src/lib/util/anchor.ts index 5032990..e90e113 100644 --- a/src/lib/util/anchor.ts +++ b/src/lib/util/anchor.ts @@ -52,7 +52,17 @@ export class AnchorHandler implements IAnchorHandler { // - 4. The router can handle the route const routeMatched = this.routerSlot?.getRouteMatch($anchor.pathname); - if (!hrefIsRelative || differentFrameTargetted || isDisabled || !routeMatched) { + // - 5. User is not holding down metaKey (Command on Mac, Control on Windows) + // which is typically used to open a new tab. + const userIsHoldingMetaKey = e.metaKey; + + if ( + !hrefIsRelative || + differentFrameTargetted || + isDisabled || + !routeMatched || + userIsHoldingMetaKey + ) { return; } From ce23bcbefbde6e77fac41f01733cabcbdbd1895b Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Sat, 16 Jan 2021 14:31:19 -0800 Subject: [PATCH 04/10] Update comment --- src/lib/util/anchor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/util/anchor.ts b/src/lib/util/anchor.ts index e90e113..b062146 100644 --- a/src/lib/util/anchor.ts +++ b/src/lib/util/anchor.ts @@ -52,7 +52,7 @@ export class AnchorHandler implements IAnchorHandler { // - 4. The router can handle the route const routeMatched = this.routerSlot?.getRouteMatch($anchor.pathname); - // - 5. User is not holding down metaKey (Command on Mac, Control on Windows) + // - 5. User is not holding down the meta key, (Command on Mac, Control on Windows) // which is typically used to open a new tab. const userIsHoldingMetaKey = e.metaKey; From 5477d86079fc0c2781789aceae96f73e34664898 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Sat, 16 Jan 2021 14:49:39 -0800 Subject: [PATCH 05/10] Small reorganize and add comment --- src/lib/router-slot.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/router-slot.ts b/src/lib/router-slot.ts index a6263b2..99aa566 100644 --- a/src/lib/router-slot.ts +++ b/src/lib/router-slot.ts @@ -159,6 +159,15 @@ export class RouterSlot extends HTMLElement implements IRouter this._routes.length = 0; } + /** + * Return a route match for a given path or null if there's no match + * @param path + */ + getRouteMatch(path: string | PathFragment): IRouteMatch | null { + const match = matchRoutes(this._routes, path); + return match; + } + /** * Each time the path changes, load the new path. */ @@ -228,11 +237,6 @@ export class RouterSlot extends HTMLElement implements IRouter removeListeners(this.listeners); } - getRouteMatch(path: string | PathFragment): IRouteMatch | null { - const match = matchRoutes(this._routes, path); - return match; - } - /** * Loads a new path based on the routes. * Returns true if a navigation was made to a new page. From 957008fb6e54f81f82f0d3a1570dbbd8328f5412 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Sat, 16 Jan 2021 15:35:57 -0800 Subject: [PATCH 06/10] Add test for catch-all and fix anchor tests --- src/test/anchor.test.ts | 44 ++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/test/anchor.test.ts b/src/test/anchor.test.ts index 4a9bb54..3619c6f 100644 --- a/src/test/anchor.test.ts +++ b/src/test/anchor.test.ts @@ -10,12 +10,12 @@ describe("AnchorHandler", () => { let $anchor!: HTMLAnchorElement; let $slot = new RouterSlot(); let $anchorHandler = new AnchorHandler($slot); + let $windowPushstateCallbackHandler: () => void; const addTestRoute = () => { $slot.add([ { path: testPath, - pathMatch: "suffix", component: () => document.createElement("div") } ]) @@ -35,6 +35,7 @@ describe("AnchorHandler", () => { }); afterEach(() => { $slot.clear(); + window.removeEventListener('pushstate', $windowPushstateCallbackHandler); }); after(() => { clearHistory(); @@ -44,10 +45,12 @@ describe("AnchorHandler", () => { it("[AnchorHandler] should change anchors to use history API", done => { addTestRoute(); - window.addEventListener("pushstate", () => { + $windowPushstateCallbackHandler = () => { expect(path({end: false})).to.equal(testPath); done(); - }); + }; + + window.addEventListener("pushstate", $windowPushstateCallbackHandler); $anchor.click(); }); @@ -55,9 +58,11 @@ describe("AnchorHandler", () => { it("[AnchorHandler] should not change anchors with target _blank", done => { addTestRoute(); - window.addEventListener("pushstate", () => { + $windowPushstateCallbackHandler = () => { expect(true).to.equal(false); - }); + } + + window.addEventListener("pushstate", $windowPushstateCallbackHandler); $anchor.target = "_blank"; $anchor.click(); @@ -67,9 +72,11 @@ describe("AnchorHandler", () => { it("[AnchorHandler] should not change anchors with [data-router-slot]='disabled'", done => { addTestRoute(); - window.addEventListener("pushstate", () => { + $windowPushstateCallbackHandler = () => { expect(true).to.equal(false); - }); + } + + window.addEventListener("pushstate", $windowPushstateCallbackHandler); $anchor.setAttribute("data-router-slot", "disabled"); $anchor.click(); @@ -80,11 +87,30 @@ describe("AnchorHandler", () => { // there are no routes added to the $slot in this test // so the router will not attempt to handle it - window.addEventListener("pushstate", () => { + $windowPushstateCallbackHandler = () => { expect(true).to.equal(false); - }); + } + + window.addEventListener("pushstate", $windowPushstateCallbackHandler); $anchor.click(); done(); }); + + it("[AnchorHandler] should change anchors if there is a catch-all route", done => { + $slot.add([ + { + path: '**', + component: () => document.createElement("div") + } + ]); + + $windowPushstateCallbackHandler = () => { + expect(path({ end: false })).to.equal(testPath); + done(); + } + window.addEventListener("pushstate", $windowPushstateCallbackHandler); + + $anchor.click(); + }); }); From 578e74899f00cb465f67c448f30f9ed0e6e775a4 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Sat, 16 Jan 2021 15:39:31 -0800 Subject: [PATCH 07/10] Rename var for brevity --- src/test/anchor.test.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/test/anchor.test.ts b/src/test/anchor.test.ts index 3619c6f..2bcdf6e 100644 --- a/src/test/anchor.test.ts +++ b/src/test/anchor.test.ts @@ -10,7 +10,7 @@ describe("AnchorHandler", () => { let $anchor!: HTMLAnchorElement; let $slot = new RouterSlot(); let $anchorHandler = new AnchorHandler($slot); - let $windowPushstateCallbackHandler: () => void; + let $windowPushstateHandler: () => void; const addTestRoute = () => { $slot.add([ @@ -35,7 +35,7 @@ describe("AnchorHandler", () => { }); afterEach(() => { $slot.clear(); - window.removeEventListener('pushstate', $windowPushstateCallbackHandler); + window.removeEventListener('pushstate', $windowPushstateHandler); }); after(() => { clearHistory(); @@ -45,12 +45,12 @@ describe("AnchorHandler", () => { it("[AnchorHandler] should change anchors to use history API", done => { addTestRoute(); - $windowPushstateCallbackHandler = () => { + $windowPushstateHandler = () => { expect(path({end: false})).to.equal(testPath); done(); }; - window.addEventListener("pushstate", $windowPushstateCallbackHandler); + window.addEventListener("pushstate", $windowPushstateHandler); $anchor.click(); }); @@ -58,11 +58,11 @@ describe("AnchorHandler", () => { it("[AnchorHandler] should not change anchors with target _blank", done => { addTestRoute(); - $windowPushstateCallbackHandler = () => { + $windowPushstateHandler = () => { expect(true).to.equal(false); } - window.addEventListener("pushstate", $windowPushstateCallbackHandler); + window.addEventListener("pushstate", $windowPushstateHandler); $anchor.target = "_blank"; $anchor.click(); @@ -72,11 +72,11 @@ describe("AnchorHandler", () => { it("[AnchorHandler] should not change anchors with [data-router-slot]='disabled'", done => { addTestRoute(); - $windowPushstateCallbackHandler = () => { + $windowPushstateHandler = () => { expect(true).to.equal(false); } - window.addEventListener("pushstate", $windowPushstateCallbackHandler); + window.addEventListener("pushstate", $windowPushstateHandler); $anchor.setAttribute("data-router-slot", "disabled"); $anchor.click(); @@ -87,11 +87,11 @@ describe("AnchorHandler", () => { // there are no routes added to the $slot in this test // so the router will not attempt to handle it - $windowPushstateCallbackHandler = () => { + $windowPushstateHandler = () => { expect(true).to.equal(false); } - window.addEventListener("pushstate", $windowPushstateCallbackHandler); + window.addEventListener("pushstate", $windowPushstateHandler); $anchor.click(); done(); @@ -105,11 +105,11 @@ describe("AnchorHandler", () => { } ]); - $windowPushstateCallbackHandler = () => { + $windowPushstateHandler = () => { expect(path({ end: false })).to.equal(testPath); done(); } - window.addEventListener("pushstate", $windowPushstateCallbackHandler); + window.addEventListener("pushstate", $windowPushstateHandler); $anchor.click(); }); From 91b76bbf31707741a399b3ed1cb8ceaf1ec2aef0 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Sat, 16 Jan 2021 15:49:50 -0800 Subject: [PATCH 08/10] Update Readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 873042d..a447d20 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Go [`here`](https://developer.mozilla.org/en-US/docs/Web/API/History) to read mo #### Anchor element -Normally an [`anchor element`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) reloads the page when clicked. This library however changes the default behavior of all anchor element to use the history API instead. +Normally an [`anchor element`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) reloads the page when clicked. This library however changes the default behavior of all anchor element to use the history API if the router supports the route. ```html Go to home! From 6387a451d505ae0126f5ee4a59602196381798f1 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Sat, 16 Jan 2021 19:58:51 -0800 Subject: [PATCH 09/10] Fix the anchor event handler removal and add test We weren't properly binding the anchor click handler so it wasn't being unbound properly --- src/lib/util/anchor.ts | 10 +++++++--- src/test/anchor.test.ts | 41 ++++++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/lib/util/anchor.ts b/src/lib/util/anchor.ts index b062146..2352290 100644 --- a/src/lib/util/anchor.ts +++ b/src/lib/util/anchor.ts @@ -17,20 +17,24 @@ export class AnchorHandler implements IAnchorHandler { } setup(): void { + // store a reference to the bound event handler so we can unbind later + this.boundEventHandler = this.handleEvent.bind(this); window.addEventListener( 'click', - (e) => this.handleEvent(e) + this.boundEventHandler ); } teardown(): void { window.removeEventListener( 'click', - (e) => this.handleEvent(e) + this.boundEventHandler ); } - private handleEvent(e: MouseEvent) { + private boundEventHandler?: any; + + private handleEvent(e: MouseEvent): void { // Find the target by using the composed path to get the element through the shadow boundaries. const $anchor = ("composedPath" in e as any) ? e.composedPath().find($elem => $elem instanceof HTMLAnchorElement) : e.target; diff --git a/src/test/anchor.test.ts b/src/test/anchor.test.ts index 2bcdf6e..91a79e7 100644 --- a/src/test/anchor.test.ts +++ b/src/test/anchor.test.ts @@ -1,4 +1,4 @@ -import { AnchorHandler, RouterSlot } from "../lib"; +import { RouterSlot } from "../lib"; import { ensureHistoryEvents } from "../lib/util/history"; import { path } from "../lib/util/url"; import { addBaseTag, clearHistory } from "./test-helpers"; @@ -9,7 +9,6 @@ describe("AnchorHandler", () => { const {expect} = chai; let $anchor!: HTMLAnchorElement; let $slot = new RouterSlot(); - let $anchorHandler = new AnchorHandler($slot); let $windowPushstateHandler: () => void; const addTestRoute = () => { @@ -24,22 +23,25 @@ describe("AnchorHandler", () => { before(() => { ensureHistoryEvents(); addBaseTag(); + // we instantiate the AnchorHandler when the router-slot is connected document.body.appendChild($slot); - $anchorHandler.setup(); }); beforeEach(() => { - document.body.innerHTML = ` - Anchor - `; + const anchor = document.createElement('a'); + anchor.id = "anchor"; + anchor.href = testPath; + anchor.innerHTML = "Anchor"; + document.body.appendChild(anchor); $anchor = document.body.querySelector("#anchor")!; }); afterEach(() => { + $anchor.remove(); $slot.clear(); window.removeEventListener('pushstate', $windowPushstateHandler); }); after(() => { clearHistory(); - $anchorHandler.teardown(); + $slot.remove(); }); it("[AnchorHandler] should change anchors to use history API", done => { @@ -113,4 +115,29 @@ describe("AnchorHandler", () => { $anchor.click(); }); + + it("[AnchorHandler] removes the listener when `teardown()` called", done => { + // the router should be handling this because we have a catch-all, but + // we're tearing down the anchorHandler before the click so it shouldn't handle + $slot.add([ + { + path: '**', + component: () => document.createElement("div") + } + ]); + + // remove the slot, which should tear down the handler + $slot.remove(); + + $windowPushstateHandler = () => { + // should never reach here + expect(true).to.equal(false); + } + window.addEventListener("pushstate", $windowPushstateHandler); + + $anchor.click(); + done(); + // set back up for future tests + document.body.appendChild($slot); + }); }); From b8acb91cca1c9847b000f396a45c9ebc74f2a606 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Sun, 17 Jan 2021 10:41:53 -0800 Subject: [PATCH 10/10] Only bind the AnchorHandler in the root router-slot Previously if there were multiple router-slots on-screen, each one would have its own AnchorHandler responding to itself. We only want one at the root. --- src/lib/router-slot.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/router-slot.ts b/src/lib/router-slot.ts index 99aa566..c292b88 100644 --- a/src/lib/router-slot.ts +++ b/src/lib/router-slot.ts @@ -196,6 +196,10 @@ export class RouterSlot extends HTMLElement implements IRouter * Attach the anchor listener */ protected setupAnchorListener(): void { + // only bind the AnchorHandler to the root router + // otherwise, we get multiple click handlers, + // each responding to a different router + if (!this.isRoot) return; this.anchorHandler = new AnchorHandler(this); this.anchorHandler?.setup(); }