on master help sign in
bridge/src/lib/useRouter.tsx
184 lines 4.6 kB history raw
1import { useCallback, useMemo, useState, useRef, useEffect } from 'react';
2import { last, includes as _includes, findIndex, get } from 'lodash';
3import { ROUTES } from './router';
4import { ROUTE_NAMES } from './routeNames';
6const NULL_DATA = {};
8type RouteKey = keyof typeof ROUTES | string;
9export interface Route {
10 key: RouteKey;
11 data?: any;
14interface UseRouterParams {
15 names: Record<string, string>;
16 views: any;
17 initialRoutes: Route[];
20type OnPopStateHandler = (ev: PopStateEvent) => any;
22export interface Router {
23 Route: any;
24 names: typeof ROUTE_NAMES | Record<string, string>;
25 routes: Route[];
26 push: (key: RouteKey, data?: any) => void;
27 popAndPush: (key: RouteKey, data?: any) => void;
28 pop: () => void;
29 popTo: (key: string) => void;
30 replaceWith: (routes: Route[]) => void;
31 reset: () => void;
32 peek: () => Route | undefined;
33 size: number;
34 includes: (key: RouteKey) => boolean;
35 data: any;
38/**
39 * @param names map of string to view key, used for consumer references
40 * @param views map of view keys to react component views
41 * @param initialRoutes initial set of routes following the { key, data } format
42 */
43export default function useRouter({
44 names = {} as typeof ROUTE_NAMES,
45 views = {},
46 initialRoutes = [],
47}: UseRouterParams): Router {
48 const [routes, setRoutes] = useState(initialRoutes);
50 const oldPopState = useRef<OnPopStateHandler>();
52 const size = routes.length;
53 const push = useCallback(
54 (key, data) => {
55 if (!views[key]) {
56 throw new Error(
57 `Invalid Route: ${key}. Possible Routes: ${Object.keys(views).join(
58 ', '
59 )}`
60 );
61 }
62 setRoutes(routes => [...routes, { key, data }]);
63 },
64 [setRoutes, views]
65 );
66 const popAndPush = useCallback(
67 (key, data = {}) => {
68 if (!views[key]) {
69 throw new Error(
70 `Invalid Route: ${key}. Possible Routes: ${Object.keys(views).join(
71 ', '
72 )}`
73 );
74 }
76 setRoutes(routes => [
77 ...routes.slice(0, routes.length - 1),
78 { key, data },
79 ]);
80 },
81 [setRoutes, views]
82 );
83 const pop = useCallback(
84 (count = 1) => {
85 if (size <= 1) {
86 // if we are at the root, pass this event to our parent
87 if (oldPopState.current) {
88 oldPopState.current({} as PopStateEvent);
89 }
90 return;
91 }
93 // on pop, tell the browser of a new state to avoid giving the user
94 // the ability to go forward
95 // TODO: allow the user to go forward by storing our data in history
96 // and using the url for other state
97 // (or just importing an actually good router lib)
98 window.history.pushState(null, '', null);
100 // pop as expected
101 return setRoutes(routes => [...routes.slice(0, routes.length - count)]);
102 },
103 [size, setRoutes, oldPopState]
104 );
105 const popTo = useCallback(
106 name => {
107 const index = findIndex(routes, route => route.key === name);
108 return pop(routes.length - 1 - index);
109 },
110 [pop, routes]
111 );
112 const peek = useCallback(() => last(routes), [routes]);
113 const replaceWith = useCallback(
114 (routes: Route[]) => setRoutes(() => routes),
115 [setRoutes]
116 );
117 const reset = useCallback(() => setRoutes(initialRoutes), [
118 setRoutes,
119 initialRoutes,
120 ]);
121 const includes = useCallback(
122 (key: RouteKey) =>
123 _includes(
124 routes.map(r => r.key),
125 key
126 ),
127 [routes]
128 );
129 const data = useMemo(() => {
130 return get(last(routes), 'data', NULL_DATA);
131 }, [routes]);
132 const Route = useMemo(() => {
133 const key = peek()?.key;
135 if (!key) {
136 return null;
137 }
139 return views[key];
140 }, [views, peek]);
142 // Scroll to top of page with each route transition
143 useEffect(() => {
144 window.scrollTo(0, 0);
145 }, [routes]);
147 useEffect(() => {
148 // on router mount, register new state with browser
149 window.history.pushState(null, '', null);
150 }, []);
152 // capture browser pop in primary router
153 useEffect(() => {
154 // store the previous onpopstate handler
155 oldPopState.current = window.onpopstate?.bind(window) as OnPopStateHandler;
157 // construct new onpopstate handler
158 window.onpopstate = e => {
159 e && e.stopImmediatePropagation();
160 pop();
161 };
163 return () => {
164 // reset the onpopstate to the previous version
165 window.onpopstate = oldPopState.current as typeof window.onpopstate;
166 };
167 }, [size, pop, oldPopState]);
169 return {
170 Route,
171 names,
172 routes,
173 push,
174 popAndPush,
175 pop,
176 popTo,
177 replaceWith,
178 reset,
179 peek,
180 size,
181 includes,
182 data,
183 };