Repository: single-spa/single-spa-layout (js)

Issue:
Support route parameters
```js
import { constructRoutes } from 'single-spa-layout';

const routes = constructRoutes({
  routes: [
    { type: 'route', path: "/user/:id/settings", routes: [] },
  ]
})
```

Pull request description:
Support for dynamic route parameters. Resolves #9.
See #9. This PR is sorta big and complicated - the main change is that we're no longer doing `path.startsWith` and are instead doing `activeWhen(window.location)`

Note that this does not fully support the following scenario:

```js
const routes = constructRoutes({
  routes: [
    { type: 'route', path: "/user/new", routes: [] },
    { type: 'route', path: "/user/:id", routes: [] },
  ]
})
```

In this case, the desired behavior is likely that for route `/user/new` that only the first route would match instead of both. However, the behavior in this PR is that both routes would match.

Patch:
diff --git a/src/constructApplications.js b/src/constructApplications.js
index 95482f1..aeb8372 100644
--- a/src/constructApplications.js
+++ b/src/constructApplications.js
@@ -1,5 +1,3 @@
-import { resolvePath } from "./matchRoute";
-
 /**
  * @typedef {{
  * routes: import('./constructRoutes').ResolvedRoutesConfig;
@@ -12,12 +10,23 @@ import { resolvePath } from "./matchRoute";
  * app: (config: import('single-spa').AppProps) => Promise<import('single-spa').LifeCycles>
  * }} WithLoadFunction
  *
+ * @typedef {{
+ * [name]: Array<AppRoute>
+ * }} ApplicationMap
+ *
+ * @typedef {{
+ * props: object;
+ * activeWhen: import('single-spa').ActivityFn;
+ * }} AppRoute
+ *
  * @param {ApplicationOptions} applicationOptions
  * @returns {Array<import('single-spa').RegisterApplicationConfig & WithLoadFunction>}
  */
 export function constructApplications({ routes, loadApp }) {
+  /** @type {ApplicationMap} */
   const applicationMap = {};
-  recurseRoutes(applicationMap, routes.base, {}, routes.routes);
+
+  recurseRoutes(applicationMap, topLevelActiveWhen, {}, routes.routes);
 
   /**
    * @type {Array<{
@@ -27,18 +36,17 @@ export function constructApplications({ routes, loadApp }) {
    * }>}
    */
   const partialApplications = Object.keys(applicationMap).map((name) => {
-    const resolvedRoutes = applicationMap[name];
+    /** @type {AppRoute} */
+    const appRoutes = applicationMap[name];
     return {
       name,
       customProps: (_name, location) => {
-        const route = resolvedRoutes.find((route) =>
-          location[routes.mode === "hash" ? "hash" : "pathname"].startsWith(
-            route.path
-          )
+        const appRoute = appRoutes.find((appRoute) =>
+          appRoute.activeWhen(location)
         );
-        return route ? route.props : {};
+        return appRoute ? appRoute.props : {};
       },
-      activeWhen: resolvedRoutes.map((r) => r.path),
+      activeWhen: appRoutes.map((appRoute) => appRoute.activeWhen),
     };
   });
 
@@ -49,22 +57,14 @@ export function constructApplications({ routes, loadApp }) {
 }
 
 /**
- * @typedef {{
- * path: string;
- * props: object;
- * }} ResolvedRoute
- *
- * @typedef {{
- * [name]: Array<ResolvedRoute>
- * }} ApplicationMap
  *
  * @param {ApplicationMap} applicationMap
- * @param {string} path
+ * @param {import('single-spa').ActivityFn} activeWhen
  * @param {object} props
  * @param {Array<import('./constructRoutes').Route>} routes
  * @returns void
  */
-function recurseRoutes(applicationMap, path, props, routes) {
+function recurseRoutes(applicationMap, activeWhen, props, routes) {
   routes.forEach((route) => {
     if (route.type === "application") {
       if (!applicationMap[route.name]) {
@@ -72,19 +72,18 @@ function recurseRoutes(applicationMap, path, props, routes) {
       }
 
       applicationMap[route.name].push({
-        path,
+        activeWhen,
         props: mergeProps(props, route.props),
       });
     } else if (route.type === "route") {
-      const resolvedPath = resolvePath(path, route.path);
       recurseRoutes(
         applicationMap,
-        resolvedPath,
+        route.activeWhen,
         mergeProps(props, route.props),
         route.routes
       );
     } else if (route.routes) {
-      recurseRoutes(applicationMap, path, props, route.routes);
+      recurseRoutes(applicationMap, activeWhen, props, route.routes);
     }
   });
 }
@@ -92,3 +91,8 @@ function recurseRoutes(applicationMap, path, props, routes) {
 function mergeProps(originalProps, newProps = {}) {
   return { ...originalProps, ...newProps };
 }
+
+function topLevelActiveWhen() {
+  // All applications not under routes are active
+  return true;
+}
diff --git a/src/constructLayoutEngine.js b/src/constructLayoutEngine.js
index d6003b7..d8a5edf 100644
--- a/src/constructLayoutEngine.js
+++ b/src/constructLayoutEngine.js
@@ -73,7 +73,7 @@ export function constructLayoutEngine({
   function arrangeDomElements() {
     const path = location[resolvedRoutes.mode === "hash" ? "hash" : "pathname"];
 
-    if (!path.startsWith(baseWithoutSlash)) {
+    if (path.indexOf(baseWithoutSlash) !== 0) {
       // Base URL doesn't match, no need to recurse routes
       return;
     }
@@ -84,8 +84,7 @@ export function constructLayoutEngine({
         : resolvedRoutes.containerEl;
 
     recurseRoutes({
-      path,
-      pathMatch: baseWithoutSlash,
+      location: window.location,
       routes: resolvedRoutes.routes,
       parentContainer,
       shouldMount: true,
@@ -112,8 +111,7 @@ export function constructLayoutEngine({
 
 /**
  * @typedef {{
- * path: string,
- * pathMatch: string,
+ * location: URL,
  * routes: Array<import('./constructRoutes').Route>,
  * parentContainer: HTMLElement,
  * previousSibling?: HTMLElement,
@@ -128,8 +126,7 @@ export function constructLayoutEngine({
  * @returns {HTMLElement}
  */
 function recurseRoutes({
-  path,
-  pathMatch,
+  location,
   routes,
   parentContainer,
   previousSibling,
@@ -150,14 +147,12 @@ function recurseRoutes({
         previousSibling = applicationElement;
       }
     } else if (route.type === "route") {
-      const childPathMatch = resolvePath(pathMatch, route.path);
       previousSibling = recurseRoutes({
-        path,
-        pathMatch: childPathMatch,
+        location,
         routes: route.routes,
         parentContainer,
         previousSibling,
-        shouldMount: shouldMount && path.startsWith(childPathMatch),
+        shouldMount: shouldMount && route.activeWhen(location),
         pendingRemovals,
       });
     } else if (route instanceof Node) {
@@ -170,8 +165,7 @@ function recurseRoutes({
 
         if (route.routes) {
           recurseRoutes({
-            path,
-            pathMatch,
+            location,
             routes: route.routes,
             parentContainer: route.connectedNode,
             previousSibling: null,
diff --git a/src/constructRoutes.js b/src/constructRoutes.js
index f2191ab..9668719 100644
--- a/src/constructRoutes.js
+++ b/src/constructRoutes.js
@@ -7,6 +7,8 @@ import {
   validateContainerEl,
 } from "./validation-helpers.js";
 import { inBrowser } from "./environment-helpers.js";
+import { pathToActiveWhen } from "single-spa";
+import { resolvePath } from "./matchRoute.js";
 
 /**
  * @typedef {InputRoutesConfigObject | HTMLElement | import('parse5').DefaultTreeDocument} RoutesConfig
@@ -23,15 +25,12 @@ import { inBrowser } from "./environment-helpers.js";
  * mode: string;
  * base: string;
  * containerEl: ContainerEl;
- * routes: Array<RouteChild>;
+ * routes: Array<ResolvedRouteChild>;
  * }} ResolvedRoutesConfig
  *
- * @typedef {UrlRoute | Application | Node | HTMLElementDescription} RouteChild
+ * @typedef {UrlRoute | Application | Node} RouteChild
  *
- * @typedef {{
- * type: string;
- * [attributeName: string]: string;
- * }} HTMLElementDescription
+ * @typedef {ResolvedUrlRoute | Application | Node} ResolvedRouteChild
  *
  * @typedef {string | HTMLElement | import('parse5').Element} ContainerEl
  *
@@ -39,6 +38,13 @@ import { inBrowser } from "./environment-helpers.js";
  * type: string;
  * path: string;
  * routes: Array<Route>;
+ * activeWhen: import('single-spa').ActivityFn;
+ * }} ResolvedUrlRoute
+ *
+ * @typedef {{
+ * type: string;
+ * path: string;
+ * routes: Array<Route>;
  * }} UrlRoute
  *
  * @typedef {{
@@ -215,24 +221,47 @@ function validateAndSanitize(routesConfig) {
     routesConfig.base = "/";
   }
 
-  validateArray("routesConfig.routes", routesConfig.routes, validateRoute);
+  const pathname = inBrowser ? window.location.pathname : "/";
+  const hashPrefix = routesConfig.mode === "hash" ? pathname + "#" : "";
 
-  function validateRoute(route, propertyName) {
+  validateArray(
+    "routesConfig.routes",
+    routesConfig.routes,
+    validateRoute,
+    hashPrefix + routesConfig.base
+  );
+
+  function validateRoute(route, propertyName, parentPath) {
     validateObject(propertyName, route);
 
     if (route.type === "application") {
-      validateKeys(propertyName, route, ["type", "name"], disableWarnings);
+      validateKeys(
+        propertyName,
+        route,
+        ["type", "name", "props"],
+        disableWarnings
+      );
+      if (route.props) {
+        validateObject(`${propertyName}.props`, route.props);
+      }
       validateString(`${propertyName}.name`, route.name);
     } else if (route.type === "route") {
       validateKeys(
         propertyName,
         route,
-        ["type", "path", "routes"],
+        ["type", "path", "routes", "props"],
         disableWarnings
       );
       validateString(`${propertyName}.path`, route.path);
+      const fullPath = resolvePath(parentPath, route.path);
+      route.activeWhen = pathToActiveWhen(fullPath);
       if (route.routes)
-        validateArray(`${propertyName}.routes`, route.routes, validateRoute);
+        validateArray(
+          `${propertyName}.routes`,
+          route.routes,
+          validateRoute,
+          fullPath
+        );
     } else {
       if (typeof Node !== "undefined" && route instanceof Node) {
         // HTMLElements are allowed
@@ -244,7 +273,12 @@ function validateAndSanitize(routesConfig) {
         }
       }
       if (route.routes)
-        validateArray(`${propertyName}.routes`, route.routes, validateRoute);
+        validateArray(
+          `${propertyName}.routes`,
+          route.routes,
+          validateRoute,
+          parentPath
+        );
     }
   }
 
@@ -252,11 +286,11 @@ function validateAndSanitize(routesConfig) {
 }
 
 function sanitizeBase(base) {
-  if (!base.startsWith("/")) {
+  if (base.indexOf("/") !== 0) {
     base = "/" + base;
   }
 
-  if (!base.endsWith("/")) {
+  if (base[base.length - 1] !== "/") {
     base = base + "/";
   }
 
diff --git a/src/matchRoute.js b/src/matchRoute.js
index ac80f76..8cdfe12 100644
--- a/src/matchRoute.js
+++ b/src/matchRoute.js
@@ -1,3 +1,5 @@
+import { inBrowser } from "./environment-helpers";
+
 /**
  *
  * @param {import('./constructRoutes').ResolvedRoutesConfig} resolvedRoutesConfig
@@ -12,12 +14,11 @@ export function matchRoute(resolvedRoutesConfig, pathMatch) {
     resolvedRoutesConfig.base.length - 1
   );
 
-  if (pathMatch.startsWith(baseWithoutSlash)) {
-    result.routes = recurseRoutes(
-      pathMatch,
-      resolvedRoutesConfig.base,
-      resolvedRoutesConfig.routes
-    );
+  if (pathMatch.indexOf(baseWithoutSlash) === 0) {
+    const origin = inBrowser ? window.location.origin : "http://localhost";
+    const location = new URL(resolvePath(origin, pathMatch));
+
+    result.routes = recurseRoutes(location, resolvedRoutesConfig.routes);
   } else {
     result.routes = [];
   }
@@ -27,25 +28,27 @@ export function matchRoute(resolvedRoutesConfig, pathMatch) {
 
 /**
  *
- * @param {string} pathMatch
- * @param {string} startPath
- * @param {Array<import('./constructRoutes').Route>} routes
+ * @param {URL} location
+ * @param {Array<import('./constructRoutes').ResolvedRouteChild>} routes
  */
-function recurseRoutes(pathMatch, startPath, routes) {
+function recurseRoutes(location, routes) {
   const result = [];
 
   routes.forEach((route) => {
     if (route.type === "application") {
       result.push(route);
-    } else {
-      const resolvedPath = resolvePath(startPath, route.path);
-
-      if (pathMatch.startsWith(resolvedPath)) {
+    } else if (route.type === "route") {
+      if (route.activeWhen(location)) {
         result.push({
           ...route,
-          routes: recurseRoutes(pathMatch, resolvedPath, route.routes),
+          routes: recurseRoutes(location, route.routes),
         });
       }
+    } else if (Array.isArray(route.routes)) {
+      result.push({
+        ...route,
+        routes: recurseRoutes(location, route.routes),
+      });
     }
   });
 
@@ -53,14 +56,14 @@ function recurseRoutes(pathMatch, startPath, routes) {
 }
 
 export function resolvePath(prefix, path) {
-  if (prefix.endsWith("/")) {
-    if (path.startsWith("/")) {
+  if (prefix.substr(-1) === "/") {
+    if (path[0] === "/") {
       return prefix + path.slice(1);
     } else {
       return prefix + path;
     }
   } else {
-    if (path.startsWith("/")) {
+    if (path[0] === "/") {
       return prefix + path;
     } else {
       return prefix + "/" + path;
diff --git a/src/validation-helpers.js b/src/validation-helpers.js
index 49a9fbe..abbe861 100644
--- a/src/validation-helpers.js
+++ b/src/validation-helpers.js
@@ -52,7 +52,7 @@ export function validateString(propertyName, val) {
   }
 }
 
-export function validateArray(propertyName, arr, cbk) {
+export function validateArray(propertyName, arr, cbk, ...extraArgs) {
   if (
     !Array.isArray(arr) &&
     (typeof typeof arr !== "object" || arr.length !== "number")
@@ -63,7 +63,7 @@ export function validateArray(propertyName, arr, cbk) {
   }
 
   for (let i = 0; i < arr.length; i++) {
-    cbk(arr[i], `${propertyName}[${i}]`);
+    cbk(arr[i], `${propertyName}[${i}]`, ...extraArgs);
   }
 }
 


Test patch:
diff --git a/test/browser-only/constructLayoutEngine-browser.test.js b/test/browser-only/constructLayoutEngine-browser.test.js
index f37b4f2..e7e47e0 100644
--- a/test/browser-only/constructLayoutEngine-browser.test.js
+++ b/test/browser-only/constructLayoutEngine-browser.test.js
@@ -20,12 +20,12 @@ describe(`constructLayoutEngine browser`, () => {
 
   it(`starts out activated`, () => {
     /** @type {import('../../src/constructRoutes').ResolvedRoutesConfig} */
-    const routes = {
+    const routes = constructRoutes({
       containerEl: "body",
       base: "/",
       mode: "history",
       routes: [{ type: "application", name: "@org-name/header" }],
-    };
+    });
 
     layoutEngine = constructLayoutEngine({
       routes,
@@ -36,12 +36,12 @@ describe(`constructLayoutEngine browser`, () => {
 
   it(`can handle any calls to activate() / deactivate()`, () => {
     /** @type {import('../../src/constructRoutes').ResolvedRoutesConfig} */
-    const routes = {
+    const routes = constructRoutes({
       containerEl: "body",
       base: "/",
       mode: "history",
       routes: [{ type: "application", name: "@org-name/header" }],
-    };
+    });
 
     layoutEngine = constructLayoutEngine({
       routes,
@@ -63,7 +63,7 @@ describe(`constructLayoutEngine browser`, () => {
 
   it(`can successfully construct a layout engine and respond to routing events`, () => {
     /** @type {import('../../src/constructRoutes').ResolvedRoutesConfig} */
-    const routes = {
+    const routes = constructRoutes({
       containerEl: "body",
       base: "/",
       mode: "history",
@@ -76,7 +76,7 @@ describe(`constructLayoutEngine browser`, () => {
         },
         { type: "application", name: "@org-name/footer" },
       ],
-    };
+    });
 
     layoutEngine = constructLayoutEngine({
       routes,
@@ -181,7 +181,7 @@ describe(`constructLayoutEngine browser`, () => {
     let headerEl, footerEl, app1El, app2El;
 
     /** @type {import('../../src/constructRoutes').ResolvedRoutesConfig} */
-    const routes = {
+    const routes = constructRoutes({
       containerEl: "body",
       base: "/",
       mode: "history",
@@ -205,7 +205,7 @@ describe(`constructLayoutEngine browser`, () => {
         },
         { type: "application", name: "@org-name/footer" },
       ],
-    };
+    });
 
     layoutEngine = constructLayoutEngine({
       routes,
diff --git a/test/constructApplications.test.js b/test/constructApplications.test.js
index c995c0c..93824f8 100644
--- a/test/constructApplications.test.js
+++ b/test/constructApplications.test.js
@@ -6,7 +6,7 @@ import { parseFixture } from "./html-utils.js";
 
 describe(`constructApplications`, () => {
   it(`can handle a medium complexity case`, () => {
-    const routes = {
+    const routes = constructRoutes({
       mode: "history",
       base: "/",
       containerEl: "body",
@@ -42,39 +42,72 @@ describe(`constructApplications`, () => {
         },
         { type: "application", name: "footer" },
       ],
-    };
+    });
 
     const loadApp = jest.fn();
 
     const applications = constructApplications({ routes, loadApp });
 
-    const activeWhens = applications.map((app) => ({
-      name: app.name,
-      activeWhen: app.activeWhen,
-    }));
-
-    expect(activeWhens).toEqual([
-      {
-        name: "nav",
-        activeWhen: ["/"],
-      },
-      {
-        name: "app1",
-        activeWhen: ["/app1"],
-      },
-      {
-        name: "subroute",
-        activeWhen: ["/app1/subroute", "/app2/subroute"],
-      },
-      {
-        name: "app2",
-        activeWhen: ["/app2"],
-      },
-      {
-        name: "footer",
-        activeWhen: ["/"],
-      },
-    ]);
+    expect(applications.length).toBe(5);
+    expect(applications[0].name).toBe("nav");
+    expect(
+      applications[0].activeWhen.some((fn) => fn(new URL("http://localhost")))
+    ).toBe(true);
+    expect(
+      applications[0].activeWhen.some((fn) =>
+        fn(new URL("http://localhost/app1"))
+      )
+    ).toBe(true);
+
+    expect(applications[1].name).toBe("app1");
+    expect(
+      applications[1].activeWhen.some((fn) => fn(new URL("http://localhost")))
+    ).toBe(false);
+    expect(
+      applications[1].activeWhen.some((fn) =>
+        fn(new URL("http://localhost/app1"))
+      )
+    ).toBe(true);
+
+    expect(applications[2].name).toBe("subroute");
+    expect(
+      applications[2].activeWhen.some((fn) => fn(new URL("http://localhost")))
+    ).toBe(false);
+    expect(
+      applications[2].activeWhen.some((fn) =>
+        fn(new URL("http://localhost/app1"))
+      )
+    ).toBe(false);
+    expect(
+      applications[2].activeWhen.some((fn) =>
+        fn(new URL("http://localhost/app1/subroute"))
+      )
+    ).toBe(true);
+    expect(
+      applications[2].activeWhen.some((fn) =>
+        fn(new URL("http://localhost/app2/subroute"))
+      )
+    ).toBe(true);
+
+    expect(applications[3].name).toBe("app2");
+    expect(
+      applications[3].activeWhen.some((fn) => fn(new URL("http://localhost")))
+    ).toBe(false);
+    expect(
+      applications[3].activeWhen.some((fn) =>
+        fn(new URL("http://localhost/app2"))
+      )
+    ).toBe(true);
+
+    expect(applications[4].name).toBe("footer");
+    expect(
+      applications[4].activeWhen.some((fn) => fn(new URL("http://localhost")))
+    ).toBe(true);
+    expect(
+      applications[4].activeWhen.some((fn) =>
+        fn(new URL("http://localhost/app2"))
+      )
+    ).toBe(true);
 
     expect(
       applications
@@ -147,8 +180,23 @@ describe(`constructApplications`, () => {
     const applications = constructApplications({ routes, loadApp });
     expect(applications.length).toBe(2);
     expect(applications[0].name).toBe("header");
-    expect(applications[0].activeWhen).toEqual(["/"]);
+    expect(
+      applications[0].activeWhen.some((fn) => fn(new URL("http://localhost/")))
+    ).toBe(true);
+    expect(
+      applications[0].activeWhen.some((fn) =>
+        fn(new URL("http://localhost/app1"))
+      )
+    ).toBe(true);
+
     expect(applications[1].name).toBe("app1");
-    expect(applications[1].activeWhen).toEqual(["/app1"]);
+    expect(
+      applications[1].activeWhen.some((fn) => fn(new URL("http://localhost/")))
+    ).toBe(false);
+    expect(
+      applications[1].activeWhen.some((fn) =>
+        fn(new URL("http://localhost/app1"))
+      )
+    ).toBe(true);
   });
 });
diff --git a/test/constructRoutes.test.js b/test/constructRoutes.test.js
index a679fbe..9138abd 100644
--- a/test/constructRoutes.test.js
+++ b/test/constructRoutes.test.js
@@ -184,7 +184,7 @@ describe("constructRoutes", () => {
       });
       expect(console.warn).toHaveBeenCalled();
       expect(console.warn.mock.calls[0][0].message).toEqual(
-        `Invalid routesConfig.routes[0]: received invalid properties 'somethingElse', but valid properties are type, path, routes`
+        `Invalid routesConfig.routes[0]: received invalid properties 'somethingElse', but valid properties are type, path, routes, props`
       );
     });
 
@@ -212,7 +212,7 @@ describe("constructRoutes", () => {
       });
       expect(console.warn).toHaveBeenCalled();
       expect(console.warn.mock.calls[0][0].message).toEqual(
-        `Invalid routesConfig.routes[0]: received invalid properties 'somethingElse', but valid properties are type, name`
+        `Invalid routesConfig.routes[0]: received invalid properties 'somethingElse', but valid properties are type, name, props`
       );
 
       console.warn.mockReset();
@@ -228,7 +228,7 @@ describe("constructRoutes", () => {
       });
       expect(console.warn).toHaveBeenCalled();
       expect(console.warn.mock.calls[0][0].message).toEqual(
-        `Invalid routesConfig.routes[0]: received invalid properties 'routes', but valid properties are type, name`
+        `Invalid routesConfig.routes[0]: received invalid properties 'routes', but valid properties are type, name, props`
       );
     });
 
@@ -268,7 +268,7 @@ describe("constructRoutes", () => {
       });
       expect(console.warn).toHaveBeenCalled();
       expect(console.warn.mock.calls[0][0].message).toEqual(
-        `Invalid routesConfig.routes[0].routes[0]: received invalid properties 'somethingElse', but valid properties are type, name`
+        `Invalid routesConfig.routes[0].routes[0]: received invalid properties 'somethingElse', but valid properties are type, name, props`
       );
     });
 
@@ -308,7 +308,7 @@ describe("constructRoutes", () => {
       });
       expect(console.warn).toHaveBeenCalled();
       expect(console.warn.mock.calls[0][0].message).toEqual(
-        "Invalid routesConfig.routes[1]: received invalid properties 'irrelevantProperty', but valid properties are type, path, routes"
+        "Invalid routesConfig.routes[1]: received invalid properties 'irrelevantProperty', but valid properties are type, path, routes, props"
       );
     });
 
@@ -403,5 +403,172 @@ describe("constructRoutes", () => {
         routes: [{ type: "application", name: "nav" }],
       });
     });
+
+    it(`constructs an activeWhen for all routes`, () => {
+      const resolvedRoutes = constructRoutes({
+        routes: [
+          {
+            type: "route",
+            path: "/settings",
+            routes: [{ type: "route", path: "users", routes: [] }],
+          },
+          { type: "route", path: "/clients", routes: [] },
+        ],
+      });
+
+      const settingsActiveWhen = resolvedRoutes.routes[0].activeWhen;
+      const usersActiveWhen = resolvedRoutes.routes[0].routes[0].activeWhen;
+      const clientsActiveWhen = resolvedRoutes.routes[1].activeWhen;
+
+      expect(settingsActiveWhen).toBeDefined();
+      expect(usersActiveWhen).toBeDefined();
+      expect(clientsActiveWhen).toBeDefined();
+
+      expect(settingsActiveWhen(new URL("http://localhost/"))).toBe(false);
+      expect(settingsActiveWhen(new URL("http://localhost/clients"))).toBe(
+        false
+      );
+      expect(settingsActiveWhen(new URL("http://localhost/other"))).toBe(false);
+      expect(settingsActiveWhen(new URL("http://localhost/settings"))).toBe(
+        true
+      );
+      expect(settingsActiveWhen(new URL("http://localhost/settings/"))).toBe(
+        true
+      );
+      expect(
+        settingsActiveWhen(new URL("http://localhost/settings/users"))
+      ).toBe(true);
+      expect(
+        settingsActiveWhen(new URL("http://localhost/settings/other"))
+      ).toBe(true);
+
+      expect(usersActiveWhen(new URL("http://localhost/"))).toBe(false);
+      expect(usersActiveWhen(new URL("http://localhost/clients"))).toBe(false);
+      expect(usersActiveWhen(new URL("http://localhost/other"))).toBe(false);
+      expect(usersActiveWhen(new URL("http://localhost/settings"))).toBe(false);
+      expect(usersActiveWhen(new URL("http://localhost/settings/"))).toBe(
+        false
+      );
+      expect(usersActiveWhen(new URL("http://localhost/settings/users"))).toBe(
+        true
+      );
+      expect(
+        usersActiveWhen(new URL("http://localhost/settings/users/1"))
+      ).toBe(true);
+      expect(usersActiveWhen(new URL("http://localhost/settings/other"))).toBe(
+        false
+      );
+
+      expect(clientsActiveWhen(new URL("http://localhost/"))).toBe(false);
+      expect(clientsActiveWhen(new URL("http://localhost/clients"))).toBe(true);
+      expect(clientsActiveWhen(new URL("http://localhost/other"))).toBe(false);
+      expect(clientsActiveWhen(new URL("http://localhost/settings"))).toBe(
+        false
+      );
+      expect(clientsActiveWhen(new URL("http://localhost/settings/"))).toBe(
+        false
+      );
+      expect(
+        clientsActiveWhen(new URL("http://localhost/settings/users"))
+      ).toBe(false);
+      expect(
+        clientsActiveWhen(new URL("http://localhost/settings/other"))
+      ).toBe(false);
+    });
+
+    it(`constructs an activeWhen that works with hash routing`, () => {
+      const resolvedRoutes = constructRoutes({
+        mode: "hash",
+        routes: [
+          {
+            type: "route",
+            path: "settings",
+            routes: [{ type: "route", path: "users", routes: [] }],
+          },
+          { type: "route", path: "clients", routes: [] },
+        ],
+      });
+
+      const settingsActiveWhen = resolvedRoutes.routes[0].activeWhen;
+      const usersActiveWhen = resolvedRoutes.routes[0].routes[0].activeWhen;
+      const clientsActiveWhen = resolvedRoutes.routes[1].activeWhen;
+
+      expect(settingsActiveWhen).toBeDefined();
+      expect(usersActiveWhen).toBeDefined();
+      expect(clientsActiveWhen).toBeDefined();
+
+      expect(settingsActiveWhen(new URL("http://localhost#/"))).toBe(false);
+      expect(settingsActiveWhen(new URL("http://localhost#/clients"))).toBe(
+        false
+      );
+      expect(settingsActiveWhen(new URL("http://localhost#/other"))).toBe(
+        false
+      );
+      expect(settingsActiveWhen(new URL("http://localhost#/settings"))).toBe(
+        true
+      );
+      expect(settingsActiveWhen(new URL("http://localhost#/settings/"))).toBe(
+        true
+      );
+      expect(
+        settingsActiveWhen(new URL("http://localhost#/settings/users"))
+      ).toBe(true);
+      expect(
+        settingsActiveWhen(new URL("http://localhost#/settings/other"))
+      ).toBe(true);
+
+      expect(usersActiveWhen(new URL("http://localhost#/"))).toBe(false);
+      expect(usersActiveWhen(new URL("http://localhost#/clients"))).toBe(false);
+      expect(usersActiveWhen(new URL("http://localhost#/other"))).toBe(false);
+      expect(usersActiveWhen(new URL("http://localhost#/settings"))).toBe(
+        false
+      );
+      expect(usersActiveWhen(new URL("http://localhost#/settings/"))).toBe(
+        false
+      );
+      expect(usersActiveWhen(new URL("http://localhost#/settings/users"))).toBe(
+        true
+      );
+      expect(
+        usersActiveWhen(new URL("http://localhost#/settings/users/1"))
+      ).toBe(true);
+      expect(usersActiveWhen(new URL("http://localhost#/settings/other"))).toBe(
+        false
+      );
+
+      expect(clientsActiveWhen(new URL("http://localhost#/"))).toBe(false);
+      expect(clientsActiveWhen(new URL("http://localhost#/clients"))).toBe(
+        true
+      );
+      expect(clientsActiveWhen(new URL("http://localhost#/other"))).toBe(false);
+      expect(clientsActiveWhen(new URL("http://localhost#/settings"))).toBe(
+        false
+      );
+      expect(clientsActiveWhen(new URL("http://localhost#/settings/"))).toBe(
+        false
+      );
+      expect(
+        clientsActiveWhen(new URL("http://localhost#/settings/users"))
+      ).toBe(false);
+      expect(
+        clientsActiveWhen(new URL("http://localhost#/settings/other"))
+      ).toBe(false);
+    });
+
+    it(`constructs routes that allow for dynamic paths in the URLs`, () => {
+      const resolvedRoutes = constructRoutes({
+        routes: [{ type: "route", path: "/users/:id/permissions", routes: [] }],
+      });
+
+      const activeWhen = resolvedRoutes.routes[0].activeWhen;
+      expect(activeWhen(new URL("http://localhost/"))).toBe(false);
+      expect(activeWhen(new URL("http://localhost/users/1/permissions"))).toBe(
+        true
+      );
+      expect(
+        activeWhen(new URL("http://localhost/users/asdf/permissions"))
+      ).toBe(true);
+      expect(activeWhen(new URL("http://localhost/users/new"))).toBe(false);
+    });
   });
 });
diff --git a/test/matchRoute.test.js b/test/matchRoute.test.js
index 8bf86ed..d1740f6 100644
--- a/test/matchRoute.test.js
+++ b/test/matchRoute.test.js
@@ -1,10 +1,10 @@
-import { matchRoute } from "../src/single-spa-layout.js";
+import { matchRoute, constructRoutes } from "../src/single-spa-layout.js";
 
 describe(`matchRoute`, () => {
   let routesConfig;
 
   beforeEach(() => {
-    routesConfig = {
+    routesConfig = constructRoutes({
       mode: "history",
       base: "/",
       containerEl: "body",
@@ -34,9 +34,21 @@ describe(`matchRoute`, () => {
             },
           ],
         },
+        {
+          type: "route",
+          path: "users/:id",
+          routes: [
+            { type: "application", name: "user-home" },
+            {
+              type: "route",
+              path: "settings",
+              routes: [{ type: "application", name: "user-settings" }],
+            },
+          ],
+        },
         { type: "application", name: "footer" },
       ],
-    };
+    });
   });
 
   it(`returns a filtered routes array`, () => {
@@ -50,7 +62,7 @@ describe(`matchRoute`, () => {
   });
 
   it(`matches nested routes`, () => {
-    expect(matchRoute(routesConfig, "/app1")).toEqual({
+    expect(matchRoute(routesConfig, "/app1")).toMatchObject({
       ...routesConfig,
       routes: [
         { type: "application", name: "nav" },
@@ -63,7 +75,7 @@ describe(`matchRoute`, () => {
       ],
     });
 
-    expect(matchRoute(routesConfig, "/app2")).toEqual({
+    expect(matchRoute(routesConfig, "/app2")).toMatchObject({
       ...routesConfig,
       routes: [
         { type: "application", name: "nav" },
@@ -78,7 +90,7 @@ describe(`matchRoute`, () => {
   });
 
   it(`matches deeply nested routes`, () => {
-    expect(matchRoute(routesConfig, "/app1/subroute")).toEqual({
+    expect(matchRoute(routesConfig, "/app1/subroute")).toMatchObject({
       ...routesConfig,
       routes: [
         { type: "application", name: "nav" },
@@ -123,4 +135,39 @@ describe(`matchRoute`, () => {
       ],
     });
   });
+
+  it(`matches dynamic paths`, () => {
+    expect(matchRoute(routesConfig, "users/123")).toMatchObject({
+      ...routesConfig,
+      routes: [
+        { type: "application", name: "nav" },
+        {
+          type: "route",
+          path: "users/:id",
+          routes: [{ type: "application", name: "user-home" }],
+        },
+        { type: "application", name: "footer" },
+      ],
+    });
+
+    expect(matchRoute(routesConfig, "users/123/settings")).toMatchObject({
+      ...routesConfig,
+      routes: [
+        { type: "application", name: "nav" },
+        {
+          type: "route",
+          path: "users/:id",
+          routes: [
+            { type: "application", name: "user-home" },
+            {
+              type: "route",
+              path: "settings",
+              routes: [{ type: "application", name: "user-settings" }],
+            },
+          ],
+        },
+        { type: "application", name: "footer" },
+      ],
+    });
+  });
 });
diff --git a/test/single-spa-layout.test-d.ts b/test/single-spa-layout.test-d.ts
index 90d567c..ccf5ccf 100644
--- a/test/single-spa-layout.test-d.ts
+++ b/test/single-spa-layout.test-d.ts
@@ -14,6 +14,7 @@ import {
   constructLayoutEngine,
 } from "../src/single-spa-layout";
 import { parse, Element, DefaultTreeDocument } from "parse5";
+import { ResolvedUrlRoute } from "../src/constructRoutes";
 
 // test constructRoutes
 expectError(constructRoutes());
@@ -32,6 +33,8 @@ const routes = constructRoutes({
   ],
 });
 
+(routes.routes[0] as ResolvedUrlRoute).activeWhen(window.location);
+
 const parse5Doc = parse(
   `<single-spa-router></single-spa-router>`
 ) as DefaultTreeDocument;
@@ -45,7 +48,7 @@ expectType<import("../src/constructRoutes").ContainerEl>(
   matchedRoutes.containerEl
 );
 expectType<string>(matchedRoutes.mode);
-expectType<Array<import("../src/constructRoutes").RouteChild>>(
+expectType<Array<import("../src/constructRoutes").ResolvedRouteChild>>(
   matchedRoutes.routes
 );