From b735ec442e9990dd30ac8ee4c017d93e1afa496e Mon Sep 17 00:00:00 2001 From: Stanislav Golovenko Date: Thu, 23 Feb 2023 12:00:33 +1100 Subject: [PATCH] Introduce plugins and extract resources as a plugin (#192) --- examples/basic-routing/index.tsx | 2 +- examples/hooks/index.tsx | 2 +- examples/hydration-with-plugins/home.tsx | 29 +++ examples/hydration-with-plugins/index.html | 11 ++ examples/hydration-with-plugins/index.tsx | 62 +++++++ examples/hydration-with-plugins/routes.tsx | 10 + examples/hydration/home.tsx | 2 +- examples/hydration/index.tsx | 4 +- examples/routing-with-resources/about.tsx | 2 +- examples/routing-with-resources/home.tsx | 2 +- examples/routing-with-resources/index.tsx | 4 +- package-lock.json | 4 +- resources/package.json | 5 + src/__tests__/integration.test.tsx | 1 + src/common/types.ts | 16 ++ .../utils/is-server-environment/index.ts | 11 +- src/controllers/plugins/index.ts | 24 +++ src/controllers/plugins/test.ts | 28 +++ src/controllers/router-store/index.tsx | 112 ++++++------ src/controllers/router-store/test.tsx | 151 +++++++++++++++- src/controllers/router-store/types.ts | 12 +- src/controllers/router/index.tsx | 42 +++-- src/controllers/router/test.tsx | 171 ++++++++++++++++++ src/controllers/router/types.ts | 2 + src/controllers/use-path-param/test.tsx | 14 +- src/controllers/use-query-param/test.tsx | 18 +- src/controllers/use-router-actions/test.tsx | 3 +- src/controllers/use-router/test.tsx | 3 +- src/controllers/with-router/index.tsx | 8 +- src/index.ts | 3 + src/resources.ts | 1 + src/resources/plugin/index.ts | 106 +++++++++++ src/resources/plugin/test.ts | 117 ++++++++++++ src/ui/link/index.tsx | 2 +- src/ui/link/test.tsx | 2 +- src/ui/route-component/test.tsx | 2 +- 36 files changed, 873 insertions(+), 115 deletions(-) create mode 100644 examples/hydration-with-plugins/home.tsx create mode 100644 examples/hydration-with-plugins/index.html create mode 100644 examples/hydration-with-plugins/index.tsx create mode 100644 examples/hydration-with-plugins/routes.tsx create mode 100644 resources/package.json create mode 100644 src/controllers/plugins/index.ts create mode 100644 src/controllers/plugins/test.ts create mode 100644 src/resources.ts create mode 100644 src/resources/plugin/index.ts create mode 100644 src/resources/plugin/test.ts diff --git a/examples/basic-routing/index.tsx b/examples/basic-routing/index.tsx index 424c3fbc..5e6f8ae6 100644 --- a/examples/basic-routing/index.tsx +++ b/examples/basic-routing/index.tsx @@ -31,7 +31,7 @@ const appRoutes = [ const App = () => { return ( - + ); diff --git a/examples/hooks/index.tsx b/examples/hooks/index.tsx index 2d3a9150..69230bb5 100644 --- a/examples/hooks/index.tsx +++ b/examples/hooks/index.tsx @@ -47,7 +47,7 @@ const Title = () => { const App = () => { return ( - + <RouteComponent /> </Router> diff --git a/examples/hydration-with-plugins/home.tsx b/examples/hydration-with-plugins/home.tsx new file mode 100644 index 00000000..559477db --- /dev/null +++ b/examples/hydration-with-plugins/home.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { createResource, useResource } from 'react-resource-router'; + +export const homeResource = createResource({ + type: 'home', + getKey: () => 'breedList', + maxAge: 10000, + getData: async () => { + const response = await fetch('https://dog.ceo/api/breeds/image/random'); + const result: { message: string } = await response.json(); + + return result; + }, +}); + +export const Home = () => { + // eslint-disable-next-line + const { data, loading, error } = useResource(homeResource); + + return ( + <div> + <h1>Random Dog</h1> + <section> + {data?.message && <img src={data.message} style={{ width: '400px' }} />} + </section> + </div> + ); +}; diff --git a/examples/hydration-with-plugins/index.html b/examples/hydration-with-plugins/index.html new file mode 100644 index 00000000..1fad6688 --- /dev/null +++ b/examples/hydration-with-plugins/index.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <title>Example - Hydration + + + +
+ + + diff --git a/examples/hydration-with-plugins/index.tsx b/examples/hydration-with-plugins/index.tsx new file mode 100644 index 00000000..496862ac --- /dev/null +++ b/examples/hydration-with-plugins/index.tsx @@ -0,0 +1,62 @@ +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { render } from 'react-dom'; +import { defaultRegistry } from 'react-sweet-state'; + +import { createResourcesPlugin } from '../../src/resources'; + +import { homeRoute } from './routes'; + +import { + Router, + RouteComponent, + createBrowserHistory, + invokePluginLoad, +} from 'react-resource-router'; + +const myHistory = createBrowserHistory(); + +const appRoutes = [homeRoute]; + +const getStateFromServer = async () => { + const resourcesPlugin = createResourcesPlugin({ + resourceData: null, + }); + + invokePluginLoad([resourcesPlugin], { + history: createMemoryHistory({ initialEntries: [location] }), + routes: appRoutes, + basePath: '/hydration-with-plugins', + }); + + const resourceData = await resourcesPlugin.getSerializedResources(); + + // clearing the store + defaultRegistry.stores.clear(); + + return resourceData; +}; + +const main = async () => { + const data = await getStateFromServer(); + const resourcesPlugin = createResourcesPlugin({ + resourceData: data, + }); + + const App = () => { + return ( + + + + ); + }; + + render(, document.getElementById('root')); +}; + +main(); diff --git a/examples/hydration-with-plugins/routes.tsx b/examples/hydration-with-plugins/routes.tsx new file mode 100644 index 00000000..37711d8c --- /dev/null +++ b/examples/hydration-with-plugins/routes.tsx @@ -0,0 +1,10 @@ +import { Home, homeResource } from './home'; + +export const homeRoute = { + name: 'home', + path: '/', + exact: true, + component: Home, + navigation: null, + resources: [homeResource], +}; diff --git a/examples/hydration/home.tsx b/examples/hydration/home.tsx index d4b07ade..328f3dc7 100644 --- a/examples/hydration/home.tsx +++ b/examples/hydration/home.tsx @@ -23,7 +23,7 @@ export const Home = () => {

Random Dog

- {data?.message && } + {data?.message && }
); diff --git a/examples/hydration/index.tsx b/examples/hydration/index.tsx index 98afa2a6..e2ed2b7c 100644 --- a/examples/hydration/index.tsx +++ b/examples/hydration/index.tsx @@ -32,10 +32,10 @@ const main = async () => { const App = () => { return ( diff --git a/examples/routing-with-resources/about.tsx b/examples/routing-with-resources/about.tsx index f6cc8d80..36c0a599 100644 --- a/examples/routing-with-resources/about.tsx +++ b/examples/routing-with-resources/about.tsx @@ -35,7 +35,7 @@ export const About = () => {

{breedName}

Go to home
- {!loading && A cute dog!} + {!loading && A cute dog!}
); diff --git a/examples/routing-with-resources/home.tsx b/examples/routing-with-resources/home.tsx index c429b85d..b86abb87 100644 --- a/examples/routing-with-resources/home.tsx +++ b/examples/routing-with-resources/home.tsx @@ -28,7 +28,7 @@ export const Home = () => {
    {breeds.slice(0, 25).map(breed => (
  • - + {breed}
  • diff --git a/examples/routing-with-resources/index.tsx b/examples/routing-with-resources/index.tsx index 63f43295..53d4806f 100644 --- a/examples/routing-with-resources/index.tsx +++ b/examples/routing-with-resources/index.tsx @@ -16,10 +16,10 @@ const appRoutes = [homeRoute, aboutRoute]; const App = () => { return ( console.log('Prefetching route', route.name)} + routes={appRoutes} > diff --git a/package-lock.json b/package-lock.json index e642b2d6..b615f2dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-resource-router", - "version": "0.21.2", + "version": "0.21.3-dev-192.0.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "react-resource-router", - "version": "0.21.2", + "version": "0.21.3-dev-192.0.2.0", "license": "Apache-2.0", "dependencies": { "lodash.debounce": "^4.0.8", diff --git a/resources/package.json b/resources/package.json new file mode 100644 index 00000000..22859543 --- /dev/null +++ b/resources/package.json @@ -0,0 +1,5 @@ +{ + "main": "../build/cjs/resources.js", + "module": "../build/esm/resources.js", + "types": "../build/esm/resources.d.ts" +} diff --git a/src/__tests__/integration.test.tsx b/src/__tests__/integration.test.tsx index 4f7f4147..d5ae00ed 100644 --- a/src/__tests__/integration.test.tsx +++ b/src/__tests__/integration.test.tsx @@ -239,6 +239,7 @@ describe(' client-side integration tests', () => { expect(router.html()).toBe('loading:cache loading:network'); await waitForData(); + router.update(); expect(router.html()).toBe('data:cache-1 data:network-1'); diff --git a/src/common/types.ts b/src/common/types.ts index a4b5cbec..21c06bb2 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -326,3 +326,19 @@ export type FindRouterContextOptions = { location: Location; basePath?: string; }; + +export type Plugin = { + beforeRouteLoad?: (params: { + context: RouterContext; + nextContext: RouterContext; + }) => void; + routeLoad?: (params: { + context: RouterContext; + prevContext?: RouterContext; + }) => void; + routePrefetch?: (params: { + context: RouterContext; + nextContext: RouterContext; + }) => void; + [key: string]: any; +}; diff --git a/src/common/utils/is-server-environment/index.ts b/src/common/utils/is-server-environment/index.ts index 4616bd08..c2b429da 100644 --- a/src/common/utils/is-server-environment/index.ts +++ b/src/common/utils/is-server-environment/index.ts @@ -4,14 +4,15 @@ */ const isJsDomEnvironment = () => window.name === 'nodejs' || - navigator.userAgent.includes('Node.js') || - navigator.userAgent.includes('jsdom'); + navigator?.userAgent.includes('Node.js') || + navigator?.userAgent.includes('jsdom'); export const isServerEnvironment = () => { if ( - typeof process !== 'undefined' && - process.versions != null && - process.versions.node != null + typeof window === 'undefined' || + (typeof process !== 'undefined' && + process.versions != null && + process.versions.node != null) ) { return true; } diff --git a/src/controllers/plugins/index.ts b/src/controllers/plugins/index.ts new file mode 100644 index 00000000..5bcfb83a --- /dev/null +++ b/src/controllers/plugins/index.ts @@ -0,0 +1,24 @@ +import { History, Plugin, Routes } from '../../common/types'; +import { findRouterContext } from '../../common/utils'; + +export const invokePluginLoad = ( + plugins: Plugin[], + { + routes, + history, + basePath, + }: { + history: History; + routes: Routes; + basePath?: string; + } +) => { + const context = findRouterContext(routes, { + location: history.location, + basePath, + }); + + plugins.forEach(p => { + p.routeLoad?.({ context }); + }); +}; diff --git a/src/controllers/plugins/test.ts b/src/controllers/plugins/test.ts new file mode 100644 index 00000000..5b1978d1 --- /dev/null +++ b/src/controllers/plugins/test.ts @@ -0,0 +1,28 @@ +import { createMemoryHistory } from 'history'; + +import { invokePluginLoad } from './index'; + +describe('invokePluginLoad', () => { + it('calls each plugin load method', () => { + // (findRouterContext as any).mockReturnValue(firstContextMock); + const pluginOne = { + routeLoad: jest.fn(), + }; + const pluginTwo = { + routeLoad: jest.fn(), + }; + + const pluginThree = { + routeLoad: jest.fn(), + }; + + invokePluginLoad([pluginOne, pluginTwo, pluginThree], { + history: createMemoryHistory(), + routes: [], + }); + + expect(pluginOne.routeLoad).toBeCalled(); + expect(pluginTwo.routeLoad).toBeCalled(); + expect(pluginThree.routeLoad).toBeCalled(); + }); +}); diff --git a/src/controllers/router-store/index.tsx b/src/controllers/router-store/index.tsx index 906d961f..fc19cf84 100644 --- a/src/controllers/router-store/index.tsx +++ b/src/controllers/router-store/index.tsx @@ -24,7 +24,6 @@ import { warmupMatchRouteCache, } from '../../common/utils'; import { getResourceStore } from '../resource-store'; -import { getResourcesForNextLocation } from '../resource-store/utils'; import { AllRouterActions, @@ -50,6 +49,7 @@ export const INITIAL_STATE: EntireRouterState = { route: DEFAULT_ROUTE, routes: [], unlisten: null, + plugins: [], }; const actions: AllRouterActions = { @@ -65,9 +65,8 @@ const actions: AllRouterActions = { history, initialRoute, onPrefetch, - resourceContext, - resourceData, routes, + plugins, } = props; const routerContext = findRouterContext( initialRoute ? [initialRoute] : routes, @@ -82,10 +81,9 @@ const actions: AllRouterActions = { routes, location: history.location, action: history.action, + plugins, }); - getResourceStore().actions.hydrate({ resourceContext, resourceData }); - if (!isServerEnvironment()) { dispatch(actions.listen()); } @@ -102,6 +100,7 @@ const actions: AllRouterActions = { const { route, match, query } = getState(); return getResourceStore().actions.requestAllResources( + // same as requestResources() from the page { route, match, @@ -111,33 +110,6 @@ const actions: AllRouterActions = { ); }, - prefetchNextRouteResources: - (path, nextContext) => - ({ getState }) => { - const { routes, basePath, onPrefetch, route, match, query } = getState(); - const { prefetchResources, getContext: getResourceStoreContext } = - getResourceStore().actions; - - if (!nextContext && !isExternalAbsolutePath(path)) { - const location = parsePath(getRelativePath(path, basePath) as any); - nextContext = findRouterContext(routes, { location, basePath }); - } - - if (nextContext == null) return; - const nextLocationContext = nextContext; - - const nextResources = getResourcesForNextLocation( - { route, match, query }, - nextLocationContext, - getResourceStoreContext() - ); - - batch(() => { - prefetchResources(nextResources, nextLocationContext, {}); - if (onPrefetch) onPrefetch(nextLocationContext); - }); - }, - /** * Starts listening to browser history and sets the unlisten function in state. * Will request route resources on route change. @@ -158,6 +130,7 @@ const actions: AllRouterActions = { const action = update.length === 2 ? update[1] : update[0].action; const { + plugins, routes, basePath, match: currentMatch, @@ -165,27 +138,16 @@ const actions: AllRouterActions = { query: currentQuery, } = getState(); - const { - cleanExpiredResources, - requestResources, - getContext: getResourceStoreContext, - } = getResourceStore().actions; - - const nextContext = findRouterContext(routes, { location, basePath }); - const nextLocationContext = { - route: nextContext.route, - match: nextContext.match, - query: nextContext.query, + const nextContext = findRouterContext(routes, { + location, + basePath, + }); + + const prevContext = { + route: currentRoute, + match: currentMatch, + query: currentQuery, }; - const nextResources = getResourcesForNextLocation( - { - match: currentMatch, - query: currentQuery, - route: currentRoute, - }, - nextLocationContext, - getResourceStoreContext() - ); /* Explicitly batch update * as we need resources cleaned + route changed + resource fetch started together @@ -193,13 +155,22 @@ const actions: AllRouterActions = { * fetching has not started yet, making the app render with data null */ batch(() => { - cleanExpiredResources(nextResources, nextLocationContext); + plugins.forEach(p => + p.beforeRouteLoad?.({ + context: prevContext, + nextContext, + }) + ); + setState({ ...nextContext, location, action, }); - requestResources(nextResources, nextLocationContext, {}); + + plugins.forEach(p => + p.routeLoad?.({ context: nextContext, prevContext }) + ); }); } ); @@ -366,6 +337,37 @@ const actions: AllRouterActions = { history[updateType](updatedRelativePath); } }, + loadPlugins: + () => + ({ getState }) => { + const { plugins, match, query, route } = getState(); + + plugins.forEach(p => p.routeLoad?.({ context: { match, query, route } })); + }, + prefetchRoute: + (path, nextContext) => + ({ getState }) => { + const { plugins, routes, basePath, onPrefetch } = getState(); + const { route, match, query } = getRouterState(); + + if (!nextContext && !isExternalAbsolutePath(path)) { + const location = parsePath(getRelativePath(path, basePath) as any); + nextContext = findRouterContext(routes, { location, basePath }); + } + + if (nextContext == null) return; + const nextLocationContext = nextContext; + + batch(() => { + plugins.forEach(p => + p.routePrefetch?.({ + context: { route, match, query }, + nextContext: nextLocationContext, + }) + ); + if (onPrefetch) onPrefetch(nextLocationContext); + }); + }, }; type State = EntireRouterState; @@ -386,7 +388,7 @@ export const RouterContainer = createContainer( () => ({ dispatch }, props) => { dispatch(actions.bootstrapStore(props)); - !isServerEnvironment() && dispatch(actions.requestRouteResources()); + !isServerEnvironment() && dispatch(actions.loadPlugins()); }, onCleanup: () => () => { if (process.env.NODE_ENV === 'development') { diff --git a/src/controllers/router-store/test.tsx b/src/controllers/router-store/test.tsx index e80f6f09..4644310e 100644 --- a/src/controllers/router-store/test.tsx +++ b/src/controllers/router-store/test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { defaultRegistry } from 'react-sweet-state'; import * as isServerEnvironment from '../../common/utils/is-server-environment'; +import { createResourcesPlugin } from '../../resources/plugin'; import { getResourceStore } from '../resource-store'; import { ContainerProps } from './types'; @@ -17,6 +18,21 @@ import { RouterContainer, } from './index'; +const createPlugins = ({ + context, + resourceData, +}: { + context: any; + resourceData: any; +}) => { + const resourcesPlugin = createResourcesPlugin({ + context, + resourceData, + }); + + return [resourcesPlugin]; +}; + describe('RouterStore', () => { describe.each([ ['v4', history4], @@ -49,10 +65,18 @@ describe('RouterStore', () => { const push = jest.spyOn(history, 'push'); const replace = jest.spyOn(history, 'replace'); + const plugins = + props.plugins || + createPlugins({ + context: props.resourceContext, + resourceData: props.resourceData, + }); + mount( @@ -66,6 +90,7 @@ describe('RouterStore', () => { push, replace, }), + plugins, }; } @@ -122,7 +147,9 @@ describe('RouterStore', () => { it('returns the expected state', () => { const onPrefetch = jest.fn(); - const { history, getState } = renderRouterContainer({ onPrefetch }); + const { history, getState, plugins } = renderRouterContainer({ + onPrefetch, + }); expect(getState()).toMatchObject({ ...INITIAL_STATE, @@ -142,6 +169,7 @@ describe('RouterStore', () => { route: routes[0], routes: routes, unlisten: expect.any(Function), + plugins, }); }); @@ -172,6 +200,19 @@ describe('RouterStore', () => { }); }); + it('plugin routeLoad is called on initial render', () => { + const plugin = { + routeLoad: jest.fn(), + }; + const plugins = [plugin]; + + renderRouterContainer({ + plugins, + }); + + expect(plugin.routeLoad).toBeCalled(); + }); + it('requests route resources', () => { const requestAllResources = jest.spyOn( getResourceStore().actions, @@ -249,6 +290,92 @@ describe('RouterStore', () => { route: routes[1], }); }); + + it('plugin route load actions are called on route change', async () => { + const plugin = { + beforeRouteLoad: jest.fn(), + routeLoad: jest.fn(), + }; + const plugins = [plugin]; + + const { actions } = renderRouterContainer({ + plugins, + }); + const nextLocation = { pathname: '/pages/1', search: '', hash: '' }; + + actions.push(nextLocation); + + expect(plugin.beforeRouteLoad).toBeCalledWith({ + nextContext: { + match: { + isExact: true, + params: { id: '1' }, + path: '/pages/:id', + query: {}, + url: '/pages/1', + }, + query: {}, + route: { + component: routes[1].component, + name: 'page', + path: '/pages/:id', + }, + }, + context: { + match: { + isExact: true, + params: {}, + path: '/pages', + query: {}, + url: '/pages', + }, + query: { key: 'value' }, + route: { + component: routes[0].component, + exact: true, + name: 'pages', + path: '/pages', + }, + }, + }); + + // ignore onRouteLoad call on initial render and check the one after route change + expect(plugin.routeLoad.mock.calls[1]).toEqual([ + { + context: { + match: { + isExact: true, + params: { id: '1' }, + path: '/pages/:id', + query: {}, + url: '/pages/1', + }, + query: {}, + route: { + component: routes[1].component, + name: 'page', + path: '/pages/:id', + }, + }, + prevContext: { + match: { + isExact: true, + params: {}, + path: '/pages', + query: {}, + url: '/pages', + }, + query: { key: 'value' }, + route: { + component: routes[0].component, + exact: true, + name: 'pages', + path: '/pages', + }, + }, + }, + ]); + }); } }); @@ -357,8 +484,17 @@ describe('RouterStore', () => { path: '', }; + const plugins = createPlugins({ + context: {}, + resourceData: null, + }); + const wrapper = mount( - + ); @@ -387,8 +523,17 @@ describe('RouterStore', () => { path: '', }; + const plugins = createPlugins({ + context: {}, + resourceData: null, + }); + const wrapper = mount( - + ); diff --git a/src/controllers/router-store/types.ts b/src/controllers/router-store/types.ts index 4e9675f6..1cdd538c 100644 --- a/src/controllers/router-store/types.ts +++ b/src/controllers/router-store/types.ts @@ -16,6 +16,7 @@ import { Route, RouterContext, Routes, + Plugin, } from '../../common/types'; type PublicStateProperties = { @@ -34,6 +35,7 @@ type PrivateStateProperties = { onPrefetch?: (routerContext: RouterContext) => void; routes: Routes; unlisten: UnlistenHistory | null; + plugins: Plugin[]; }; export type EntireRouterState = PublicStateProperties & PrivateStateProperties; @@ -47,6 +49,7 @@ export type ContainerProps = { resourceContext?: ResourceStoreContext; resourceData?: ResourceStoreData; routes: Routes; + plugins: Plugin[]; }; export type RouterAction = Action; @@ -67,10 +70,6 @@ type PrivateRouterActions = { requestRouteResources: ( options?: RequestRouteResourcesOptions ) => Action>; - prefetchNextRouteResources: ( - path: Href, - nextContext: RouterContext | null - ) => RouterAction; listen: () => RouterAction; getContext: () => Action< EntireRouterState, @@ -89,6 +88,11 @@ type PrivateRouterActions = { }, updateType?: HistoryUpdateType ) => RouterAction; + loadPlugins: () => RouterAction; + prefetchRoute: ( + path: Href, + nextContext: RouterContext | null + ) => RouterAction; }; type PublicRouterActions = { diff --git a/src/controllers/router/index.tsx b/src/controllers/router/index.tsx index 8f04d1bb..06aa8107 100644 --- a/src/controllers/router/index.tsx +++ b/src/controllers/router/index.tsx @@ -1,12 +1,10 @@ import { createMemoryHistory } from 'history'; import React, { useMemo, useEffect } from 'react'; +import { invokePluginLoad } from '../../controllers/plugins/index'; +import { createResourcesPlugin } from '../../resources/plugin'; import { getResourceStore, ResourceContainer } from '../resource-store'; -import { - getRouterState, - RouterContainer, - getRouterStore, -} from '../router-store'; +import { getRouterState, RouterContainer } from '../router-store'; import { RouterProps, @@ -20,6 +18,7 @@ export const Router = ({ history, initialRoute, isGlobal = true, + plugins, onPrefetch, resourceContext, resourceData, @@ -33,6 +32,18 @@ export const Router = ({ }; }, []); + const pluginsWithFallback = useMemo(() => { + if (plugins) return plugins; + + // default 'plugins' fallback for the first relase + const resourcesPlugin = createResourcesPlugin({ + context: resourceContext, + resourceData, + }); + + return [resourcesPlugin]; + }, [resourceContext, resourceData, plugins]); + return ( { - const { bootstrapStore, requestRouteResources } = getRouterStore().actions; + const resourcesPlugin = createResourcesPlugin({ + context, + resourceData: null, + timeout, + }); - bootstrapStore({ - ...bootstrapProps, + const plugins = [resourcesPlugin]; + + invokePluginLoad(plugins, { history: history || createMemoryHistory({ initialEntries: [location] }), + routes: routes, }); - await requestRouteResources({ timeout }); - - return getResourceStore().actions.getSafeData(); + return await resourcesPlugin.getSerializedResources(); }; Router.addResourcesListener = (fn: (...args: any) => any) => diff --git a/src/controllers/router/test.tsx b/src/controllers/router/test.tsx index ee0af6af..1b70bcf5 100644 --- a/src/controllers/router/test.tsx +++ b/src/controllers/router/test.tsx @@ -4,6 +4,8 @@ import React, { ReactNode } from 'react'; import { Route } from '../../common/types'; import * as isServerEnvironment from '../../common/utils/is-server-environment'; +import { createResourcesPlugin } from '../../resources/plugin'; +import { invokePluginLoad } from '../plugins/index'; import { createResource, getResourceStore } from '../resource-store'; import { Router } from './index'; @@ -247,4 +249,173 @@ describe('', () => { } }); }); + + describe('loadPlugins', () => { + describe('should support resources as requestResources', () => { + const resolver = (r: any, d = 0) => + new Promise(resolve => setTimeout(() => resolve(r), d)); + + function createRequestResourceParams({ timeout }: { timeout?: number }) { + const resourcesPlugin = createResourcesPlugin({ + context: {}, + resourceData: null, + timeout, + }); + + return { + history: createMemoryHistory({ initialEntries: ['/path'] }), + routes: [ + { + name: '', + path: '/path', + component: () =>
    test
    , + resources: [ + createResource({ + getData: () => resolver('data-1', 250), + getKey: () => 'key', + type: 'TYPE_1', + }), + createResource({ + getData: () => resolver('data-2', 500), + getKey: () => 'key', + type: 'TYPE_2', + }), + ], + }, + ], + plugins: [resourcesPlugin], + }; + } + + it('should be expose as a static method', () => { + expect(typeof invokePluginLoad).toBe('function'); + }); + + it('should return hydratable, cleaned resource store state.data when awaited', async () => { + const { plugins, ...props } = createRequestResourceParams({}); + invokePluginLoad(plugins, props); + + const data = await plugins[0].getSerializedResources(); + + expect(data).toEqual({ + TYPE_1: { + key: { + accessedAt: null, + data: 'data-1', + error: null, + expiresAt: null, + loading: false, + promise: null, + }, + }, + TYPE_2: { + key: { + accessedAt: null, + data: 'data-2', + error: null, + expiresAt: null, + loading: false, + promise: null, + }, + }, + }); + }); + + it('should respect timeout when fetching resources', async () => { + const { plugins, ...props } = createRequestResourceParams({ + timeout: 350, + }); + invokePluginLoad(plugins, props); + + const data = await plugins[0].getSerializedResources(); + + expect(data).toEqual({ + TYPE_1: { + key: { + accessedAt: null, + expiresAt: null, + data: 'data-1', + error: null, + loading: false, + promise: null, + }, + }, + TYPE_2: { + key: { + accessedAt: null, + expiresAt: null, + data: null, + error: { + message: 'Resource timed out: TYPE_2', + name: 'TimeoutError', + stack: expect.any(String), + }, + loading: true, + promise: null, + }, + }, + }); + }); + + it('should maintain the pre-requested state in the resource store when mounted', async () => { + const { plugins, ...props } = createRequestResourceParams({}); + invokePluginLoad(plugins, props); + + const data = await plugins[0].getSerializedResources(); + + mount(); + + expect(data).toEqual({ + TYPE_1: { + key: { + accessedAt: null, + expiresAt: null, + data: 'data-1', + error: null, + loading: false, + promise: null, + }, + }, + TYPE_2: { + key: { + accessedAt: null, + expiresAt: null, + data: 'data-2', + error: null, + loading: false, + promise: null, + }, + }, + }); + }); + + it('should not re-request resources when they have already been requested by requestResources on the server', async () => { + jest + .spyOn(isServerEnvironment, 'isServerEnvironment') + .mockReturnValue(true); + + const { plugins, ...params } = createRequestResourceParams({}); + + const route = params.routes[0]; + const resources = route.resources.map(resource => + jest.spyOn(resource, 'getData') + ); + + invokePluginLoad(plugins, params); + + await plugins[0].getSerializedResources(); + + mount( + + ); + + for (const resource of resources) { + expect(resource).toHaveBeenCalledTimes(1); + } + }); + }); + }); }); diff --git a/src/controllers/router/types.ts b/src/controllers/router/types.ts index 78d49b63..b1200cad 100644 --- a/src/controllers/router/types.ts +++ b/src/controllers/router/types.ts @@ -2,6 +2,7 @@ import { PropsWithChildren } from 'react'; import { History, + Plugin, ResourceStoreContext, ResourceStoreData, Route, @@ -18,6 +19,7 @@ export type RouterProps = PropsWithChildren<{ resourceContext?: ResourceStoreContext; resourceData?: ResourceStoreData; routes: Routes; + plugins?: Plugin[]; }>; export type MemoryRouterProps = PropsWithChildren<{ diff --git a/src/controllers/use-path-param/test.tsx b/src/controllers/use-path-param/test.tsx index 4a9d1cdf..45a11a70 100644 --- a/src/controllers/use-path-param/test.tsx +++ b/src/controllers/use-path-param/test.tsx @@ -62,7 +62,7 @@ describe('usePathParam()', () => { let ppVal: string | undefined; mount( - + {() => { const [param] = usePathParam('projectId'); @@ -79,7 +79,7 @@ describe('usePathParam()', () => { it('should return undefined for non-existent params', () => { let ppVal: string | undefined; mount( - + {() => { const [param] = usePathParam('iamnotapathparam'); @@ -101,7 +101,7 @@ describe('usePathParam()', () => { ) => void; mount( - + {() => { const [param, setParam] = usePathParam('projectId'); @@ -134,7 +134,7 @@ describe('usePathParam()', () => { let renderCount = 0; mount( - + {() => { const [param, setParam] = usePathParam('projectId'); @@ -173,7 +173,7 @@ describe('usePathParam()', () => { ) => void; mount( - + {() => { [ppVal, ppUpdateFn] = usePathParam('projectId'); @@ -208,7 +208,7 @@ describe('usePathParam()', () => { name: '', }; mount( - + {() => { const [param, setParam] = usePathParam('issueId'); @@ -255,7 +255,7 @@ describe('usePathParam()', () => { name: '', }; mount( - + {() => { const [param, setParam] = usePathParam('boardId'); diff --git a/src/controllers/use-query-param/test.tsx b/src/controllers/use-query-param/test.tsx index 13971aba..e62513c2 100644 --- a/src/controllers/use-query-param/test.tsx +++ b/src/controllers/use-query-param/test.tsx @@ -60,7 +60,7 @@ describe('useQueryParam()', () => { it('should return the right param value', () => { mount( - + {() => { const [param] = useQueryParam('foo'); @@ -75,7 +75,7 @@ describe('useQueryParam()', () => { it('should return undefined for non-existent params', () => { mount( - + {() => { const [param] = useQueryParam('iamnotaqueryparam'); @@ -94,7 +94,7 @@ describe('useQueryParam()', () => { let qpUpdateFn: (qp: string) => void; mount( - + {() => { const [param, setParam] = useQueryParam('newqueryparam'); @@ -121,7 +121,7 @@ describe('useQueryParam()', () => { let qpUpdateFn: (qp: string, updateType?: 'push' | 'replace') => void; mount( - + {() => { const [param, setParam] = useQueryParam('foo'); @@ -151,7 +151,7 @@ describe('useQueryParam()', () => { let renderCount = 0; mount( - + {() => { const [param, setParam] = useQueryParam('foo'); @@ -183,7 +183,7 @@ describe('useQueryParam()', () => { let qpUpdateFn: (qp: string, updateType?: 'push' | 'replace') => void; mount( - + {() => { [qpVal, qpUpdateFn] = useQueryParam('foo'); @@ -210,7 +210,7 @@ describe('useQueryParam()', () => { let qpUpdateFn: (qp: string | undefined) => void; mount( - + {() => { const [param, setParam] = useQueryParam('foo'); @@ -260,8 +260,8 @@ describe('useQueryParam()', () => { mount( @@ -319,7 +319,7 @@ describe('useQueryParam()', () => { let barUpdateFn: (qp: string) => void; mount( - + {() => { const [foo, setFoo] = useQueryParam('foo'); diff --git a/src/controllers/use-router-actions/test.tsx b/src/controllers/use-router-actions/test.tsx index 10550cfe..c70dfbea 100644 --- a/src/controllers/use-router-actions/test.tsx +++ b/src/controllers/use-router-actions/test.tsx @@ -23,7 +23,8 @@ describe('useRouterActions()', () => { "goBack": [Function], "goForward": [Function], "listen": [Function], - "prefetchNextRouteResources": [Function], + "loadPlugins": [Function], + "prefetchRoute": [Function], "push": [Function], "pushTo": [Function], "registerBlock": [Function], diff --git a/src/controllers/use-router/test.tsx b/src/controllers/use-router/test.tsx index 65da20ca..24afe5a3 100644 --- a/src/controllers/use-router/test.tsx +++ b/src/controllers/use-router/test.tsx @@ -35,7 +35,8 @@ describe('useRouter()', () => { "goBack": [Function], "goForward": [Function], "listen": [Function], - "prefetchNextRouteResources": [Function], + "loadPlugins": [Function], + "prefetchRoute": [Function], "push": [Function], "pushTo": [Function], "registerBlock": [Function], diff --git a/src/controllers/with-router/index.tsx b/src/controllers/with-router/index.tsx index dc5ef3c4..4d996dfb 100644 --- a/src/controllers/with-router/index.tsx +++ b/src/controllers/with-router/index.tsx @@ -36,14 +36,14 @@ export const withRouter =

    >( return ( ); }; diff --git a/src/index.ts b/src/index.ts index 233cf15b..50b02daf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,8 @@ export { matchRoute, } from './common/utils'; +export { invokePluginLoad } from './controllers/plugins'; + export type { AdditionalRouteAttributes, BrowserHistory, @@ -68,6 +70,7 @@ export type { RouterContext, RouterDataContext, UseResourceHookResponse, + Plugin, } from './common/types'; export type { diff --git a/src/resources.ts b/src/resources.ts new file mode 100644 index 00000000..13ea19cd --- /dev/null +++ b/src/resources.ts @@ -0,0 +1 @@ +export { createResourcesPlugin } from './resources/plugin'; diff --git a/src/resources/plugin/index.ts b/src/resources/plugin/index.ts new file mode 100644 index 00000000..ffcd4961 --- /dev/null +++ b/src/resources/plugin/index.ts @@ -0,0 +1,106 @@ +import type { + Plugin, + ResourceStoreContext, + RouterContext, + RouteResourceResponse, + ResourceStoreData, +} from '../../common/types'; +import { getResourceStore } from '../../controllers/resource-store'; +import { getResourcesForNextLocation } from '../../controllers/resource-store/utils'; + +const loadOnUrlChange = ( + context: RouterContext, + prevContext: RouterContext +) => { + const { requestResources, getContext: getResourceStoreContext } = + getResourceStore().actions; + + const nextResources = getResourcesForNextLocation( + prevContext, + context, + getResourceStoreContext() + ); + + return Promise.all(requestResources(nextResources, context, {})); +}; + +const beforeLoad = ({ + context, + nextContext, +}: { + context: RouterContext; + nextContext: RouterContext; +}) => { + const { cleanExpiredResources, getContext: getResourceStoreContext } = + getResourceStore().actions; + const nextResources = getResourcesForNextLocation( + context, + nextContext, + getResourceStoreContext() + ); + cleanExpiredResources(nextResources, nextContext); +}; + +type LoadedResources = Promise[]>; + +type ResourcesPlugin = Plugin & { + getSerializedResources: () => Promise; +}; + +export const createResourcesPlugin = ({ + context: initialResourceContext, + resourceData: initialResourceData, + timeout, +}: { + context?: ResourceStoreContext; + resourceData: any; + timeout?: number; +}): ResourcesPlugin => { + let latestLoadedResources: LoadedResources = Promise.resolve([]); + + getResourceStore().actions.hydrate({ + resourceContext: initialResourceContext, + resourceData: initialResourceData, + }); + + return { + beforeRouteLoad: beforeLoad, + routeLoad: ({ context, prevContext }) => { + const { route, match, query } = context; + // TODO: in next refactoring add `if (route.resources)` check + // For now requesting resources for every route even if `resources` prop is missing on Route + if (prevContext) { + latestLoadedResources = loadOnUrlChange(context, prevContext); + } else { + latestLoadedResources = getResourceStore().actions.requestAllResources( + { + route, + match, + query, + }, + { timeout } + ); + } + }, + routePrefetch: ({ context, nextContext }) => { + const { prefetchResources, getContext: getResourceStoreContext } = + getResourceStore().actions; + + const nextResources = getResourcesForNextLocation( + context, + nextContext, + getResourceStoreContext() + ); + + return { + resources: prefetchResources(nextResources, context, {}), + }; + }, + getLatestResources: (): LoadedResources => latestLoadedResources, + getSerializedResources: async () => { + await latestLoadedResources; + + return getResourceStore().actions.getSafeData(); + }, + }; +}; diff --git a/src/resources/plugin/test.ts b/src/resources/plugin/test.ts new file mode 100644 index 00000000..b85be163 --- /dev/null +++ b/src/resources/plugin/test.ts @@ -0,0 +1,117 @@ +import { getResourceStore } from '../../controllers/resource-store'; + +import { createResourcesPlugin } from './index'; + +const firstContextMock = { + match: { + isExact: true, + params: {}, + path: '/pages', + query: {}, + url: '/pages', + }, + query: { key: 'value' }, + route: { + component: () => null, + exact: true, + name: 'pages', + path: '/pages', + }, +}; + +const secondContextMock = { + match: { + isExact: true, + params: { id: '1' }, + path: '/pages/:id', + query: {}, + url: '/pages/1', + }, + query: {}, + route: { + component: () => null, + name: 'page', + path: '/pages/:id', + }, +}; + +describe('Resources plugin', () => { + it('cleans up expired resources before route change', () => { + const cleanExpiredResources = jest.spyOn( + getResourceStore().actions, + 'cleanExpiredResources' + ); + const plugin = createResourcesPlugin({ + context: {}, + resourceData: {}, + }); + + if (plugin.beforeRouteLoad !== undefined) + plugin.beforeRouteLoad({ + context: firstContextMock, + nextContext: secondContextMock, + }); + + expect(cleanExpiredResources).toBeCalledWith([], secondContextMock); + }); + + it('resources are requested after router init', () => { + const requestAllResources = jest.spyOn( + getResourceStore().actions, + 'requestAllResources' + ); + const plugin = createResourcesPlugin({ + context: {}, + resourceData: {}, + timeout: 1000, + }); + + if (plugin.routeLoad !== undefined) + plugin.routeLoad({ + context: secondContextMock, + }); + + expect(requestAllResources).toBeCalledWith(secondContextMock, { + timeout: 1000, + }); + }); + + it('resources are requested after route change', () => { + const requestResources = jest.spyOn( + getResourceStore().actions, + 'requestResources' + ); + const plugin = createResourcesPlugin({ + context: {}, + resourceData: {}, + timeout: 1000, + }); + + if (plugin.routeLoad !== undefined) + plugin.routeLoad({ + context: secondContextMock, + prevContext: firstContextMock, + }); + + expect(requestResources).toBeCalledWith([], secondContextMock, {}); + }); + + it('resources are prefetched', () => { + const prefetchResources = jest.spyOn( + getResourceStore().actions, + 'prefetchResources' + ); + const plugin = createResourcesPlugin({ + context: {}, + resourceData: {}, + }); + + if (plugin.routePrefetch !== undefined) + plugin.routePrefetch({ + context: firstContextMock, + nextContext: secondContextMock, + }); + + expect(prefetchResources).toBeCalledWith([], firstContextMock, {}); + }); +}); diff --git a/src/ui/link/index.tsx b/src/ui/link/index.tsx index da40945f..06037821 100644 --- a/src/ui/link/index.tsx +++ b/src/ui/link/index.tsx @@ -80,7 +80,7 @@ const Link = forwardRef( typeof to !== 'string' && route ? createRouterContext(route, { params, query }) : null; - routerActions.prefetchNextRouteResources(linkDestination, context); + routerActions.prefetchRoute(linkDestination, context); // omit params & query as already in linkDestination // eslint-disable-next-line react-hooks/exhaustive-deps }, [route, linkDestination, routerActions]); diff --git a/src/ui/link/test.tsx b/src/ui/link/test.tsx index 73bb0eb1..6041c68c 100644 --- a/src/ui/link/test.tsx +++ b/src/ui/link/test.tsx @@ -54,7 +54,7 @@ describe('', () => { ) => mount( // @ts-expect-error - + {children} ); diff --git a/src/ui/route-component/test.tsx b/src/ui/route-component/test.tsx index 69b965da..e97050a0 100644 --- a/src/ui/route-component/test.tsx +++ b/src/ui/route-component/test.tsx @@ -36,7 +36,7 @@ describe('', () => { it('renders a the route component', () => { const wrapper = mount( // @ts-expect-error - + );