diff --git a/docs/api/components.md b/docs/api/components.md index b4ee9fc6..834fc90b 100644 --- a/docs/api/components.md +++ b/docs/api/components.md @@ -1,6 +1,6 @@ ## Router -The `Router` component should ideally wrap your client app as high up in the tree as possible. +The `Router` component should ideally wrap your client app as high up in the tree as possible. If you are planning to render your application on the server, we recommend creating a composition boundary between your router and the core of your application, including your `RouteComponent`. @@ -25,21 +25,25 @@ import { appRoutes } from './routing'; const resourcesPlugin = createResourcesPlugin({}); - + ; ``` ### Router props -| prop | type | description | -| ----------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `routes` | `Routes[]` | Your application's routes | -| `history` | `History` | The history instance for the router, if omitted memory history will be used (optional but recommended) | -| `plugins` | `Plugin[]` | Plugin allows you to hook into Router API and extra login on route load/prefetch/etc | -| `basePath` | `string` | Base path string that will get prepended to all route paths (optional) | -| `initialRoute` | `Route` | The route your application is initially showing, it's a performance optimisation to avoid route matching cost on initial render(optional) | -| `onPrefetch` | `function(RouterContext)` | Called when prefetch is triggered from a Link (optional) | +| prop | type | description | +| -------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `routes` | `Routes[]` | Your application's routes | +| `history` | `History` | The history instance for the router, if omitted memory history will be used (optional but recommended) | +| `plugins` | `Plugin[]` | Plugin allows you to hook into Router API and extra login on route load/prefetch/etc | +| `basePath` | `string` | Base path string that will get prepended to all route paths (optional) | +| `initialRoute` | `Route` | The route your application is initially showing, it's a performance optimisation to avoid route matching cost on initial render(optional) | +| `onPrefetch` | `function(RouterContext)` | Called when prefetch is triggered from a Link (optional) | ## Resources plugin @@ -77,12 +81,11 @@ export const routes = [ ### Resources plugin props -| prop | type | description | -| ----------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `context` | `ResourceContext` | Custom contextual data that will be provided to all your resources' `getKey` and `getData` methods (optional) | -| `resourceData` | `ResourceData` | Pre-resolved resource data. When provided, the router will not request resources on mount (optional) | -| `timeout` | `number` | `timout` is used to prevent slow APIs from causing long renders on the server, If a route resource does not return within the specified time then its data and promise will be set to null.(optional) | - +| prop | type | description | +| -------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `context` | `ResourceContext` | Custom contextual data that will be provided to all your resources' `getKey` and `getData` methods (optional) | +| `resourceData` | `ResourceData` | Pre-resolved resource data. When provided, the router will not request resources on mount (optional) | +| `timeout` | `number` | `timout` is used to prevent slow APIs from causing long renders on the server, If a route resource does not return within the specified time then its data and promise will be set to null.(optional) | ## MemoryRouter @@ -133,6 +136,7 @@ export const LinkExample = ({ href = '/' }) => { | `params` | `{ [key]: string }` | Used with `to` to generate correct path url | | `query` | `{ [key]: string }` | Used with `to` to generate correct query string url | | `prefetch` | `false` or `hover` or `mount` | Used to start prefetching router resources | +| `state` | `unknown` | Allows you to pass state via location | ## Redirect @@ -254,10 +258,10 @@ Actions that communicate with the router's routing functionality are exposed saf By using either of these you will gain access to the following actions | prop | type | arguments | description | -| --------------- | ---------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `push` | `function` | `path: Href | Location, state?: any` | Calls `history.push` with the supplied args | +| --------------- | ---------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `push` | `function` | `path: Href | Location, state?: any` | Calls `history.push` with the supplied args | | `pushTo` | `function` | `route: Route, attributes?: { params?: {}, query?: {} }` | Calls `history.push` generating the path from supplied route and attributes | -| `replace` | `function` | `path: Href | Location, state?: any` | Calls `history.replace` with the supplied args | +| `replace` | `function` | `path: Href | Location, state?: any` | Calls `history.replace` with the supplied args | | `replaceTo` | `function` | `route: Route, attributes?: { params?: {}, query?: {} }` | Calls `history.replace` generating the path from supplied route and attributes | | `goBack` | `function` | | Goes to the previous route in history | | `goForward` | `function` | | Goes to the next route in history | diff --git a/docs/api/hooks.md b/docs/api/hooks.md index 4fcabd3c..a408fe86 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -12,7 +12,8 @@ import { FeedRefresher } from './FeedRefresher'; import { FeedClearance } from './FeedCleaner'; export const Feed = () => { - const { data, loading, error, update, refresh, clear } = useResource(feedResource); + const { data, loading, error, update, refresh, clear } = + useResource(feedResource); if (error) { return ; @@ -48,9 +49,10 @@ As well as returning actions that act on the resource (i.e. update and refresh), Where `-prev-` indicates the field will remain unchanged from any previous state, possibly the inital state. -It is important to note -* The timeout state is essentially a hung loading state, with the difference that `promise = null` and `error != null`. Developers should give priority to `loading` when deciding between loading or error states for their components. Promises/errors should only ever be thrown on the client. -* The `promise` reflects the last operation, either async or explicit update. Update will clear `error`, set `data`. It will also set a `promise` consistent with that `data` so long as no async is `loading`. When `loading` the `promise` will always reflect the future `data` or `error` from the pending async. +It is important to note + +- The timeout state is essentially a hung loading state, with the difference that `promise = null` and `error != null`. Developers should give priority to `loading` when deciding between loading or error states for their components. Promises/errors should only ever be thrown on the client. +- The `promise` reflects the last operation, either async or explicit update. Update will clear `error`, set `data`. It will also set a `promise` consistent with that `data` so long as no async is `loading`. When `loading` the `promise` will always reflect the future `data` or `error` from the pending async. Additionaly `useResource` accepts additional arguments to customise behaviour, like `routerContext`. Check out [this section](../resources/usage.md) for more details on how to use the `useResource` hook. @@ -71,6 +73,40 @@ export const MyRouteComponent = () => { }; ``` +You can also use the `location` inside the `routerState` to access state passed via a `Link` or `push` from `routerActions`. + +```js +const StartPage = () => { + const [routerState, routerActions] = useRouter(); + + const handleButtonClick = () => { + const url = '/destination'; + const state = { referrer: 'StartPage' }; + routerActions.push(url, state); + }; + + return ( +
+

Welcome to the Start Page

+ +
+ ); +}; + +const DestinationPage = () => { + const [routerState] = useRouter(); + + const referrer = routerState.location?.state?.referrer; + + return ( +
+

Welcome to the Destination Page

+ {referrer &&

You came from the {referrer}!

} +
+ ); +}; +``` + ## createRouterSelector If you are worried about `useRouter` re-rendering too much, you can create custom router hooks using selectors that will trigger a re-render only when the selector output changes. diff --git a/src/common/types.ts b/src/common/types.ts index d87674cf..e6c28289 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -16,6 +16,7 @@ export type Location = { pathname: string; search: string; hash: string; + state?: unknown; }; export type BrowserHistory = ( @@ -23,8 +24,8 @@ export type BrowserHistory = ( | Omit ) & { location: Location; - push: (path: string | Location) => void; - replace: (path: string | Location) => void; + push: (path: string | Location, state?: unknown) => void; + replace: (path: string | Location, state?: unknown) => void; }; export type History = BrowserHistory; @@ -135,6 +136,7 @@ export type LinkProps = AnchorHTMLAttributes & { params?: MatchParams; query?: Query; prefetch?: false | 'hover' | 'mount'; + state?: unknown; }; export type HistoryBlocker = ( diff --git a/src/controllers/redirect/test.tsx b/src/controllers/redirect/test.tsx index dac87e8b..5c4087f4 100644 --- a/src/controllers/redirect/test.tsx +++ b/src/controllers/redirect/test.tsx @@ -78,14 +78,14 @@ describe('', () => { const to = '/cool-page'; expect(() => mountInRouter({ to })).not.toThrow(); - expect(MockHistory.push).toHaveBeenCalledWith(to); + expect(MockHistory.push).toHaveBeenCalledWith(to, undefined); }); it("doesn't break / throw when rendered with location `to` created from string", () => { const to = '/go-out?search=foo#hash'; expect(() => mountInRouter({ to })).not.toThrow(); - expect(MockHistory.push).toHaveBeenCalledWith(to); + expect(MockHistory.push).toHaveBeenCalledWith(to, undefined); }); it.each([ @@ -121,7 +121,7 @@ describe('', () => { "doesn't break / throw when rendered with `to` as a Route object, %s", (_, to, params, query, expected) => { expect(() => mountInRouter({ to, params, query })).not.toThrow(); - expect(MockHistory.push).toHaveBeenCalledWith(expected); + expect(MockHistory.push).toHaveBeenCalledWith(expected, undefined); } ); @@ -132,7 +132,7 @@ describe('', () => { 'should navigate to given route %s correctly', (_, to, params, query, expected) => { mountInRouter({ to, query, params, push: false }); - expect(MockHistory.replace).toHaveBeenCalledWith(expected); + expect(MockHistory.replace).toHaveBeenCalledWith(expected, undefined); expect(MockHistory.push).not.toHaveBeenCalled(); } ); @@ -168,7 +168,7 @@ describe('', () => { 'should use push history correctly with given route %s', (_, to, params, expected) => { mountInRouter({ to, params, push: true }); - expect(MockHistory.push).toHaveBeenCalledWith(expected); + expect(MockHistory.push).toHaveBeenCalledWith(expected, undefined); expect(MockHistory.replace).not.toHaveBeenCalled(); } ); diff --git a/src/controllers/router-actions/test.tsx b/src/controllers/router-actions/test.tsx index 096eb786..2d192aac 100644 --- a/src/controllers/router-actions/test.tsx +++ b/src/controllers/router-actions/test.tsx @@ -98,8 +98,8 @@ describe('', () => {
); - expect(HistoryMock.push).toBeCalledWith('push'); - expect(HistoryMock.replace).toBeCalledWith('replace'); + expect(HistoryMock.push).toBeCalledWith('push', undefined); + expect(HistoryMock.replace).toBeCalledWith('replace', undefined); expect(HistoryMock.goBack).toBeCalled(); expect(HistoryMock.goForward).toBeCalled(); expect(HistoryMock.block).toHaveBeenCalledWith(blockCallback); diff --git a/src/controllers/router-store/index.tsx b/src/controllers/router-store/index.tsx index b72a15f0..83b6738c 100644 --- a/src/controllers/router-store/index.tsx +++ b/src/controllers/router-store/index.tsx @@ -174,13 +174,13 @@ const actions: AllRouterActions = { }, push: - path => + (path, state) => ({ getState }) => { const { history, basePath } = getState(); if (isExternalAbsolutePath(path)) { window.location.assign(path as string); } else { - history.push(getRelativePath(path, basePath)); + history.push(getRelativePath(path, basePath), state); } }, @@ -198,17 +198,17 @@ const actions: AllRouterActions = { attributes.query, basePath ); - history.push(location as any); + history.push(location as any, attributes?.state); }, replace: - path => + (path, state) => ({ getState }) => { const { history, basePath } = getState(); if (isExternalAbsolutePath(path)) { window.location.replace(path as string); } else { - history.replace(getRelativePath(path, basePath) as any); + history.replace(getRelativePath(path, basePath) as any, state); } }, @@ -226,7 +226,7 @@ const actions: AllRouterActions = { attributes.query, basePath ); - history.replace(location as any); + history.replace(location as any, attributes?.state); }, goBack: diff --git a/src/controllers/router-store/test.tsx b/src/controllers/router-store/test.tsx index 20e8d73f..522f5bfd 100644 --- a/src/controllers/router-store/test.tsx +++ b/src/controllers/router-store/test.tsx @@ -175,7 +175,11 @@ describe('RouterStore', () => { actions.push('/pages/1'); - expect(history.push).toBeCalledWith(`${basePath ?? ''}/pages/1`); + expect(history.push).toBeCalledWith( + `${basePath ?? ''}/pages/1`, + undefined + ); + expect(getState()).toMatchObject({ action: 'PUSH', route: routes[1], @@ -188,7 +192,10 @@ describe('RouterStore', () => { actions.push(`http://localhost:3000${basePath ?? ''}/pages/1`); - expect(history.push).toBeCalledWith(`${basePath ?? ''}/pages/1`); + expect(history.push).toBeCalledWith( + `${basePath ?? ''}/pages/1`, + undefined + ); expect(getState()).toMatchObject({ action: 'PUSH', route: routes[1], @@ -200,7 +207,7 @@ describe('RouterStore', () => { actions.push(`http://localhost:3000/pages/1`); - expect(history.push).toBeCalledWith('/pages/1'); + expect(history.push).toBeCalledWith('/pages/1', undefined); expect(getState()).toMatchObject({ action: 'PUSH', route: routes[1], @@ -213,7 +220,7 @@ describe('RouterStore', () => { actions.push(nextLocation); - expect(history.push).toBeCalledWith(nextLocation); + expect(history.push).toBeCalledWith(nextLocation, undefined); expect(getState()).toMatchObject({ action: 'PUSH', route: routes[1], @@ -350,6 +357,27 @@ describe('RouterStore', () => { expect(assign).toBeCalledWith('http://example.com'); }); + + it('passes state when passed to push', () => { + const basePath = '/base-path'; + const { actions, getState, history } = renderRouterContainer({ + basePath, + }); + + const pushedState = { ids: [1, 2, 3, 4, 5] }; + actions.push('/pages/1', pushedState); + + expect(history.push).toBeCalledWith( + `${basePath ?? ''}/pages/1`, + pushedState + ); + + expect(getState()).toMatchObject({ + action: 'PUSH', + route: routes[1], + }); + expect(getState().location?.state).toMatchObject(pushedState); + }); }); describe('pushTo()', () => { @@ -359,16 +387,46 @@ describe('RouterStore', () => { actions.pushTo(route, { params: { id: '1' }, query: { uid: '1' } }); - expect(history.push).toBeCalledWith({ - hash: '', - pathname: '/pages/1', - search: '?uid=1', + expect(history.push).toBeCalledWith( + { + hash: '', + pathname: '/pages/1', + search: '?uid=1', + }, + undefined + ); + + expect(getState()).toMatchObject({ + action: 'PUSH', + route, }); + }); + + it('passes state when passed to pushTo', () => { + const route = routes[1]; + const { actions, getState, history } = renderRouterContainer(); + + const pushedState = { ids: [1, 2, 3, 4, 5] }; + actions.pushTo(route, { + params: { id: '1' }, + query: { uid: '1' }, + state: pushedState, + }); + + expect(history.push).toBeCalledWith( + { + hash: '', + pathname: '/pages/1', + search: '?uid=1', + }, + pushedState + ); expect(getState()).toMatchObject({ action: 'PUSH', route, }); + expect(getState().location?.state).toMatchObject(pushedState); }); }); @@ -381,7 +439,10 @@ describe('RouterStore', () => { actions.replace('/pages/1'); - expect(history.replace).toBeCalledWith(`${basePath ?? ''}/pages/1`); + expect(history.replace).toBeCalledWith( + `${basePath ?? ''}/pages/1`, + undefined + ); expect(getState()).toMatchObject({ action: 'REPLACE', route: routes[1], @@ -395,7 +456,7 @@ describe('RouterStore', () => { actions.replace(path); - expect(history.replace).toBeCalledWith('/pages/1'); + expect(history.replace).toBeCalledWith('/pages/1', undefined); expect(getState()).toMatchObject({ action: 'REPLACE', route: routes[1], @@ -410,6 +471,21 @@ describe('RouterStore', () => { expect(replace).toBeCalledWith('http://example.com'); }); + + it('it passes state to replace', () => { + const path = 'http://localhost:3000/pages/1'; + const { actions, getState, history } = renderRouterContainer(); + + const pushedState = { ids: [1, 2, 3, 4, 5] }; + actions.replace(path, pushedState); + + expect(history.replace).toBeCalledWith('/pages/1', pushedState); + expect(getState()).toMatchObject({ + action: 'REPLACE', + route: routes[1], + }); + expect(getState().location?.state).toMatchObject(pushedState); + }); }); describe('replaceTo()', () => { @@ -419,16 +495,46 @@ describe('RouterStore', () => { actions.replaceTo(route, { params: { id: '1' }, query: { uid: '1' } }); - expect(history.replace).toBeCalledWith({ - hash: '', - pathname: '/pages/1', - search: '?uid=1', + expect(history.replace).toBeCalledWith( + { + hash: '', + pathname: '/pages/1', + search: '?uid=1', + }, + undefined + ); + + expect(getState()).toMatchObject({ + action: 'REPLACE', + route, }); + }); + + it('it passes state to replaceTo', () => { + const route = routes[1]; + const { actions, getState, history } = renderRouterContainer(); + + const pushedState = { ids: [1, 2, 3, 4, 5] }; + actions.replaceTo(route, { + params: { id: '1' }, + query: { uid: '1' }, + state: pushedState, + }); + + expect(history.replace).toBeCalledWith( + { + hash: '', + pathname: '/pages/1', + search: '?uid=1', + }, + pushedState + ); expect(getState()).toMatchObject({ action: 'REPLACE', route, }); + expect(getState().location?.state).toMatchObject(pushedState); }); }); diff --git a/src/controllers/router-store/types.ts b/src/controllers/router-store/types.ts index bd05b5b9..5639bcb0 100644 --- a/src/controllers/router-store/types.ts +++ b/src/controllers/router-store/types.ts @@ -55,6 +55,7 @@ export type HistoryUpdateType = 'push' | 'replace'; type ToAttributes = { query?: Query; params?: MatchParams; + state?: unknown; }; type PrivateRouterActions = { @@ -85,9 +86,9 @@ type PrivateRouterActions = { }; type PublicRouterActions = { - push: (path: Href | Location, state?: any) => RouterAction; + push: (path: Href | Location, state?: unknown) => RouterAction; pushTo: (route: Route, attributes?: ToAttributes) => RouterAction; - replace: (path: Href | Location) => RouterAction; + replace: (path: Href | Location, state?: unknown) => RouterAction; replaceTo: (route: Route, attributes?: ToAttributes) => RouterAction; goBack: () => RouterAction; goForward: () => RouterAction; diff --git a/src/ui/link/index.tsx b/src/ui/link/index.tsx index 06037821..25525c53 100644 --- a/src/ui/link/index.tsx +++ b/src/ui/link/index.tsx @@ -40,6 +40,7 @@ const Link = forwardRef( params, query, prefetch = false, + state = undefined, ...rest }, ref @@ -101,6 +102,7 @@ const Link = forwardRef( routerActions, href: linkDestination, to: route && [route, { params, query }], + state, }); const handleMouseEnter = (e: MouseEvent) => { diff --git a/src/ui/link/test.tsx b/src/ui/link/test.tsx index 6041c68c..143f1d30 100644 --- a/src/ui/link/test.tsx +++ b/src/ui/link/test.tsx @@ -115,7 +115,7 @@ describe('', () => { component.simulate('click', baseClickEvent); expect(HistoryMock.push).toHaveBeenCalledTimes(1); - expect(HistoryMock.push).toHaveBeenCalledWith(newPath); + expect(HistoryMock.push).toHaveBeenCalledWith(newPath, undefined); }); it('should call `event.preventDefault() on navigation`', () => { @@ -149,7 +149,7 @@ describe('', () => { component.simulate('click', baseClickEvent); expect(HistoryMock.replace).toHaveBeenCalledTimes(1); - expect(HistoryMock.replace).toHaveBeenCalledWith(newPath); + expect(HistoryMock.replace).toHaveBeenCalledWith(newPath, undefined); }); describe('preventing navigation', () => { @@ -237,11 +237,14 @@ describe('', () => { component.simulate('click', baseClickEvent); expect(HistoryMock.push).toHaveBeenCalledTimes(1); - expect(HistoryMock.push).toHaveBeenCalledWith({ - hash: '', - pathname: '/base/my-page/1', - search: '?foo=bar', - }); + expect(HistoryMock.push).toHaveBeenCalledWith( + { + hash: '', + pathname: '/base/my-page/1', + search: '?foo=bar', + }, + undefined + ); }); it('should handle async route imports', async () => { @@ -259,11 +262,14 @@ describe('', () => { 'my link' ); expect(HistoryMock.push).toHaveBeenCalledTimes(1); - expect(HistoryMock.push).toHaveBeenCalledWith({ - hash: '', - pathname: '/my-page/1', - search: '?foo=bar', - }); + expect(HistoryMock.push).toHaveBeenCalledWith( + { + hash: '', + pathname: '/my-page/1', + search: '?foo=bar', + }, + undefined + ); }); it('should error if required route parameters are missing', () => { @@ -287,7 +293,7 @@ describe('', () => { }); expect(HistoryMock.push).toHaveBeenCalledTimes(1); - expect(HistoryMock.push).toHaveBeenCalledWith(newPath); + expect(HistoryMock.push).toHaveBeenCalledWith(newPath, undefined); }); it('should not navigate for any other key', () => { diff --git a/src/ui/link/utils/handle-navigation.tsx b/src/ui/link/utils/handle-navigation.tsx index 2ab24868..aa815dd4 100644 --- a/src/ui/link/utils/handle-navigation.tsx +++ b/src/ui/link/utils/handle-navigation.tsx @@ -8,20 +8,21 @@ type LinkNavigationEvent = MouseEvent | KeyboardEvent; type LinkPressArgs = { target?: string; routerActions: { - push: (href: string) => void; - replace: (href: string) => void; - pushTo: (route: Route, attributes: any) => void; - replaceTo: (route: Route, attributes: any) => void; + push: (href: string, state?: unknown) => void; + replace: (href: string, state?: unknown) => void; + pushTo: (route: Route, attributes: any, state?: unknown) => void; + replaceTo: (route: Route, attributes: any, state?: unknown) => void; }; replace: boolean; href: string; onClick?: (e: LinkNavigationEvent) => void; to: [Route, any] | void; + state?: unknown; }; export const handleNavigation = ( event: any, - { onClick, target, replace, routerActions, href, to }: LinkPressArgs + { onClick, target, replace, routerActions, href, to, state }: LinkPressArgs ): void => { if (isKeyboardEvent(event) && event.keyCode !== 13) { return; @@ -38,10 +39,10 @@ export const handleNavigation = ( event.preventDefault(); if (to) { const method = replace ? routerActions.replaceTo : routerActions.pushTo; - method(...to); + method(...to, state); } else { const method = replace ? routerActions.replace : routerActions.push; - method(href); + method(href, state); } } };