diff --git a/README.md b/README.md index b2abf8c..c67769d 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,7 @@ dispatch Redux actions in response to route changes. - [Install](#install) - [Usage](#usage) - [Behavior](#behavior) -- [Parameters](#parameters) -- [Route Matching](#route-matching) +- [Routes](#routes) - [Options](#options) - [Navigation](#navigation) - [Hash History](#hash-history) @@ -103,7 +102,35 @@ your application will continue to function when you hit other routes. That also means you should ensure you handle any potential errors that could occur in your route sagas. -## Parameters +## Routes + +Routes may be expressed as either an object or an array with the main difference +being that the array form preserves order and, therefore, the precedence of +routes. + +```js +const objectFormRoutes = { + '/foo': fooHandler, + '/bar': barHandler, +}; + +const arrayFormRoutes = [ + { pattern: '/foo', handler: fooHandler }, + { pattern: '/bar', handler: barHandler }, +]; +``` + +### Exact Matching + +This route will only match `/foo` exactly. + +```js +const routes = { + '/foo': saga, +}; +``` + +### Path Parameters You can capture dynamic path parameters by prepending them with the `:` symbol. The name you use will be assigned to a property of the same name on a parameters @@ -125,22 +152,6 @@ const routes = { }; ``` -## Route Matching - -Here are some examples of how route matching works. - -### Exact Matching - -This route will only match `/foo` exactly. - -```js -const routes = { - '/foo': saga, -}; -``` - -### Path Parameters - If you specify a dynamic path parameter, then it will be required. This route will match `/bar/42` but NOT `/bar`. @@ -184,6 +195,28 @@ const routes = { }; ``` +### Route Precedence + +It is often desirable to have some routes take higher precedence over others. +Consider the case where one route accepts a parameter but for a particular value +of that parameter, we want to use a separate handler: + +```js +const routes = [ + { pattern: '/foo/baz', handler: bazHandler }, + { pattern: '/foo/:arg', handler: genericHandler }, +] +``` + +By using the array form to define routes, and placing the specific pattern +before the general pattern (higher precedence), we can have `bazHandler` invoked +whenever the route `/foo/baz` is visited, and `genericHandler` for +anything else under `/foo`. + +Note that if we were to reverse the order of the above routes, `genericHandler` +would be invoked for both `/foo/baz` and anything else under `/foo`, leaving +`bazHandler` unreachable. + ## Options As mentioned earlier, the `router` saga may also take a third argument, an diff --git a/__tests__/buildRouteMatcher.test.js b/__tests__/buildRouteMatcher.test.js index 90a34b4..08f7030 100644 --- a/__tests__/buildRouteMatcher.test.js +++ b/__tests__/buildRouteMatcher.test.js @@ -1,6 +1,6 @@ import buildRouteMatcher from '../src/buildRouteMatcher'; -test('creates a route matcher', () => { +test('creates a route matcher by object syntax', () => { const rootRoute = () => 'root route'; const fooRoute = () => 'foo route'; @@ -19,6 +19,55 @@ test('creates a route matcher', () => { expect(fooMatch.action).toBe(fooRoute); }); +test('creates a route matcher by array syntax, honouring route order', () => { + const fooRoute = () => 'foo route'; + const fooBarRoute = () => 'foo bar route'; + + const descendingSpecificityMatcher = buildRouteMatcher([ + { pattern: '/foo/bar', handler: fooBarRoute }, + { pattern: '/foo/:arg', handler: fooRoute }, + ]); + const ascendingSpecificityMatcher = buildRouteMatcher([ + { pattern: '/foo/:arg', handler: fooRoute }, + { pattern: '/foo/bar', handler: fooBarRoute }, + ]); + + const descendingFooMatch = descendingSpecificityMatcher.match('/foo/baz'); + const descendingFooBarMatch = descendingSpecificityMatcher.match('/foo/bar'); + const ascendingFooMatch = ascendingSpecificityMatcher.match('/foo/baz'); + const ascendingFooBarMatch = ascendingSpecificityMatcher.match('/foo/bar'); + + expect(descendingFooMatch).not.toBe(null); + expect(descendingFooBarMatch).not.toBe(null); + expect(ascendingFooMatch).not.toBe(null); + expect(ascendingFooBarMatch).not.toBe(null); + + expect(descendingFooMatch.action).toBe(fooRoute); + expect(descendingFooBarMatch.action).toBe(fooBarRoute); + expect(ascendingFooMatch.action).toBe(fooRoute); + expect(ascendingFooBarMatch.action).toBe(fooRoute); + expect(ascendingFooMatch.params.arg).toBe('baz'); + expect(ascendingFooBarMatch.params.arg).toBe('bar'); +}); + +test('throws an error when given incompatible values for routes', () => { + expect(() => { + buildRouteMatcher(null); + }).toThrow(); + + expect(() => { + buildRouteMatcher(undefined); + }).toThrow(); + + expect(() => { + buildRouteMatcher('string'); + }).toThrow(); + + expect(() => { + buildRouteMatcher(1234); + }).toThrow(); +}); + test('recognizes fall-through pattern', () => { const rootRoute = () => 'root route'; const fooRoute = () => 'foo route'; diff --git a/src/buildRouteMatcher.js b/src/buildRouteMatcher.js index e35b454..9bdffcf 100644 --- a/src/buildRouteMatcher.js +++ b/src/buildRouteMatcher.js @@ -1,10 +1,24 @@ import ruta3 from 'ruta3'; +function normalizeRoutes(routes) { + if (Array.isArray(routes)) { + return routes; + } else if (routes !== null && typeof routes === 'object') { + return Object.keys(routes).map((pattern) => ({ pattern, handler: routes[pattern] })); + } + + throw new Error( + 'Provided routes must either be an object in the form ' + + '{ [pattern]: handler }, or an array whose elements are objects in the ' + + 'form { pattern: string, handler: function }.' + ); +} + export default function buildRouteMatcher(routes) { const routeMatcher = ruta3(); - Object.keys(routes).forEach((route) => { - routeMatcher.addRoute(route, routes[route]); + normalizeRoutes(routes).forEach(({ pattern, handler }) => { + routeMatcher.addRoute(pattern, handler); }); return routeMatcher;