10 | ): string => {
11 | let componentDisplayName = 'UNDEFINED';
12 | const { displayName, name } = component;
13 |
14 | if (displayName) {
15 | componentDisplayName = displayName;
16 | }
17 |
18 | if (name) {
19 | componentDisplayName = name;
20 | }
21 |
22 | return `withRouter(${componentDisplayName})`;
23 | };
24 |
25 | export const withRouter = >(
26 | WrappedComponent: ComponentType
27 | ) => {
28 | const displayName = getWrappedComponentDisplayName(WrappedComponent);
29 | const Component = WrappedComponent as ComponentType;
30 | const ComponentWithRouter = (props: P) => {
31 | const [
32 | { action, history, location, match, query, route },
33 | { push, replace },
34 | ] = useRouterStore();
35 |
36 | return (
37 |
48 | );
49 | };
50 |
51 | ComponentWithRouter.displayName = displayName;
52 |
53 | return ComponentWithRouter;
54 | };
55 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { createBrowserHistory, createMemoryHistory } from 'history';
2 |
3 | export {
4 | createRouterSelector,
5 | MemoryRouter,
6 | Redirect,
7 | Router,
8 | RouterActions,
9 | RouterSubscriber,
10 | usePathParam,
11 | useQueryParam,
12 | useRouter,
13 | useRouterActions,
14 | withRouter,
15 | } from './controllers';
16 |
17 | export { RouteComponent, Link } from './ui';
18 |
19 | export {
20 | createLegacyHistory,
21 | createRouterContext,
22 | findRouterContext,
23 | generatePath,
24 | matchRoute,
25 | } from './common/utils';
26 |
27 | export { invokePluginLoad } from './controllers/plugins';
28 |
29 | export type {
30 | AdditionalRouteAttributes,
31 | BrowserHistory,
32 | CreateRouterContextOptions,
33 | FindRouterContextOptions,
34 | GenerateLocationOptions,
35 | History,
36 | HistoryAction,
37 | HistoryActions,
38 | HistoryBlocker,
39 | HistoryListen,
40 | LinkProps,
41 | Location,
42 | LocationShape,
43 | Match,
44 | MatchParams,
45 | MatchedInvariantRoute,
46 | MatchedRoute,
47 | Query,
48 | Route,
49 | Routes,
50 | RouteContext,
51 | RouterContext,
52 | Plugin,
53 | ShouldReloadFunction,
54 | } from './common/types';
55 |
56 | export type {
57 | RouterActionsType,
58 | RouterActionPush,
59 | RouterActionReplace,
60 | RouterSubscriberProps,
61 | } from './controllers/router-store/types';
62 |
63 | // extra exports for resources only
64 | export {
65 | RouterStore,
66 | useRouterStoreActions,
67 | getRouterState,
68 | } from './controllers/router-store';
69 | export type {
70 | EntireRouterState,
71 | AllRouterActions,
72 | } from './controllers/router-store/types';
73 |
74 | export { DEFAULT_MATCH, DEFAULT_ROUTE } from './common/constants';
75 |
76 | // re-export resources entry-point to keep it backwards compatible with 0.20.x version
77 | export {
78 | createResource,
79 | ResourceDependencyError,
80 | ResourceSubscriber,
81 | useResource,
82 | useResourceStoreContext,
83 | } from './resources';
84 |
85 | export type {
86 | RouteResources,
87 | ResourceStoreContext,
88 | ResourceStoreData,
89 | RouteResource,
90 | RouteResourceError,
91 | RouteResourceLoading,
92 | RouteResourceResponse,
93 | RouteResourceUpdater,
94 | RouterDataContext,
95 | UseResourceHookResponse,
96 | } from './resources';
97 |
98 | export type {
99 | CreateResourceArgSync,
100 | CreateResourceArgAsync,
101 | CreateResourceArgBase,
102 | } from './resources';
103 |
--------------------------------------------------------------------------------
/src/mocks.ts:
--------------------------------------------------------------------------------
1 | export {
2 | mockLocation,
3 | mockMatch,
4 | mockMatchedRoute,
5 | mockQuery,
6 | mockRoute,
7 | mockRouteContext,
8 | mockRouteContextProp,
9 | mockRouterActions,
10 | mockRouterStoreContext,
11 | mockRouterStoreContextProp,
12 | mockRoutes,
13 | } from './common/mocks';
14 |
15 | export { mockRouteResourceResponse } from './resources';
16 |
--------------------------------------------------------------------------------
/src/resources/common/mocks/index.ts:
--------------------------------------------------------------------------------
1 | export const mockRouteResourceResponse = {
2 | loading: false,
3 | error: null,
4 | data: { foo: 'bar' },
5 | promise: null,
6 | expiresAt: Date.now(),
7 | accessedAt: Date.now(),
8 | key: '',
9 | };
10 |
--------------------------------------------------------------------------------
/src/resources/common/types.ts:
--------------------------------------------------------------------------------
1 | import type { RouterContext, Route } from '../../index';
2 |
3 | export type RouteResource = {
4 | type: ResourceType;
5 | getKey: (
6 | routerContext: RouterContext,
7 | customContext: ResourceStoreContext
8 | ) => ResourceKey;
9 | maxAge: number;
10 | getData: (
11 | routerContext: RouterDataContext,
12 | customContext: ResourceStoreContext
13 | ) => T | Promise;
14 | maxCache: number;
15 | isBrowserOnly: boolean;
16 | depends: ResourceType[] | null;
17 | };
18 |
19 | export type RouteResources = RouteResource[];
20 |
21 | export interface ResourceStoreContext {}
22 |
23 | export type ResourceType = string;
24 | export type ResourceKey = string;
25 |
26 | export type RouteResourceLoading = boolean;
27 |
28 | export type RouteResourceTimestamp = number | null;
29 |
30 | export type RouteResourceError = Record | Error;
31 |
32 | export type RouteResourceDataPayload = Record;
33 |
34 | export type RouteResourceUpdater = (
35 | data: RouteResourceData
36 | ) => RouteResourceData;
37 |
38 | export type EmptyObject = {
39 | [K in any]: never;
40 | };
41 |
42 | export type RouteResourceSyncResult =
43 | | {
44 | data: RouteResourceData;
45 | error: null;
46 | loading: true;
47 | // promise: existing value retained
48 | }
49 | | {
50 | data: RouteResourceData;
51 | error: null;
52 | loading: false;
53 | promise: Promise;
54 | };
55 |
56 | export type RouteResourceAsyncResult =
57 | | {
58 | data: RouteResourceData;
59 | error: null;
60 | loading: false;
61 | promise: Promise;
62 | }
63 | | {
64 | // data: existing value retained
65 | error: RouteResourceError;
66 | loading: false;
67 | promise: Promise;
68 | }
69 | | {
70 | // data: existing value retained
71 | error: RouteResourceError;
72 | loading: true;
73 | promise: null;
74 | };
75 |
76 | type RouteResourceResponseBase = {
77 | key?: string;
78 | loading: RouteResourceLoading;
79 | error: RouteResourceError | null;
80 | data: RouteResourceData | null;
81 | promise: Promise | null;
82 | expiresAt: RouteResourceTimestamp;
83 | accessedAt: RouteResourceTimestamp;
84 | };
85 |
86 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
87 | export type RouteResourceResponseInitial = {
88 | loading: false;
89 | error: null;
90 | data: null;
91 | promise: null;
92 | };
93 |
94 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
95 | export type RouteResourceResponseLoading = {
96 | loading: true;
97 | };
98 |
99 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
100 | export type RouteResourceResponseError = {
101 | loading: false;
102 | error: RouteResourceError;
103 | };
104 |
105 | export type RouteResourceResponseLoaded = {
106 | loading: false;
107 | error: null;
108 | data: RouteResourceData;
109 | };
110 |
111 | export type RouteResourceResponse =
112 | RouteResourceResponseBase &
113 | (
114 | | RouteResourceResponseInitial
115 | | RouteResourceResponseLoading
116 | | RouteResourceResponseError
117 | | RouteResourceResponseLoaded
118 | );
119 |
120 | export type ResourceDependencies = {
121 | [type: string]: RouteResourceResponse | undefined;
122 | };
123 |
124 | export type RouterDataContext = RouterContext & {
125 | isPrefetch: boolean;
126 | dependencies: ResourceDependencies;
127 | };
128 |
129 | export type UseResourceHookResponse =
130 | RouteResourceResponse & {
131 | update: (getNewData: RouteResourceUpdater) => void;
132 | refresh: () => void;
133 | clear: () => void;
134 | clearAll: () => void;
135 | };
136 |
137 | export type RouteResourceDataForType = Record<
138 | string,
139 | RouteResourceResponse
140 | >;
141 |
142 | export type ResourceStoreData = Record;
143 |
144 | export type RouteWithResources = Route & {
145 | resources?: RouteResources;
146 | };
147 |
--------------------------------------------------------------------------------
/src/resources/controllers/add-resource-listener/index.ts:
--------------------------------------------------------------------------------
1 | import { getResourceStore } from '../resource-store';
2 |
3 | export const addResourcesListener = (fn: (...args: any) => any) =>
4 | getResourceStore().storeState.subscribe(fn);
5 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/selectors.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ResourceStoreContext,
3 | ResourceStoreData,
4 | RouteResourceResponse,
5 | } from '../../common/types';
6 |
7 | import { ResourceSliceIdentifier, State } from './types';
8 | import { getDefaultStateSlice } from './utils';
9 |
10 | export const getSliceForResource = (
11 | state: { data: ResourceStoreData; context?: ResourceStoreContext },
12 | props: ResourceSliceIdentifier
13 | ): RouteResourceResponse => {
14 | const { type, key } = props;
15 | const slice =
16 | state.data[type] && (state.data[type][key] as RouteResourceResponse);
17 |
18 | return slice ? { ...slice } : getDefaultStateSlice();
19 | };
20 |
21 | export const getResourceStoreContext = (state: State): ResourceStoreContext =>
22 | state.context;
23 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/types.ts:
--------------------------------------------------------------------------------
1 | import { Action } from 'react-sweet-state';
2 |
3 | import {
4 | ResourceStoreContext,
5 | ResourceStoreData,
6 | RouteResource,
7 | } from '../../common/types';
8 |
9 | export type ExecutionTuple = [RouteResource, ResourceAction];
10 | export type ExecutionMaybeTuple = [RouteResource, ResourceAction | null];
11 | export type PrefetchSlice = {
12 | promise: Promise;
13 | data: unknown;
14 | expiresAt: number;
15 | };
16 |
17 | export type State = {
18 | data: ResourceStoreData;
19 | context: ResourceStoreContext;
20 | executing: ExecutionMaybeTuple[] | null;
21 | prefetching: Record> | null;
22 | };
23 |
24 | // eslint-disable-next-line @typescript-eslint/ban-types
25 | export type ContainerProps = {};
26 |
27 | export type ResourceSliceIdentifier = {
28 | type: string;
29 | key: string;
30 | };
31 |
32 | export type GetResourceOptions = {
33 | prefetch?: boolean;
34 | timeout?: number;
35 | };
36 |
37 | export type ResourceAction = Action;
38 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/accessed-at/index.ts:
--------------------------------------------------------------------------------
1 | export const getAccessedAt = (): number => Date.now();
2 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/accessed-at/test.ts:
--------------------------------------------------------------------------------
1 | import { getAccessedAt } from './index';
2 |
3 | describe('getAccessedAt()', () => {
4 | it('should return current timestamp', () => {
5 | jest.spyOn(global.Date, 'now').mockReturnValue(10);
6 |
7 | expect(getAccessedAt()).toEqual(10);
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/create-loading-slice/constants.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_PREFETCH_MAX_AGE = 10 * 1000;
2 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/create-loading-slice/index.ts:
--------------------------------------------------------------------------------
1 | import type { RouterContext } from '../../../../../index';
2 | import type {
3 | ResourceDependencies,
4 | ResourceStoreContext,
5 | RouteResource,
6 | } from '../../../../common/types';
7 | import { GetResourceOptions, PrefetchSlice } from '../../types';
8 | import { DEFAULT_RESOURCE_MAX_AGE } from '../create-resource/constants';
9 | import { getExpiresAt } from '../expires-at';
10 | import { generateTimeGuard } from '../generate-time-guard';
11 | import { TimeoutError } from '../timeout-error';
12 |
13 | import { DEFAULT_PREFETCH_MAX_AGE } from './constants';
14 |
15 | export function createLoadingSlice({
16 | context,
17 | dependencies,
18 | options,
19 | resource,
20 | routerStoreContext,
21 | }: {
22 | context: ResourceStoreContext;
23 | dependencies: () => ResourceDependencies;
24 | options: GetResourceOptions;
25 | resource: RouteResource;
26 | routerStoreContext: RouterContext;
27 | }): PrefetchSlice {
28 | const { type, getData } = resource;
29 | const { prefetch, timeout } = options;
30 |
31 | // hard errors in dependencies or getData are converted into softer async error
32 | let promiseOrData: unknown | Promise;
33 | try {
34 | promiseOrData = getData(
35 | {
36 | ...routerStoreContext,
37 | isPrefetch: !!prefetch,
38 | dependencies: dependencies(),
39 | },
40 | context
41 | );
42 | } catch (error) {
43 | promiseOrData = Promise.reject(error);
44 | }
45 |
46 | // ensure the promise includes any timeout error
47 | const timeoutGuard = timeout ? generateTimeGuard(timeout) : null;
48 |
49 | // check if getData was sync, by looking for a Promise-like shape
50 | const data =
51 | typeof (promiseOrData as any)?.then === 'function'
52 | ? undefined
53 | : promiseOrData;
54 | const promise = timeout
55 | ? Promise.race([promiseOrData, timeoutGuard?.promise]).then(maybeData => {
56 | if (timeoutGuard && !timeoutGuard.isPending) {
57 | throw new TimeoutError(type);
58 | }
59 | timeoutGuard?.timerId && clearTimeout(timeoutGuard.timerId);
60 |
61 | return maybeData;
62 | })
63 | : // if we already have a result, wrap it so consumers can access it via same API
64 | data !== undefined
65 | ? Promise.resolve(data)
66 | : (promiseOrData as Promise);
67 |
68 | const resourceMaxAge = getExpiresAt(
69 | resource.maxAge ?? DEFAULT_RESOURCE_MAX_AGE
70 | );
71 |
72 | return {
73 | promise,
74 | data,
75 | expiresAt: prefetch
76 | ? Math.max(resourceMaxAge, getExpiresAt(DEFAULT_PREFETCH_MAX_AGE))
77 | : resourceMaxAge,
78 | };
79 | }
80 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/create-resource/constants.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_CACHE_MAX_LIMIT = 100;
2 | export const DEFAULT_RESOURCE_BROWSER_ONLY = false;
3 | export const DEFAULT_RESOURCE_MAX_AGE = 0;
4 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/create-resource/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ResourceType,
3 | RouteResource,
4 | RouteResourceDataPayload,
5 | } from '../../../../common/types';
6 |
7 | import {
8 | DEFAULT_CACHE_MAX_LIMIT,
9 | DEFAULT_RESOURCE_BROWSER_ONLY,
10 | DEFAULT_RESOURCE_MAX_AGE,
11 | } from './constants';
12 |
13 | /**
14 | * Utility method to created async versions of getData functions
15 | */
16 | type GetDataLoader = () => Promise<{
17 | default: RouteResource['getData'];
18 | }>;
19 |
20 | export type CreateResourceArgBase = Pick &
21 | Partial> & {
22 | depends?: ResourceType[];
23 | };
24 |
25 | export type CreateResourceArgSync = CreateResourceArgBase & {
26 | getData: RouteResource['getData'];
27 | };
28 |
29 | export type CreateResourceArgAsync = CreateResourceArgBase & {
30 | getDataLoader: GetDataLoader;
31 | };
32 |
33 | const handleGetDataLoader =
34 | (asyncImport: GetDataLoader) =>
35 | async (...args: Parameters['getData']>) => {
36 | const { default: getDataFn } = await asyncImport();
37 |
38 | return getDataFn(...args);
39 | };
40 |
41 | export const createResource = (
42 | arg: CreateResourceArgSync | CreateResourceArgAsync
43 | ): RouteResource => ({
44 | type: arg.type,
45 | getKey: arg.getKey,
46 | getData:
47 | (arg as CreateResourceArgSync).getData ??
48 | handleGetDataLoader((arg as CreateResourceArgAsync).getDataLoader),
49 | maxAge:
50 | typeof arg.maxAge === 'number' ? arg.maxAge : DEFAULT_RESOURCE_MAX_AGE,
51 | maxCache:
52 | typeof arg.maxCache === 'number' ? arg.maxCache : DEFAULT_CACHE_MAX_LIMIT,
53 | isBrowserOnly:
54 | typeof arg.isBrowserOnly === 'boolean'
55 | ? arg.isBrowserOnly
56 | : DEFAULT_RESOURCE_BROWSER_ONLY,
57 | depends: arg.depends ?? null,
58 | });
59 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/create-resource/test.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_CACHE_MAX_LIMIT } from './constants';
2 |
3 | import { createResource } from './index';
4 |
5 | describe('resource utils', () => {
6 | describe('createResource', () => {
7 | it('should return a resource object', async () => {
8 | const resource = createResource({
9 | type: 'TEST',
10 | getKey: () => '',
11 | getData: () => null,
12 | });
13 |
14 | expect(resource).toEqual({
15 | type: expect.any(String),
16 | getKey: expect.any(Function),
17 | getData: expect.any(Function),
18 | maxAge: 0,
19 | maxCache: DEFAULT_CACHE_MAX_LIMIT,
20 | isBrowserOnly: false,
21 | depends: null,
22 | });
23 | });
24 |
25 | it('should return a resource object with getData loader', async () => {
26 | const getDataMock = jest.fn();
27 | const routerContext: any = {};
28 |
29 | const resource = createResource({
30 | type: 'TEST',
31 | getKey: () => '',
32 | getDataLoader: () => Promise.resolve({ default: getDataMock }),
33 | });
34 |
35 | expect(resource).toEqual({
36 | type: expect.any(String),
37 | getKey: expect.any(Function),
38 | getData: expect.any(Function),
39 | maxAge: 0,
40 | maxCache: DEFAULT_CACHE_MAX_LIMIT,
41 | isBrowserOnly: false,
42 | depends: null,
43 | });
44 |
45 | await resource.getData(routerContext, {});
46 | expect(getDataMock).toHaveBeenCalled();
47 | });
48 |
49 | it('should return a resource object with a custom maxAge', async () => {
50 | const resource = createResource({
51 | type: 'TEST',
52 | getKey: () => '',
53 | getData: () => null,
54 | maxAge: 400,
55 | });
56 |
57 | expect(resource).toEqual({
58 | type: expect.any(String),
59 | getKey: expect.any(Function),
60 | getData: expect.any(Function),
61 | maxAge: 400,
62 | maxCache: DEFAULT_CACHE_MAX_LIMIT,
63 | isBrowserOnly: false,
64 | depends: null,
65 | });
66 | });
67 |
68 | it('should return a resource object with a custom maxCache limit', async () => {
69 | const resource = createResource({
70 | type: 'TEST',
71 | getKey: () => '',
72 | getData: () => null,
73 | maxAge: 0,
74 | maxCache: 3,
75 | });
76 |
77 | expect(resource).toEqual({
78 | type: expect.any(String),
79 | getKey: expect.any(Function),
80 | getData: expect.any(Function),
81 | maxAge: 0,
82 | maxCache: 3,
83 | isBrowserOnly: false,
84 | depends: null,
85 | });
86 | });
87 |
88 | it('should return a resource object with a custom isBrowserOnly', async () => {
89 | const resource = createResource({
90 | type: 'TEST',
91 | getKey: () => '',
92 | getData: () => null,
93 | maxAge: 0,
94 | maxCache: 3,
95 | isBrowserOnly: true,
96 | });
97 |
98 | expect(resource).toEqual({
99 | type: expect.any(String),
100 | getKey: expect.any(Function),
101 | getData: expect.any(Function),
102 | maxAge: 0,
103 | maxCache: 3,
104 | isBrowserOnly: true,
105 | depends: null,
106 | });
107 | });
108 | });
109 | });
110 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/expires-at/index.ts:
--------------------------------------------------------------------------------
1 | import { RouteResourceResponse } from '../../../../common/types';
2 |
3 | export const getExpiresAt = (maxAge: number): number => Date.now() + maxAge;
4 |
5 | export const setExpiresAt = (
6 | slice: RouteResourceResponse,
7 | maxAge: number
8 | ): RouteResourceResponse =>
9 | slice.expiresAt === null
10 | ? { ...slice, expiresAt: getExpiresAt(maxAge) }
11 | : slice;
12 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/expires-at/test.ts:
--------------------------------------------------------------------------------
1 | import { mockRouteResourceResponse } from '../../../../common/mocks';
2 |
3 | import { getExpiresAt, setExpiresAt } from './index';
4 |
5 | describe('getExpiresAt()', () => {
6 | it('should return the value passed plus the current timestamp', () => {
7 | jest.spyOn(global.Date, 'now').mockReturnValue(10);
8 |
9 | expect(getExpiresAt(100)).toEqual(110);
10 | });
11 | });
12 |
13 | describe('setExpiresAt()', () => {
14 | it('should return an object with expiresAt set if it was null when passed', () => {
15 | const mock = { ...mockRouteResourceResponse, expiresAt: null };
16 |
17 | jest.spyOn(global.Date, 'now').mockReturnValue(0);
18 |
19 | expect(setExpiresAt(mock, 10)).toEqual({
20 | ...mockRouteResourceResponse,
21 | expiresAt: 10,
22 | });
23 | });
24 |
25 | it('should return an object with the same expiresAt as passed if it was not null', () => {
26 | expect(setExpiresAt(mockRouteResourceResponse, 100)).toEqual(
27 | mockRouteResourceResponse
28 | );
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/generate-time-guard/index.ts:
--------------------------------------------------------------------------------
1 | import { GenerateTimeGuardReturn } from './types';
2 |
3 | export const generateTimeGuard = (
4 | duration: number
5 | ): GenerateTimeGuardReturn => {
6 | const promiseState: GenerateTimeGuardReturn = {
7 | timerId: null,
8 | isPending: true,
9 | promise: undefined,
10 | };
11 |
12 | promiseState.promise = new Promise(r => {
13 | const timerId = setTimeout(() => {
14 | promiseState.isPending = false;
15 | r();
16 | }, duration);
17 | promiseState.timerId = timerId;
18 | });
19 |
20 | return promiseState;
21 | };
22 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/generate-time-guard/test.ts:
--------------------------------------------------------------------------------
1 | import { generateTimeGuard } from './index';
2 |
3 | describe('generateTimeGuard()', () => {
4 | beforeEach(() => {
5 | jest.useFakeTimers();
6 | });
7 |
8 | it('should set pending to true while promise is pending', async () => {
9 | const timeGuard = generateTimeGuard(1000);
10 | expect(timeGuard.isPending).toBe(true);
11 | });
12 |
13 | it('should set pending to false when promise is resolved', async () => {
14 | const timeGuard = generateTimeGuard(1000);
15 | jest.runAllTimers();
16 | await timeGuard.promise;
17 | expect(timeGuard.isPending).toBe(false);
18 | });
19 |
20 | it('should return timer id', async () => {
21 | const timeGuard = generateTimeGuard(1000);
22 | expect(typeof timeGuard.timerId).toBe('number');
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/generate-time-guard/types.ts:
--------------------------------------------------------------------------------
1 | export type GenerateTimeGuardReturn = {
2 | timerId: null | NodeJS.Timeout;
3 | isPending: boolean;
4 | promise: undefined | Promise;
5 | };
6 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/get-default-state-slice/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The base defaults which should be fed into any factory that needs to derive other props.
3 | */
4 | export const BASE_DEFAULT_STATE_SLICE = {
5 | data: null,
6 | error: null,
7 | loading: false,
8 | promise: null,
9 | };
10 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/get-default-state-slice/index.ts:
--------------------------------------------------------------------------------
1 | import { RouteResourceResponse } from '../../../../common/types';
2 | import { getAccessedAt } from '../accessed-at';
3 | import { DEFAULT_RESOURCE_MAX_AGE } from '../create-resource/constants';
4 | import { getExpiresAt } from '../expires-at';
5 |
6 | import { BASE_DEFAULT_STATE_SLICE } from './constants';
7 |
8 | export const getDefaultStateSlice = (): RouteResourceResponse => ({
9 | ...BASE_DEFAULT_STATE_SLICE,
10 | expiresAt: getExpiresAt(DEFAULT_RESOURCE_MAX_AGE),
11 | accessedAt: getAccessedAt(),
12 | });
13 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/get-default-state-slice/test.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_RESOURCE_MAX_AGE } from '../create-resource/constants';
2 |
3 | import { getDefaultStateSlice } from './index';
4 |
5 | describe('getDefaultStateSlice()', () => {
6 | it('should return the correct default state slice', () => {
7 | jest.spyOn(global.Date, 'now').mockReturnValue(0);
8 |
9 | expect(getDefaultStateSlice()).toEqual({
10 | data: null,
11 | error: null,
12 | loading: false,
13 | promise: null,
14 | expiresAt: DEFAULT_RESOURCE_MAX_AGE,
15 | accessedAt: 0,
16 | });
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/get-resource-identifier/index.ts:
--------------------------------------------------------------------------------
1 | import type { RouterContext } from '../../../../../index';
2 | import type {
3 | ResourceStoreContext,
4 | RouteResource,
5 | } from '../../../../common/types';
6 |
7 | export const getResourceIdentifier = (
8 | resource: RouteResource,
9 | routerStoreContext: RouterContext,
10 | resourceStoreContext: ResourceStoreContext
11 | ): string => {
12 | const { type, getKey } = resource;
13 | const key = getKey(routerStoreContext, resourceStoreContext);
14 |
15 | return `${type}/${key}`;
16 | };
17 |
18 | export const getResourceIdentifiers = (
19 | resources: RouteResource[],
20 | routerStoreContext: RouterContext,
21 | resourceStoreContext: ResourceStoreContext
22 | ): string[] =>
23 | resources.map(resource =>
24 | getResourceIdentifier(resource, routerStoreContext, resourceStoreContext)
25 | );
26 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/get-resource-identifier/test.ts:
--------------------------------------------------------------------------------
1 | import { createResource } from '../../../resource-store';
2 |
3 | import { getResourceIdentifier, getResourceIdentifiers } from './index';
4 |
5 | const getDataPromise = Promise.resolve();
6 | const type = 'my-cool-type';
7 | const key = 'Ky';
8 | const mockResource = createResource({
9 | type,
10 | getKey: () => key,
11 | getData: () => getDataPromise,
12 | });
13 | const mockRoute = {
14 | name: '',
15 | path: '',
16 | component: () => null,
17 | resources: [],
18 | };
19 | const mockMatch = {
20 | params: {},
21 | query: {},
22 | isExact: false,
23 | path: '',
24 | url: '',
25 | };
26 | const mockRouterStoreContext = {
27 | route: mockRoute,
28 | match: mockMatch,
29 | query: {},
30 | };
31 |
32 | const mockResourceStoreContext = { foo: 'bar' };
33 |
34 | describe('getResourceIdentifier()', () => {
35 | it('should return the type and key as a concatenated string', () => {
36 | expect(
37 | getResourceIdentifier(
38 | mockResource,
39 | mockRouterStoreContext,
40 | mockResourceStoreContext
41 | )
42 | ).toEqual(`${type}/${key}`);
43 | });
44 | });
45 |
46 | describe('getResourceIdentifiers()', () => {
47 | it('should create an array of resource identifiers for the provided resources', () => {
48 | const mockResource2 = createResource({
49 | type: 'mockResource2',
50 | getKey: () => 'mockResource2Key',
51 | getData: () => getDataPromise,
52 | });
53 | const mockResource3 = createResource({
54 | type: 'mockResource3',
55 | getKey: () => 'mockResource3Key',
56 | getData: () => getDataPromise,
57 | });
58 |
59 | expect(
60 | getResourceIdentifiers(
61 | [mockResource, mockResource2, mockResource3],
62 | mockRouterStoreContext,
63 | mockResourceStoreContext
64 | )
65 | ).toEqual([
66 | `${type}/${key}`,
67 | 'mockResource2/mockResource2Key',
68 | 'mockResource3/mockResource3Key',
69 | ]);
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/get-resources-for-next-location/index.ts:
--------------------------------------------------------------------------------
1 | import { type RouterContext } from '../../../../../index';
2 | import {
3 | ResourceStoreContext,
4 | RouteResource,
5 | RouteWithResources,
6 | } from '../../../../common/types';
7 | import {
8 | getResourceIdentifier,
9 | getResourceIdentifiers,
10 | } from '../get-resource-identifier';
11 | import { routeHasChanged, routeHasResources } from '../route-checks';
12 |
13 | /**
14 | * Gets the requestable resources for the next location.
15 | */
16 | export const getResourcesForNextLocation = (
17 | prevRouterStoreContext: RouterContext,
18 | nextRouterStoreContext: RouterContext,
19 | resourceStoreContext: ResourceStoreContext
20 | ): RouteResource[] => {
21 | const { route: prevRoute } = prevRouterStoreContext;
22 | const { resources: prevResources = [] } =
23 | (prevRoute as RouteWithResources) || {};
24 | const { route: nextRoute } = nextRouterStoreContext;
25 | const { resources: nextResources = [] } =
26 | (nextRoute as RouteWithResources) || {};
27 |
28 | if (!routeHasResources(nextRoute)) {
29 | return [];
30 | }
31 |
32 | if (routeHasChanged(prevRoute, nextRoute)) {
33 | return nextResources;
34 | }
35 |
36 | const prevResourceIdentifiers = getResourceIdentifiers(
37 | prevResources,
38 | prevRouterStoreContext,
39 | resourceStoreContext
40 | );
41 |
42 | return nextResources.filter(
43 | resource =>
44 | !prevResourceIdentifiers.includes(
45 | getResourceIdentifier(
46 | resource,
47 | nextRouterStoreContext,
48 | resourceStoreContext
49 | )
50 | )
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/get-resources-for-next-location/test.ts:
--------------------------------------------------------------------------------
1 | import { createResource } from '../create-resource';
2 |
3 | import { getResourcesForNextLocation } from './index';
4 |
5 | const type = 'mockResourceType';
6 | const result = 'some result';
7 | const getDataPromise = Promise.resolve(result);
8 |
9 | const mockRoute = {
10 | name: '',
11 | path: '',
12 | component: () => null,
13 | resources: [],
14 | };
15 | const mockMatch = {
16 | params: {},
17 | query: {},
18 | isExact: false,
19 | path: '',
20 | url: '',
21 | };
22 | const mockRouterStoreContext = {
23 | route: mockRoute,
24 | match: mockMatch,
25 | query: {},
26 | };
27 |
28 | const mockResource = createResource({
29 | type,
30 | getKey: ({ match }: { match: any }) =>
31 | (match.params && match.params.key) || '',
32 | getData: () => getDataPromise,
33 | });
34 |
35 | const mockResourceStoreContext = {
36 | mock: 'context',
37 | };
38 |
39 | describe('getResourcesForNextLocation()', () => {
40 | describe('when the next route has no resources', () => {
41 | it('should return an empty array', () => {
42 | const prevRoute = {
43 | path: '/prev-route',
44 | resources: [mockResource],
45 | };
46 |
47 | const prevRouterStoreContext = {
48 | ...mockRouterStoreContext,
49 | route: prevRoute,
50 | };
51 | const nextRouterStoreContext = {
52 | ...mockRouterStoreContext,
53 | route: {
54 | path: '/next-route',
55 | resources: [],
56 | },
57 | };
58 |
59 | const nextResources = getResourcesForNextLocation(
60 | // @ts-ignore - not providing all route properties on mocks
61 | prevRouterStoreContext,
62 | nextRouterStoreContext,
63 | mockResourceStoreContext
64 | );
65 |
66 | expect(nextResources).toEqual([]);
67 | });
68 | });
69 |
70 | describe('when the next route does not match the prev route', () => {
71 | it('should request all resources on the next route', async () => {
72 | const prevRoute = {
73 | name: 'prev-route',
74 | path: '/prev-route',
75 | resources: [mockResource],
76 | };
77 | const nextRoute = {
78 | name: 'next-route',
79 | path: '/next-route',
80 | resources: [
81 | mockResource,
82 | { ...mockResource, type: 'another-resource' },
83 | { ...mockResource, type: 'even-more-resource' },
84 | ],
85 | };
86 | const prevRouterStoreContext = {
87 | ...mockRouterStoreContext,
88 | route: prevRoute,
89 | };
90 | const nextRouterStoreContext = {
91 | ...mockRouterStoreContext,
92 | route: nextRoute,
93 | };
94 |
95 | const nextResources = getResourcesForNextLocation(
96 | // @ts-ignore not providing all route properties on mocks
97 | prevRouterStoreContext,
98 | nextRouterStoreContext,
99 | mockResourceStoreContext
100 | );
101 |
102 | expect(nextResources).toEqual(nextRoute.resources);
103 | });
104 | });
105 |
106 | describe('when the next route is the same as the prev route', () => {
107 | it('should return all resources that will change between the next match and the prev match', async () => {
108 | const resourcesThatWillChange = [
109 | mockResource,
110 | { ...mockResource, type: 'another-resource' },
111 | { ...mockResource, type: 'even-more-resource' },
112 | ];
113 | const route = {
114 | path: '/my-cool-path',
115 | resources: [
116 | ...resourcesThatWillChange,
117 | {
118 | ...mockResource,
119 | type: 'an-unchanging-resource',
120 | getKey: () => 'no-change',
121 | },
122 | ],
123 | };
124 | const prevRouterStoreContext = {
125 | ...mockRouterStoreContext,
126 | route,
127 | match: { ...mockMatch, params: { key: 'one' } },
128 | };
129 | const nextRouterStoreContext = {
130 | ...mockRouterStoreContext,
131 | route,
132 | match: { ...mockMatch, params: { key: 'two' } },
133 | };
134 |
135 | const nextResources = getResourcesForNextLocation(
136 | // @ts-ignore not providing all route properties on mocks
137 | prevRouterStoreContext,
138 | nextRouterStoreContext,
139 | mockResourceStoreContext
140 | );
141 |
142 | expect(nextResources).toEqual(resourcesThatWillChange);
143 | });
144 | });
145 | });
146 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { getAccessedAt } from './accessed-at';
2 | export { createLoadingSlice } from './create-loading-slice';
3 | export { createResource } from './create-resource';
4 | export type {
5 | CreateResourceArgAsync,
6 | CreateResourceArgBase,
7 | CreateResourceArgSync,
8 | } from './create-resource';
9 | export {
10 | ResourceDependencyError,
11 | actionWithDependencies,
12 | mapActionWithDependencies,
13 | executeForDependents,
14 | getDependencies,
15 | } from './dependent-resources';
16 | export { getExpiresAt, setExpiresAt } from './expires-at';
17 | export { generateTimeGuard } from './generate-time-guard';
18 | export { getDefaultStateSlice } from './get-default-state-slice';
19 | export { getResourceIdentifier } from './get-resource-identifier';
20 | export { getResourcesForNextLocation } from './get-resources-for-next-location';
21 | export { validateLRUCache } from './lru-cache';
22 | export {
23 | deleteResourceState,
24 | getResourceState,
25 | setResourceState,
26 | getPrefetchSlice,
27 | setPrefetchSlice,
28 | } from './manage-resource-state';
29 | export { routeHasChanged, routeHasResources } from './route-checks';
30 | export { serializeError, deserializeError } from './serialize-error';
31 | export { shouldUseCache, isFromSsr } from './should-use-cache';
32 | export { setSsrDataPromise } from './ssr-data-promise';
33 | export { TimeoutError } from './timeout-error';
34 | export { transformData } from './transform-data';
35 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/lru-cache/index.ts:
--------------------------------------------------------------------------------
1 | import { StoreActionApi } from 'react-sweet-state';
2 |
3 | import {
4 | RouteResource,
5 | RouteResourceDataForType,
6 | } from '../../../../common/types';
7 | import { State } from '../../types';
8 | import { deleteResourceState } from '../manage-resource-state';
9 |
10 | export const getExpiredResourceDataKeys = (
11 | routeResourceDataForType: RouteResourceDataForType,
12 | currentKey: string
13 | ): string[] =>
14 | Object.keys(routeResourceDataForType).filter(resourceDataKey => {
15 | const {
16 | [resourceDataKey]: { expiresAt },
17 | } = routeResourceDataForType;
18 |
19 | return (
20 | resourceDataKey !== currentKey && expiresAt && expiresAt <= Date.now()
21 | );
22 | });
23 |
24 | export const getLRUResourceKey = (
25 | maxCache: number,
26 | resourceDataForType: RouteResourceDataForType,
27 | currentKey: string
28 | ): null | string => {
29 | if (maxCache === Infinity || maxCache < 1) {
30 | return null;
31 | }
32 |
33 | const resourceDataKeys = Object.keys(resourceDataForType);
34 |
35 | if (resourceDataKeys.length < maxCache) {
36 | return null;
37 | }
38 |
39 | const expiredResourceDataKeys = getExpiredResourceDataKeys(
40 | resourceDataForType,
41 | currentKey
42 | );
43 |
44 | if (expiredResourceDataKeys.length > 0) {
45 | return expiredResourceDataKeys[0];
46 | }
47 |
48 | return resourceDataKeys.reduce((leastRecentKey: string, key: string) => {
49 | const {
50 | [key]: { accessedAt },
51 | [leastRecentKey]: { accessedAt: leastRecentAccessedAt },
52 | } = resourceDataForType;
53 |
54 | if (
55 | accessedAt &&
56 | leastRecentAccessedAt &&
57 | accessedAt < leastRecentAccessedAt
58 | ) {
59 | return key;
60 | }
61 |
62 | return leastRecentKey;
63 | }, resourceDataKeys[0]);
64 | };
65 |
66 | export const validateLRUCache =
67 | (resource: RouteResource, key: string) =>
68 | ({ getState, dispatch }: StoreActionApi) => {
69 | const { type, maxCache } = resource;
70 | const {
71 | data: { [type]: resourceDataForType },
72 | } = getState();
73 |
74 | if (!resourceDataForType) {
75 | return;
76 | }
77 |
78 | const keyTobeDeleted = getLRUResourceKey(
79 | maxCache,
80 | resourceDataForType,
81 | key
82 | );
83 | if (!keyTobeDeleted) {
84 | return;
85 | }
86 | dispatch(deleteResourceState(type, keyTobeDeleted));
87 | };
88 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/lru-cache/test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | RouteResourceDataForType,
3 | RouteResourceResponse,
4 | RouteResourceTimestamp,
5 | } from '../../../../common/types';
6 |
7 | import { getLRUResourceKey } from './index';
8 |
9 | const mock = ({
10 | data,
11 | accessedAt,
12 | expiresAt,
13 | }: {
14 | data: T;
15 | accessedAt: RouteResourceTimestamp;
16 | expiresAt: RouteResourceTimestamp;
17 | }): RouteResourceResponse => ({
18 | loading: false,
19 | error: null,
20 | data,
21 | promise: Promise.resolve(data),
22 | accessedAt,
23 | expiresAt,
24 | });
25 |
26 | describe('getLRUResourceKey()', () => {
27 | const resourceDataForTypeWithExpiredKeys: RouteResourceDataForType = {
28 | home: mock({
29 | data: 'home',
30 | accessedAt: 2500,
31 | expiresAt: 5000,
32 | }),
33 | about: mock({
34 | data: 'about',
35 | accessedAt: 500,
36 | expiresAt: 1000,
37 | }),
38 | shop: mock({
39 | data: 'shop',
40 | accessedAt: 2500,
41 | expiresAt: 3000,
42 | }),
43 | };
44 |
45 | const resourceDataForTypeWithNoExpiredKeys: RouteResourceDataForType = {
46 | home: mock({
47 | data: 'home',
48 | accessedAt: 2400,
49 | expiresAt: 5000,
50 | }),
51 | about: mock({
52 | data: 'about',
53 | accessedAt: 2600,
54 | expiresAt: 3500,
55 | }),
56 | shop: mock({
57 | data: 'shop',
58 | accessedAt: 2500,
59 | expiresAt: 3000,
60 | }),
61 | };
62 |
63 | const currentTime = 2000;
64 |
65 | beforeEach(() => {
66 | jest.spyOn(global.Date, 'now').mockReturnValue(currentTime);
67 | });
68 |
69 | it('should return null if max cache is equal to Infinity', () => {
70 | const key = getLRUResourceKey(
71 | Infinity,
72 | resourceDataForTypeWithExpiredKeys,
73 | 'home'
74 | );
75 | expect(key).toBeNull();
76 | });
77 |
78 | it('should return null if max cache is less than 1', () => {
79 | const key = getLRUResourceKey(
80 | 0,
81 | resourceDataForTypeWithExpiredKeys,
82 | 'home'
83 | );
84 | expect(key).toBeNull();
85 | });
86 |
87 | it('should return expired key if keys for a type are less than the max cache value', () => {
88 | const key = getLRUResourceKey(
89 | 2,
90 | resourceDataForTypeWithExpiredKeys,
91 | 'home'
92 | );
93 | expect(key).toEqual('about');
94 | });
95 |
96 | it('should return the least recent key which is not equal to the current key if max cache is attained for a type', () => {
97 | expect(
98 | getLRUResourceKey(2, resourceDataForTypeWithNoExpiredKeys, 'home')
99 | ).toEqual('home');
100 |
101 | expect(
102 | getLRUResourceKey(2, resourceDataForTypeWithNoExpiredKeys, 'shop')
103 | ).toEqual('home');
104 | });
105 | });
106 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/lru-cache/types.ts:
--------------------------------------------------------------------------------
1 | export type ResourceSliceOptions = {
2 | type: string;
3 | key: string;
4 | maxCache: number;
5 | };
6 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/manage-resource-state/index.ts:
--------------------------------------------------------------------------------
1 | import { StoreActionApi } from 'react-sweet-state';
2 |
3 | import {
4 | ResourceType,
5 | ResourceKey,
6 | RouteResourceResponse,
7 | } from '../../../../common/types';
8 | import { PrefetchSlice, State } from '../../types';
9 |
10 | export const getPrefetchSlice =
11 | (type: ResourceType, key: ResourceKey) =>
12 | ({ getState }: StoreActionApi) => {
13 | const { prefetching } = getState();
14 | const slice = prefetching?.[type]?.[key];
15 |
16 | // check if slice is still fresh
17 | if (slice && Date.now() < Number(slice.expiresAt)) {
18 | return slice;
19 | }
20 |
21 | return undefined;
22 | };
23 |
24 | export const setPrefetchSlice =
25 | (type: ResourceType, key: ResourceKey, slice: PrefetchSlice | undefined) =>
26 | ({ setState, getState }: StoreActionApi) => {
27 | const { prefetching } = getState();
28 | // avoid doing extra set if same value
29 | if (prefetching?.[type]?.[key] === slice) return;
30 |
31 | // cheap optimisation to provide prefetched result syncronously
32 | slice?.promise?.then(maybeData => (slice.data = maybeData));
33 |
34 | setState({
35 | prefetching: {
36 | ...prefetching,
37 | [type]: { ...prefetching?.[type], [key]: slice },
38 | },
39 | });
40 | };
41 |
42 | export const setResourceState =
43 | (type: ResourceType, key: ResourceKey, state: RouteResourceResponse) =>
44 | ({ setState, getState, dispatch }: StoreActionApi) => {
45 | const { data } = getState();
46 | // every time we override a resource we kill its prefetched
47 | dispatch(setPrefetchSlice(type, key, undefined));
48 |
49 | setState({
50 | data: {
51 | ...data,
52 | [type]: {
53 | ...(data[type] || {}),
54 | [key]: state,
55 | },
56 | },
57 | });
58 | };
59 |
60 | export const getResourceState =
61 | (type: ResourceType, key: ResourceKey) =>
62 | ({ getState }: StoreActionApi) => {
63 | const {
64 | data: { [type]: resourceDataForType },
65 | } = getState();
66 |
67 | return resourceDataForType?.[key];
68 | };
69 |
70 | export const deleteResourceState =
71 | (type: ResourceType, key?: ResourceKey) =>
72 | ({ getState, setState }: StoreActionApi) => {
73 | const { data } = getState();
74 | const { [type]: resourceForType, ...remainingData } = data;
75 |
76 | if (key === undefined) {
77 | setState({
78 | data: remainingData,
79 | });
80 | } else if (resourceForType) {
81 | const { [key]: _, ...remainingForType } = resourceForType;
82 | setState({
83 | data: {
84 | ...remainingData,
85 | [type]: remainingForType,
86 | },
87 | });
88 | }
89 | };
90 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/route-checks/index.ts:
--------------------------------------------------------------------------------
1 | import { RouteWithResources } from '../../../../common/types';
2 |
3 | export const routeHasResources = (route: RouteWithResources | null): boolean =>
4 | !!(route && route.resources && route.resources.length > 0);
5 |
6 | export const routeHasChanged = (
7 | prev: RouteWithResources,
8 | next: RouteWithResources
9 | ): boolean => prev.name !== next.name || prev.path !== next.path;
10 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/route-checks/test.ts:
--------------------------------------------------------------------------------
1 | import { routeHasChanged, routeHasResources } from './index';
2 |
3 | const mockRoute = {
4 | name: 'foo',
5 | path: '/some-path',
6 | component: () => null,
7 | };
8 |
9 | describe('routeHasChanged()', () => {
10 | it('should return true if the route name does not match', () => {
11 | expect(
12 | routeHasChanged(mockRoute, {
13 | ...mockRoute,
14 | name: 'bar',
15 | })
16 | ).toBeTruthy();
17 | });
18 |
19 | it('should return true if the route path does not match', () => {
20 | expect(
21 | routeHasChanged(mockRoute, {
22 | ...mockRoute,
23 | path: '/bar',
24 | })
25 | ).toBeTruthy();
26 | });
27 |
28 | it('should return false if the route name matches', () => {
29 | expect(routeHasChanged(mockRoute, { ...mockRoute })).toBeFalsy();
30 | });
31 | });
32 |
33 | describe('routeHasResources()', () => {
34 | it('should return true if the route has one or more resources', () => {
35 | const route = {
36 | path: '/some-path',
37 | component: () => null,
38 | resources: [{ mock: 'resource' }],
39 | };
40 | // @ts-ignore - not providing all properties on mock
41 | expect(routeHasResources(route)).toBeTruthy();
42 | });
43 |
44 | it('should return false if the route does not exist', () => {
45 | const route = null;
46 | expect(routeHasResources(route)).toBeFalsy();
47 | });
48 |
49 | it('should return false if the route has no resources', () => {
50 | const route = {
51 | path: '/some-path',
52 | component: () => null,
53 | resources: [],
54 | };
55 | // @ts-ignore - not providing all properties on mock
56 | expect(routeHasResources(route)).toBeFalsy();
57 | });
58 |
59 | it('should return false if the route does not have a resources property', () => {
60 | const route = {
61 | path: '/some-path',
62 | component: () => null,
63 | };
64 | // @ts-ignore - not providing all properties on mock
65 | expect(routeHasResources(route)).toBeFalsy();
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/serialize-error/index.ts:
--------------------------------------------------------------------------------
1 | // NOTE! This has been copy pasted from https://github.com/sindresorhus/serialize-error/blob/master/index.js
2 | // When the router moves to its own package, this must become a dependency
3 | // For now, we have put it here so that we don't need to get it included in Jira's vendor bundle
4 |
5 | class NonError extends Error {
6 | constructor(message: string) {
7 | super(message);
8 | this.name = 'NonError';
9 |
10 | if (Error.captureStackTrace) {
11 | Error.captureStackTrace(this, NonError);
12 | }
13 | }
14 | }
15 |
16 | const commonProperties = ['name', 'message', 'stack', 'code'];
17 |
18 | const destroyCircular = (from: any, seen: any, to_?: any) => {
19 | const to = to_ || (Array.isArray(from) ? [] : {});
20 |
21 | seen.push(from);
22 |
23 | for (const [key, value] of Object.entries(from)) {
24 | if (typeof value === 'function') {
25 | continue;
26 | }
27 |
28 | if (!value || typeof value !== 'object') {
29 | to[key] = value;
30 | continue;
31 | }
32 |
33 | if (!seen.includes(from[key])) {
34 | to[key] = destroyCircular(from[key], seen.slice());
35 | continue;
36 | }
37 |
38 | to[key] = '[Circular]';
39 | }
40 |
41 | for (const property of commonProperties) {
42 | if (typeof from[property] === 'string') {
43 | to[property] = from[property];
44 | }
45 | }
46 |
47 | return to;
48 | };
49 |
50 | // const serializeError = (value: ErrorType) => {
51 | export const serializeError = (value: any) => {
52 | if (typeof value === 'object' && value !== null) {
53 | return destroyCircular(value, []);
54 | }
55 |
56 | // People sometimes throw things besides Error objects…
57 | if (typeof value === 'function') {
58 | // `JSON.stringify()` discards functions. We do too, unless a function is thrown directly.
59 | return `[Function: ${value.name || 'anonymous'}]`;
60 | }
61 |
62 | return value;
63 | };
64 |
65 | export const deserializeError = (value: any) => {
66 | if (value instanceof Error) {
67 | return value;
68 | }
69 |
70 | if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
71 | const newError = new Error();
72 | destroyCircular(value, [], newError);
73 |
74 | return newError;
75 | }
76 |
77 | return new NonError(value);
78 | };
79 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/should-use-cache/index.tsx:
--------------------------------------------------------------------------------
1 | import { RouteResourceResponse } from '../../../../common/types';
2 |
3 | export const isFromSsr = ({ expiresAt }: RouteResourceResponse): boolean =>
4 | expiresAt === null;
5 |
6 | const isFresh = (resource: RouteResourceResponse): boolean => {
7 | if (isFromSsr(resource)) {
8 | return true;
9 | }
10 |
11 | return Date.now() < Number(resource.expiresAt);
12 | };
13 |
14 | export const shouldUseCache = (resource: RouteResourceResponse): boolean => {
15 | if (resource?.error?.name === 'TimeoutError') {
16 | return false;
17 | }
18 |
19 | if (resource.loading) {
20 | return true;
21 | }
22 |
23 | if (isFresh(resource)) {
24 | return true;
25 | }
26 |
27 | return false;
28 | };
29 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/should-use-cache/test.ts:
--------------------------------------------------------------------------------
1 | import { mockRouteResourceResponse } from '../../../../common/mocks';
2 |
3 | import { isFromSsr, shouldUseCache } from './index';
4 |
5 | describe('shouldUseCache()', () => {
6 | afterEach(() => {
7 | jest.clearAllMocks();
8 | });
9 |
10 | it('should use cached data if the resource is currently loading', () => {
11 | const response = { ...mockRouteResourceResponse, loading: true };
12 |
13 | expect(shouldUseCache(response)).toBeTruthy();
14 | });
15 |
16 | it('should use cached data if the resource has been hydrated on the server ie., expiresAt is null', () => {
17 | const response = {
18 | ...mockRouteResourceResponse,
19 | loading: false,
20 | expiresAt: null,
21 | };
22 |
23 | expect(shouldUseCache(response)).toBeTruthy();
24 | });
25 |
26 | it('should use cached data if the current timestamp has not yet reached the expiresAt value', () => {
27 | const response = { ...mockRouteResourceResponse, expiresAt: 2 };
28 |
29 | jest.spyOn(global.Date, 'now').mockReturnValue(1);
30 |
31 | expect(shouldUseCache(response)).toBeTruthy();
32 | });
33 |
34 | it('should use cached data if the data is a falsy primitive type and the resource has not expired', () => {
35 | const response = {
36 | ...mockRouteResourceResponse,
37 | expiresAt: 2,
38 | data: null,
39 | error: null,
40 | };
41 |
42 | jest.spyOn(global.Date, 'now').mockReturnValue(1);
43 |
44 | expect(shouldUseCache(response)).toBeTruthy();
45 | });
46 |
47 | it('should not use cached data if the resource has expired', () => {
48 | const response = { ...mockRouteResourceResponse, expiresAt: 1 };
49 |
50 | jest.spyOn(global.Date, 'now').mockReturnValue(5);
51 |
52 | expect(shouldUseCache(response)).toBeFalsy();
53 | });
54 | });
55 |
56 | describe('isFromSsr()', () => {
57 | it('should return true if the slice is from ssr', () => {
58 | const slice = { ...mockRouteResourceResponse, expiresAt: null };
59 |
60 | expect(isFromSsr(slice)).toBeTruthy();
61 | });
62 |
63 | it('should return false if the slice is not from ssr', () => {
64 | const slice = { ...mockRouteResourceResponse, expiresAt: 1 };
65 |
66 | expect(isFromSsr(slice)).toBeFalsy();
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/ssr-data-promise/index.ts:
--------------------------------------------------------------------------------
1 | import { RouteResourceResponse } from '../../../../common/types';
2 |
3 | export const setSsrDataPromise = (
4 | slice: RouteResourceResponse
5 | ): RouteResourceResponse =>
6 | slice.promise === null
7 | ? { ...slice, promise: Promise.resolve(slice.data) }
8 | : slice;
9 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/timeout-error/index.ts:
--------------------------------------------------------------------------------
1 | export class TimeoutError extends Error {
2 | constructor(message: string) {
3 | super('Resource timed out: ' + message);
4 | this.name = 'TimeoutError';
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/transform-data/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ResourceStoreData,
3 | RouteResourceResponse,
4 | } from '../../../../common/types';
5 |
6 | export const transformData = (
7 | data: ResourceStoreData,
8 | transformer: (
9 | slice: RouteResourceResponse
10 | ) => RouteResourceResponse
11 | ) =>
12 | Object.keys(data).reduce((acc: ResourceStoreData, type: string) => {
13 | if (!acc[type]) {
14 | acc[type] = {};
15 | }
16 |
17 | Object.keys(data[type]).forEach(key => {
18 | const slice = data[type][key];
19 |
20 | acc[type][key] = transformer(slice);
21 | });
22 |
23 | return acc;
24 | }, {});
25 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-store/utils/transform-data/test.ts:
--------------------------------------------------------------------------------
1 | import { transformData } from './index';
2 |
3 | describe('transformData()', () => {
4 | it('should transform the supplied data with the transformer function passed', () => {
5 | const staticProps = { promise: null, error: null, loading: false };
6 | const transformFrom = {
7 | data: { hello: 'world' },
8 | expiresAt: Infinity,
9 | accessedAt: 0,
10 | };
11 | const transformTo = {
12 | data: { goodbye: 'cruel world' },
13 | expiresAt: 1000,
14 | accessedAt: 0,
15 | };
16 | const data = {
17 | type: {
18 | key: {
19 | ...staticProps,
20 | ...transformFrom,
21 | },
22 | },
23 | };
24 | const transformed = transformData(data, slice => ({
25 | ...slice,
26 | ...transformTo,
27 | }));
28 |
29 | expect(transformed).toEqual({
30 | type: {
31 | key: {
32 | ...staticProps,
33 | ...transformTo,
34 | },
35 | },
36 | });
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/src/resources/controllers/resource-subscriber/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react';
2 |
3 | import type { RouterContext } from '../../../index';
4 | import type {
5 | RouteResource,
6 | RouteResourceResponse,
7 | RouteResourceUpdater,
8 | } from '../../common/types';
9 | import { useResource } from '../use-resource';
10 |
11 | type Props = {
12 | children: (
13 | resource: RouteResourceResponse & {
14 | update: (getNewData: RouteResourceUpdater) => void;
15 | refresh: () => void;
16 | }
17 | ) => ReactNode;
18 | resource: RouteResource;
19 | options?: {
20 | routerContext?: RouterContext;
21 | };
22 | };
23 |
24 | export const ResourceSubscriber = ({
25 | children,
26 | resource,
27 | options,
28 | }: Props) => {
29 | const result = useResource(resource, options);
30 |
31 | return <>{children(result)}>;
32 | };
33 |
--------------------------------------------------------------------------------
/src/resources/controllers/use-resource/index.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useMemo } from 'react';
2 | import { createHook } from 'react-sweet-state';
3 |
4 | import { type RouterContext } from '../../../common/types';
5 | import {
6 | RouterStore,
7 | useRouterStoreActions,
8 | } from '../../../controllers/router-store';
9 | import {
10 | type EntireRouterState,
11 | type AllRouterActions,
12 | } from '../../../controllers/router-store/types';
13 | import {
14 | RouteResource,
15 | RouteResourceResponse,
16 | RouteResourceUpdater,
17 | UseResourceHookResponse,
18 | } from '../../common/types';
19 | import { useResourceStore, useResourceStoreActions } from '../resource-store';
20 |
21 | type UseResourceOptions = {
22 | routerContext?: RouterContext;
23 | };
24 |
25 | export const useResource = (
26 | resource: RouteResource,
27 | options?: UseResourceOptions
28 | ): UseResourceHookResponse => {
29 | const actions = useResourceStoreActions();
30 | const { getContext: getRouterContext } = useRouterStoreActions();
31 |
32 | // Dynamically generate a router subscriber based on the resource:
33 | // makes the component re-render only when key changes instead of
34 | // after every route change
35 | const useKey = useMemo(
36 | () =>
37 | createHook<
38 | EntireRouterState,
39 | AllRouterActions,
40 | string,
41 | RouterContext | void
42 | >(RouterStore, {
43 | selector: ({ match, query, route }, keyArg): string =>
44 | resource.getKey(
45 | keyArg != null ? keyArg : { match, query, route },
46 | actions.getContext()
47 | ),
48 | }),
49 | [resource, actions]
50 | );
51 |
52 | // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
53 | const key = useKey(options?.routerContext!)[0];
54 | const [slice] = useResourceStore({
55 | type: resource.type,
56 | key,
57 | }) as RouteResourceResponse[];
58 |
59 | // we keep route context bound to key, so route context won't refresh
60 | // unless key changes. This allows refresh to be called on effect cleanup
61 | // or asynchronously, when route context might already have changed
62 | const routerContext = useMemo(
63 | // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
64 | () => options?.routerContext! || getRouterContext(),
65 | // eslint-disable-next-line react-hooks/exhaustive-deps
66 | [key]
67 | );
68 |
69 | const update = useCallback(
70 | (updater: RouteResourceUpdater) => {
71 | actions.updateResourceState(
72 | resource as RouteResource,
73 | routerContext,
74 | updater as RouteResourceUpdater
75 | );
76 | },
77 | [resource, routerContext, actions]
78 | );
79 |
80 | const clear = useCallback(() => {
81 | actions.clearResource(resource, routerContext);
82 | }, [resource, routerContext, actions]);
83 |
84 | const clearAll = useCallback(() => {
85 | actions.clearResource(resource);
86 | }, [resource, actions]);
87 |
88 | const refresh = useCallback(() => {
89 | actions.getResourceFromRemote(
90 | resource as RouteResource,
91 | routerContext,
92 | { prefetch: false }
93 | );
94 | }, [resource, routerContext, actions]);
95 |
96 | return { ...slice, update, key, refresh, clear, clearAll };
97 | };
98 |
--------------------------------------------------------------------------------
/src/resources/index.ts:
--------------------------------------------------------------------------------
1 | export { createResourcesPlugin } from './plugin';
2 |
3 | export { ResourceSubscriber } from './controllers/resource-subscriber';
4 | export { useResource } from './controllers/use-resource';
5 |
6 | export { addResourcesListener } from './controllers/add-resource-listener';
7 |
8 | export {
9 | createResource,
10 | useResourceStoreContext,
11 | ResourceDependencyError,
12 | getResourceStore,
13 | ResourceStore,
14 | } from './controllers/resource-store';
15 |
16 | export type {
17 | CreateResourceArgBase,
18 | CreateResourceArgSync,
19 | CreateResourceArgAsync,
20 | } from './controllers/resource-store';
21 |
22 | export type {
23 | RouteResources,
24 | ResourceStoreContext,
25 | ResourceStoreData,
26 | RouteResource,
27 | RouteResourceError,
28 | RouteResourceLoading,
29 | RouteResourceResponse,
30 | RouteResourceUpdater,
31 | RouterDataContext,
32 | UseResourceHookResponse,
33 | } from './common/types';
34 |
35 | export { PLUGIN_ID } from './plugin/index';
36 |
37 | export { mockRouteResourceResponse } from './common/mocks';
38 |
--------------------------------------------------------------------------------
/src/resources/plugin/index.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin, RouterContext } from '../../index';
2 | import type {
3 | ResourceStoreContext,
4 | RouteResourceResponse,
5 | ResourceStoreData,
6 | } from '../common/types';
7 | import { getResourceStore } from '../controllers/resource-store';
8 | import { getResourcesForNextLocation } from '../controllers/resource-store/utils';
9 |
10 | export const PLUGIN_ID = 'resources-plugin';
11 |
12 | const loadOnUrlChange = (
13 | context: RouterContext,
14 | prevContext: RouterContext
15 | ) => {
16 | const { requestResources, getContext: getResourceStoreContext } =
17 | getResourceStore().actions;
18 |
19 | const nextResources = getResourcesForNextLocation(
20 | prevContext,
21 | context,
22 | getResourceStoreContext()
23 | );
24 |
25 | return Promise.all(requestResources(nextResources, context, {}));
26 | };
27 |
28 | const beforeLoad = ({
29 | context,
30 | nextContext,
31 | }: {
32 | context: RouterContext;
33 | nextContext: RouterContext;
34 | }) => {
35 | const { cleanExpiredResources, getContext: getResourceStoreContext } =
36 | getResourceStore().actions;
37 | const nextResources = getResourcesForNextLocation(
38 | context,
39 | nextContext,
40 | getResourceStoreContext()
41 | );
42 | cleanExpiredResources(nextResources, nextContext);
43 | };
44 |
45 | type LoadedResources = Promise[]>;
46 |
47 | interface ResourcesPlugin extends Plugin {
48 | getSerializedResources: () => Promise;
49 | }
50 |
51 | export const createResourcesPlugin = ({
52 | context: initialResourceContext,
53 | resourceData: initialResourceData,
54 | timeout,
55 | }: {
56 | context?: ResourceStoreContext;
57 | resourceData?: ResourceStoreData;
58 | timeout?: number;
59 | }): ResourcesPlugin => {
60 | let latestLoadedResources: LoadedResources = Promise.resolve([]);
61 |
62 | getResourceStore().actions.hydrate({
63 | resourceContext: initialResourceContext,
64 | resourceData: initialResourceData,
65 | });
66 |
67 | return {
68 | id: PLUGIN_ID,
69 | beforeRouteLoad: beforeLoad,
70 | routeLoad: ({ context, prevContext }) => {
71 | const { route, match, query } = context;
72 | // TODO: in next refactoring add `if (route.resources)` check
73 | // For now requesting resources for every route even if `resources` prop is missing on Route
74 | if (prevContext) {
75 | latestLoadedResources = loadOnUrlChange(context, prevContext);
76 | } else {
77 | latestLoadedResources = getResourceStore().actions.requestAllResources(
78 | {
79 | route,
80 | match,
81 | query,
82 | },
83 | { timeout }
84 | );
85 | }
86 | },
87 | routePrefetch: ({ context, nextContext }) => {
88 | const { prefetchResources, getContext: getResourceStoreContext } =
89 | getResourceStore().actions;
90 |
91 | const nextResources = getResourcesForNextLocation(
92 | context,
93 | nextContext,
94 | getResourceStoreContext()
95 | );
96 |
97 | return {
98 | resources: prefetchResources(nextResources, nextContext, {}),
99 | };
100 | },
101 | getLatestResources: (): LoadedResources => latestLoadedResources,
102 | getSerializedResources: async () => {
103 | await latestLoadedResources;
104 |
105 | return getResourceStore().actions.getSafeData();
106 | },
107 | };
108 | };
109 |
--------------------------------------------------------------------------------
/src/resources/plugin/test.ts:
--------------------------------------------------------------------------------
1 | import { getResourceStore } from '../controllers/resource-store';
2 |
3 | import { createResourcesPlugin } from './index';
4 |
5 | const firstContextMock = {
6 | match: {
7 | isExact: true,
8 | params: {},
9 | path: '/pages',
10 | query: {},
11 | url: '/pages',
12 | },
13 | query: { key: 'value' },
14 | route: {
15 | component: () => null,
16 | exact: true,
17 | name: 'pages',
18 | path: '/pages',
19 | },
20 | };
21 |
22 | const secondContextMock = {
23 | match: {
24 | isExact: true,
25 | params: { id: '1' },
26 | path: '/pages/:id',
27 | query: {},
28 | url: '/pages/1',
29 | },
30 | query: {},
31 | route: {
32 | component: () => null,
33 | name: 'page',
34 | path: '/pages/:id',
35 | },
36 | };
37 |
38 | describe('Resources plugin', () => {
39 | it('cleans up expired resources before route change', () => {
40 | const cleanExpiredResources = jest.spyOn(
41 | getResourceStore().actions,
42 | 'cleanExpiredResources'
43 | );
44 | const plugin = createResourcesPlugin({
45 | context: {},
46 | resourceData: {},
47 | });
48 |
49 | if (plugin.beforeRouteLoad !== undefined)
50 | plugin.beforeRouteLoad({
51 | context: firstContextMock,
52 | nextContext: secondContextMock,
53 | });
54 |
55 | expect(cleanExpiredResources).toBeCalledWith([], secondContextMock);
56 | });
57 |
58 | it('resources are requested after router init', () => {
59 | const requestAllResources = jest.spyOn(
60 | getResourceStore().actions,
61 | 'requestAllResources'
62 | );
63 | const plugin = createResourcesPlugin({
64 | context: {},
65 | resourceData: {},
66 | timeout: 1000,
67 | });
68 |
69 | if (plugin.routeLoad !== undefined)
70 | plugin.routeLoad({
71 | context: secondContextMock,
72 | });
73 |
74 | expect(requestAllResources).toBeCalledWith(secondContextMock, {
75 | timeout: 1000,
76 | });
77 | });
78 |
79 | it('resources are requested after route change', () => {
80 | const requestResources = jest.spyOn(
81 | getResourceStore().actions,
82 | 'requestResources'
83 | );
84 | const plugin = createResourcesPlugin({
85 | context: {},
86 | resourceData: {},
87 | timeout: 1000,
88 | });
89 |
90 | if (plugin.routeLoad !== undefined)
91 | plugin.routeLoad({
92 | context: secondContextMock,
93 | prevContext: firstContextMock,
94 | });
95 |
96 | expect(requestResources).toBeCalledWith([], secondContextMock, {});
97 | });
98 |
99 | it('Next route resources are prefetched', () => {
100 | const prefetchResources = jest.spyOn(
101 | getResourceStore().actions,
102 | 'prefetchResources'
103 | );
104 | const plugin = createResourcesPlugin({
105 | context: {},
106 | resourceData: {},
107 | });
108 |
109 | if (plugin.routePrefetch !== undefined)
110 | plugin.routePrefetch({
111 | context: firstContextMock,
112 | nextContext: secondContextMock,
113 | });
114 |
115 | expect(prefetchResources).toBeCalledWith([], secondContextMock, {});
116 | });
117 | });
118 |
--------------------------------------------------------------------------------
/src/ui/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Link } from './link';
2 | export { RouteComponent } from './route-component';
3 |
--------------------------------------------------------------------------------
/src/ui/link/utils/get-valid-link-type.tsx:
--------------------------------------------------------------------------------
1 | import { LinkElementType } from '../../../common/types';
2 |
3 | const VALID_LINK_TYPES = ['a', 'button'];
4 |
5 | export const getValidLinkType = (type: LinkElementType): LinkElementType =>
6 | VALID_LINK_TYPES.includes(type) ? type : 'a';
7 |
--------------------------------------------------------------------------------
/src/ui/link/utils/handle-navigation.tsx:
--------------------------------------------------------------------------------
1 | import { KeyboardEvent, MouseEvent } from 'react';
2 |
3 | import { Route } from '../../../common/types';
4 | import { isKeyboardEvent, isModifiedEvent } from '../../../common/utils/event';
5 |
6 | type LinkNavigationEvent = MouseEvent | KeyboardEvent;
7 |
8 | type LinkPressArgs = {
9 | target?: string;
10 | routerActions: {
11 | push: (href: string, state?: unknown) => void;
12 | replace: (href: string, state?: unknown) => void;
13 | pushTo: (route: Route, attributes: any, state?: unknown) => void;
14 | replaceTo: (route: Route, attributes: any, state?: unknown) => void;
15 | };
16 | replace: boolean;
17 | href: string;
18 | onClick?: (e: LinkNavigationEvent) => void;
19 | to: [Route, any] | void;
20 | state?: unknown;
21 | };
22 |
23 | export const handleNavigation = (
24 | event: any,
25 | { onClick, target, replace, routerActions, href, to, state }: LinkPressArgs
26 | ): void => {
27 | if (isKeyboardEvent(event) && event.key !== 'Enter') {
28 | return;
29 | }
30 |
31 | onClick && onClick(event);
32 |
33 | if (
34 | !event.defaultPrevented && // onClick prevented default
35 | ((isKeyboardEvent(event) && event.key === 'Enter') || event.button === 0) && // ignore everything but left clicks and Enter key
36 | (!target || target === '_self') && // let browser handle "target=_blank" etc.
37 | !isModifiedEvent(event) // ignore clicks with modifier keys
38 | ) {
39 | event.preventDefault();
40 | if (to) {
41 | const method = replace ? routerActions.replaceTo : routerActions.pushTo;
42 | method(...to, state);
43 | } else {
44 | const method = replace ? routerActions.replace : routerActions.push;
45 | method(href, state);
46 | }
47 | }
48 | };
49 |
--------------------------------------------------------------------------------
/src/ui/link/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { getValidLinkType } from './get-valid-link-type';
2 | export { handleNavigation } from './handle-navigation';
3 |
--------------------------------------------------------------------------------
/src/ui/route-component/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useRouter } from '../../controllers';
4 |
5 | export const RouteComponent = () => {
6 | const [{ action, location, match, query, route }] = useRouter();
7 |
8 | if (!route || !route.component) {
9 | return null;
10 | }
11 |
12 | return (
13 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/ui/route-component/test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import React from 'react';
4 |
5 | import { Router } from '../../controllers';
6 |
7 | import { RouteComponent } from './index';
8 |
9 | const MockComponent = () => My component
;
10 |
11 | const MockLocation = {
12 | pathname: '/home',
13 | search: '',
14 | hash: '',
15 | };
16 |
17 | const HistoryMock = {
18 | push: jest.fn(),
19 | replace: jest.fn(),
20 | goBack: jest.fn(),
21 | goForward: jest.fn(),
22 | registerBlock: jest.fn(),
23 | listen: () => jest.fn(),
24 | createHref: jest.fn(),
25 | location: MockLocation,
26 | _history: jest.fn(),
27 | };
28 |
29 | const routes = [
30 | {
31 | component: MockComponent,
32 | path: '/home',
33 | },
34 | ];
35 |
36 | describe('', () => {
37 | it('renders the route component', () => {
38 | render(
39 | // @ts-expect-error
40 |
41 |
42 |
43 | );
44 |
45 | // Check if the mock component is rendered
46 | const component = screen.getByText('My component');
47 | expect(component).toBeInTheDocument();
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export {
2 | createLegacyHistory,
3 | generatePath,
4 | findRouterContext,
5 | matchRoute,
6 | matchInvariantRoute,
7 | isSameRouteMatch,
8 | } from './common/utils';
9 |
10 | export { shouldReloadWhenRouteMatchChanges } from './common/utils/should-reload-when-route-match-changes';
11 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": [
4 | "src/**/*"
5 | ],
6 | "exclude": [
7 | "examples/**/*",
8 | "src/__tests__/**/*",
9 | "src/**/*test.ts",
10 | "src/**/*test.tsx"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "declaration": true,
5 | "esModuleInterop": true,
6 | "isolatedModules": true,
7 | "jsx": "preserve",
8 | "module": "esnext",
9 | "moduleResolution": "node",
10 | "paths": {
11 | "react-resource-router": ["src"],
12 | "react-resource-router/resources": ["src/resources"]
13 | },
14 | "preserveConstEnums": true,
15 | "skipLibCheck": true,
16 | "strict": true,
17 | "target": "esnext",
18 | "typeRoots": ["./node_modules/@types", "types"]
19 | },
20 | "include": ["examples/**/*", "src/**/*", "codemods/**/*"]
21 | }
22 |
--------------------------------------------------------------------------------
/utils/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "../build/cjs/utils.js",
3 | "module": "../build/esm/utils.js",
4 | "types": "../build/cjs/utils.d.ts"
5 | }
6 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const { lstatSync, readdirSync } = require('fs');
2 | const path = require('path');
3 |
4 | // This function generates configuration for files in the
5 | // ./src/examples/ folder
6 | const generateExampleEntries = function () {
7 | const src = './examples';
8 |
9 | // Get all subdirectories in the ./src/apps,
10 | // so we can just add a new folder there and
11 | // have automatically the entry points updated
12 |
13 | const getDirectories = source =>
14 | readdirSync(source)
15 | .map(name => path.resolve(source, name))
16 | .filter(s => lstatSync(s).isDirectory());
17 |
18 | const exampleDirs = getDirectories(src);
19 |
20 | return exampleDirs.reduce((entry, dir) => {
21 | entry['./' + path.basename(dir) + '/bundle'] = `${dir}/index`;
22 |
23 | return entry;
24 | }, {});
25 | };
26 |
27 | module.exports = {
28 | mode: 'development',
29 |
30 | entry: generateExampleEntries(),
31 |
32 | devServer: {
33 | static: {
34 | directory: path.resolve(__dirname, 'examples'),
35 | publicPath: '/',
36 | },
37 | },
38 |
39 | output: {
40 | filename: '[name].js',
41 | path: path.resolve(__dirname, 'dist'),
42 | },
43 |
44 | module: {
45 | rules: [
46 | {
47 | test: /\.(t|j)sx?$/,
48 | loader: 'babel-loader',
49 | options: {
50 | presets: [
51 | ['@babel/preset-env', { targets: { chrome: '60' } }],
52 | '@babel/preset-typescript',
53 | ],
54 | },
55 | },
56 | ],
57 | },
58 |
59 | resolve: {
60 | alias: {
61 | 'react-resource-router': path.resolve(__dirname, './src'),
62 | },
63 | extensions: ['.ts', '.tsx', '.js', '.json'],
64 | },
65 | };
66 |
--------------------------------------------------------------------------------