Skip to the code
cf.

Client-side router

Write createRouter(routes), the part of a single-page app's router that decides which page a URL shows. routes is an array of { path, handler } objects. It returns an object with one method, resolve(url).

Paths are compared segment by segment, splitting on /. Empty segments don't count, so /users/42, /users/42/, and users/42 are the same path, and / and "" both have no segments.

In a route's path, a plain segment such as users matches only the same text. A segment that starts with :, such as :id, matches any one segment and captures it as a param named id. Params are decoded with decodeURIComponent.

A * as the last segment of a route's path matches the rest of the URL's path, even when nothing is left. It captures the rest as params["*"]: the remaining segments joined with /, not decoded.

resolve(url) ignores everything from the first ? or # in the URL. It tries the routes in order and calls the handler of the first one that matches as handler(params), where params is an object of the captured params ({} when there are none). It returns what the handler returns, or null when no route matches.

const router = createRouter([
  { path: "/", handler: () => "home" },
  { path: "/users/new", handler: () => "new user form" },
  { path: "/users/:id", handler: ({ id }) => "user " + id },
  { path: "/files/*", handler: (params) => "file " + params["*"] },
  { path: "*", handler: () => "not found" },
]);
router.resolve("/");                   // "home"
router.resolve("/users/new");          // "new user form"
router.resolve("/users/42?tab=posts"); // "user 42"
router.resolve("/users/ada%20l/");     // "user ada l"
router.resolve("/files/docs/a.txt");   // "file docs/a.txt"
router.resolve("/users/42/edit");      // "not found"
 
createRouter([]).resolve("/");         // null

What the tests check

  1. captures a named param
    const router = createRouter([{ path: "/users/:id", handler: (params) => params }]);
    expect(router.resolve("/users/42")).toEqual({ id: "42" });
    
  2. captures several params
    const router = createRouter([{ path: "/posts/:postId/comments/:commentId", handler: (params) => params }]);
    expect(router.resolve("/posts/7/comments/99")).toEqual({ postId: "7", commentId: "99" });
    
  3. matches only paths with the same segments, and returns null otherwise
    const router = createRouter([{ path: "/users/:id", handler: () => "user" }]);
    expect(router.resolve("/users/ada")).toBe("user");
    expect(router.resolve("/users")).toBeNull();
    expect(router.resolve("/users/ada/edit")).toBeNull();
    expect(router.resolve("/people/ada")).toBeNull();
    expect(router.resolve("/admin/users/ada")).toBeNull();
    
  4. ignores the query string and the hash
    const router = createRouter([{ path: "/users/:id", handler: (params) => params }]);
    expect(router.resolve("/users/42?tab=posts")).toEqual({ id: "42" });
    expect(router.resolve("/users/42#bio")).toEqual({ id: "42" });
    expect(router.resolve("/users/42/?tab=posts#bio")).toEqual({ id: "42" });
    
  5. ignores empty segments, so extra slashes make no difference
    const router = createRouter([
      { path: "/", handler: () => "home" },
      { path: "/about/", handler: () => "about" },
    ]);
    expect(router.resolve("/")).toBe("home");
    expect(router.resolve("")).toBe("home");
    expect(router.resolve("/about")).toBe("about");
    expect(router.resolve("about/")).toBe("about");
    expect(router.resolve("//about")).toBe("about");
    
  6. the first matching route wins
    const routes = [
      { path: "/users/new", handler: () => "new user form" },
      { path: "/users/:id", handler: ({ id }) => "user " + id },
    ];
    expect(createRouter(routes).resolve("/users/new")).toBe("new user form");
    expect(createRouter([...routes].reverse()).resolve("/users/new")).toBe("user new");
    
  7. a final * matches the rest of the path, even when it's empty
    const router = createRouter([
      { path: "/files/*", handler: (params) => params },
      { path: "*", handler: () => "fallback" },
    ]);
    expect(router.resolve("/files/docs/a.txt")).toEqual({ "*": "docs/a.txt" });
    expect(router.resolve("/files/my%20docs/a.txt/")).toEqual({ "*": "my%20docs/a.txt" });
    expect(router.resolve("/files")).toEqual({ "*": "" });
    expect(router.resolve("/images/a.png")).toBe("fallback");
    expect(router.resolve("/")).toBe("fallback");
    
  8. decodes params after splitting the path
    const router = createRouter([{ path: "/tags/:tag", handler: ({ tag }) => tag }]);
    expect(router.resolve("/tags/caf%C3%A9%20au%20lait")).toBe("café au lait");
    expect(router.resolve("/tags/a%2Fb")).toBe("a/b");
    
  9. calls the handler once with the params and returns what it returns
    const handler = spy(() => 0);
    const router = createRouter([{ path: "/about", handler }]);
    expect(router.resolve("/about")).toBe(0);
    expect(handler).toHaveBeenCalledTimes(1);
    expect(handler).toHaveBeenCalledWith({});
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).