From 3b8dc013a8741c4e1fafd32cdd797d44f66f153f Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 16 Dec 2022 15:46:06 +0100 Subject: [PATCH 01/72] initial code --- .../__tests__/loaderOnRoute.spec.jsx | 113 ++++++++++++++++++ packages/inferno-router/src/Route.ts | 5 +- packages/inferno-router/src/Router.ts | 48 ++++++-- 3 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 packages/inferno-router/__tests__/loaderOnRoute.spec.jsx diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx new file mode 100644 index 000000000..278dd04ca --- /dev/null +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -0,0 +1,113 @@ +import { render } from 'inferno'; +import { MemoryRouter, Route, Router } from 'inferno-router'; +import { createMemoryHistory } from 'history'; + +describe('A ', () => { + // let container; + + // beforeEach(function () { + // container = document.createElement('div'); + // document.body.appendChild(container); + // }); + + // afterEach(function () { + // render(null, container); + // container.innerHTML = ''; + // document.body.removeChild(container); + // }); + + // it('renders at the root', () => { + // const TEXT = 'Mrs. Kato'; + // const node = document.createElement('div'); + + // render( + // + //

{TEXT}

} /> + //
, + // node + // ); + + // expect(node.innerHTML).toContain(TEXT); + // }); + + // it('does not render when it does not match', () => { + // const TEXT = 'bubblegum'; + // const node = document.createElement('div'); + + // render( + // + //

{TEXT}

} /> + //
, + // node + // ); + + // expect(node.innerHTML).not.toContain(TEXT); + // }); + + + // it('supports preact by nulling out children prop when empty array is passed', () => { + // const TEXT = 'Mrs. Kato'; + // const node = document.createElement('div'); + + // render( + // + //

{TEXT}

}> + // {[]} + //
+ //
, + // node + // ); + + // expect(node.innerHTML).toContain(TEXT); + // }); + + // it('Should change activeClass on links without functional component Wrapper, Github #1345', () => { + // function CompList() { + // return
FIRST
; + // } + + // function CreateComp() { + // return
SECOND
; + // } + + // const tree = ( + // + //
+ // + // + // + //
Publish
} /> + //
+ //
+ // ); + + // render(tree, container); + + // const links = container.querySelectorAll('li'); + // links[1].firstChild.click(); + + // expect(container.querySelector('#second')).toBeNull(); + // }); +}); diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index a50bd1366..8fa9e6510 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -4,12 +4,14 @@ import { invariant, warning } from './utils'; import { matchPath } from './matchPath'; import { combineFrom, isFunction } from 'inferno-shared'; import type { History, Location } from 'history'; +import type { TLoaderProps } from './Router'; export interface Match

{ params: P; isExact: boolean; path: string; url: string; + loader?(props: TLoaderProps

): Promise; } export interface RouteComponentProps

{ @@ -25,6 +27,7 @@ export interface IRouteProps { exact?: boolean; strict?: boolean; sensitive?: boolean; + loader?(props: TLoaderProps): Promise; component?: Inferno.ComponentClass | ((props: any, context: any) => InfernoNode); render?: (props: RouteComponentProps, context: any) => InfernoNode; location?: Partial; @@ -59,7 +62,7 @@ class Route extends Component, RouteState> { }; } - public computeMatch({ computedMatch, location, path, strict, exact, sensitive }, router) { + public computeMatch({ computedMatch, location, path, strict, exact, sensitive, loader }, router) { if (computedMatch) { // already computed the match for us return computedMatch; diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index 50cfb9cf9..0b4a419a5 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -3,6 +3,11 @@ import { warning } from './utils'; import { combineFrom } from 'inferno-shared'; import type { History } from 'history'; +export type TLoaderProps

= { + params?: P; + request: any; // Fetch request +} + export interface IRouterProps { history: History; children: InfernoNode; @@ -13,11 +18,15 @@ export interface IRouterProps { */ export class Router extends Component { public unlisten; + private _loaderCount; constructor(props: IRouterProps, context?: any) { super(props, context); + this._loaderCount = 0; this.state = { - match: this.computeMatch(props.history.location.pathname) + match: this.computeMatch(props.history.location.pathname), + loaderRes: undefined, // TODO: Populate with rehydrated data + loaderErr: undefined, // TODO: Populate with rehydrated data }; } @@ -27,7 +36,9 @@ export class Router extends Component { childContext.history = this.props.history; childContext.route = { location: childContext.history.location, - match: this.state?.match + match: this.state?.match, + loaderRes: this.state?.loaderRes, + loaderErr: this.state?.loaderErr, }; return { @@ -40,7 +51,8 @@ export class Router extends Component { isExact: pathname === '/', params: {}, path: '/', - url: '/' + url: '/', + loader: ({ params = {}, request }: TLoaderProps<{}>) => { params; request; return }, }; } @@ -50,10 +62,32 @@ export class Router extends Component { // Do this here so we can setState when a changes the // location in componentWillMount. This happens e.g. when doing // server rendering using a . - this.unlisten = history.listen(() => { - this.setState({ - match: this.computeMatch(history.location.pathname) - }); + this.unlisten = history.listen(async () => { + // Note: Resets counter at 10k to Allow infinite loads + const currentLoaderCount = this._loaderCount = (this._loaderCount + 1) % 10000; + const match = this.computeMatch(history.location.pathname); + let loaderRes; + let loaderErr; + if (match.loader) { + const params = undefined; + const request = undefined; + try { + const res = await match.loader({ params, request }); + // TODO: should we parse json? + loaderRes = res; + } catch (err) { + loaderErr = err; + } + } + // If a new loader has completed prior to current loader, + // don't overwrite with stale data. + if (currentLoaderCount === this._loaderCount) { + this.setState({ + match, + loaderRes, + loaderErr, + }); + } }); } From 886b621fbeabb314f74f3af4c723d84d42573d05 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 16 Dec 2022 17:12:19 +0100 Subject: [PATCH 02/72] test beginning to work --- .../__tests__/loaderOnRoute.spec.jsx | 174 +++++++++--------- packages/inferno-router/src/MemoryRouter.ts | 6 +- packages/inferno-router/src/Route.ts | 2 +- packages/inferno-router/src/Router.ts | 18 +- 4 files changed, 103 insertions(+), 97 deletions(-) diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index 278dd04ca..02921a1cd 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -1,113 +1,109 @@ import { render } from 'inferno'; -import { MemoryRouter, Route, Router } from 'inferno-router'; +import { MemoryRouter, Route, NavLink } from 'inferno-router'; import { createMemoryHistory } from 'history'; -describe('A ', () => { - // let container; +describe('A with loader', () => { + let container; - // beforeEach(function () { - // container = document.createElement('div'); - // document.body.appendChild(container); - // }); + beforeEach(function () { + container = document.createElement('div'); + document.body.appendChild(container); + }); - // afterEach(function () { - // render(null, container); - // container.innerHTML = ''; - // document.body.removeChild(container); - // }); + afterEach(function () { + render(null, container); + container.innerHTML = ''; + document.body.removeChild(container); + }); - // it('renders at the root', () => { + // it('renders on initial', () => { // const TEXT = 'Mrs. Kato'; - // const node = document.createElement('div'); + // const loaderFunc = async () => { return { message: "ok" }} // render( // - //

{TEXT}

} /> + //

{TEXT}

} loader={loaderFunc} /> // , - // node + // container // ); - // expect(node.innerHTML).toContain(TEXT); + // expect(container.innerHTML).toContain(TEXT); // }); - // it('does not render when it does not match', () => { + // it('Can access loader result', async () => { // const TEXT = 'bubblegum'; - // const node = document.createElement('div'); - - // render( - // - //

{TEXT}

} /> - //
, - // node - // ); - - // expect(node.innerHTML).not.toContain(TEXT); - // }); - - - // it('supports preact by nulling out children prop when empty array is passed', () => { - // const TEXT = 'Mrs. Kato'; - // const node = document.createElement('div'); + // const Component = (props, { router }) => { + // const { loaderRes } = router.route; + // return

{loaderRes.message}

+ // } + // const loaderFunc = async () => { return { message: TEXT }} + // const loaderData = { + // res: await loaderFunc(), + // err: undefined, + // } // render( - // - //

{TEXT}

}> - // {[]} - //
+ // + // // , - // node + // container // ); - // expect(node.innerHTML).toContain(TEXT); + // expect(container.innerHTML).toContain(TEXT); // }); - // it('Should change activeClass on links without functional component Wrapper, Github #1345', () => { - // function CompList() { - // return
FIRST
; - // } - - // function CreateComp() { - // return
SECOND
; - // } - - // const tree = ( - // - //
- // - // - // - //
Publish
} /> - //
- //
- // ); - - // render(tree, container); - - // const links = container.querySelectorAll('li'); - // links[1].firstChild.click(); - - // expect(container.querySelector('#second')).toBeNull(); - // }); + it('Should render component after after click', () => { + const TEST = "ok"; + const loaderFunc = async () => { return { message: TEST }} + + function RootComp() { + return
ROOT
; + } + + function CreateComp(props, { router }) { + const { res, err } = router.route; + return
{res.message}
; + } + + function PublishComp() { + return
PUBLISH
; + } + + const tree = ( + +
+ + + + +
+
+ ); + + render(tree, container); + + // Click create + const link = container.querySelector('#createNav'); + link.firstChild.click(); + + expect(container.querySelector('#create').innerHTML).toContain(TEST); + }); }); diff --git a/packages/inferno-router/src/MemoryRouter.ts b/packages/inferno-router/src/MemoryRouter.ts index 0841a0f56..fbd4003f5 100644 --- a/packages/inferno-router/src/MemoryRouter.ts +++ b/packages/inferno-router/src/MemoryRouter.ts @@ -1,11 +1,12 @@ import { Component, createComponentVNode, VNode } from 'inferno'; import { VNodeFlags } from 'inferno-vnode-flags'; import { createMemoryHistory } from 'history'; -import { Router } from './Router'; +import { Router, TLoaderData } from './Router'; import { warning } from './utils'; export interface IMemoryRouterProps { initialEntries?: string[]; + loaderData?: TLoaderData; initialIndex?: number; getUserConfirmation?: () => {}; keyLength?: number; @@ -23,7 +24,8 @@ export class MemoryRouter extends Component { public render(): VNode { return createComponentVNode(VNodeFlags.ComponentClass, Router, { children: this.props.children, - history: this.history + history: this.history, + loaderData: this.props.loaderData, }); } } diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index 8fa9e6510..a58ec46da 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -75,7 +75,7 @@ class Route extends Component, RouteState> { const { route } = router; const pathname = (location || route.location).pathname; - return path ? matchPath(pathname, { path, strict, exact, sensitive }) : route.match; + return path ? matchPath(pathname, { path, strict, exact, sensitive, loader }) : route.match; } public componentWillReceiveProps(nextProps, nextContext) { diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index 0b4a419a5..58bf5c888 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -2,15 +2,21 @@ import { Component, InfernoNode } from 'inferno'; import { warning } from './utils'; import { combineFrom } from 'inferno-shared'; import type { History } from 'history'; +import { Match } from './Route'; export type TLoaderProps

= { params?: P; request: any; // Fetch request } +export type TLoaderData = { + res: any; + err: any; +} export interface IRouterProps { history: History; children: InfernoNode; + loaderData?: TLoaderData; } /** @@ -23,16 +29,17 @@ export class Router extends Component { constructor(props: IRouterProps, context?: any) { super(props, context); this._loaderCount = 0; + const { res, err } = props.loaderData ?? {}; this.state = { match: this.computeMatch(props.history.location.pathname), - loaderRes: undefined, // TODO: Populate with rehydrated data - loaderErr: undefined, // TODO: Populate with rehydrated data + loaderRes: res, // TODO: Populate with rehydrated data + loaderErr: err, // TODO: Populate with rehydrated data }; } public getChildContext() { const childContext: any = combineFrom(this.context.router, null); - + debugger childContext.history = this.props.history; childContext.route = { location: childContext.history.location, @@ -46,13 +53,14 @@ export class Router extends Component { }; } - public computeMatch(pathname) { + + public computeMatch(pathname): Match<{}> { return { isExact: pathname === '/', params: {}, path: '/', url: '/', - loader: ({ params = {}, request }: TLoaderProps<{}>) => { params; request; return }, + loader: undefined, }; } From 6953829050b2e2b7eaca6a1c3c1a4fcb93e88e98 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Sat, 17 Dec 2022 21:36:54 +0100 Subject: [PATCH 03/72] working implementation with server side prefetch of loaders for SSR + rehydration --- .../__tests__/loaderOnRoute.spec.jsx | 341 +++++++++++++++--- .../inferno-router/__tests__/testUtils.js | 19 + packages/inferno-router/src/BrowserRouter.ts | 10 +- packages/inferno-router/src/MemoryRouter.ts | 2 +- packages/inferno-router/src/Route.ts | 78 +++- packages/inferno-router/src/Router.ts | 57 +-- packages/inferno-router/src/StaticRouter.ts | 8 +- packages/inferno-router/src/helpers.ts | 9 + packages/inferno-router/src/index.ts | 2 + packages/inferno-router/src/matchPath.ts | 11 +- .../inferno-router/src/ssrLoaderResolver.ts | 62 ++++ 11 files changed, 489 insertions(+), 110 deletions(-) create mode 100644 packages/inferno-router/__tests__/testUtils.js create mode 100644 packages/inferno-router/src/helpers.ts create mode 100644 packages/inferno-router/src/ssrLoaderResolver.ts diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index 02921a1cd..c26128790 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -1,8 +1,9 @@ import { render } from 'inferno'; -import { MemoryRouter, Route, NavLink } from 'inferno-router'; +import { BrowserRouter, MemoryRouter, StaticRouter, Route, NavLink, resolveLoaders, useLoaderData, useLoaderError } from 'inferno-router'; import { createMemoryHistory } from 'history'; +import { createEventGuard } from './testUtils'; -describe('A with loader', () => { +describe('A with loader in a MemoryRouter', () => { let container; beforeEach(function () { @@ -16,52 +17,56 @@ describe('A with loader', () => { document.body.removeChild(container); }); - // it('renders on initial', () => { - // const TEXT = 'Mrs. Kato'; - // const loaderFunc = async () => { return { message: "ok" }} - - // render( - // - //

{TEXT}

} loader={loaderFunc} /> - //
, - // container - // ); - - // expect(container.innerHTML).toContain(TEXT); - // }); - - // it('Can access loader result', async () => { - // const TEXT = 'bubblegum'; - // const Component = (props, { router }) => { - // const { loaderRes } = router.route; - // return

{loaderRes.message}

- // } - // const loaderFunc = async () => { return { message: TEXT }} - // const loaderData = { - // res: await loaderFunc(), - // err: undefined, - // } - - // render( - // - // - // , - // container - // ); - - // expect(container.innerHTML).toContain(TEXT); - // }); - - it('Should render component after after click', () => { + it('renders on initial', () => { + const TEXT = 'Mrs. Kato'; + const loaderFunc = async () => { return { message: "ok" }} + + render( + +

{TEXT}

} loader={loaderFunc} /> +
, + container + ); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('Can access initial loaderData (for hydration)', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + const loaderFunc = async () => { return { message: TEXT }} + const loaderData = { + '/flowers': { res: await loaderFunc(), err: undefined, } + } + + render( + + + , + container + ); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('Should render component after after click', async () => { + const [setDone, waitForRerender] = createEventGuard(); const TEST = "ok"; - const loaderFunc = async () => { return { message: TEST }} + const loaderFunc = async () => { + setDone(); + return { message: TEST } + }; function RootComp() { return
ROOT
; } - function CreateComp(props, { router }) { - const { res, err } = router.route; + function CreateComp(props) { + const res = useLoaderData(props); + const err = useLoaderError(props); return
{res.message}
; } @@ -100,10 +105,262 @@ describe('A with loader', () => { render(tree, container); + expect(container.innerHTML).toContain("ROOT"); + // Click create const link = container.querySelector('#createNav'); link.firstChild.click(); + // Wait until async loader has completed + await waitForRerender(); + expect(container.querySelector('#create').innerHTML).toContain(TEST); }); }); + +describe('A with loader in a BrowserRouter', () => { + let container; + + beforeEach(function () { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(function () { + render(null, container); + container.innerHTML = ''; + document.body.removeChild(container); + // Reset history to root + history.replaceState(undefined, undefined, '/'); + }); + + it('renders on initial', () => { + const TEXT = 'Mrs. Kato'; + const loaderFunc = async () => { return { message: "ok" }} + + render( + +

{TEXT}

} loader={loaderFunc} /> +
, + container + ); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('Can access initial loaderData (for hydration)', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + + const loaderFunc = async () => { return { message: TEXT }} + + const loaderData = { + '/flowers': { res: await loaderFunc(), err: undefined, } + } + + history.replaceState(undefined, undefined, '/flowers'); + render( + + + , + container + ); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('Should render component after after click', async () => { + const [setDone, waitForRerender] = createEventGuard(); + const TEST = "ok"; + const loaderFunc = async () => { + setDone(); + return { message: TEST } + }; + + function RootComp() { + return
ROOT
; + } + + function CreateComp(props) { + const res = useLoaderData(props); + const err = useLoaderError(props); + return
{res.message}
; + } + + function PublishComp() { + return
PUBLISH
; + } + + const tree = ( + +
+ + + + +
+
+ ); + + render(tree, container); + + expect(container.innerHTML).toContain("ROOT"); + + // Click create + const link = container.querySelector('#createNav'); + link.firstChild.click(); + + // Wait until async loader has completed + await waitForRerender(); + + expect(container.querySelector('#create').innerHTML).toContain(TEST); + }); +}); + +describe('A with loader in a StaticRouter', () => { + let container; + + beforeEach(function () { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(function () { + render(null, container); + container.innerHTML = ''; + document.body.removeChild(container); + // Reset history to root + history.replaceState(undefined, undefined, '/'); + }); + + it('renders on initial', () => { + const TEXT = 'Mrs. Kato'; + const loaderFunc = async () => { return { message: "ok" }} + + render( + +

{TEXT}

} loader={loaderFunc} /> +
, + container + ); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('Can access initial loaderData (for hydration)', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + + const loaderFunc = async () => { return { message: TEXT }} + + const loaderData = { + '/flowers': { res: await loaderFunc(), err: undefined, } + } + + render( + + + , + container + ); + + expect(container.innerHTML).toContain(TEXT); + }); +}); + +describe('Resolve loaders during server side rendering', () => { + it('Can resolve with single route', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + + const loaderFunc = async () => { return { message: TEXT }} + + const loaderData = { + '/flowers': { res: await loaderFunc() } + } + + const app = + + ; + + const result = await resolveLoaders('/flowers', app); + expect(result).toEqual(loaderData); + }); + + it('Can resolve with multiple routes', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + + const loaderFuncNoHit = async () => { return { message: 'no' }} + const loaderFunc = async () => { return { message: TEXT }} + + const loaderData = { + '/birds': { res: await loaderFunc() } + } + + const app = + + + + ; + + const result = await resolveLoaders('/birds', app); + expect(result).toEqual(loaderData); + }); + + it('Can resolve with nested routes', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + + const loaderFuncNoHit = async () => { return { message: 'no' }} + const loaderFunc = async () => { return { message: TEXT }} + + const loaderData = { + '/flowers': { res: await loaderFunc() }, + '/flowers/birds': { res: await loaderFunc() } + } + + const app = + + + + + ; + + const result = await resolveLoaders('/flowers/birds', app); + expect(result).toEqual(loaderData); + }); +}) \ No newline at end of file diff --git a/packages/inferno-router/__tests__/testUtils.js b/packages/inferno-router/__tests__/testUtils.js new file mode 100644 index 000000000..6fc92f70e --- /dev/null +++ b/packages/inferno-router/__tests__/testUtils.js @@ -0,0 +1,19 @@ +export function createEventGuard() { + const eventState = { done: false }; + const markEventCompleted = () => { + eventState.done = true; + } + const waitForEventToTriggerRender = async () => { + // Wait until event is marked as completed + while (!eventState.done) { + await new Promise((resolved) => setTimeout(resolved, 1)); + } + // Allow render loop to run + await new Promise((resolved) => setTimeout(resolved, 0)); + } + + return [ + markEventCompleted, + waitForEventToTriggerRender + ] +} diff --git a/packages/inferno-router/src/BrowserRouter.ts b/packages/inferno-router/src/BrowserRouter.ts index e580f9534..ebb0a5f15 100644 --- a/packages/inferno-router/src/BrowserRouter.ts +++ b/packages/inferno-router/src/BrowserRouter.ts @@ -1,10 +1,11 @@ import { Component, createComponentVNode, VNode } from 'inferno'; import { VNodeFlags } from 'inferno-vnode-flags'; import { createBrowserHistory } from 'history'; -import { Router } from './Router'; +import { Router, TLoaderData } from './Router'; import { warning } from './utils'; export interface IBrowserRouterProps { + loaderData?: Record; basename?: string; forceRefresh?: boolean; getUserConfirmation?: () => {}; @@ -15,15 +16,16 @@ export interface IBrowserRouterProps { export class BrowserRouter extends Component { public history; - constructor(props?: any, context?: any) { + constructor(props?: IBrowserRouterProps, context?: any) { super(props, context); - this.history = createBrowserHistory(props); + this.history = createBrowserHistory(/*props*/); // TODO: None of the props are defined in createBrowserHistory so skipping } public render(): VNode { return createComponentVNode(VNodeFlags.ComponentClass, Router, { children: this.props.children, - history: this.history + history: this.history, + loaderData: this.props.loaderData, }); } } diff --git a/packages/inferno-router/src/MemoryRouter.ts b/packages/inferno-router/src/MemoryRouter.ts index fbd4003f5..59306dd10 100644 --- a/packages/inferno-router/src/MemoryRouter.ts +++ b/packages/inferno-router/src/MemoryRouter.ts @@ -6,8 +6,8 @@ import { warning } from './utils'; export interface IMemoryRouterProps { initialEntries?: string[]; - loaderData?: TLoaderData; initialIndex?: number; + loaderData?: Record; getUserConfirmation?: () => {}; keyLength?: number; children: Component[]; diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index a58ec46da..67cb49579 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -2,19 +2,20 @@ import { Component, createComponentVNode, Inferno, InfernoNode } from 'inferno'; import { VNodeFlags } from 'inferno-vnode-flags'; import { invariant, warning } from './utils'; import { matchPath } from './matchPath'; -import { combineFrom, isFunction } from 'inferno-shared'; +import { combineFrom, isFunction, isNull, isNullOrUndef } from 'inferno-shared'; import type { History, Location } from 'history'; -import type { TLoaderProps } from './Router'; +import type { TLoader, TLoaderData, TLoaderProps } from './Router'; -export interface Match

{ +export interface Match

> { params: P; isExact: boolean; path: string; url: string; loader?(props: TLoaderProps

): Promise; + loaderData?: TLoaderData; } -export interface RouteComponentProps

{ +export interface RouteComponentProps

> { match: Match

; location: Location; history: History; @@ -38,16 +39,19 @@ export interface IRouteProps { * The public API for matching a single path and rendering. */ type RouteState = { - match: boolean; -}; + match: Match; + __loaderData__: TLoaderData; +} class Route extends Component, RouteState> { + _initialLoader = null; + public getChildContext() { const childContext: any = combineFrom(this.context.router, null); childContext.route = { location: this.props.location || this.context.router.route.location, - match: this.state!.match + match: this.state!.match, }; return { @@ -57,25 +61,48 @@ class Route extends Component, RouteState> { constructor(props?: any, context?: any) { super(props, context); + + const match = this.computeMatch(props, context.router); + + const { res, err } = match?.loaderData ?? {}; + if (isNullOrUndef(match?.loaderData)) { + this._initialLoader = match?.loader ?? null; + } + this.state = { - match: this.computeMatch(props, context.router) + match, + __loaderData__: { res, err }, }; } - public computeMatch({ computedMatch, location, path, strict, exact, sensitive, loader }, router) { + private async runLoader(loader: TLoader, params, request, match) { + // TODO: Pass progress callback to loader + try { + const res = await loader({ params, request }); + // TODO: should we parse json? + this.setState({ match, __loaderData__: { res } }); + } catch (err) { + // Loaders should throw errors + this.setState({ match, __loaderData__: { err } }) + } + } + + public computeMatch({ computedMatch, ...props }, router) { if (computedMatch) { // already computed the match for us return computedMatch; } + const { location, path, strict, exact, sensitive, loader } = props; + if (process.env.NODE_ENV !== 'production') { invariant(router, 'You should not use or withRouter() outside a '); } - const { route } = router; + const { route, loaderData } = router; // This is the parent route const pathname = (location || route.location).pathname; - return path ? matchPath(pathname, { path, strict, exact, sensitive, loader }) : route.match; + return path ? matchPath(pathname, { path, strict, exact, sensitive, loader, loaderData }) : route.match; } public componentWillReceiveProps(nextProps, nextContext) { @@ -90,18 +117,35 @@ class Route extends Component, RouteState> { ' elements should not change from controlled to uncontrolled (or vice versa). You provided a "location" prop initially but omitted it on a subsequent render.' ); } + const match = this.computeMatch(nextProps, nextContext.router); + // Am I a match? In which case check for loader + + if (nextProps?.loader) { + const params = undefined; + const request = undefined; + this.runLoader(nextProps.loader, params, request, match); + return; + } - this.setState({ - match: this.computeMatch(nextProps, nextContext.router) - }); + this.setState({ match }); } public render() { - const { match } = this.state!; - const { children, component, render } = this.props; + const { match, __loaderData__ } = this.state!; + + // QUESTION: Is there a better way to invoke this on/after first render? + if (!isNull(this._initialLoader)) { + const params = undefined; + const request = undefined; + // match.loader has been checked in constructor + setTimeout(() => this.runLoader(this._initialLoader!, params, request, match), 0); + this._initialLoader = null; + } + + const { children, component, render, loader } = this.props; const { history, route, staticContext } = this.context.router; const location = this.props.location || route.location; - const props = { match, location, history, staticContext }; + const props = { match, location, history, staticContext, component, render, loader, __loaderData__ }; if (component) { if (process.env.NODE_ENV !== 'production') { diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index 58bf5c888..fe79ebf3d 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -4,19 +4,25 @@ import { combineFrom } from 'inferno-shared'; import type { History } from 'history'; import { Match } from './Route'; -export type TLoaderProps

= { - params?: P; - request: any; // Fetch request +export type TLoaderProps

> = { + params?: P; // Match params (if any) + request: Request; // Fetch API Request + onProgress?(perc: number): void; } +/** + * Loader returns Response and throws error + */ +export type TLoader

, R extends Response> = ({params, request}: TLoaderProps

) => Promise; + export type TLoaderData = { - res: any; - err: any; + res?: Response; + err?: any; } export interface IRouterProps { history: History; children: InfernoNode; - loaderData?: TLoaderData; + loaderData?: Record; // key is route path to allow resolving } /** @@ -24,29 +30,22 @@ export interface IRouterProps { */ export class Router extends Component { public unlisten; - private _loaderCount; constructor(props: IRouterProps, context?: any) { super(props, context); - this._loaderCount = 0; - const { res, err } = props.loaderData ?? {}; this.state = { match: this.computeMatch(props.history.location.pathname), - loaderRes: res, // TODO: Populate with rehydrated data - loaderErr: err, // TODO: Populate with rehydrated data }; } public getChildContext() { const childContext: any = combineFrom(this.context.router, null); - debugger childContext.history = this.props.history; childContext.route = { location: childContext.history.location, - match: this.state?.match, - loaderRes: this.state?.loaderRes, - loaderErr: this.state?.loaderErr, + match: this.state?.match, // Why are we sending this? it appears useless. }; + childContext.loaderData = this.props.loaderData; // this is a dictionary of all data available return { router: childContext @@ -71,31 +70,9 @@ export class Router extends Component { // location in componentWillMount. This happens e.g. when doing // server rendering using a . this.unlisten = history.listen(async () => { - // Note: Resets counter at 10k to Allow infinite loads - const currentLoaderCount = this._loaderCount = (this._loaderCount + 1) % 10000; - const match = this.computeMatch(history.location.pathname); - let loaderRes; - let loaderErr; - if (match.loader) { - const params = undefined; - const request = undefined; - try { - const res = await match.loader({ params, request }); - // TODO: should we parse json? - loaderRes = res; - } catch (err) { - loaderErr = err; - } - } - // If a new loader has completed prior to current loader, - // don't overwrite with stale data. - if (currentLoaderCount === this._loaderCount) { - this.setState({ - match, - loaderRes, - loaderErr, - }); - } + this.setState({ + match: this.computeMatch(history.location.pathname) + }); }); } diff --git a/packages/inferno-router/src/StaticRouter.ts b/packages/inferno-router/src/StaticRouter.ts index 9933173aa..f0866739b 100644 --- a/packages/inferno-router/src/StaticRouter.ts +++ b/packages/inferno-router/src/StaticRouter.ts @@ -1,7 +1,7 @@ import { Component, createComponentVNode, Props, VNode } from 'inferno'; import { VNodeFlags } from 'inferno-vnode-flags'; import { parsePath } from 'history'; -import { Router } from './Router'; +import { Router, TLoaderData } from './Router'; import { combinePath, invariant, warning } from './utils'; import { combineFrom, isString } from 'inferno-shared'; @@ -13,6 +13,7 @@ function addLeadingSlash(path) { const noop = () => {}; export interface IStaticRouterProps extends Props { + loaderData?: Record; basename?: string; context: any; location: any; @@ -27,7 +28,8 @@ export class StaticRouter extends Component, S> { public getChildContext() { return { router: { - staticContext: this.props.context + staticContext: this.props.context, + loaderData: this.props.loaderData, } }; } @@ -70,7 +72,7 @@ export class StaticRouter extends Component, S> { location: stripBasename(basename, createLocation(location)), push: this.handlePush, replace: this.handleReplace - } + }, }) as any ); } diff --git a/packages/inferno-router/src/helpers.ts b/packages/inferno-router/src/helpers.ts new file mode 100644 index 000000000..0ea67bfc1 --- /dev/null +++ b/packages/inferno-router/src/helpers.ts @@ -0,0 +1,9 @@ +import { TLoaderData } from "./Router"; + +export function useLoaderData(props: { __loaderData__: TLoaderData }) { + return props.__loaderData__?.res; +} + +export function useLoaderError(props: { __loaderData__: TLoaderData }) { + return props.__loaderData__?.err; +} \ No newline at end of file diff --git a/packages/inferno-router/src/index.ts b/packages/inferno-router/src/index.ts index 576130985..6e0ce15e6 100644 --- a/packages/inferno-router/src/index.ts +++ b/packages/inferno-router/src/index.ts @@ -11,5 +11,7 @@ import { Prompt } from './Prompt'; import { Redirect } from './Redirect'; import { matchPath } from './matchPath'; import { withRouter } from './withRouter'; +export * from './helpers'; +export * from './ssrLoaderResolver'; export { BrowserRouter, HashRouter, Link, MemoryRouter, NavLink, Prompt, Redirect, Route, Router, StaticRouter, Switch, matchPath, withRouter }; diff --git a/packages/inferno-router/src/matchPath.ts b/packages/inferno-router/src/matchPath.ts index 8da4c5bfe..451273b55 100644 --- a/packages/inferno-router/src/matchPath.ts +++ b/packages/inferno-router/src/matchPath.ts @@ -1,4 +1,5 @@ import pathToRegexp from 'path-to-regexp-es6'; +import { Match } from './Route'; const patternCache = {}; const cacheLimit = 10000; @@ -27,12 +28,12 @@ const compilePath = (pattern, options) => { /** * Public API for matching a URL pathname to a path pattern. */ -export function matchPath(pathname, options: any) { +export function matchPath(pathname, options: any): Match | null { if (typeof options === 'string') { options = { path: options }; } - const { path = '/', exact = false, strict = false, sensitive = false } = options; + const { path = '/', exact = false, strict = false, sensitive = false, loader = undefined, loaderData = {} } = options; const { re, keys } = compilePath(path, { end: exact, strict, sensitive }); const match = re.exec(pathname); @@ -40,6 +41,8 @@ export function matchPath(pathname, options: any) { return null; } + const loaderDataEntry = loaderData[path]; + const [url, ...values] = match; const isExact = pathname === url; @@ -54,6 +57,8 @@ export function matchPath(pathname, options: any) { return memo; }, {}), path, // the path pattern used to match - url: path === '/' && url === '' ? '/' : url // the matched portion of the URL + url: path === '/' && url === '' ? '/' : url, // the matched portion of the URL + loader, + loaderData: loaderDataEntry }; } diff --git a/packages/inferno-router/src/ssrLoaderResolver.ts b/packages/inferno-router/src/ssrLoaderResolver.ts new file mode 100644 index 000000000..d09e8380c --- /dev/null +++ b/packages/inferno-router/src/ssrLoaderResolver.ts @@ -0,0 +1,62 @@ +import { isNull, isNullOrUndef } from "inferno-shared"; +import { matchPath } from "./matchPath"; +import { TLoaderData } from "./Router"; + +export async function resolveLoaders(location: string, tree: any): Promise> { + const promises = traverseLoaders(location, tree).map((({path, loader}) => resolveEntry(path, loader))); + const result = await Promise.all(promises); + return Object.fromEntries(result); +} + +type TLoaderEntry = { + path: string, + loader: Function, +} + +function traverseLoaders(location: string, tree: any): TLoaderEntry[] { + if (Array.isArray(tree)) { + const entries = tree.reduce((res, node) => { + const outpArr = traverseLoaders(location, node); + if (isNull(outpArr)) { + return res; + } + return [...res, ...outpArr]; + }, []); + return entries; + } + + // This is a single node + let outp: TLoaderEntry[] = []; + // Add any loader on this node + if (tree.props.loader && tree.props.path) { + // TODO: Should we check if we are in Router? It is defensive and could save a bit of time, but is it worth it? + const { path, exact = false, strict = false, sensitive = false } = tree.props; + if (matchPath(location, { + path, + exact, + strict, + sensitive, + })) { + outp.push({ + path, + loader: tree.props.loader, + }) + } + } + // Traverse children + if (!isNullOrUndef(tree.props.children)) { + const entries = traverseLoaders(location, tree.props.children); + outp = [...outp, ...entries] + } + + return outp; +} + +async function resolveEntry(path, loader): Promise { + try { + const res = await loader() + return [path, { res }]; + } catch (err) { + return [ path, { err } ]; + } +} \ No newline at end of file From facdda461fbed59ebf5febd6de946d80ea6fc558 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Sun, 18 Dec 2022 10:30:15 +0100 Subject: [PATCH 04/72] Fixed call to loader on first render --- packages/inferno-router/src/Route.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index 67cb49579..442e9a9ac 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -2,7 +2,7 @@ import { Component, createComponentVNode, Inferno, InfernoNode } from 'inferno'; import { VNodeFlags } from 'inferno-vnode-flags'; import { invariant, warning } from './utils'; import { matchPath } from './matchPath'; -import { combineFrom, isFunction, isNull, isNullOrUndef } from 'inferno-shared'; +import { combineFrom, isFunction, isNull } from 'inferno-shared'; import type { History, Location } from 'history'; import type { TLoader, TLoaderData, TLoaderProps } from './Router'; @@ -65,9 +65,7 @@ class Route extends Component, RouteState> { const match = this.computeMatch(props, context.router); const { res, err } = match?.loaderData ?? {}; - if (isNullOrUndef(match?.loaderData)) { - this._initialLoader = match?.loader ?? null; - } + this._initialLoader = match?.loader ?? null; this.state = { match, @@ -138,7 +136,8 @@ class Route extends Component, RouteState> { const params = undefined; const request = undefined; // match.loader has been checked in constructor - setTimeout(() => this.runLoader(this._initialLoader!, params, request, match), 0); + const _loader = this._initialLoader + setTimeout(() => this.runLoader(_loader, params, request, match), 0); this._initialLoader = null; } From 296c67e737dd8303c1f0dda8efbbd049df29d3fe Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Sun, 18 Dec 2022 10:30:47 +0100 Subject: [PATCH 05/72] Fixed tests and added SSR and browser render --- .../__tests__/loaderOnRoute.spec.jsx | 185 ++++++++++++++++-- .../inferno-router/src/ssrLoaderResolver.ts | 9 +- 2 files changed, 176 insertions(+), 18 deletions(-) diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index c26128790..7d628d409 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -1,6 +1,6 @@ import { render } from 'inferno'; +import { renderToString } from 'inferno-server' import { BrowserRouter, MemoryRouter, StaticRouter, Route, NavLink, resolveLoaders, useLoaderData, useLoaderError } from 'inferno-router'; -import { createMemoryHistory } from 'history'; import { createEventGuard } from './testUtils'; describe('A with loader in a MemoryRouter', () => { @@ -17,17 +17,53 @@ describe('A with loader in a MemoryRouter', () => { document.body.removeChild(container); }); - it('renders on initial', () => { - const TEXT = 'Mrs. Kato'; - const loaderFunc = async () => { return { message: "ok" }} + it('renders on initial', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "ok"; + const loaderFunc = async () => { + setDone(); + return { message: TEXT } + } + + render( + + { + const data = useLoaderData(props); + return

{data?.message}

+ }} loader={loaderFunc} /> + , + container + ); + + // Wait until async loader has completed + await waitForRerender(); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('renders error on initial', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "An error"; + const loaderFunc = async () => { + setDone(); + throw new Error(TEXT) + } render( -

{TEXT}

} loader={loaderFunc} /> + { + const err = useLoaderError(props); + return

{err?.message}

+ }} loader={loaderFunc} />
, container ); + // Wait until async loader has completed + await waitForRerender(); + expect(container.innerHTML).toContain(TEXT); }); @@ -54,6 +90,7 @@ describe('A with loader in a MemoryRouter', () => { it('Should render component after after click', async () => { const [setDone, waitForRerender] = createEventGuard(); + const TEST = "ok"; const loaderFunc = async () => { setDone(); @@ -134,17 +171,53 @@ describe('A with loader in a BrowserRouter', () => { history.replaceState(undefined, undefined, '/'); }); - it('renders on initial', () => { - const TEXT = 'Mrs. Kato'; - const loaderFunc = async () => { return { message: "ok" }} + it('renders on initial', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "ok"; + const loaderFunc = async () => { + setDone(); + return { message: TEXT } + } render( -

{TEXT}

} loader={loaderFunc} /> + { + const data = useLoaderData(props); + return

{data?.message}

+ }} loader={loaderFunc} />
, container ); + // Wait until async loader has completed + await waitForRerender(); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('renders error on initial', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "An error"; + const loaderFunc = async () => { + setDone(); + throw new Error(TEXT) + } + + render( + + { + const err = useLoaderError(props); + return

{err?.message}

+ }} loader={loaderFunc} /> +
, + container + ); + + // Wait until async loader has completed + await waitForRerender(); + expect(container.innerHTML).toContain(TEXT); }); @@ -254,17 +327,53 @@ describe('A with loader in a StaticRouter', () => { history.replaceState(undefined, undefined, '/'); }); - it('renders on initial', () => { - const TEXT = 'Mrs. Kato'; - const loaderFunc = async () => { return { message: "ok" }} + it('renders on initial', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "ok"; + const loaderFunc = async () => { + setDone(); + return { message: TEXT } + } + + render( + + { + const data = useLoaderData(props); + return

{data?.message}

+ }} loader={loaderFunc} /> +
, + container + ); + + // Wait until async loader has completed + await waitForRerender(); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('renders error on initial', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "An error"; + const loaderFunc = async () => { + setDone(); + throw new Error(TEXT) + } render( -

{TEXT}

} loader={loaderFunc} /> + { + const err = useLoaderError(props); + return

{err?.message}

+ }} loader={loaderFunc} />
, container ); + // Wait until async loader has completed + await waitForRerender(); + expect(container.innerHTML).toContain(TEXT); }); @@ -293,6 +402,21 @@ describe('A with loader in a StaticRouter', () => { }); describe('Resolve loaders during server side rendering', () => { + let container; + + beforeEach(function () { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(function () { + render(null, container); + container.innerHTML = ''; + document.body.removeChild(container); + // Reset history to root + history.replaceState(undefined, undefined, '/'); + }); + it('Can resolve with single route', async () => { const TEXT = 'bubblegum'; const Component = (props, { router }) => { @@ -357,10 +481,45 @@ describe('Resolve loaders during server side rendering', () => { + {null} ; const result = await resolveLoaders('/flowers/birds', app); expect(result).toEqual(loaderData); }); + + + it('SSR renders same result as browser', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + + const loaderFuncNoHit = async () => { return { message: 'no' }} + const loaderFunc = async () => { return { message: TEXT }} + + const loaderData = { + '/birds': { res: await loaderFunc() } + } + + const routes = [ + , + , + , + ] + + const initialData = await resolveLoaders('/birds', routes); + + // Render on server + const html = renderToString({routes}); + + // Render in browser + history.replaceState(undefined, undefined, '/birds'); + render({routes}, container); + + + expect(`${container.innerHTML}`).toEqual(html); + }); }) \ No newline at end of file diff --git a/packages/inferno-router/src/ssrLoaderResolver.ts b/packages/inferno-router/src/ssrLoaderResolver.ts index d09e8380c..a99743a71 100644 --- a/packages/inferno-router/src/ssrLoaderResolver.ts +++ b/packages/inferno-router/src/ssrLoaderResolver.ts @@ -1,4 +1,4 @@ -import { isNull, isNullOrUndef } from "inferno-shared"; +import { isNullOrUndef } from "inferno-shared"; import { matchPath } from "./matchPath"; import { TLoaderData } from "./Router"; @@ -17,15 +17,14 @@ function traverseLoaders(location: string, tree: any): TLoaderEntry[] { if (Array.isArray(tree)) { const entries = tree.reduce((res, node) => { const outpArr = traverseLoaders(location, node); - if (isNull(outpArr)) { - return res; - } return [...res, ...outpArr]; }, []); return entries; } - // This is a single node + // This is a single node make sure it isn't null + if (isNullOrUndef(tree) || isNullOrUndef(tree.props)) return []; + let outp: TLoaderEntry[] = []; // Add any loader on this node if (tree.props.loader && tree.props.path) { From 8f4b69a07be718af69e99e078d914a34d7f843fb Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Sun, 18 Dec 2022 10:49:25 +0100 Subject: [PATCH 06/72] Moved SSR render test to inferno-server --- .../__tests__/loaderOnRoute.spec.jsx | 52 +------------------ .../__tests__/loaderOnRoute.spec.server.jsx | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 51 deletions(-) create mode 100644 packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index 7d628d409..af64ef15c 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -1,5 +1,4 @@ import { render } from 'inferno'; -import { renderToString } from 'inferno-server' import { BrowserRouter, MemoryRouter, StaticRouter, Route, NavLink, resolveLoaders, useLoaderData, useLoaderError } from 'inferno-router'; import { createEventGuard } from './testUtils'; @@ -402,21 +401,6 @@ describe('A with loader in a StaticRouter', () => { }); describe('Resolve loaders during server side rendering', () => { - let container; - - beforeEach(function () { - container = document.createElement('div'); - document.body.appendChild(container); - }); - - afterEach(function () { - render(null, container); - container.innerHTML = ''; - document.body.removeChild(container); - // Reset history to root - history.replaceState(undefined, undefined, '/'); - }); - it('Can resolve with single route', async () => { const TEXT = 'bubblegum'; const Component = (props, { router }) => { @@ -488,38 +472,4 @@ describe('Resolve loaders during server side rendering', () => { const result = await resolveLoaders('/flowers/birds', app); expect(result).toEqual(loaderData); }); - - - it('SSR renders same result as browser', async () => { - const TEXT = 'bubblegum'; - const Component = (props, { router }) => { - const res = useLoaderData(props); - return

{res?.message}

- } - - const loaderFuncNoHit = async () => { return { message: 'no' }} - const loaderFunc = async () => { return { message: TEXT }} - - const loaderData = { - '/birds': { res: await loaderFunc() } - } - - const routes = [ - , - , - , - ] - - const initialData = await resolveLoaders('/birds', routes); - - // Render on server - const html = renderToString({routes}); - - // Render in browser - history.replaceState(undefined, undefined, '/birds'); - render({routes}, container); - - - expect(`${container.innerHTML}`).toEqual(html); - }); -}) \ No newline at end of file +}) diff --git a/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx b/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx new file mode 100644 index 000000000..9a752db05 --- /dev/null +++ b/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx @@ -0,0 +1,52 @@ +import { render } from 'inferno'; +import { renderToString } from 'inferno-server' +import { BrowserRouter, StaticRouter, Route, resolveLoaders, useLoaderData } from 'inferno-router'; + +describe('Resolve loaders during server side rendering', () => { + let container; + + beforeEach(function () { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(function () { + render(null, container); + container.innerHTML = ''; + document.body.removeChild(container); + // Reset history to root + history.replaceState(undefined, undefined, '/'); + }); + + it('SSR renders same result as browser', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + + const loaderFuncNoHit = async () => { return { message: 'no' }} + const loaderFunc = async () => { return { message: TEXT }} + + const loaderData = { + '/birds': { res: await loaderFunc() } + } + + const routes = [ + , + , + , + ] + + const initialData = await resolveLoaders('/birds', routes); + + // Render on server + const html = renderToString({routes}); + + // Render in browser + history.replaceState(undefined, undefined, '/birds'); + render({routes}, container); + + expect(`${container.innerHTML}`).toEqual(html); + }); +}) \ No newline at end of file From 70b0d3f80ceea4a87749d106a0cf13921d528cdf Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Mon, 19 Dec 2022 09:18:43 +0100 Subject: [PATCH 07/72] Shrink and move --- .../__tests__/loaderOnRoute.spec.jsx | 5 +++-- packages/inferno-router/package.json | 1 + packages/inferno-router/src/Route.ts | 20 ++++++++++--------- packages/inferno-router/src/Router.ts | 2 +- packages/inferno-router/src/index.ts | 1 - packages/inferno-server/src/index.ts | 1 + .../src/ssrLoaderResolver.ts | 20 ++++++++----------- 7 files changed, 25 insertions(+), 25 deletions(-) rename packages/{inferno-router => inferno-server}/src/ssrLoaderResolver.ts (74%) diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index af64ef15c..8be2e276d 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -1,6 +1,7 @@ import { render } from 'inferno'; -import { BrowserRouter, MemoryRouter, StaticRouter, Route, NavLink, resolveLoaders, useLoaderData, useLoaderError } from 'inferno-router'; -import { createEventGuard } from './testUtils'; +import { BrowserRouter, MemoryRouter, StaticRouter, Route, NavLink, useLoaderData, useLoaderError } from 'inferno-router'; +import { resolveLoaders } from 'inferno-server'; +import { createEventGuard } from 'inferno-router/__tests__/testUtils'; describe('A with loader in a MemoryRouter', () => { let container; diff --git a/packages/inferno-router/package.json b/packages/inferno-router/package.json index 0faf66976..da2ed72ec 100644 --- a/packages/inferno-router/package.json +++ b/packages/inferno-router/package.json @@ -46,6 +46,7 @@ "path-to-regexp-es6": "1.7.0" }, "devDependencies": { + "inferno-server": "8.1.1", "inferno-vnode-flags": "8.1.1", "mobx": "*" }, diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index 442e9a9ac..89fe81eb1 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -73,16 +73,18 @@ class Route extends Component, RouteState> { }; } - private async runLoader(loader: TLoader, params, request, match) { + private runLoader(loader: TLoader, params, request, match) { // TODO: Pass progress callback to loader - try { - const res = await loader({ params, request }); - // TODO: should we parse json? - this.setState({ match, __loaderData__: { res } }); - } catch (err) { - // Loaders should throw errors - this.setState({ match, __loaderData__: { err } }) - } + loader({ params, request }) + .then((res) => { + // TODO: should we parse json? + this.setState({ match, __loaderData__: { res } }); + }) + .catch((err) => { + // Loaders should throw errors + this.setState({ match, __loaderData__: { err } }) + + }); } public computeMatch({ computedMatch, ...props }, router) { diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index fe79ebf3d..d1f01df33 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -69,7 +69,7 @@ export class Router extends Component { // Do this here so we can setState when a changes the // location in componentWillMount. This happens e.g. when doing // server rendering using a . - this.unlisten = history.listen(async () => { + this.unlisten = history.listen(() => { this.setState({ match: this.computeMatch(history.location.pathname) }); diff --git a/packages/inferno-router/src/index.ts b/packages/inferno-router/src/index.ts index 6e0ce15e6..6cdd316cb 100644 --- a/packages/inferno-router/src/index.ts +++ b/packages/inferno-router/src/index.ts @@ -12,6 +12,5 @@ import { Redirect } from './Redirect'; import { matchPath } from './matchPath'; import { withRouter } from './withRouter'; export * from './helpers'; -export * from './ssrLoaderResolver'; export { BrowserRouter, HashRouter, Link, MemoryRouter, NavLink, Prompt, Redirect, Route, Router, StaticRouter, Switch, matchPath, withRouter }; diff --git a/packages/inferno-server/src/index.ts b/packages/inferno-server/src/index.ts index 985196af0..33c64b8a8 100644 --- a/packages/inferno-server/src/index.ts +++ b/packages/inferno-server/src/index.ts @@ -1,6 +1,7 @@ import { renderToString } from './renderToString'; import { RenderQueueStream, streamQueueAsString } from './renderToString.queuestream'; import { RenderStream, streamAsString } from './renderToString.stream'; +export * from './ssrLoaderResolver'; // Inferno does not generate "data-root" attributes so staticMarkup is literally same as renderToString export { diff --git a/packages/inferno-router/src/ssrLoaderResolver.ts b/packages/inferno-server/src/ssrLoaderResolver.ts similarity index 74% rename from packages/inferno-router/src/ssrLoaderResolver.ts rename to packages/inferno-server/src/ssrLoaderResolver.ts index a99743a71..4608c5283 100644 --- a/packages/inferno-router/src/ssrLoaderResolver.ts +++ b/packages/inferno-server/src/ssrLoaderResolver.ts @@ -1,11 +1,10 @@ import { isNullOrUndef } from "inferno-shared"; -import { matchPath } from "./matchPath"; -import { TLoaderData } from "./Router"; +import { matchPath } from "inferno-router"; +import type { TLoaderData } from "inferno-router/src/Router"; -export async function resolveLoaders(location: string, tree: any): Promise> { +export function resolveLoaders(location: string, tree: any): Promise> { const promises = traverseLoaders(location, tree).map((({path, loader}) => resolveEntry(path, loader))); - const result = await Promise.all(promises); - return Object.fromEntries(result); + return Promise.all(promises).then((result) => Object.fromEntries(result)); } type TLoaderEntry = { @@ -51,11 +50,8 @@ function traverseLoaders(location: string, tree: any): TLoaderEntry[] { return outp; } -async function resolveEntry(path, loader): Promise { - try { - const res = await loader() - return [path, { res }]; - } catch (err) { - return [ path, { err } ]; - } +function resolveEntry(path, loader): Promise { + return loader() + .then((res) => [path, { res }]) + .catch((err) => [ path, { err } ]); } \ No newline at end of file From 66b2dbc3b419f390032db4d144b67e1de2c0968e Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Mon, 19 Dec 2022 09:21:19 +0100 Subject: [PATCH 08/72] Use async await on server --- packages/inferno-server/src/ssrLoaderResolver.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/inferno-server/src/ssrLoaderResolver.ts b/packages/inferno-server/src/ssrLoaderResolver.ts index 4608c5283..c2ee61f2d 100644 --- a/packages/inferno-server/src/ssrLoaderResolver.ts +++ b/packages/inferno-server/src/ssrLoaderResolver.ts @@ -2,9 +2,10 @@ import { isNullOrUndef } from "inferno-shared"; import { matchPath } from "inferno-router"; import type { TLoaderData } from "inferno-router/src/Router"; -export function resolveLoaders(location: string, tree: any): Promise> { +export async function resolveLoaders(location: string, tree: any): Promise> { const promises = traverseLoaders(location, tree).map((({path, loader}) => resolveEntry(path, loader))); - return Promise.all(promises).then((result) => Object.fromEntries(result)); + const result = await Promise.all(promises); + return Object.fromEntries(result); } type TLoaderEntry = { @@ -50,8 +51,11 @@ function traverseLoaders(location: string, tree: any): TLoaderEntry[] { return outp; } -function resolveEntry(path, loader): Promise { - return loader() - .then((res) => [path, { res }]) - .catch((err) => [ path, { err } ]); +async function resolveEntry(path, loader): Promise { + try { + const res = await loader() + return [path, { res }]; + } catch (err) { + return [ path, { err } ]; + } } \ No newline at end of file From 9759f3f4cc313cfb92b257ad288fa59783792594 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Mon, 19 Dec 2022 09:41:57 +0100 Subject: [PATCH 09/72] Fix tests --- packages/inferno-router/__tests__/loaderOnRoute.spec.jsx | 5 +++-- .../inferno-server/__tests__/loaderOnRoute.spec.server.jsx | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index 8be2e276d..ebeb95aa1 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -1,7 +1,8 @@ import { render } from 'inferno'; import { BrowserRouter, MemoryRouter, StaticRouter, Route, NavLink, useLoaderData, useLoaderError } from 'inferno-router'; -import { resolveLoaders } from 'inferno-server'; -import { createEventGuard } from 'inferno-router/__tests__/testUtils'; +// Cherry picked relative import so we don't get node-stuff from inferno-server in browser test +import { resolveLoaders } from '../../inferno-server/src/ssrLoaderResolver'; +import { createEventGuard } from './testUtils'; describe('A with loader in a MemoryRouter', () => { let container; diff --git a/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx b/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx index 9a752db05..158fd0b9c 100644 --- a/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx +++ b/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx @@ -1,6 +1,6 @@ import { render } from 'inferno'; -import { renderToString } from 'inferno-server' -import { BrowserRouter, StaticRouter, Route, resolveLoaders, useLoaderData } from 'inferno-router'; +import { renderToString, resolveLoaders } from 'inferno-server'; +import { BrowserRouter, StaticRouter, Route, useLoaderData } from 'inferno-router'; describe('Resolve loaders during server side rendering', () => { let container; From 58bdfc35476ab02608e832428a9b59e5f7e1805c Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Mon, 19 Dec 2022 17:02:12 +0100 Subject: [PATCH 10/72] renamed loaderData to initialData --- .../__tests__/loaderOnRoute.spec.jsx | 30 +++++++++---------- packages/inferno-router/src/BrowserRouter.ts | 4 +-- packages/inferno-router/src/MemoryRouter.ts | 4 +-- packages/inferno-router/src/Route.ts | 13 ++++---- packages/inferno-router/src/Router.ts | 4 +-- packages/inferno-router/src/StaticRouter.ts | 4 +-- packages/inferno-router/src/matchPath.ts | 6 ++-- .../__tests__/loaderOnRoute.spec.server.jsx | 8 ++--- 8 files changed, 36 insertions(+), 37 deletions(-) diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index ebeb95aa1..8a153ee6c 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -68,19 +68,19 @@ describe('A with loader in a MemoryRouter', () => { expect(container.innerHTML).toContain(TEXT); }); - it('Can access initial loaderData (for hydration)', async () => { + it('Can access initialData (for hydration)', async () => { const TEXT = 'bubblegum'; const Component = (props, { router }) => { const res = useLoaderData(props); return

{res?.message}

} const loaderFunc = async () => { return { message: TEXT }} - const loaderData = { + const initialData = { '/flowers': { res: await loaderFunc(), err: undefined, } } render( - + , container @@ -222,7 +222,7 @@ describe('A with loader in a BrowserRouter', () => { expect(container.innerHTML).toContain(TEXT); }); - it('Can access initial loaderData (for hydration)', async () => { + it('Can access initialData (for hydration)', async () => { const TEXT = 'bubblegum'; const Component = (props, { router }) => { const res = useLoaderData(props); @@ -231,13 +231,13 @@ describe('A with loader in a BrowserRouter', () => { const loaderFunc = async () => { return { message: TEXT }} - const loaderData = { + const initialData = { '/flowers': { res: await loaderFunc(), err: undefined, } } history.replaceState(undefined, undefined, '/flowers'); render( - + , container @@ -378,7 +378,7 @@ describe('A with loader in a StaticRouter', () => { expect(container.innerHTML).toContain(TEXT); }); - it('Can access initial loaderData (for hydration)', async () => { + it('Can access initialData (for hydration)', async () => { const TEXT = 'bubblegum'; const Component = (props, { router }) => { const res = useLoaderData(props); @@ -387,12 +387,12 @@ describe('A with loader in a StaticRouter', () => { const loaderFunc = async () => { return { message: TEXT }} - const loaderData = { + const initialData = { '/flowers': { res: await loaderFunc(), err: undefined, } } render( - + , container @@ -412,7 +412,7 @@ describe('Resolve loaders during server side rendering', () => { const loaderFunc = async () => { return { message: TEXT }} - const loaderData = { + const initialData = { '/flowers': { res: await loaderFunc() } } @@ -421,7 +421,7 @@ describe('Resolve loaders during server side rendering', () => { ; const result = await resolveLoaders('/flowers', app); - expect(result).toEqual(loaderData); + expect(result).toEqual(initialData); }); it('Can resolve with multiple routes', async () => { @@ -434,7 +434,7 @@ describe('Resolve loaders during server side rendering', () => { const loaderFuncNoHit = async () => { return { message: 'no' }} const loaderFunc = async () => { return { message: TEXT }} - const loaderData = { + const initialData = { '/birds': { res: await loaderFunc() } } @@ -445,7 +445,7 @@ describe('Resolve loaders during server side rendering', () => {
; const result = await resolveLoaders('/birds', app); - expect(result).toEqual(loaderData); + expect(result).toEqual(initialData); }); it('Can resolve with nested routes', async () => { @@ -458,7 +458,7 @@ describe('Resolve loaders during server side rendering', () => { const loaderFuncNoHit = async () => { return { message: 'no' }} const loaderFunc = async () => { return { message: TEXT }} - const loaderData = { + const initialData = { '/flowers': { res: await loaderFunc() }, '/flowers/birds': { res: await loaderFunc() } } @@ -472,6 +472,6 @@ describe('Resolve loaders during server side rendering', () => { ; const result = await resolveLoaders('/flowers/birds', app); - expect(result).toEqual(loaderData); + expect(result).toEqual(initialData); }); }) diff --git a/packages/inferno-router/src/BrowserRouter.ts b/packages/inferno-router/src/BrowserRouter.ts index ebb0a5f15..508388a3c 100644 --- a/packages/inferno-router/src/BrowserRouter.ts +++ b/packages/inferno-router/src/BrowserRouter.ts @@ -5,7 +5,7 @@ import { Router, TLoaderData } from './Router'; import { warning } from './utils'; export interface IBrowserRouterProps { - loaderData?: Record; + initialData?: Record; basename?: string; forceRefresh?: boolean; getUserConfirmation?: () => {}; @@ -25,7 +25,7 @@ export class BrowserRouter extends Component { return createComponentVNode(VNodeFlags.ComponentClass, Router, { children: this.props.children, history: this.history, - loaderData: this.props.loaderData, + initialData: this.props.initialData, }); } } diff --git a/packages/inferno-router/src/MemoryRouter.ts b/packages/inferno-router/src/MemoryRouter.ts index 59306dd10..64d7817e1 100644 --- a/packages/inferno-router/src/MemoryRouter.ts +++ b/packages/inferno-router/src/MemoryRouter.ts @@ -7,7 +7,7 @@ import { warning } from './utils'; export interface IMemoryRouterProps { initialEntries?: string[]; initialIndex?: number; - loaderData?: Record; + initialData?: Record; getUserConfirmation?: () => {}; keyLength?: number; children: Component[]; @@ -25,7 +25,7 @@ export class MemoryRouter extends Component { return createComponentVNode(VNodeFlags.ComponentClass, Router, { children: this.props.children, history: this.history, - loaderData: this.props.loaderData, + initialData: this.props.initialData, }); } } diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index 89fe81eb1..726399181 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -12,7 +12,7 @@ export interface Match

> { path: string; url: string; loader?(props: TLoaderProps

): Promise; - loaderData?: TLoaderData; + initialData?: TLoaderData; } export interface RouteComponentProps

> { @@ -64,8 +64,11 @@ class Route extends Component, RouteState> { const match = this.computeMatch(props, context.router); - const { res, err } = match?.loaderData ?? {}; - this._initialLoader = match?.loader ?? null; + const { res, err } = match?.initialData ?? {}; + // Only run loader on first render if no data is passed + if (!match?.initialData) { + this._initialLoader = match?.loader ?? null; + } this.state = { match, @@ -99,10 +102,10 @@ class Route extends Component, RouteState> { invariant(router, 'You should not use or withRouter() outside a '); } - const { route, loaderData } = router; // This is the parent route + const { route, initialData } = router; // This is the parent route const pathname = (location || route.location).pathname; - return path ? matchPath(pathname, { path, strict, exact, sensitive, loader, loaderData }) : route.match; + return path ? matchPath(pathname, { path, strict, exact, sensitive, loader, initialData }) : route.match; } public componentWillReceiveProps(nextProps, nextContext) { diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index d1f01df33..d562201c1 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -22,7 +22,7 @@ export type TLoaderData = { export interface IRouterProps { history: History; children: InfernoNode; - loaderData?: Record; // key is route path to allow resolving + initialData?: Record; // key is route path to allow resolving } /** @@ -45,7 +45,7 @@ export class Router extends Component { location: childContext.history.location, match: this.state?.match, // Why are we sending this? it appears useless. }; - childContext.loaderData = this.props.loaderData; // this is a dictionary of all data available + childContext.initialData = this.props.initialData; // this is a dictionary of all data available return { router: childContext diff --git a/packages/inferno-router/src/StaticRouter.ts b/packages/inferno-router/src/StaticRouter.ts index f0866739b..3b85428a7 100644 --- a/packages/inferno-router/src/StaticRouter.ts +++ b/packages/inferno-router/src/StaticRouter.ts @@ -13,7 +13,7 @@ function addLeadingSlash(path) { const noop = () => {}; export interface IStaticRouterProps extends Props { - loaderData?: Record; + initialData?: Record; basename?: string; context: any; location: any; @@ -29,7 +29,7 @@ export class StaticRouter extends Component, S> { return { router: { staticContext: this.props.context, - loaderData: this.props.loaderData, + initialData: this.props.initialData, } }; } diff --git a/packages/inferno-router/src/matchPath.ts b/packages/inferno-router/src/matchPath.ts index 451273b55..5b471fccf 100644 --- a/packages/inferno-router/src/matchPath.ts +++ b/packages/inferno-router/src/matchPath.ts @@ -33,7 +33,7 @@ export function matchPath(pathname, options: any): Match | null { options = { path: options }; } - const { path = '/', exact = false, strict = false, sensitive = false, loader = undefined, loaderData = {} } = options; + const { path = '/', exact = false, strict = false, sensitive = false, loader = undefined, initialData = {} } = options; const { re, keys } = compilePath(path, { end: exact, strict, sensitive }); const match = re.exec(pathname); @@ -41,7 +41,7 @@ export function matchPath(pathname, options: any): Match | null { return null; } - const loaderDataEntry = loaderData[path]; + const intialDataEntry = initialData[path]; const [url, ...values] = match; const isExact = pathname === url; @@ -59,6 +59,6 @@ export function matchPath(pathname, options: any): Match | null { path, // the path pattern used to match url: path === '/' && url === '' ? '/' : url, // the matched portion of the URL loader, - loaderData: loaderDataEntry + initialData: intialDataEntry }; } diff --git a/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx b/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx index 158fd0b9c..13c6cf871 100644 --- a/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx +++ b/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx @@ -28,10 +28,6 @@ describe('Resolve loaders during server side rendering', () => { const loaderFuncNoHit = async () => { return { message: 'no' }} const loaderFunc = async () => { return { message: TEXT }} - const loaderData = { - '/birds': { res: await loaderFunc() } - } - const routes = [ , , @@ -41,11 +37,11 @@ describe('Resolve loaders during server side rendering', () => { const initialData = await resolveLoaders('/birds', routes); // Render on server - const html = renderToString({routes}); + const html = renderToString({routes}); // Render in browser history.replaceState(undefined, undefined, '/birds'); - render({routes}, container); + render({routes}, container); expect(`${container.innerHTML}`).toEqual(html); }); From 7a17f2adf04d61f6dd03a573a323e37baa83e584 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Mon, 19 Dec 2022 17:13:56 +0100 Subject: [PATCH 11/72] Pass match params to async loader --- packages/inferno-router/src/Route.ts | 8 ++++---- packages/inferno-server/src/ssrLoaderResolver.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index 726399181..7bc7e3977 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -44,7 +44,7 @@ type RouteState = { } class Route extends Component, RouteState> { - _initialLoader = null; + _initialLoader: ((props: TLoaderProps) => Promise) | null = null; public getChildContext() { const childContext: any = combineFrom(this.context.router, null); @@ -90,7 +90,7 @@ class Route extends Component, RouteState> { }); } - public computeMatch({ computedMatch, ...props }, router) { + public computeMatch({ computedMatch, ...props }, router): Match { if (computedMatch) { // already computed the match for us return computedMatch; @@ -124,7 +124,7 @@ class Route extends Component, RouteState> { // Am I a match? In which case check for loader if (nextProps?.loader) { - const params = undefined; + const params = match.params; const request = undefined; this.runLoader(nextProps.loader, params, request, match); return; @@ -138,7 +138,7 @@ class Route extends Component, RouteState> { // QUESTION: Is there a better way to invoke this on/after first render? if (!isNull(this._initialLoader)) { - const params = undefined; + const params = match.params; const request = undefined; // match.loader has been checked in constructor const _loader = this._initialLoader diff --git a/packages/inferno-server/src/ssrLoaderResolver.ts b/packages/inferno-server/src/ssrLoaderResolver.ts index c2ee61f2d..7eea5e538 100644 --- a/packages/inferno-server/src/ssrLoaderResolver.ts +++ b/packages/inferno-server/src/ssrLoaderResolver.ts @@ -30,15 +30,18 @@ function traverseLoaders(location: string, tree: any): TLoaderEntry[] { if (tree.props.loader && tree.props.path) { // TODO: Should we check if we are in Router? It is defensive and could save a bit of time, but is it worth it? const { path, exact = false, strict = false, sensitive = false } = tree.props; - if (matchPath(location, { + const match = matchPath(location, { path, exact, strict, sensitive, - })) { + }); + if (match) { + const { params } = match; + // TODO: How do I pass request? outp.push({ path, - loader: tree.props.loader, + loader: () => tree.props.loader({ params }), }) } } From e9e8697a9af751bb50668d3d1ea0378f031d7650 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Mon, 19 Dec 2022 22:06:36 +0100 Subject: [PATCH 12/72] Move first invokation of loader to lifecycle hook where it belongs --- packages/inferno-router/src/Route.ts | 37 +++++++++++----------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index 7bc7e3977..14b9910bb 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -2,7 +2,7 @@ import { Component, createComponentVNode, Inferno, InfernoNode } from 'inferno'; import { VNodeFlags } from 'inferno-vnode-flags'; import { invariant, warning } from './utils'; import { matchPath } from './matchPath'; -import { combineFrom, isFunction, isNull } from 'inferno-shared'; +import { combineFrom, isFunction, isUndefined } from 'inferno-shared'; import type { History, Location } from 'history'; import type { TLoader, TLoaderData, TLoaderProps } from './Router'; @@ -40,12 +40,10 @@ export interface IRouteProps { */ type RouteState = { match: Match; - __loaderData__: TLoaderData; + __loaderData__?: TLoaderData; } class Route extends Component, RouteState> { - _initialLoader: ((props: TLoaderProps) => Promise) | null = null; - public getChildContext() { const childContext: any = combineFrom(this.context.router, null); @@ -63,16 +61,10 @@ class Route extends Component, RouteState> { super(props, context); const match = this.computeMatch(props, context.router); - - const { res, err } = match?.initialData ?? {}; - // Only run loader on first render if no data is passed - if (!match?.initialData) { - this._initialLoader = match?.loader ?? null; - } this.state = { match, - __loaderData__: { res, err }, + __loaderData__: match?.initialData, }; } @@ -86,7 +78,6 @@ class Route extends Component, RouteState> { .catch((err) => { // Loaders should throw errors this.setState({ match, __loaderData__: { err } }) - }); } @@ -108,6 +99,17 @@ class Route extends Component, RouteState> { return path ? matchPath(pathname, { path, strict, exact, sensitive, loader, initialData }) : route.match; } + public componentDidMount(): void { + const { match, __loaderData__ } = this.state!; + // QUESTION: Is there a better way to invoke this on/after first render? + if (!isUndefined(match?.loader) && isUndefined(__loaderData__)) { + const params = match.params; + const request = undefined; + setTimeout(() => this.runLoader(match.loader!, params, request, match), 0); + } + + } + public componentWillReceiveProps(nextProps, nextContext) { if (process.env.NODE_ENV !== 'production') { warning( @@ -135,17 +137,6 @@ class Route extends Component, RouteState> { public render() { const { match, __loaderData__ } = this.state!; - - // QUESTION: Is there a better way to invoke this on/after first render? - if (!isNull(this._initialLoader)) { - const params = match.params; - const request = undefined; - // match.loader has been checked in constructor - const _loader = this._initialLoader - setTimeout(() => this.runLoader(_loader, params, request, match), 0); - this._initialLoader = null; - } - const { children, component, render, loader } = this.props; const { history, route, staticContext } = this.context.router; const location = this.props.location || route.location; From 5177eec99aeb0113a249e1522939e104c7f6f716 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Wed, 21 Dec 2022 18:57:51 +0100 Subject: [PATCH 13/72] The condition can be solved through recursion for readability and reduced bundle size --- packages/inferno-router/src/Switch.ts | 29 ++++++++------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/packages/inferno-router/src/Switch.ts b/packages/inferno-router/src/Switch.ts index ce7577353..6c216fcf3 100644 --- a/packages/inferno-router/src/Switch.ts +++ b/packages/inferno-router/src/Switch.ts @@ -10,32 +10,19 @@ function getMatch({ path, exact, strict, sensitive, from }, route, location) { return pathProp ? matchPath(location.pathname, { path: pathProp, exact, strict, sensitive }) : route.match; } -function extractMatchFromChildren(children, route, location) { - let match; - let _child: any; - +function extractMatchFromChildren(children, route, location, router) { if (isArray(children)) { for (let i = 0; i < children.length; ++i) { - _child = children[i]; - - if (isArray(_child)) { - const nestedMatch = extractMatchFromChildren(_child, route, location); - match = nestedMatch.match; - _child = nestedMatch._child; - } else { - match = getMatch(_child.props, route, location); - } - - if (match) { - break; - } + const nestedMatch = extractMatchFromChildren(children[i], route, location, router); + if (nestedMatch.match) return nestedMatch; } - } else { - match = getMatch((children as any).props, route, location); - _child = children; } - return { match, _child }; + return { + match: getMatch((children as any).props, route, location, router), + _child: children + } +} } export class Switch extends Component { From 02269b238da778c737fd6c43d408857d73a7ed13 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Wed, 21 Dec 2022 19:02:40 +0100 Subject: [PATCH 14/72] Some tests working --- .../__tests__/loaderWithSwitch.spec.jsx | 250 ++++++++++++++++++ packages/inferno-router/src/BrowserRouter.ts | 2 +- packages/inferno-router/src/Route.ts | 15 +- packages/inferno-router/src/Switch.ts | 85 +++++- 4 files changed, 334 insertions(+), 18 deletions(-) create mode 100644 packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx diff --git a/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx b/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx new file mode 100644 index 000000000..ae026417f --- /dev/null +++ b/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx @@ -0,0 +1,250 @@ +import { render } from 'inferno'; +import { MemoryRouter, Route, Switch, NavLink, useLoaderData, useLoaderError } from 'inferno-router'; +// Cherry picked relative import so we don't get node-stuff from inferno-server in browser test +import { resolveLoaders } from '../../inferno-server/src/ssrLoaderResolver'; +import { createEventGuard } from './testUtils'; + +describe('A with loader in a MemoryRouter', () => { + let container; + + beforeEach(function () { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(function () { + render(null, container); + container.innerHTML = ''; + document.body.removeChild(container); + }); + + it('renders on initial', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "ok"; + const loaderFunc = async () => { + setDone(); + return { message: TEXT } + } + + render( + + { + const data = useLoaderData(props); + return

{data?.message}

+ }} loader={loaderFunc} /> + , + container + ); + + // Wait until async loader has completed + await waitForRerender(); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('renders error on initial', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "An error"; + const loaderFunc = async () => { + setDone(); + throw new Error(TEXT) + } + + render( + + { + const err = useLoaderError(props); + return

{err?.message}

+ }} loader={loaderFunc} /> +
, + container + ); + + // Wait until async loader has completed + await waitForRerender(); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('Can access initialData (for hydration)', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + const loaderFunc = async () => { return { message: TEXT }} + const initialData = { + '/flowers': { res: await loaderFunc(), err: undefined, } + } + + render( + + + , + container + ); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('Should render component after click', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEST = "ok"; + const loaderFunc = async () => { + setDone(); + return { message: TEST } + }; + + function RootComp() { + return
ROOT
; + } + + function CreateComp(props) { + const res = useLoaderData(props); + const err = useLoaderError(props); + return
{res.message}
; + } + + function PublishComp() { + return
PUBLISH
; + } + + const tree = ( + +
+ + + + + + +
+
+ ); + + render(tree, container); + + expect(container.innerHTML).toContain("ROOT"); + + // Click create + const link = container.querySelector('#createNav'); + link.firstChild.click(); + + // Wait until async loader has completed + await waitForRerender(); + + expect(container.querySelector('#create').innerHTML).toContain(TEST); + }); + + + it('Can access initialData (for hydration)', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + const loaderFunc = async () => { return { message: TEXT }} + const initialData = { + '/flowers': { res: await loaderFunc(), err: undefined, } + } + + render( + + + , + container + ); + + expect(container.innerHTML).toContain(TEXT); + }); + + it('Should only render one (1) component after click', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEST = "ok"; + const loaderFunc = async () => { + setDone(); + return { message: TEST } + }; + + function RootComp() { + return
ROOT
; + } + + function CreateComp(props) { + const res = useLoaderData(props); + const err = useLoaderError(props); + return
{res.message}
; + } + + function PublishComp() { + return
PUBLISH
; + } + + const tree = ( + +
+ + + + + + +
+
+ ); + + render(tree, container); + + expect(container.innerHTML).toContain("ROOT"); + + // Click create + const link = container.querySelector('#createNav'); + link.firstChild.click(); + + // Wait until async loader has completed + await waitForRerender(); + + expect(container.querySelector('#create').innerHTML).toContain(TEST); + expect(container.querySelector('#publish')).toBeNull(); + }); +}); diff --git a/packages/inferno-router/src/BrowserRouter.ts b/packages/inferno-router/src/BrowserRouter.ts index 508388a3c..4a886e120 100644 --- a/packages/inferno-router/src/BrowserRouter.ts +++ b/packages/inferno-router/src/BrowserRouter.ts @@ -18,7 +18,7 @@ export class BrowserRouter extends Component { constructor(props?: IBrowserRouterProps, context?: any) { super(props, context); - this.history = createBrowserHistory(/*props*/); // TODO: None of the props are defined in createBrowserHistory so skipping + this.history = createBrowserHistory(); } public render(): VNode { diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index 14b9910bb..fbbd30aef 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -105,9 +105,8 @@ class Route extends Component, RouteState> { if (!isUndefined(match?.loader) && isUndefined(__loaderData__)) { const params = match.params; const request = undefined; - setTimeout(() => this.runLoader(match.loader!, params, request, match), 0); + this.runLoader(match.loader!, params, request, match); } - } public componentWillReceiveProps(nextProps, nextContext) { @@ -123,16 +122,20 @@ class Route extends Component, RouteState> { ); } const match = this.computeMatch(nextProps, nextContext.router); - // Am I a match? In which case check for loader - if (nextProps?.loader) { + + // Am I a match? In which case check for loader + if (match?.loader && !match.initialData) { const params = match.params; const request = undefined; - this.runLoader(nextProps.loader, params, request, match); + this.runLoader(match.loader, params, request, match); return; } - this.setState({ match }); + this.setState({ + match, + __loaderData__: match?.initialData, + }); } public render() { diff --git a/packages/inferno-router/src/Switch.ts b/packages/inferno-router/src/Switch.ts index 6c216fcf3..03e0a09af 100644 --- a/packages/inferno-router/src/Switch.ts +++ b/packages/inferno-router/src/Switch.ts @@ -1,13 +1,15 @@ import { Component, createComponentVNode, VNode } from 'inferno'; import { matchPath } from './matchPath'; import { invariant, warning } from './utils'; -import { combineFrom, isArray, isInvalid } from 'inferno-shared'; -import { IRouteProps } from './Route'; +import { combineFrom, isArray, isInvalid, isUndefined } from 'inferno-shared'; +import { IRouteProps, Match } from './Route'; +import { TLoader } from './Router'; -function getMatch({ path, exact, strict, sensitive, from }, route, location) { +function getMatch({ path, exact, strict, sensitive, loader, from }, route, location, router) { const pathProp = path || from; + const { initialData } = router; // This is the parent route - return pathProp ? matchPath(location.pathname, { path: pathProp, exact, strict, sensitive }) : route.match; + return pathProp ? matchPath(location.pathname, { path: pathProp, exact, strict, sensitive, loader, initialData }) : route.match; } function extractMatchFromChildren(children, route, location, router) { @@ -16,6 +18,7 @@ function extractMatchFromChildren(children, route, location, router) { const nestedMatch = extractMatchFromChildren(children[i], route, location, router); if (nestedMatch.match) return nestedMatch; } + return; } return { @@ -23,20 +26,80 @@ function extractMatchFromChildren(children, route, location, router) { _child: children } } + +type SwitchState = { + match: Match; + _child: any; } -export class Switch extends Component { - public render(): VNode | null { - const { route } = this.context.router; - const { children } = this.props; - const location = this.props.location || route.location; +export class Switch extends Component { + constructor(props, context) { + super(props); + + const { route } = context.router; + const { location = route.location, children } = props; + const { match, _child } = extractMatchFromChildren(children, route, location, context.router); + + this.state = { + match, + _child, + } + } + + private runLoader(loader: TLoader, params, request, match, _child) { + // TODO: Pass progress callback to loader + loader({ params, request }) + .then((res) => { + // TODO: should we parse json? + match.initialData = { res }; + this.setState({ match, _child }); + }) + .catch((err) => { + // Loaders should throw errors + match.initialData = { err }; + this.setState({ match, _child }) + }); + } + + public componentDidMount(): void { + const { match, _child } = this.state!; + // QUESTION: Is there a better way to invoke this on/after first render? + if (!isUndefined(match?.loader) && isUndefined(match?.initialData)) { + const params = match.params; + const request = undefined; + this.runLoader(match.loader!, params, request, match, _child); + } + } + + + public componentWillUpdate(nextProps, nextState, nextContext: any): void { + if (nextContext === this.context) return; + + nextState; + const { route } = nextContext.router; + const { location = route.location, children } = nextProps; + + // TODO: Check if location has updated? + const { match, _child } = extractMatchFromChildren(children, route, location, nextContext.router); + + if (match?.loader) { + const params = match.params; + const request = undefined; + this.runLoader(match.loader, params, request, match, _child); + return; + } + + this.setState({ + match, _child + }) + } + + public render({ children }, { match, _child }): VNode | null { if (isInvalid(children)) { return null; } - const { match, _child } = extractMatchFromChildren(children, route, location); - if (match) { return createComponentVNode(_child.flags, _child.type, combineFrom(_child.props, { location, computedMatch: match })); } From 75bba0fd6d5652b7bed097249536fa299ae9c320 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Thu, 22 Dec 2022 09:46:32 +0100 Subject: [PATCH 15/72] Fixed regression bugs --- packages/inferno-router/src/Switch.ts | 71 +++++++++++++++++---------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/packages/inferno-router/src/Switch.ts b/packages/inferno-router/src/Switch.ts index 03e0a09af..ccc8104d3 100644 --- a/packages/inferno-router/src/Switch.ts +++ b/packages/inferno-router/src/Switch.ts @@ -18,7 +18,7 @@ function extractMatchFromChildren(children, route, location, router) { const nestedMatch = extractMatchFromChildren(children[i], route, location, router); if (nestedMatch.match) return nestedMatch; } - return; + return {}; } return { @@ -36,6 +36,10 @@ export class Switch extends Component { constructor(props, context) { super(props); + if (process.env.NODE_ENV !== 'production') { + invariant(context.router, 'You should not use outside a '); + } + const { route } = context.router; const { location = route.location, children } = props; const { match, _child } = extractMatchFromChildren(children, route, location, context.router); @@ -71,11 +75,19 @@ export class Switch extends Component { } } + public componentWillReceiveProps(nextProps, nextContext: any): void { + if (process.env.NODE_ENV !== 'production') { + warning( + !(nextProps.location && !this.props.location), + ' elements should not change from uncontrolled to controlled (or vice versa). You initially used no "location" prop and then provided one on a subsequent render.' + ); + + warning( + !(!nextProps.location && this.props.location), + ' elements should not change from controlled to uncontrolled (or vice versa). You provided a "location" prop initially but omitted it on a subsequent render.' + ); + } - public componentWillUpdate(nextProps, nextState, nextContext: any): void { - if (nextContext === this.context) return; - - nextState; const { route } = nextContext.router; const { location = route.location, children } = nextProps; @@ -89,18 +101,25 @@ export class Switch extends Component { return; } - this.setState({ - match, _child - }) + this.setState({ match, _child }) } - public render({ children }, { match, _child }): VNode | null { + // public componentWillUpdate(nextProps, nextState, nextContext: any): void { + // if (nextContext === this.context) return; + + // nextState; + + // } + + public render({ children, location }, { match, _child }, context): VNode | null { if (isInvalid(children)) { return null; } + if (match) { + location ??= context.router.location; return createComponentVNode(_child.flags, _child.type, combineFrom(_child.props, { location, computedMatch: match })); } @@ -108,20 +127,20 @@ export class Switch extends Component { } } -if (process.env.NODE_ENV !== 'production') { - Switch.prototype.componentWillMount = function () { - invariant(this.context.router, 'You should not use outside a '); - }; - - Switch.prototype.componentWillReceiveProps = function (nextProps) { - warning( - !(nextProps.location && !this.props.location), - ' elements should not change from uncontrolled to controlled (or vice versa). You initially used no "location" prop and then provided one on a subsequent render.' - ); - - warning( - !(!nextProps.location && this.props.location), - ' elements should not change from controlled to uncontrolled (or vice versa). You provided a "location" prop initially but omitted it on a subsequent render.' - ); - }; -} +// if (process.env.NODE_ENV !== 'production') { +// Switch.prototype.componentWillMount = function () { +// invariant(this.context.router, 'You should not use outside a '); +// }; + +// Switch.prototype.componentWillReceiveProps = function (nextProps) { +// warning( +// !(nextProps.location && !this.props.location), +// ' elements should not change from uncontrolled to controlled (or vice versa). You initially used no "location" prop and then provided one on a subsequent render.' +// ); + +// warning( +// !(!nextProps.location && this.props.location), +// ' elements should not change from controlled to uncontrolled (or vice versa). You provided a "location" prop initially but omitted it on a subsequent render.' +// ); +// }; +// } From ff377403f166d885e5c8f3df25ba9d853ae2eb03 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Thu, 22 Dec 2022 18:20:59 +0100 Subject: [PATCH 16/72] Remove moved code --- packages/inferno-router/src/Switch.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/packages/inferno-router/src/Switch.ts b/packages/inferno-router/src/Switch.ts index ccc8104d3..4bd3cf06b 100644 --- a/packages/inferno-router/src/Switch.ts +++ b/packages/inferno-router/src/Switch.ts @@ -104,13 +104,6 @@ export class Switch extends Component { this.setState({ match, _child }) } - // public componentWillUpdate(nextProps, nextState, nextContext: any): void { - // if (nextContext === this.context) return; - - // nextState; - - // } - public render({ children, location }, { match, _child }, context): VNode | null { if (isInvalid(children)) { @@ -126,21 +119,3 @@ export class Switch extends Component { return null; } } - -// if (process.env.NODE_ENV !== 'production') { -// Switch.prototype.componentWillMount = function () { -// invariant(this.context.router, 'You should not use outside a '); -// }; - -// Switch.prototype.componentWillReceiveProps = function (nextProps) { -// warning( -// !(nextProps.location && !this.props.location), -// ' elements should not change from uncontrolled to controlled (or vice versa). You initially used no "location" prop and then provided one on a subsequent render.' -// ); - -// warning( -// !(!nextProps.location && this.props.location), -// ' elements should not change from controlled to uncontrolled (or vice versa). You provided a "location" prop initially but omitted it on a subsequent render.' -// ); -// }; -// } From b50c46510f6459299c1e68c45c8b8ed1701ab626 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Thu, 22 Dec 2022 22:58:12 +0100 Subject: [PATCH 17/72] Improve typing and structure components to be more similar. Added switch test --- .../__tests__/loaderWithSwitch.spec.jsx | 48 ++++++++++++ packages/inferno-router/src/Route.ts | 77 +++++++++---------- packages/inferno-router/src/Router.ts | 40 +++++++--- packages/inferno-router/src/Switch.ts | 45 +++++------ 4 files changed, 136 insertions(+), 74 deletions(-) diff --git a/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx b/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx index ae026417f..25cb59966 100644 --- a/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx +++ b/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx @@ -247,4 +247,52 @@ describe('A with loader in a MemoryRouter', () => { expect(container.querySelector('#create').innerHTML).toContain(TEST); expect(container.querySelector('#publish')).toBeNull(); }); + + it('can use a `location` prop instead of `router.location`', async () => { + const [setSwitch, waitForSwitch] = createEventGuard(); + const [setDone, waitForRerender] = createEventGuard(); + + const TEST = "ok"; + const loaderFunc = async () => { + await waitForSwitch(); + setDone(); + return { message: TEST } + }; + + render( + + Link + +

one

} /> + { + const res = useLoaderData(props); + return

{res.message}

+ }} loader={loaderFunc} /> +
+
, + container + ); + + // Check that we are starting in the right place + expect(container.innerHTML).toContain('one'); + + const link = container.querySelector('#link'); + link.click(); + + await new Promise((resolve) => { + // If router doesn't wait for loader the switch to /two would be performed + // during setTimeout. + setTimeout(async () => { + expect(container.innerHTML).toContain('one'); + // Now loader can be allowed to complete + setSwitch(); + // and now wait for the loader to complete + await waitForRerender(); + // so /two is rendered + expect(container.innerHTML).toContain(TEST); + resolve(); + }, 0) + }) + + }); }); diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index fbbd30aef..f66c6a9f2 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -2,9 +2,9 @@ import { Component, createComponentVNode, Inferno, InfernoNode } from 'inferno'; import { VNodeFlags } from 'inferno-vnode-flags'; import { invariant, warning } from './utils'; import { matchPath } from './matchPath'; -import { combineFrom, isFunction, isUndefined } from 'inferno-shared'; +import { combineFrom, isFunction, isNullOrUndef } from 'inferno-shared'; import type { History, Location } from 'history'; -import type { TLoader, TLoaderData, TLoaderProps } from './Router'; +import type { RouterContext, TContextRouter, TLoader, TLoaderData, TLoaderProps } from './Router'; export interface Match

> { params: P; @@ -23,7 +23,7 @@ export interface RouteComponentProps

> { } export interface IRouteProps { - computedMatch?: any; // private, from + computedMatch?: Match | null; // private, from path?: string; exact?: boolean; strict?: boolean; @@ -31,7 +31,7 @@ export interface IRouteProps { loader?(props: TLoaderProps): Promise; component?: Inferno.ComponentClass | ((props: any, context: any) => InfernoNode); render?: (props: RouteComponentProps, context: any) => InfernoNode; - location?: Partial; + location?: Pick; children?: ((props: RouteComponentProps) => InfernoNode) | InfernoNode; } @@ -39,40 +39,39 @@ export interface IRouteProps { * The public API for matching a single path and rendering. */ type RouteState = { - match: Match; + match: Match | null; __loaderData__?: TLoaderData; } class Route extends Component, RouteState> { - public getChildContext() { - const childContext: any = combineFrom(this.context.router, null); - - childContext.route = { - location: this.props.location || this.context.router.route.location, - match: this.state!.match, - }; - - return { - router: childContext - }; - } - - constructor(props?: any, context?: any) { + constructor(props: IRouteProps, context: RouterContext) { super(props, context); - const match = this.computeMatch(props, context.router); - this.state = { match, __loaderData__: match?.initialData, }; } + public getChildContext(): RouterContext { + const parentRouter: TContextRouter = this.context.router; + const router: TContextRouter = combineFrom(parentRouter, null); + + router.route = { + location: this.props.location || parentRouter.route.location, + match: this.state!.match, + }; + + return { + router + }; + } + private runLoader(loader: TLoader, params, request, match) { // TODO: Pass progress callback to loader loader({ params, request }) .then((res) => { - // TODO: should we parse json? + // TODO: react-router parses json this.setState({ match, __loaderData__: { res } }); }) .catch((err) => { @@ -81,20 +80,20 @@ class Route extends Component, RouteState> { }); } - public computeMatch({ computedMatch, ...props }, router): Match { - if (computedMatch) { + public computeMatch({ computedMatch, ...props }: IRouteProps, router: TContextRouter): Match | null { + if (!isNullOrUndef(computedMatch)) { // already computed the match for us return computedMatch; } - const { location, path, strict, exact, sensitive, loader } = props; + const { path, strict, exact, sensitive, loader } = props; if (process.env.NODE_ENV !== 'production') { invariant(router, 'You should not use or withRouter() outside a '); } const { route, initialData } = router; // This is the parent route - const pathname = (location || route.location).pathname; + const pathname = (props.location || route.location).pathname; return path ? matchPath(pathname, { path, strict, exact, sensitive, loader, initialData }) : route.match; } @@ -102,10 +101,9 @@ class Route extends Component, RouteState> { public componentDidMount(): void { const { match, __loaderData__ } = this.state!; // QUESTION: Is there a better way to invoke this on/after first render? - if (!isUndefined(match?.loader) && isUndefined(__loaderData__)) { - const params = match.params; + if (match?.loader && !__loaderData__) { const request = undefined; - this.runLoader(match.loader!, params, request, match); + this.runLoader(match.loader, match.params, request, match); } } @@ -126,9 +124,8 @@ class Route extends Component, RouteState> { // Am I a match? In which case check for loader if (match?.loader && !match.initialData) { - const params = match.params; const request = undefined; - this.runLoader(match.loader, params, request, match); + this.runLoader(match.loader, match.params, request, match); return; } @@ -138,12 +135,12 @@ class Route extends Component, RouteState> { }); } - public render() { - const { match, __loaderData__ } = this.state!; - const { children, component, render, loader } = this.props; - const { history, route, staticContext } = this.context.router; - const location = this.props.location || route.location; - const props = { match, location, history, staticContext, component, render, loader, __loaderData__ }; + public render(props: IRouteProps, state: RouteState, context: { router: TContextRouter }) { + const { match, __loaderData__ } = state!; + const { children, component, render, loader } = props; + const { history, route, staticContext } = context.router; + const location = props.location || route.location; + const renderProps = { match, location, history, staticContext, component, render, loader, __loaderData__ }; if (component) { if (process.env.NODE_ENV !== 'production') { @@ -151,16 +148,16 @@ class Route extends Component, RouteState> { throw new Error("Inferno error: - 'component' property must be prototype of class or functional component, not vNode."); } } - return match ? createComponentVNode(VNodeFlags.ComponentUnknown, component, props) : null; + return match ? createComponentVNode(VNodeFlags.ComponentUnknown, component, renderProps) : null; } if (render) { // @ts-ignore - return match ? render(props, this.context) : null; + return match ? render(renderProps, this.context) : null; } if (typeof children === 'function') { - return (children as Function)(props); + return (children as Function)(renderProps); } return children; diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index d562201c1..7a243bc2c 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -1,7 +1,7 @@ import { Component, InfernoNode } from 'inferno'; import { warning } from './utils'; import { combineFrom } from 'inferno-shared'; -import type { History } from 'history'; +import type { History, Location } from 'history'; import { Match } from './Route'; export type TLoaderProps

> = { @@ -19,36 +19,52 @@ export type TLoaderData = { res?: Response; err?: any; } + +type TInitialData = Record; // key is route path to allow resolving + export interface IRouterProps { history: History; children: InfernoNode; - initialData?: Record; // key is route path to allow resolving + initialData?: TInitialData; +} + +export type TContextRouter = { + history: History; + route: { + location: Pick, // This was Partial before but this makes pathname optional causing false type error in code + match: Match | null; + } + initialData?: TInitialData; + staticContext?: object; // TODO: This should be properly typed } +export type RouterContext = { router: TContextRouter }; + /** * The public API for putting history on context. */ export class Router extends Component { public unlisten; - constructor(props: IRouterProps, context?: any) { + constructor(props: IRouterProps, context: { router: TContextRouter }) { super(props, context); + const match = this.computeMatch(props.history.location.pathname); this.state = { - match: this.computeMatch(props.history.location.pathname), + match, }; } - public getChildContext() { - const childContext: any = combineFrom(this.context.router, null); - childContext.history = this.props.history; - childContext.route = { - location: childContext.history.location, + public getChildContext(): RouterContext { + const parentRouter: TContextRouter = this.context.router; + const router: TContextRouter = combineFrom(parentRouter, null); + router.history = this.props.history; + router.route = { + location: router.history.location, match: this.state?.match, // Why are we sending this? it appears useless. }; - childContext.initialData = this.props.initialData; // this is a dictionary of all data available - + router.initialData = this.props.initialData; // this is a dictionary of all data available return { - router: childContext + router }; } diff --git a/packages/inferno-router/src/Switch.ts b/packages/inferno-router/src/Switch.ts index 4bd3cf06b..543a9835d 100644 --- a/packages/inferno-router/src/Switch.ts +++ b/packages/inferno-router/src/Switch.ts @@ -3,26 +3,26 @@ import { matchPath } from './matchPath'; import { invariant, warning } from './utils'; import { combineFrom, isArray, isInvalid, isUndefined } from 'inferno-shared'; import { IRouteProps, Match } from './Route'; -import { TLoader } from './Router'; +import { RouterContext, TLoader } from './Router'; -function getMatch({ path, exact, strict, sensitive, loader, from }, route, location, router) { - const pathProp = path || from; - const { initialData } = router; // This is the parent route +function getMatch(pathname: string, { path, exact, strict, sensitive, loader, from }, router: { route, initialData?: any }) { + path ??= from; + const { initialData, route } = router; // This is the parent route - return pathProp ? matchPath(location.pathname, { path: pathProp, exact, strict, sensitive, loader, initialData }) : route.match; + return path ? matchPath(pathname, { path, exact, strict, sensitive, loader, initialData }) : route.match; } -function extractMatchFromChildren(children, route, location, router) { +function extractMatchFromChildren(pathname: string, children, router) { if (isArray(children)) { for (let i = 0; i < children.length; ++i) { - const nestedMatch = extractMatchFromChildren(children[i], route, location, router); + const nestedMatch = extractMatchFromChildren(pathname, children[i], router); if (nestedMatch.match) return nestedMatch; } return {}; } return { - match: getMatch((children as any).props, route, location, router), + match: getMatch(pathname, (children as any).props, router), _child: children } } @@ -33,16 +33,17 @@ type SwitchState = { } export class Switch extends Component { - constructor(props, context) { - super(props); + constructor(props, context: RouterContext) { + super(props, context); if (process.env.NODE_ENV !== 'production') { invariant(context.router, 'You should not use outside a '); } - const { route } = context.router; - const { location = route.location, children } = props; - const { match, _child } = extractMatchFromChildren(children, route, location, context.router); + const { router } = context; + const { location, children } = props; + const pathname = (location || router.route.location).pathname; + const { match, _child } = extractMatchFromChildren(pathname, children, router); this.state = { match, @@ -54,7 +55,8 @@ export class Switch extends Component { // TODO: Pass progress callback to loader loader({ params, request }) .then((res) => { - // TODO: should we parse json? + // TODO: react-router parses json + // NOTE: The route stores initialData in state match.initialData = { res }; this.setState({ match, _child }); }) @@ -75,7 +77,7 @@ export class Switch extends Component { } } - public componentWillReceiveProps(nextProps, nextContext: any): void { + public componentWillReceiveProps(nextProps: IRouteProps, nextContext: RouterContext): void { if (process.env.NODE_ENV !== 'production') { warning( !(nextProps.location && !this.props.location), @@ -88,11 +90,10 @@ export class Switch extends Component { ); } - const { route } = nextContext.router; - const { location = route.location, children } = nextProps; - - // TODO: Check if location has updated? - const { match, _child } = extractMatchFromChildren(children, route, location, nextContext.router); + const { router } = nextContext; + const { location, children } = nextProps; + const pathname = (location || router.route.location).pathname; + const { match, _child } = extractMatchFromChildren(pathname, children, router); if (match?.loader) { const params = match.params; @@ -104,7 +105,7 @@ export class Switch extends Component { this.setState({ match, _child }) } - public render({ children, location }, { match, _child }, context): VNode | null { + public render({ children, location }: IRouteProps, { match, _child }: SwitchState, context: RouterContext): VNode | null { if (isInvalid(children)) { return null; @@ -112,7 +113,7 @@ export class Switch extends Component { if (match) { - location ??= context.router.location; + location ??= context.router.route.location; return createComponentVNode(_child.flags, _child.type, combineFrom(_child.props, { location, computedMatch: match })); } From ac300771e43aef5a1f84dfd4c09818283723bb93 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 23 Dec 2022 09:58:10 +0100 Subject: [PATCH 18/72] Added demo application to test inferno-router with async loading --- demo/inferno-router-demo/.babelrc | 5 ++ demo/inferno-router-demo/.gitignore | 20 +++++++ demo/inferno-router-demo/.proxyrc | 5 ++ demo/inferno-router-demo/README.md | 9 ++++ demo/inferno-router-demo/package.json | 38 +++++++++++++ demo/inferno-router-demo/src/App.tsx | 28 ++++++++++ demo/inferno-router-demo/src/index.html | 11 ++++ demo/inferno-router-demo/src/index.tsx | 8 +++ .../src/pages/AboutPage.scss | 0 .../src/pages/AboutPage.tsx | 54 +++++++++++++++++++ .../src/pages/PageTemplate.scss | 48 +++++++++++++++++ .../src/pages/PageTemplate.tsx | 34 ++++++++++++ .../src/pages/StartPage.scss | 0 .../src/pages/StartPage.tsx | 35 ++++++++++++ demo/inferno-router-demo/src/server.ts | 47 ++++++++++++++++ demo/inferno-router-demo/tsconfig.json | 18 +++++++ 16 files changed, 360 insertions(+) create mode 100644 demo/inferno-router-demo/.babelrc create mode 100644 demo/inferno-router-demo/.gitignore create mode 100644 demo/inferno-router-demo/.proxyrc create mode 100644 demo/inferno-router-demo/README.md create mode 100644 demo/inferno-router-demo/package.json create mode 100644 demo/inferno-router-demo/src/App.tsx create mode 100644 demo/inferno-router-demo/src/index.html create mode 100644 demo/inferno-router-demo/src/index.tsx create mode 100644 demo/inferno-router-demo/src/pages/AboutPage.scss create mode 100644 demo/inferno-router-demo/src/pages/AboutPage.tsx create mode 100644 demo/inferno-router-demo/src/pages/PageTemplate.scss create mode 100644 demo/inferno-router-demo/src/pages/PageTemplate.tsx create mode 100644 demo/inferno-router-demo/src/pages/StartPage.scss create mode 100644 demo/inferno-router-demo/src/pages/StartPage.tsx create mode 100644 demo/inferno-router-demo/src/server.ts create mode 100644 demo/inferno-router-demo/tsconfig.json diff --git a/demo/inferno-router-demo/.babelrc b/demo/inferno-router-demo/.babelrc new file mode 100644 index 000000000..48bb86a8e --- /dev/null +++ b/demo/inferno-router-demo/.babelrc @@ -0,0 +1,5 @@ +{ + "plugins": [ + "inferno" + ] +} \ No newline at end of file diff --git a/demo/inferno-router-demo/.gitignore b/demo/inferno-router-demo/.gitignore new file mode 100644 index 000000000..d11321d99 --- /dev/null +++ b/demo/inferno-router-demo/.gitignore @@ -0,0 +1,20 @@ +.DS_Store +.git/ + +# VS Code specific +.vscode +jsconfig.json +typings/* +typings.json + +# Dependency directory for NPM +node_modules + +# Built at startup +.env +pwd.txt +lerna-debug.log + +dist +package-lock.json +.parcel-cache diff --git a/demo/inferno-router-demo/.proxyrc b/demo/inferno-router-demo/.proxyrc new file mode 100644 index 000000000..d7bbe841b --- /dev/null +++ b/demo/inferno-router-demo/.proxyrc @@ -0,0 +1,5 @@ +{ + "/api": { + "target": "http://127.0.0.1:3000/" + } +} \ No newline at end of file diff --git a/demo/inferno-router-demo/README.md b/demo/inferno-router-demo/README.md new file mode 100644 index 000000000..7774187f5 --- /dev/null +++ b/demo/inferno-router-demo/README.md @@ -0,0 +1,9 @@ +# Demo of Inferno-Router + +```sh +cd demo/inferno-router-demo +npm i +npm run dev +``` + +Go to http://127.0.0.1:1234/ diff --git a/demo/inferno-router-demo/package.json b/demo/inferno-router-demo/package.json new file mode 100644 index 000000000..d81f8cad7 --- /dev/null +++ b/demo/inferno-router-demo/package.json @@ -0,0 +1,38 @@ +{ + "name": "inferno-router-demo", + "version": "8.0.5", + "description": "Influence CMS Demo", + "author": "Sebastian Ware (https://github.com/jhsware)", + "license": "SEE LICENSE IN LICENSE", + "source": "src/index.html", + "browserslist": "> 0.5%, last 2 versions, not dead", + "scripts": { + "dev": "npm-run-all -l --parallel dev:*", + "dev:frontend": "FORCE_COLOR=1 parcel", + "dev:backend": "FORCE_COLOR=1 ts-node-dev --respawn src/server.ts" + }, + "dependencies": { + "inferno": "file:../../packages/inferno", + "inferno-animation": "file:../../packages/inferno-animation", + "inferno-router": "file:../../packages/inferno-router", + "inferno-server": "file:../../packages/inferno-server", + "koa":"^2.14.1", + "koa-json-body":"^5.3.0", + "koa-logger":"^3.2.1", + "koa-router":"^12.0.0" + }, + "devDependencies": { + "@parcel/packager-ts": "^2.8.1", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "@parcel/transformer-babel": "^2.8.1", + "@parcel/transformer-sass": "^2.8.1", + "@parcel/transformer-typescript-types": "^2.8.1", + "@types/node": "^18.11.9", + "babel-plugin-inferno": "^6.5.0", + "jest-environment-jsdom": "^29.1.2", + "npm-run-all": "^4.1.5", + "parcel": "^2.8.2", + "ts-node-dev": "^2.0.0", + "typescript": "^4.9.4" + } +} diff --git a/demo/inferno-router-demo/src/App.tsx b/demo/inferno-router-demo/src/App.tsx new file mode 100644 index 000000000..d5acfef5e --- /dev/null +++ b/demo/inferno-router-demo/src/App.tsx @@ -0,0 +1,28 @@ +import { Component } from 'inferno' +import { Route } from 'inferno-router' + +/** + * Pages + */ +import StartPage from './pages/StartPage' +import AboutPage from './pages/AboutPage' + +/** + * The Application + */ +export class App extends Component { + render(props) { + return props.children; + } +}; + +export function appFactory () { + + return ( + + {/* Public Pages */} + + + + ) +} diff --git a/demo/inferno-router-demo/src/index.html b/demo/inferno-router-demo/src/index.html new file mode 100644 index 000000000..43185dbf7 --- /dev/null +++ b/demo/inferno-router-demo/src/index.html @@ -0,0 +1,11 @@ + + + + + Inferno Router Demo + + + +

+ + \ No newline at end of file diff --git a/demo/inferno-router-demo/src/index.tsx b/demo/inferno-router-demo/src/index.tsx new file mode 100644 index 000000000..e0e379b80 --- /dev/null +++ b/demo/inferno-router-demo/src/index.tsx @@ -0,0 +1,8 @@ +//import { hydrate } from 'inferno-hydrate'; +import { render } from 'inferno'; +import { BrowserRouter } from 'inferno-router' +import { appFactory } from './App'; + +// hydrate({appFactory()}, document.getElementById('app')) +render({appFactory()}, document.getElementById('app')) +// render(Test, document.getElementById('app')) \ No newline at end of file diff --git a/demo/inferno-router-demo/src/pages/AboutPage.scss b/demo/inferno-router-demo/src/pages/AboutPage.scss new file mode 100644 index 000000000..e69de29bb diff --git a/demo/inferno-router-demo/src/pages/AboutPage.tsx b/demo/inferno-router-demo/src/pages/AboutPage.tsx new file mode 100644 index 000000000..f0d330ff2 --- /dev/null +++ b/demo/inferno-router-demo/src/pages/AboutPage.tsx @@ -0,0 +1,54 @@ +import { Component } from 'inferno'; +import PageTemplate from './PageTemplate'; +import { useLoaderData } from 'inferno-router'; + +import './AboutPage.scss'; + +// const env: any = (typeof window === 'undefined' ? process.env : (window as any).__env__) +// const { FRONTEND_BASE_URI } = env + +interface IProps { + fetchData: any; +} + +export default class AboutPage extends Component { + static async fetchData(params) { + const pageSlug = params.id; + + const fetchOptions: RequestInit = { + headers: { + Accept: 'application/json', + }, + }; + + const res = await fetch('/api/about', fetchOptions); + + if (res.ok) { + try { + const data = await res.json(); + return data; + } catch (err) { + return { + title: `Error: ${err.message}`, + }; + } + } + + return { + title: 'Error: Backend not responding', + }; + } + + render(props, state, context) { + const data = useLoaderData(props) as any; + + return ( + +
+

{data?.title}

+

{data?.body}

+
+
+ ); + } +} diff --git a/demo/inferno-router-demo/src/pages/PageTemplate.scss b/demo/inferno-router-demo/src/pages/PageTemplate.scss new file mode 100644 index 000000000..de296e6f6 --- /dev/null +++ b/demo/inferno-router-demo/src/pages/PageTemplate.scss @@ -0,0 +1,48 @@ +:root { + --rowGap: 2rem; +} + +.page { + display: flex; + flex-flow: column nowrap; + min-height: 100vh; + + h1, h2 { + margin-bottom: 1.5em; + } + + header { + flex-grow: 0; + & > nav > ul { + display: flex; + flex-flow: row wrap; + gap: var(--rowGap); + align-items: center; + justify-content: center; + padding: 0; + + & > li { + list-style: none; + } + } + } + main { + flex-grow: 1; + display: flex; + flex-flow: column; + align-items: center; + justify-content: flex-start; + text-align: center; + + & > * { + max-width: 50rem + } + + & > .body { + width: 100%; + } + } + footer { + flex-grow: 0 + } +} diff --git a/demo/inferno-router-demo/src/pages/PageTemplate.tsx b/demo/inferno-router-demo/src/pages/PageTemplate.tsx new file mode 100644 index 000000000..5ec95e642 --- /dev/null +++ b/demo/inferno-router-demo/src/pages/PageTemplate.tsx @@ -0,0 +1,34 @@ +import { Link } from 'inferno-router' + +import './PageTemplate.scss' + +// const env = (typeof window === 'undefined' ? process.env : (window as any).__env__) +// const { FRONTEND_BASE_URI } = env + +interface IProps { + children: any, +} + +interface IState { + +} + +export default function PageTemplate({ id = undefined, children }) { + return ( +
+
+ +
+
{children}
+
+

Page Footer

+
+
+ ) + +} diff --git a/demo/inferno-router-demo/src/pages/StartPage.scss b/demo/inferno-router-demo/src/pages/StartPage.scss new file mode 100644 index 000000000..e69de29bb diff --git a/demo/inferno-router-demo/src/pages/StartPage.tsx b/demo/inferno-router-demo/src/pages/StartPage.tsx new file mode 100644 index 000000000..8cb4f594b --- /dev/null +++ b/demo/inferno-router-demo/src/pages/StartPage.tsx @@ -0,0 +1,35 @@ +import { Component } from 'inferno' +import PageTemplate from './PageTemplate' +import './StartPage.scss' + +// const env: any = (typeof window === 'undefined' ? process.env : (window as any).__env__) +// const { FRONTEND_BASE_URI } = env + +interface IProps { + fetchData: any; +} + +export default class StartPage extends Component { + static async fetchData({ registry, match, location }) { + const pageSlug = match.params.id + + // new IPageManager({ registry }).setMetaData({ + // title: 'Influence CMS Demo', + // description: 'This is a demo site.', + // url: FRONTEND_BASE_URI + location.pathname + // }) + + return []; + } + + render({ fetchData }, state, { router }) { + return ( + +
+

Start Page

+

Some content

+
+
+ ) + } +} diff --git a/demo/inferno-router-demo/src/server.ts b/demo/inferno-router-demo/src/server.ts new file mode 100644 index 000000000..f7315f7c7 --- /dev/null +++ b/demo/inferno-router-demo/src/server.ts @@ -0,0 +1,47 @@ +import koa from 'koa';; // koa@2 +import logger from 'koa-logger'; +import koaRouter from 'koa-router'; // koa-router@next +import koaJSONBody from 'koa-json-body'; +import { renderToString, resolveLoaders } from 'inferno-server'; + +const PORT = process.env.PORT || 3000 + +const app = new koa() +const api = new koaRouter() + +/** + * Logging + */ +app.use(logger((str, args) => { + console.log(str) +})) + +/** + * Endpoint for healthcheck + */ +api.get('/api/ping', (ctx) => { + ctx.body = 'ok'; +}); +api.get('/api/about', async (ctx) => { + + return new Promise((resolve) => { + setTimeout(() => { + ctx.body = { + title: "Inferno-Router", + body: "Routing with async data loader support." + }; + resolve(null); + }, 1000) + }) +}); + + +/** + * Mount all the routes for Koa to handle + */ +app.use(api.routes()); +app.use(api.allowedMethods()); + +app.listen(PORT, () => { + console.log('Server listening on: ' + PORT) +}) diff --git a/demo/inferno-router-demo/tsconfig.json b/demo/inferno-router-demo/tsconfig.json new file mode 100644 index 000000000..d9c608747 --- /dev/null +++ b/demo/inferno-router-demo/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "isolatedModules": true, + "module": "commonjs", + "target": "es2020", + "moduleResolution": "Node", + "jsx": "preserve", + "lib": [ + "dom", + "es2020" + ], + "types": [ + "inferno", + "node" + ], + } +} From 169691ee9820d7a4e1431b1bbbc5f13661a3b005 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 23 Dec 2022 10:01:50 +0100 Subject: [PATCH 19/72] Added note on possibly false test --- packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx b/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx index 25cb59966..09ff49b20 100644 --- a/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx +++ b/packages/inferno-router/__tests__/loaderWithSwitch.spec.jsx @@ -279,6 +279,7 @@ describe('A with loader in a MemoryRouter', () => { const link = container.querySelector('#link'); link.click(); + // TODO: I believe this test gives a false pass, in a demo, the page switches too early await new Promise((resolve) => { // If router doesn't wait for loader the switch to /two would be performed // during setTimeout. From 5d72431153aa95b051c829db4c2b61c90af39659 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 23 Dec 2022 11:56:35 +0100 Subject: [PATCH 20/72] unused props --- packages/inferno-router/__tests__/loaderOnRoute.spec.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index 8a153ee6c..6dde9ec75 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -338,7 +338,7 @@ describe('A with loader in a StaticRouter', () => { } render( - + { const data = useLoaderData(props); return

{data?.message}

@@ -363,7 +363,7 @@ describe('A with loader in a StaticRouter', () => { } render( - + { const err = useLoaderError(props); return

{err?.message}

From 2f8f2f08e824dc842b590dfca9e29401f033f894 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 23 Dec 2022 11:58:04 +0100 Subject: [PATCH 21/72] Demo: SSR (WIP) with resolving of loaders and using Parcel to transpile app on startup --- demo/inferno-router-demo/.gitignore | 1 + demo/inferno-router-demo/README.md | 2 + demo/inferno-router-demo/package.json | 9 +-- .../src/pages/AboutPage.tsx | 8 ++- demo/inferno-router-demo/src/server.ts | 72 ++++++++++++++++++- 5 files changed, 85 insertions(+), 7 deletions(-) diff --git a/demo/inferno-router-demo/.gitignore b/demo/inferno-router-demo/.gitignore index d11321d99..fc677e4ce 100644 --- a/demo/inferno-router-demo/.gitignore +++ b/demo/inferno-router-demo/.gitignore @@ -16,5 +16,6 @@ pwd.txt lerna-debug.log dist +distServer package-lock.json .parcel-cache diff --git a/demo/inferno-router-demo/README.md b/demo/inferno-router-demo/README.md index 7774187f5..c3f3f3c0d 100644 --- a/demo/inferno-router-demo/README.md +++ b/demo/inferno-router-demo/README.md @@ -1,5 +1,7 @@ # Demo of Inferno-Router +NOTE: Requires Nodejs >=18 (uses `fetch`) + ```sh cd demo/inferno-router-demo npm i diff --git a/demo/inferno-router-demo/package.json b/demo/inferno-router-demo/package.json index d81f8cad7..8e2bb4721 100644 --- a/demo/inferno-router-demo/package.json +++ b/demo/inferno-router-demo/package.json @@ -16,10 +16,11 @@ "inferno-animation": "file:../../packages/inferno-animation", "inferno-router": "file:../../packages/inferno-router", "inferno-server": "file:../../packages/inferno-server", - "koa":"^2.14.1", - "koa-json-body":"^5.3.0", - "koa-logger":"^3.2.1", - "koa-router":"^12.0.0" + "inferno-create-element": "file:../../packages/inferno-create-element", + "koa": "^2.14.1", + "koa-json-body": "^5.3.0", + "koa-logger": "^3.2.1", + "koa-router": "^12.0.0" }, "devDependencies": { "@parcel/packager-ts": "^2.8.1", diff --git a/demo/inferno-router-demo/src/pages/AboutPage.tsx b/demo/inferno-router-demo/src/pages/AboutPage.tsx index f0d330ff2..11f265407 100644 --- a/demo/inferno-router-demo/src/pages/AboutPage.tsx +++ b/demo/inferno-router-demo/src/pages/AboutPage.tsx @@ -3,10 +3,13 @@ import PageTemplate from './PageTemplate'; import { useLoaderData } from 'inferno-router'; import './AboutPage.scss'; +import { useLoaderError } from 'inferno-router'; // const env: any = (typeof window === 'undefined' ? process.env : (window as any).__env__) // const { FRONTEND_BASE_URI } = env +const BACKEND_HOST = 'http://localhost:1234'; + interface IProps { fetchData: any; } @@ -21,7 +24,7 @@ export default class AboutPage extends Component { }, }; - const res = await fetch('/api/about', fetchOptions); + const res = await fetch(new URL('/api/about', BACKEND_HOST), fetchOptions); if (res.ok) { try { @@ -41,12 +44,13 @@ export default class AboutPage extends Component { render(props, state, context) { const data = useLoaderData(props) as any; + const err = useLoaderError(props) as any; return (

{data?.title}

-

{data?.body}

+

{data?.body || err?.message}

); diff --git a/demo/inferno-router-demo/src/server.ts b/demo/inferno-router-demo/src/server.ts index f7315f7c7..a46df9168 100644 --- a/demo/inferno-router-demo/src/server.ts +++ b/demo/inferno-router-demo/src/server.ts @@ -3,11 +3,36 @@ import logger from 'koa-logger'; import koaRouter from 'koa-router'; // koa-router@next import koaJSONBody from 'koa-json-body'; import { renderToString, resolveLoaders } from 'inferno-server'; +import { StaticRouter } from 'inferno-router' +import {Parcel} from '@parcel/core'; +import { createElement } from 'inferno-create-element'; const PORT = process.env.PORT || 3000 +// Parcel watch subscription and bundle output +let subscription; +let bundles; + +let bundler = new Parcel({ + entries: './src/App.tsx', + defaultConfig: '@parcel/config-default', + targets: { + default: { + context: 'node', + engines: { + node: ">=18" + }, + distDir: "./distServer", + publicUrl: "/", // Should be picked up from environment + } + }, + mode: 'development' +}); + + const app = new koa() const api = new koaRouter() +const frontend = new koaRouter() /** * Logging @@ -35,13 +60,58 @@ api.get('/api/about', async (ctx) => { }) }); +frontend.get('/:slug?', async (ctx) => { + const location = ctx.params.slug === undefined ? '/': `/${ctx.params.slug}`; + + const pathToAppJs = bundles.find(b => b.name === 'App.js').filePath; + const { appFactory } = require(pathToAppJs) + const app = appFactory(); + + const initialData = await resolveLoaders(location, app); + + const htmlApp = renderToString(createElement(StaticRouter, { + context: {}, + location, + initialData, + }, app)) + + ctx.body = htmlApp; +}) + /** * Mount all the routes for Koa to handle */ app.use(api.routes()); app.use(api.allowedMethods()); +app.use(frontend.routes()); +app.use(frontend.allowedMethods()); + +// bundler.watch((err, event) => { +// if (err) { +// // fatal error +// throw err; +// } + +// if (event.type === 'buildSuccess') { +// bundles = event.bundleGraph.getBundles(); +// console.log(`✨ Built ${bundles.length} bundles in ${event.buildTime}ms!`); +// } else if (event.type === 'buildFailure') { +// console.log(event.diagnostics); +// } +// }).then((sub => subscription = sub)); + +app.listen(PORT, async () => { + + // Trigger first transpile + // https://parceljs.org/features/parcel-api/ + try { + let {bundleGraph, buildTime} = await bundler.run(); + bundles = bundleGraph.getBundles(); + console.log(`✨ Built ${bundles.length} bundles in ${buildTime}ms!`); + } catch (err) { + console.log(err.diagnostics); + } -app.listen(PORT, () => { console.log('Server listening on: ' + PORT) }) From 3c822ab78d3b7733ed1e99be80b2528dd13650a3 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 23 Dec 2022 13:17:05 +0100 Subject: [PATCH 22/72] Serve a proper SSR page to browser --- demo/inferno-router-demo/.gitignore | 1 + demo/inferno-router-demo/package.json | 4 ++- demo/inferno-router-demo/src/index.tsx | 9 +++++- demo/inferno-router-demo/src/server.ts | 39 ++++++++++++++++++++++++-- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/demo/inferno-router-demo/.gitignore b/demo/inferno-router-demo/.gitignore index fc677e4ce..66a5c29cc 100644 --- a/demo/inferno-router-demo/.gitignore +++ b/demo/inferno-router-demo/.gitignore @@ -17,5 +17,6 @@ lerna-debug.log dist distServer +distBrowser package-lock.json .parcel-cache diff --git a/demo/inferno-router-demo/package.json b/demo/inferno-router-demo/package.json index 8e2bb4721..ee5d94e3a 100644 --- a/demo/inferno-router-demo/package.json +++ b/demo/inferno-router-demo/package.json @@ -20,7 +20,9 @@ "koa": "^2.14.1", "koa-json-body": "^5.3.0", "koa-logger": "^3.2.1", - "koa-router": "^12.0.0" + "koa-mount": "^4.0.0", + "koa-router": "^12.0.0", + "koa-static": "^5.0.0" }, "devDependencies": { "@parcel/packager-ts": "^2.8.1", diff --git a/demo/inferno-router-demo/src/index.tsx b/demo/inferno-router-demo/src/index.tsx index e0e379b80..e64d275c4 100644 --- a/demo/inferno-router-demo/src/index.tsx +++ b/demo/inferno-router-demo/src/index.tsx @@ -4,5 +4,12 @@ import { BrowserRouter } from 'inferno-router' import { appFactory } from './App'; // hydrate({appFactory()}, document.getElementById('app')) -render({appFactory()}, document.getElementById('app')) + +declare global { + interface Window { + __initialData__: any + } +} + +render({appFactory()}, document.getElementById('app')) // render(Test, document.getElementById('app')) \ No newline at end of file diff --git a/demo/inferno-router-demo/src/server.ts b/demo/inferno-router-demo/src/server.ts index a46df9168..92cdc9088 100644 --- a/demo/inferno-router-demo/src/server.ts +++ b/demo/inferno-router-demo/src/server.ts @@ -1,11 +1,14 @@ import koa from 'koa';; // koa@2 import logger from 'koa-logger'; import koaRouter from 'koa-router'; // koa-router@next +import koaStatic from 'koa-static'; +import koaMount from 'koa-mount'; import koaJSONBody from 'koa-json-body'; import { renderToString, resolveLoaders } from 'inferno-server'; import { StaticRouter } from 'inferno-router' import {Parcel} from '@parcel/core'; import { createElement } from 'inferno-create-element'; +import path from 'path'; const PORT = process.env.PORT || 3000 @@ -14,7 +17,8 @@ let subscription; let bundles; let bundler = new Parcel({ - entries: './src/App.tsx', + // NOTE: Specifying target: { source: './src/App.tsx' } didn't work for me + entries: ['./src/App.tsx', './src/index.tsx'], defaultConfig: '@parcel/config-default', targets: { default: { @@ -24,6 +28,14 @@ let bundler = new Parcel({ }, distDir: "./distServer", publicUrl: "/", // Should be picked up from environment + }, + browser: { + context: 'browser', + engines: { + browsers: '> 0.5%, last 2 versions, not dead' + }, + distDir: "./distBrowser", + publicUrl: "/", // Should be picked up from environment } }, mode: 'development' @@ -60,10 +72,30 @@ api.get('/api/about', async (ctx) => { }) }); +function renderPage(html, initialData) { + return ` + + + + Inferno Router Demo + + + + + +
${html}
+ + +` + return html; +} + frontend.get('/:slug?', async (ctx) => { const location = ctx.params.slug === undefined ? '/': `/${ctx.params.slug}`; - const pathToAppJs = bundles.find(b => b.name === 'App.js').filePath; + const pathToAppJs = bundles.find(b => b.name === 'App.js' && b.env.context === 'node').filePath; const { appFactory } = require(pathToAppJs) const app = appFactory(); @@ -75,7 +107,7 @@ frontend.get('/:slug?', async (ctx) => { initialData, }, app)) - ctx.body = htmlApp; + ctx.body = renderPage(htmlApp, initialData); }) @@ -86,6 +118,7 @@ app.use(api.routes()); app.use(api.allowedMethods()); app.use(frontend.routes()); app.use(frontend.allowedMethods()); +app.use(koaMount('/dist', koaStatic('distBrowser'))); // bundler.watch((err, event) => { // if (err) { From b03109998dc36c41b9ab27bcc8b55a168b4eda98 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 23 Dec 2022 19:57:03 +0100 Subject: [PATCH 23/72] SSR and hydration --- demo/inferno-router-demo/package.json | 3 ++- demo/inferno-router-demo/src/index.tsx | 4 ---- demo/inferno-router-demo/src/indexServer.tsx | 11 +++++++++++ demo/inferno-router-demo/src/server.ts | 4 ++-- 4 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 demo/inferno-router-demo/src/indexServer.tsx diff --git a/demo/inferno-router-demo/package.json b/demo/inferno-router-demo/package.json index ee5d94e3a..fc79450df 100644 --- a/demo/inferno-router-demo/package.json +++ b/demo/inferno-router-demo/package.json @@ -14,9 +14,10 @@ "dependencies": { "inferno": "file:../../packages/inferno", "inferno-animation": "file:../../packages/inferno-animation", + "inferno-create-element": "file:../../packages/inferno-create-element", + "inferno-hydrate": "file:../../packages/inferno-hydrate", "inferno-router": "file:../../packages/inferno-router", "inferno-server": "file:../../packages/inferno-server", - "inferno-create-element": "file:../../packages/inferno-create-element", "koa": "^2.14.1", "koa-json-body": "^5.3.0", "koa-logger": "^3.2.1", diff --git a/demo/inferno-router-demo/src/index.tsx b/demo/inferno-router-demo/src/index.tsx index e64d275c4..a99658887 100644 --- a/demo/inferno-router-demo/src/index.tsx +++ b/demo/inferno-router-demo/src/index.tsx @@ -1,10 +1,7 @@ -//import { hydrate } from 'inferno-hydrate'; import { render } from 'inferno'; import { BrowserRouter } from 'inferno-router' import { appFactory } from './App'; -// hydrate({appFactory()}, document.getElementById('app')) - declare global { interface Window { __initialData__: any @@ -12,4 +9,3 @@ declare global { } render({appFactory()}, document.getElementById('app')) -// render(Test, document.getElementById('app')) \ No newline at end of file diff --git a/demo/inferno-router-demo/src/indexServer.tsx b/demo/inferno-router-demo/src/indexServer.tsx new file mode 100644 index 000000000..dcc11a629 --- /dev/null +++ b/demo/inferno-router-demo/src/indexServer.tsx @@ -0,0 +1,11 @@ +import { hydrate } from 'inferno-hydrate'; +import { BrowserRouter } from 'inferno-router' +import { appFactory } from './App'; + +declare global { + interface Window { + __initialData__: any + } +} + +hydrate({appFactory()}, document.getElementById('app')) diff --git a/demo/inferno-router-demo/src/server.ts b/demo/inferno-router-demo/src/server.ts index 92cdc9088..8ca47f187 100644 --- a/demo/inferno-router-demo/src/server.ts +++ b/demo/inferno-router-demo/src/server.ts @@ -18,7 +18,7 @@ let bundles; let bundler = new Parcel({ // NOTE: Specifying target: { source: './src/App.tsx' } didn't work for me - entries: ['./src/App.tsx', './src/index.tsx'], + entries: ['./src/App.tsx', './src/indexServer.tsx'], defaultConfig: '@parcel/config-default', targets: { default: { @@ -79,7 +79,7 @@ function renderPage(html, initialData) { Inferno Router Demo - + From b26fbc8492e307f2cf851aa68c521cd56c17c0f2 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 23 Dec 2022 19:57:49 +0100 Subject: [PATCH 24/72] Moved invocation of loader to Router --- packages/inferno-router/src/Route.ts | 41 +++--------- packages/inferno-router/src/Router.ts | 30 +++++++-- packages/inferno-router/src/Switch.ts | 8 +-- packages/inferno-router/src/resolveLoaders.ts | 63 +++++++++++++++++++ .../inferno-server/src/ssrLoaderResolver.ts | 19 +++--- 5 files changed, 108 insertions(+), 53 deletions(-) create mode 100644 packages/inferno-router/src/resolveLoaders.ts diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index f66c6a9f2..11614a7e7 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -2,9 +2,9 @@ import { Component, createComponentVNode, Inferno, InfernoNode } from 'inferno'; import { VNodeFlags } from 'inferno-vnode-flags'; import { invariant, warning } from './utils'; import { matchPath } from './matchPath'; -import { combineFrom, isFunction, isNullOrUndef } from 'inferno-shared'; +import { combineFrom, isFunction, isNullOrUndef, isUndefined } from 'inferno-shared'; import type { History, Location } from 'history'; -import type { RouterContext, TContextRouter, TLoader, TLoaderData, TLoaderProps } from './Router'; +import type { RouterContext, TContextRouter, TLoaderData, TLoaderProps } from './Router'; export interface Match

> { params: P; @@ -67,19 +67,6 @@ class Route extends Component, RouteState> { }; } - private runLoader(loader: TLoader, params, request, match) { - // TODO: Pass progress callback to loader - loader({ params, request }) - .then((res) => { - // TODO: react-router parses json - this.setState({ match, __loaderData__: { res } }); - }) - .catch((err) => { - // Loaders should throw errors - this.setState({ match, __loaderData__: { err } }) - }); - } - public computeMatch({ computedMatch, ...props }: IRouteProps, router: TContextRouter): Match | null { if (!isNullOrUndef(computedMatch)) { // already computed the match for us @@ -98,16 +85,7 @@ class Route extends Component, RouteState> { return path ? matchPath(pathname, { path, strict, exact, sensitive, loader, initialData }) : route.match; } - public componentDidMount(): void { - const { match, __loaderData__ } = this.state!; - // QUESTION: Is there a better way to invoke this on/after first render? - if (match?.loader && !__loaderData__) { - const request = undefined; - this.runLoader(match.loader, match.params, request, match); - } - } - - public componentWillReceiveProps(nextProps, nextContext) { + public componentWillReceiveProps(nextProps, nextContext: { router: TContextRouter }) { if (process.env.NODE_ENV !== 'production') { warning( !(nextProps.location && !this.props.location), @@ -121,14 +99,6 @@ class Route extends Component, RouteState> { } const match = this.computeMatch(nextProps, nextContext.router); - - // Am I a match? In which case check for loader - if (match?.loader && !match.initialData) { - const request = undefined; - this.runLoader(match.loader, match.params, request, match); - return; - } - this.setState({ match, __loaderData__: match?.initialData, @@ -142,6 +112,11 @@ class Route extends Component, RouteState> { const location = props.location || route.location; const renderProps = { match, location, history, staticContext, component, render, loader, __loaderData__ }; + // If we have a loader we don't render until it has been resolved + if (!isUndefined(loader) && isUndefined(__loaderData__)) { + return null; + } + if (component) { if (process.env.NODE_ENV !== 'production') { if (!isFunction(component)) { diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index 7a243bc2c..bf671d73a 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -1,8 +1,9 @@ import { Component, InfernoNode } from 'inferno'; -import { warning } from './utils'; -import { combineFrom } from 'inferno-shared'; +import { combineFrom, isUndefined } from 'inferno-shared'; import type { History, Location } from 'history'; +import { warning } from './utils'; import { Match } from './Route'; +import { resolveLoaders } from './resolveLoaders'; export type TLoaderProps

> = { params?: P; // Match params (if any) @@ -51,6 +52,7 @@ export class Router extends Component { const match = this.computeMatch(props.history.location.pathname); this.state = { match, + initialData: this.props.initialData, }; } @@ -62,7 +64,7 @@ export class Router extends Component { location: router.history.location, match: this.state?.match, // Why are we sending this? it appears useless. }; - router.initialData = this.props.initialData; // this is a dictionary of all data available + router.initialData = this.state?.initialData; // this is a dictionary of all data available return { router }; @@ -86,10 +88,26 @@ export class Router extends Component { // location in componentWillMount. This happens e.g. when doing // server rendering using a . this.unlisten = history.listen(() => { - this.setState({ - match: this.computeMatch(history.location.pathname) - }); + // First execution of loaders + resolveLoaders(history.location.pathname, this.props.children) + .then((initialData) => { + this.setState({ + match: this.computeMatch(history.location.pathname), + // TODO: resolveLoaders should probably return undefined if no match + initialData: Object.keys(initialData).length > 0 ? initialData : undefined, + }); + }); }); + + // First execution of loaders + if (isUndefined(this.props.initialData)) { + resolveLoaders(history.location.pathname, this.props.children) + .then((initialData) => { + this.setState({ + initialData: Object.keys(initialData).length > 0 ? initialData : undefined, + }); + }); + } } public componentWillUnmount() { diff --git a/packages/inferno-router/src/Switch.ts b/packages/inferno-router/src/Switch.ts index 543a9835d..b29678b88 100644 --- a/packages/inferno-router/src/Switch.ts +++ b/packages/inferno-router/src/Switch.ts @@ -12,10 +12,10 @@ function getMatch(pathname: string, { path, exact, strict, sensitive, loader, fr return path ? matchPath(pathname, { path, exact, strict, sensitive, loader, initialData }) : route.match; } -function extractMatchFromChildren(pathname: string, children, router) { +function extractFirstMatchFromChildren(pathname: string, children, router) { if (isArray(children)) { for (let i = 0; i < children.length; ++i) { - const nestedMatch = extractMatchFromChildren(pathname, children[i], router); + const nestedMatch = extractFirstMatchFromChildren(pathname, children[i], router); if (nestedMatch.match) return nestedMatch; } return {}; @@ -43,7 +43,7 @@ export class Switch extends Component { const { router } = context; const { location, children } = props; const pathname = (location || router.route.location).pathname; - const { match, _child } = extractMatchFromChildren(pathname, children, router); + const { match, _child } = extractFirstMatchFromChildren(pathname, children, router); this.state = { match, @@ -93,7 +93,7 @@ export class Switch extends Component { const { router } = nextContext; const { location, children } = nextProps; const pathname = (location || router.route.location).pathname; - const { match, _child } = extractMatchFromChildren(pathname, children, router); + const { match, _child } = extractFirstMatchFromChildren(pathname, children, router); if (match?.loader) { const params = match.params; diff --git a/packages/inferno-router/src/resolveLoaders.ts b/packages/inferno-router/src/resolveLoaders.ts new file mode 100644 index 000000000..3ac5a4eb9 --- /dev/null +++ b/packages/inferno-router/src/resolveLoaders.ts @@ -0,0 +1,63 @@ +import { isNullOrUndef } from "inferno-shared"; +import { matchPath } from "./matchPath"; +import type { TLoaderData } from "./Router"; + +export function resolveLoaders(location: string, tree: any): Promise> { + const promises = traverseLoaders(location, tree).map((({path, loader}) => resolveEntry(path, loader))); + return Promise.all(promises).then((result) => { + return Object.fromEntries(result); + }); +} + +type TLoaderEntry = { + path: string, + loader: Function, +} + +function traverseLoaders(location: string, tree: any): TLoaderEntry[] { + // Make sure tree isn't null + if (isNullOrUndef(tree)) return []; + + if (Array.isArray(tree)) { + const entries = tree.reduce((res, node) => { + const outpArr = traverseLoaders(location, node); + return [...res, ...outpArr]; + }, []); + return entries; + } + + + let outp: TLoaderEntry[] = []; + // Add any loader on this node + if (tree.props?.loader && tree.props?.path) { + // TODO: If we traverse a switch, only the first match should be returned + // TODO: Should we check if we are in Router? It is defensive and could save a bit of time, but is it worth it? + const { path, exact = false, strict = false, sensitive = false } = tree.props; + const match = matchPath(location, { + path, + exact, + strict, + sensitive, + }); + if (match) { + const { params } = match; + // TODO: How do I pass request? + outp.push({ + path, + loader: () => tree.props.loader({ params }), + }) + } + } + + // Traverse children + const entries = traverseLoaders(location, tree.children || tree.props?.children); + return [...outp, ...entries]; +} + +function resolveEntry(path, loader): Promise { + try { + return loader().then((res) => [path, { res }]); + } catch (err) { + return Promise.resolve([ path, { err } ]); + } +} \ No newline at end of file diff --git a/packages/inferno-server/src/ssrLoaderResolver.ts b/packages/inferno-server/src/ssrLoaderResolver.ts index 7eea5e538..a73e3b902 100644 --- a/packages/inferno-server/src/ssrLoaderResolver.ts +++ b/packages/inferno-server/src/ssrLoaderResolver.ts @@ -2,6 +2,7 @@ import { isNullOrUndef } from "inferno-shared"; import { matchPath } from "inferno-router"; import type { TLoaderData } from "inferno-router/src/Router"; +// TODO: I have moved this back to inferno-router, should import from there export async function resolveLoaders(location: string, tree: any): Promise> { const promises = traverseLoaders(location, tree).map((({path, loader}) => resolveEntry(path, loader))); const result = await Promise.all(promises); @@ -14,20 +15,22 @@ type TLoaderEntry = { } function traverseLoaders(location: string, tree: any): TLoaderEntry[] { + // Make sure tree isn't null + if (isNullOrUndef(tree)) return []; + if (Array.isArray(tree)) { const entries = tree.reduce((res, node) => { const outpArr = traverseLoaders(location, node); + // TODO: If in Switch, bail on first hit return [...res, ...outpArr]; }, []); return entries; } - // This is a single node make sure it isn't null - if (isNullOrUndef(tree) || isNullOrUndef(tree.props)) return []; - let outp: TLoaderEntry[] = []; // Add any loader on this node - if (tree.props.loader && tree.props.path) { + if (tree.props?.loader && tree.props?.path) { + // TODO: If we traverse a switch, only the first match should be returned // TODO: Should we check if we are in Router? It is defensive and could save a bit of time, but is it worth it? const { path, exact = false, strict = false, sensitive = false } = tree.props; const match = matchPath(location, { @@ -45,13 +48,9 @@ function traverseLoaders(location: string, tree: any): TLoaderEntry[] { }) } } - // Traverse children - if (!isNullOrUndef(tree.props.children)) { - const entries = traverseLoaders(location, tree.props.children); - outp = [...outp, ...entries] - } - return outp; + const entries = traverseLoaders(location, tree.children || tree.props?.children); + return [...outp, ...entries] } async function resolveEntry(path, loader): Promise { From 52fd289beb702d3d66244657212bfd9aad641267 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Fri, 23 Dec 2022 21:43:24 +0100 Subject: [PATCH 25/72] Fixed redirect issue that broke Switch tests --- .../inferno-router/__tests__/Switch.spec.tsx | 88 ++++++++++--------- packages/inferno-router/src/Router.ts | 21 +++-- packages/inferno-router/src/resolveLoaders.ts | 25 +++--- 3 files changed, 75 insertions(+), 59 deletions(-) diff --git a/packages/inferno-router/__tests__/Switch.spec.tsx b/packages/inferno-router/__tests__/Switch.spec.tsx index 104e6826b..de80af2d4 100644 --- a/packages/inferno-router/__tests__/Switch.spec.tsx +++ b/packages/inferno-router/__tests__/Switch.spec.tsx @@ -1,6 +1,7 @@ /* tslint:disable:no-console */ -import { render, rerender } from 'inferno'; +import { render, rerender, Component } from 'inferno'; import { MemoryRouter, Redirect, Route, Switch } from 'inferno-router'; +import { IRouteProps } from '../src/Route'; describe('Switch (jsx)', () => { it('renders the first that matches the URL', () => { @@ -349,48 +350,49 @@ describe('Switch (jsx)', () => { }); // TODO: This will not work because component is not mandatory - // it('Should allow using component child parameter as result, Github #1601', () => { - // const node = document.createElement('div'); - // - // class Component1 extends Component { - // constructor(p, s) { - // super(p, s); - // - // this.state.foo = 1; - // } - // public render() { - // return ( - //

- // Component - //
- // ); - // } - // } - // - // const routes: IRouteProps[] = [ - // { - // component: Component1, - // exact: true, - // path: `/` - // } - // ]; - // - // render( - // - // - // {routes.map(({ path, exact, component: Comp, ...rest }) => ( - // } - // /> - // ))} - // - // , - // node - // ); - // }); + it('Should allow using component child parameter as result, Github #1601', () => { + const node = document.createElement('div'); + + class Component1 extends Component { + state = { foo: 0 } + constructor(p, s) { + super(p, s); + + this.state.foo = 1; + } + public render() { + return ( +
+ Component +
+ ); + } + } + + const routes: IRouteProps[] = [ + { + component: Component1, + exact: true, + path: `/` + } + ]; + + render( + + + {routes.map(({ path, exact, component: Comp, ...rest }) => ( + } + /> + ))} + + , + node + ); + }); }); describe('A ', () => { diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index bf671d73a..ae7b7b9de 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -3,7 +3,7 @@ import { combineFrom, isUndefined } from 'inferno-shared'; import type { History, Location } from 'history'; import { warning } from './utils'; import { Match } from './Route'; -import { resolveLoaders } from './resolveLoaders'; +import { resolveLoaders, traverseLoaders } from './resolveLoaders'; export type TLoaderProps

> = { params?: P; // Match params (if any) @@ -88,25 +88,34 @@ export class Router extends Component { // location in componentWillMount. This happens e.g. when doing // server rendering using a . this.unlisten = history.listen(() => { + const loaderEntries = traverseLoaders(history.location.pathname, this.props.children); + const match = this.computeMatch(history.location.pathname); + if (loaderEntries.length === 0) { + this.setState({ match }); + return; + } + // First execution of loaders - resolveLoaders(history.location.pathname, this.props.children) + resolveLoaders(loaderEntries) .then((initialData) => { this.setState({ - match: this.computeMatch(history.location.pathname), - // TODO: resolveLoaders should probably return undefined if no match - initialData: Object.keys(initialData).length > 0 ? initialData : undefined, + match, + initialData, }); }); }); // First execution of loaders if (isUndefined(this.props.initialData)) { - resolveLoaders(history.location.pathname, this.props.children) + const promises = traverseLoaders(history.location.pathname, this.props.children); + if (promises.length > 0) { + resolveLoaders(promises) .then((initialData) => { this.setState({ initialData: Object.keys(initialData).length > 0 ? initialData : undefined, }); }); + } } } diff --git a/packages/inferno-router/src/resolveLoaders.ts b/packages/inferno-router/src/resolveLoaders.ts index 3ac5a4eb9..108cf7c43 100644 --- a/packages/inferno-router/src/resolveLoaders.ts +++ b/packages/inferno-router/src/resolveLoaders.ts @@ -1,9 +1,16 @@ import { isNullOrUndef } from "inferno-shared"; import { matchPath } from "./matchPath"; -import type { TLoaderData } from "./Router"; +import type { TLoaderData, TLoaderProps } from "./Router"; -export function resolveLoaders(location: string, tree: any): Promise> { - const promises = traverseLoaders(location, tree).map((({path, loader}) => resolveEntry(path, loader))); +// export function resolveLoaders(location: string, tree: any): Promise> { +// const promises = traverseLoaders(location, tree).map((({path, loader}) => resolveEntry(path, loader))); +// return Promise.all(promises).then((result) => { +// return Object.fromEntries(result); +// }); +// } + +export function resolveLoaders(loaderEntries: TLoaderEntry[]): Promise> { + const promises = loaderEntries.map((({path, loader}) => resolveEntry(path, loader))); return Promise.all(promises).then((result) => { return Object.fromEntries(result); }); @@ -11,10 +18,10 @@ export function resolveLoaders(location: string, tree: any): Promise Promise, } -function traverseLoaders(location: string, tree: any): TLoaderEntry[] { +export function traverseLoaders(location: string, tree: any): TLoaderEntry[] { // Make sure tree isn't null if (isNullOrUndef(tree)) return []; @@ -55,9 +62,7 @@ function traverseLoaders(location: string, tree: any): TLoaderEntry[] { } function resolveEntry(path, loader): Promise { - try { - return loader().then((res) => [path, { res }]); - } catch (err) { - return Promise.resolve([ path, { err } ]); - } + return loader() + .then((res) => [path, { res }]) + .catch((err) => [ path, { err } ]); } \ No newline at end of file From 6b5bd8df8b0c15110fd3c62d0d26290f767bcc9b Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Sat, 24 Dec 2022 08:55:17 +0100 Subject: [PATCH 26/72] Notes for investigation of request --- packages/inferno-router/src/Router.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index ae7b7b9de..c31d6c102 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -8,6 +8,11 @@ import { resolveLoaders, traverseLoaders } from './resolveLoaders'; export type TLoaderProps

> = { params?: P; // Match params (if any) request: Request; // Fetch API Request + // https://github.com/remix-run/react-router/blob/4f3ad7b96e6e0228cc952cd7eafe2c265c7393c7/packages/router/router.ts#L1004 + // https://github.com/remix-run/react-router/blob/11156ac7f3d7c1c557c67cc449ecbf9bd5c6a4ca/examples/ssr-data-router/src/entry.server.tsx#L66 + // https://github.com/remix-run/react-router/blob/59b319feaa12745a434afdef5cadfcabd01206f9/examples/search-params/src/App.tsx#L43 + // https://github.com/remix-run/react-router/blob/11156ac7f3d7c1c557c67cc449ecbf9bd5c6a4ca/packages/react-router-dom/__tests__/data-static-router-test.tsx#L81 + onProgress?(perc: number): void; } From 2156a4c6cc770073bb546e7444b94f1a29718625 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Sat, 24 Dec 2022 08:57:18 +0100 Subject: [PATCH 27/72] Demo using params in loader --- demo/inferno-router-demo/src/App.tsx | 2 + .../src/pages/ContentPage.scss | 0 .../src/pages/ContentPage.tsx | 58 +++++++++++++++++++ .../src/pages/PageTemplate.tsx | 2 + demo/inferno-router-demo/src/server.ts | 14 ++++- 5 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 demo/inferno-router-demo/src/pages/ContentPage.scss create mode 100644 demo/inferno-router-demo/src/pages/ContentPage.tsx diff --git a/demo/inferno-router-demo/src/App.tsx b/demo/inferno-router-demo/src/App.tsx index d5acfef5e..a5f2e999a 100644 --- a/demo/inferno-router-demo/src/App.tsx +++ b/demo/inferno-router-demo/src/App.tsx @@ -6,6 +6,7 @@ import { Route } from 'inferno-router' */ import StartPage from './pages/StartPage' import AboutPage from './pages/AboutPage' +import ContentPage from './pages/ContentPage' /** * The Application @@ -23,6 +24,7 @@ export function appFactory () { {/* Public Pages */} + ) } diff --git a/demo/inferno-router-demo/src/pages/ContentPage.scss b/demo/inferno-router-demo/src/pages/ContentPage.scss new file mode 100644 index 000000000..e69de29bb diff --git a/demo/inferno-router-demo/src/pages/ContentPage.tsx b/demo/inferno-router-demo/src/pages/ContentPage.tsx new file mode 100644 index 000000000..6d090834e --- /dev/null +++ b/demo/inferno-router-demo/src/pages/ContentPage.tsx @@ -0,0 +1,58 @@ +import { Component } from 'inferno'; +import PageTemplate from './PageTemplate'; +import { useLoaderData } from 'inferno-router'; + +import './AboutPage.scss'; +import { useLoaderError } from 'inferno-router'; + +// const env: any = (typeof window === 'undefined' ? process.env : (window as any).__env__) +// const { FRONTEND_BASE_URI } = env + +const BACKEND_HOST = 'http://localhost:1234'; + +interface IProps { + fetchData: any; +} + +export default class ContentPage extends Component { + static async fetchData({ params, request }) { + const pageSlug = params.id; + + const fetchOptions: RequestInit = { + headers: { + Accept: 'application/json', + }, + }; + + const res = await fetch(new URL(`/api/page/${params.slug}`, BACKEND_HOST), fetchOptions); + + if (res.ok) { + try { + const data = await res.json(); + return data; + } catch (err) { + return { + title: `Error: ${err.message}`, + }; + } + } + + return { + title: 'Error: Backend not responding', + }; + } + + render(props, state, context) { + const data = useLoaderData(props) as any; + const err = useLoaderError(props) as any; + + return ( + +

+

{data?.title}

+

{data?.body || err?.message}

+
+ + ); + } +} diff --git a/demo/inferno-router-demo/src/pages/PageTemplate.tsx b/demo/inferno-router-demo/src/pages/PageTemplate.tsx index 5ec95e642..6806dc212 100644 --- a/demo/inferno-router-demo/src/pages/PageTemplate.tsx +++ b/demo/inferno-router-demo/src/pages/PageTemplate.tsx @@ -21,6 +21,8 @@ export default function PageTemplate({ id = undefined, children }) {
  • Start
  • About
  • +
  • Page One
  • +
  • Page Two
diff --git a/demo/inferno-router-demo/src/server.ts b/demo/inferno-router-demo/src/server.ts index 8ca47f187..84851bea0 100644 --- a/demo/inferno-router-demo/src/server.ts +++ b/demo/inferno-router-demo/src/server.ts @@ -68,7 +68,19 @@ api.get('/api/about', async (ctx) => { body: "Routing with async data loader support." }; resolve(null); - }, 1000) + }, 500) + }) +}); + +api.get('/api/page/:slug', async (ctx) => { + return new Promise((resolve) => { + setTimeout(() => { + ctx.body = { + title: ctx.params.slug.toUpperCase(), + body: "This is a page." + }; + resolve(null); + }, 300) }) }); From b1d9488d03d4ae7c1f77addb8334ba29ab292866 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Sat, 24 Dec 2022 09:09:14 +0100 Subject: [PATCH 28/72] Added test with params --- .../__tests__/loaderOnRoute.spec.jsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index 6dde9ec75..e0225e8ac 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -154,6 +154,32 @@ describe('A with loader in a MemoryRouter', () => { expect(container.querySelector('#create').innerHTML).toContain(TEST); }); + + it('Should recieve params in loader', async () => { + const TEXT = 'bubblegum'; + const Component = (props, { router }) => { + const res = useLoaderData(props); + return

{res?.message}

{res?.slug}

+ } + const loaderFunc = async ({params, request} = {}) => { + return { message: TEXT, slug: params?.slug } + } + + const params = { slug: 'flowers' }; + const initialData = { + '/:slug': { res: await loaderFunc({ params }), err: undefined, } + } + + render( + + + , + container + ); + + expect(container.innerHTML).toContain(TEXT); + expect(container.innerHTML).toContain('flowers'); + }); }); describe('A with loader in a BrowserRouter', () => { From 9757aa51a51c516d46dd4f415d685102c19acc14 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Mon, 26 Dec 2022 19:43:00 +0100 Subject: [PATCH 29/72] First, dumbed down, try at adding request param and AbortController to allow cancelling inflight fetch on consecutive navigation WIP --- packages/inferno-router/src/Router.ts | 52 ++--- packages/inferno-router/src/resolveLoaders.ts | 192 ++++++++++++++++-- 2 files changed, 207 insertions(+), 37 deletions(-) diff --git a/packages/inferno-router/src/Router.ts b/packages/inferno-router/src/Router.ts index c31d6c102..536627888 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -51,6 +51,7 @@ export type RouterContext = { router: TContextRouter }; */ export class Router extends Component { public unlisten; + private _loaderFetchControllers: AbortController[] = []; constructor(props: IRouterProps, context: { router: TContextRouter }) { super(props, context); @@ -93,35 +94,40 @@ export class Router extends Component { // location in componentWillMount. This happens e.g. when doing // server rendering using a . this.unlisten = history.listen(() => { - const loaderEntries = traverseLoaders(history.location.pathname, this.props.children); const match = this.computeMatch(history.location.pathname); - if (loaderEntries.length === 0) { - this.setState({ match }); - return; - } - - // First execution of loaders - resolveLoaders(loaderEntries) - .then((initialData) => { - this.setState({ - match, - initialData, - }); - }); + this._matchAndResolveLoaders(match); }); // First execution of loaders if (isUndefined(this.props.initialData)) { - const promises = traverseLoaders(history.location.pathname, this.props.children); - if (promises.length > 0) { - resolveLoaders(promises) - .then((initialData) => { - this.setState({ - initialData: Object.keys(initialData).length > 0 ? initialData : undefined, - }); - }); - } + this._matchAndResolveLoaders(this.state?.match); + } + } + + private _matchAndResolveLoaders(match?: Match) { + for (const controller of this._loaderFetchControllers) { + controller.abort(); } + this._loaderFetchControllers = []; + + const { history, children } = this.props; + const loaderEntries = traverseLoaders(history.location.pathname, children); + if (loaderEntries.length === 0) { + this.setState({ match }); + return; + } + + // Store AbortController instances for each matched loader + this._loaderFetchControllers = loaderEntries.map(e => e.controller); + + // First execution of loaders + resolveLoaders(loaderEntries) + .then((initialData) => { + this.setState({ + match, + initialData, + }); + }); } public componentWillUnmount() { diff --git a/packages/inferno-router/src/resolveLoaders.ts b/packages/inferno-router/src/resolveLoaders.ts index 108cf7c43..14408e433 100644 --- a/packages/inferno-router/src/resolveLoaders.ts +++ b/packages/inferno-router/src/resolveLoaders.ts @@ -1,16 +1,12 @@ +import { Path, To } from "history"; import { isNullOrUndef } from "inferno-shared"; import { matchPath } from "./matchPath"; import type { TLoaderData, TLoaderProps } from "./Router"; -// export function resolveLoaders(location: string, tree: any): Promise> { -// const promises = traverseLoaders(location, tree).map((({path, loader}) => resolveEntry(path, loader))); -// return Promise.all(promises).then((result) => { -// return Object.fromEntries(result); -// }); -// } - export function resolveLoaders(loaderEntries: TLoaderEntry[]): Promise> { - const promises = loaderEntries.map((({path, loader}) => resolveEntry(path, loader))); + const promises = loaderEntries.map((({path, params, request, loader}) => { + return resolveEntry(path, params, request, loader); + })); return Promise.all(promises).then((result) => { return Object.fromEntries(result); }); @@ -18,6 +14,9 @@ export function resolveLoaders(loaderEntries: TLoaderEntry[]): Promise, + request: Request, + controller: AbortController, loader: (TLoaderProps) => Promise, } @@ -48,10 +47,15 @@ export function traverseLoaders(location: string, tree: any): TLoaderEntry[] { }); if (match) { const { params } = match; - // TODO: How do I pass request? + const controller = new AbortController(); + const request = createClientSideRequest(location, controller.signal); + outp.push({ path, - loader: () => tree.props.loader({ params }), + params, + request, + controller, + loader: tree.props.loader, }) } } @@ -61,8 +65,168 @@ export function traverseLoaders(location: string, tree: any): TLoaderEntry[] { return [...outp, ...entries]; } -function resolveEntry(path, loader): Promise { - return loader() - .then((res) => [path, { res }]) +function resolveEntry(path, params, request, loader): Promise { + return loader({ params, request }) + .then((res) => [ path, { res } ]) .catch((err) => [ path, { err } ]); -} \ No newline at end of file +} + +// From react-router +// NOTE: We don't currently support the submission param of createClientSideRequest which is why +// some of the related code is commented away + +export type FormEncType = + | "application/x-www-form-urlencoded" + | "multipart/form-data"; + +export type MutationFormMethod = "post" | "put" | "patch" | "delete"; +export type FormMethod = "get" | MutationFormMethod; + +// TODO: react-router supports submitting forms with loaders, this is related to that +// const validMutationMethodsArr: MutationFormMethod[] = [ +// "post", +// "put", +// "patch", +// "delete", +// ]; +// const validMutationMethods = new Set( +// validMutationMethodsArr +// ); + +/** + * @private + * Internal interface to pass around for action submissions, not intended for + * external consumption + */ +export interface Submission { + formMethod: FormMethod; + formAction: string; + formEncType: FormEncType; + formData: FormData; +} + + +function createClientSideRequest( + location: string | Location, + signal: AbortSignal, + // submission?: Submission +): Request { + let url = createClientSideURL(stripHashFromPath(location)).toString(); + let init: RequestInit = { signal }; + + // TODO: react-router supports submitting forms with loaders, but this needs more investigation + // related code is commented out in this file + // if (submission && isMutationMethod(submission.formMethod)) { + // let { formMethod, formEncType, formData } = submission; + // init.method = formMethod.toUpperCase(); + // init.body = + // formEncType === "application/x-www-form-urlencoded" + // ? convertFormDataToSearchParams(formData) + // : formData; + // } + + // Request is undefined when running tests + if (typeof Request === 'undefined' && process.env.NODE_ENV === 'test') { + // @ts-ignore + global.Request = class Request { + url; + signal; + constructor(url, init) { + this.url = url; + this.signal = init.signal; + } + } + } + + // Content-Type is inferred (https://fetch.spec.whatwg.org/#dom-request) + return new Request(url, init); +} + +/** + * Creates a string URL path from the given pathname, search, and hash components. + */ +export function createPath({ + pathname = "/", + search = "", + hash = "", +}: Partial) { + if (search && search !== "?") + pathname += search.charAt(0) === "?" ? search : "?" + search; + if (hash && hash !== "#") + pathname += hash.charAt(0) === "#" ? hash : "#" + hash; + return pathname; +} + +/** + * Parses a string URL path into its separate pathname, search, and hash components. + */ + +// TODO: Shouldn't this be done with URL? +export function parsePath(path: string): Partial { + let parsedPath: Partial = {}; + + if (path) { + let hashIndex = path.indexOf("#"); + if (hashIndex >= 0) { + parsedPath.hash = path.substring(hashIndex); + path = path.substring(0, hashIndex); + } + + let searchIndex = path.indexOf("?"); + if (searchIndex >= 0) { + parsedPath.search = path.substring(searchIndex); + path = path.substring(0, searchIndex); + } + + if (path) { + parsedPath.pathname = path; + } + } + + return parsedPath; +} + +export function createClientSideURL(location: Location | string): URL { + // window.location.origin is "null" (the literal string value) in Firefox + // under certain conditions, notably when serving from a local HTML file + // See https://bugzilla.mozilla.org/show_bug.cgi?id=878297 + let base = + typeof window !== "undefined" && + typeof window.location !== "undefined" && + window.location.origin !== "null" + ? window.location.origin + : window.location.href; + let href = typeof location === "string" ? location : createPath(location); + // invariant( + // base, + // `No window.location.(origin|href) available to create URL for href: ${href}` + // ); + return new URL(href, base); +} + +function stripHashFromPath(path: To) { + let parsedPath = typeof path === "string" ? parsePath(path) : path; + return createPath({ ...parsedPath, hash: "" }); +} + +// TODO: react-router supports submitting forms with loaders, this is related to that +// function isMutationMethod(method?: string): method is MutationFormMethod { +// return validMutationMethods.has(method as MutationFormMethod); +// } + +// function convertFormDataToSearchParams(formData: FormData): URLSearchParams { +// let searchParams = new URLSearchParams(); + +// for (let [key, value] of formData.entries()) { +// // invariant( +// // typeof value === "string", +// // 'File inputs are not supported with encType "application/x-www-form-urlencoded", ' + +// // 'please use "multipart/form-data" instead.' +// // ); +// if (typeof value === "string") { +// searchParams.append(key, value); +// } +// } + +// return searchParams; +// } \ No newline at end of file From 25405c48fbd07c6b29a6c780db87bef76d0a1484 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Mon, 26 Dec 2022 20:44:34 +0100 Subject: [PATCH 30/72] Add test for abort signal on nav --- .../__tests__/loaderOnRoute.spec.jsx | 74 +++++++++++++++++++ packages/inferno-router/src/resolveLoaders.ts | 4 +- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx index e0225e8ac..2c1ec91e7 100644 --- a/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.jsx @@ -180,6 +180,80 @@ describe('A with loader in a MemoryRouter', () => { expect(container.innerHTML).toContain(TEXT); expect(container.innerHTML).toContain('flowers'); }); + + it('Can abort fetch', async () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const [setDone, waitForRerender] = createEventGuard(); + + const TEST = "ok"; + + const loaderFunc = async ({ request }) => { + expect(request).toBeDefined(); + expect(request.signal).toBeDefined(); + return new Promise((resolve) => { + setTimeout(() => { + setDone(); + resolve({ message: TEST }) + }, 5); + }); + }; + + function RootComp() { + return
ROOT
; + } + + function CreateComp(props) { + const res = useLoaderData(props); + const err = useLoaderError(props); + return
{res.message}
; + } + + function PublishComp() { + return
PUBLISH
; + } + + const tree = ( + +
+ + + + +
+
+ ); + + render(tree, container); + + expect(container.innerHTML).toContain("ROOT"); + + // Click create + container.querySelector('#createNav').firstChild.click(); + container.querySelector('#publishNav').firstChild.click(); + + await waitForRerender(); + + expect(abortSpy).toBeCalledTimes(1); + expect(container.querySelector('#create')).toBeNull(); + }); }); describe('A with loader in a BrowserRouter', () => { diff --git a/packages/inferno-router/src/resolveLoaders.ts b/packages/inferno-router/src/resolveLoaders.ts index 14408e433..8be855f7a 100644 --- a/packages/inferno-router/src/resolveLoaders.ts +++ b/packages/inferno-router/src/resolveLoaders.ts @@ -34,8 +34,8 @@ export function traverseLoaders(location: string, tree: any): TLoaderEntry[] { let outp: TLoaderEntry[] = []; - // Add any loader on this node - if (tree.props?.loader && tree.props?.path) { + // Add any loader on this node (but only on the VNode) + if (!tree.context && tree.props?.loader && tree.props?.path) { // TODO: If we traverse a switch, only the first match should be returned // TODO: Should we check if we are in Router? It is defensive and could save a bit of time, but is it worth it? const { path, exact = false, strict = false, sensitive = false } = tree.props; From d1087677cc459f8aa9a7584b859dbb11576e0d06 Mon Sep 17 00:00:00 2001 From: Sebastian Ware Date: Tue, 27 Dec 2022 10:15:56 +0100 Subject: [PATCH 31/72] Fix demo --- demo/inferno-router-demo/src/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/demo/inferno-router-demo/src/server.ts b/demo/inferno-router-demo/src/server.ts index 84851bea0..5ffd293bd 100644 --- a/demo/inferno-router-demo/src/server.ts +++ b/demo/inferno-router-demo/src/server.ts @@ -90,7 +90,7 @@ function renderPage(html, initialData) { Inferno Router Demo - +