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..66a5c29cc --- /dev/null +++ b/demo/inferno-router-demo/.gitignore @@ -0,0 +1,22 @@ +.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 +distServer +distBrowser +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..c3f3f3c0d --- /dev/null +++ b/demo/inferno-router-demo/README.md @@ -0,0 +1,11 @@ +# Demo of Inferno-Router + +NOTE: Requires Nodejs >=18 (uses `fetch`) + +```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..56d689a97 --- /dev/null +++ b/demo/inferno-router-demo/package.json @@ -0,0 +1,53 @@ +{ + "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": "cross-env FORCE_COLOR=1 parcel", + "dev:backend": "cross-env FORCE_COLOR=1 ts-node-dev --respawn src/server.ts" + }, + "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", + "koa": "^2.14.1", + "koa-json-body": "^5.3.0", + "koa-logger": "^3.2.1", + "koa-mount": "^4.0.0", + "koa-router": "^12.0.0", + "koa-static": "^5.0.0" + }, + "devDependencies": { + "@babel/plugin-transform-modules-commonjs": "^7.20.11", + "@parcel/config-default": "^2.8.2", + "@parcel/packager-ts": "^2.8.1", + "@parcel/transformer-babel": "^2.8.1", + "@parcel/transformer-sass": "^2.8.1", + "@parcel/transformer-typescript-types": "^2.8.1", + "@types/node": "^18.13.0", + "babel-plugin-inferno": "^6.6.0", + "cross-env": "^7.0.3", + "jest-environment-jsdom": "^29.4.2", + "npm-run-all": "^4.1.5", + "parcel": "^2.8.2", + "process": "^0.11.10", + "ts-node-dev": "^2.0.0", + "typescript": "^4.9.5" + }, + "alias": { + "inferno": "inferno/dist/index.dev.esm.js", + "inferno-animation": "inferno-animation/dist/index.dev.esm.js", + "inferno-create-element": "inferno-create-element/dist/index.dev.esm.js", + "inferno-hydrate": "inferno-hydrate/dist/index.dev.esm.js", + "inferno-router": "inferno-router/dist/index.dev.esm.js", + "inferno-server": "inferno-server/dist/index.dev.esm.js" + } +} diff --git a/demo/inferno-router-demo/src/App.tsx b/demo/inferno-router-demo/src/App.tsx new file mode 100644 index 000000000..a5f2e999a --- /dev/null +++ b/demo/inferno-router-demo/src/App.tsx @@ -0,0 +1,30 @@ +import { Component } from 'inferno' +import { Route } from 'inferno-router' + +/** + * Pages + */ +import StartPage from './pages/StartPage' +import AboutPage from './pages/AboutPage' +import ContentPage from './pages/ContentPage' + +/** + * 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..a99658887 --- /dev/null +++ b/demo/inferno-router-demo/src/index.tsx @@ -0,0 +1,11 @@ +import { render } from 'inferno'; +import { BrowserRouter } from 'inferno-router' +import { appFactory } from './App'; + +declare global { + interface Window { + __initialData__: any + } +} + +render({appFactory()}, document.getElementById('app')) 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/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..0ff285c5b --- /dev/null +++ b/demo/inferno-router-demo/src/pages/AboutPage.tsx @@ -0,0 +1,35 @@ +import { Component } from 'inferno'; +import PageTemplate from './PageTemplate'; +import { useLoaderData } from 'inferno-router'; + +import './AboutPage.scss'; +import { useLoaderError } from 'inferno-router'; + +const BACKEND_HOST = 'http://localhost:1234'; + +export default class AboutPage extends Component { + static async fetchData({ request }) { + const fetchOptions: RequestInit = { + headers: { + Accept: 'application/json', + }, + signal: request?.signal, + }; + + return fetch(new URL('/api/about', BACKEND_HOST), fetchOptions); + } + + render(props) { + const data = useLoaderData<{ title: string, body: string}>(props); + const err = useLoaderError<{ message: string }>(props); + + return ( + +
+

{data?.title}

+

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

+
+
+ ); + } +} 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..4e3ee5e2e --- /dev/null +++ b/demo/inferno-router-demo/src/pages/ContentPage.tsx @@ -0,0 +1,37 @@ +import { Component } from 'inferno'; +import PageTemplate from './PageTemplate'; +import { useLoaderData } from 'inferno-router'; + +import './AboutPage.scss'; +import { useLoaderError } from 'inferno-router'; + +const BACKEND_HOST = 'http://localhost:1234'; + +export default class ContentPage extends Component { + static async fetchData({ params, request }) { + const pageSlug = params.id; + + const fetchOptions: RequestInit = { + headers: { + Accept: 'application/json', + }, + signal: request?.signal + }; + + return fetch(new URL(`/api/page/${params.slug}`, BACKEND_HOST), fetchOptions); + } + + render(props) { + const data = useLoaderData<{ title: string, body: string}>(props); + const err = useLoaderError<{ message: string }>(props); + + return ( + +
+

{data?.title}

+

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

+
+
+ ); + } +} 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..b58ed6381 --- /dev/null +++ b/demo/inferno-router-demo/src/pages/PageTemplate.tsx @@ -0,0 +1,25 @@ +import { Link } from 'inferno-router' + +import './PageTemplate.scss' + +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..060a8b7e7 --- /dev/null +++ b/demo/inferno-router-demo/src/pages/StartPage.tsx @@ -0,0 +1,25 @@ +import { Component } from 'inferno' +import PageTemplate from './PageTemplate' +import './StartPage.scss' + +interface IProps { + fetchData: any; +} + +export default class StartPage extends Component { + static async fetchData({ match }) { + const pageSlug = match.params.id + return []; + } + + render() { + 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..28cdcff70 --- /dev/null +++ b/demo/inferno-router-demo/src/server.ts @@ -0,0 +1,162 @@ +import * as koa from 'koa'; // koa@2 +import * as logger from 'koa-logger'; +import * as koaRouter from 'koa-router'; // koa-router@next +import * as koaStatic from 'koa-static'; +import * as koaMount from 'koa-mount'; +import { renderToString } from 'inferno-server'; +import { StaticRouter, resolveLoaders, traverseLoaders } from 'inferno-router' +import {Parcel} from '@parcel/core'; +import { createElement } from 'inferno-create-element'; + +const PORT = process.env.PORT || 3000; +const BASE_URI = `http://localhost:${PORT}`; + +// Parcel watch subscription and bundle output +// NOTE: Currently deactivated watcher (the following line and `bundler.watch` further down) +// let subscription; +let bundles; + +let bundler = new Parcel({ + // NOTE: Specifying target: { source: './src/App.tsx' } didn't work for me + entries: ['./src/App.tsx', './src/indexServer.tsx'], + defaultConfig: '@parcel/config-default', + targets: { + default: { + context: 'node', + engines: { + node: ">=18" + }, + 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' +}); + + +const app = new koa() +const api = new koaRouter() +const frontend = 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); + }, 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); + }, 1300) + }) +}); + +function renderPage(html, initialData) { + return ` + + + + Inferno Router Demo + + + + + +
${html}
+ + +` +} + +frontend.get('(/page)?/:slug?', async (ctx) => { + const location = ctx.path; + + const pathToAppJs = bundles.find(b => b.name === 'App.js' && b.env.context === 'node').filePath; + const { appFactory } = require(pathToAppJs) + const app = appFactory(); + + const loaderEntries = traverseLoaders(location, app, BASE_URI); + const initialData = await resolveLoaders(loaderEntries); + + const htmlApp = renderToString(createElement(StaticRouter, { + context: {}, + location, + initialData, + }, app)) + + ctx.body = renderPage(htmlApp, initialData); +}) + + +/** + * Mount all the routes for Koa to handle + */ +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) { +// // 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); + } + + 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..47912e2c0 --- /dev/null +++ b/demo/inferno-router-demo/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2020", + "moduleResolution": "Node", + "jsx": "preserve", + "lib": [ + "dom", + "es2020" + ], + "types": [ + "inferno", + "node" + ], + } +} diff --git a/fixtures/browser/package-lock.json b/fixtures/browser/package-lock.json index 4f08e7519..80717881a 100644 --- a/fixtures/browser/package-lock.json +++ b/fixtures/browser/package-lock.json @@ -6756,19 +6756,6 @@ "node": ">= 0.6" } }, - "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=12.20" - } - }, "node_modules/ua-parser-js": { "version": "0.7.35", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz", diff --git a/packages/inferno-router/README.md b/packages/inferno-router/README.md index d1e5ee8c8..36c989feb 100644 --- a/packages/inferno-router/README.md +++ b/packages/inferno-router/README.md @@ -1,6 +1,6 @@ # inferno-router -Inferno Router is a routing library for [Inferno](https://github.com/infernojs/inferno). It is a port of [react-router 4](https://reacttraining.com/react-router/). +Inferno Router is a routing library for [Inferno](https://github.com/infernojs/inferno). It is a port of [react-router 4](https://v5.reactrouter.com/web/guides/quick-start) (later updated to v5). ## Install @@ -10,16 +10,28 @@ npm install inferno-router ## Features -Same as react-router v4, except react-native support which we have tested at this point. +Same as react-router v4 (later updated to v5), except react-native support. -See official react-router [documentation](https://reacttraining.com/react-router/native/guides/philosophy) +See official react-router [documentation](https://v5.reactrouter.com/web/guides/philosophy) +Features added from react-router@5: +- NavLink supports passing function to className-attibute +- NavLink supports passing function to style-attibute + +Features added from react-router@6: +- Async data fetching before navigation using [`loader`-attribute](https://reactrouter.com/en/main/route/loader). See [demo](https://github.com/infernojs/inferno/tree/master/demo/inferno-router-demo). + +The following features aren't supported yet: +- download progress support +- form submission +- redirect support +- not exposing response headers, type or status code to render method ## Client side usage ```js import { render } from 'inferno'; -import { BrowserRouter, Route, Link } from 'inferno-router'; +import { BrowserRouter, Route, Link, useLoaderData, useLoaderError } from 'inferno-router'; const Home = () => (
@@ -27,11 +39,17 @@ const Home = () => (
); -const About = () => ( -
-

About

-
-); +const About = (props) => { + const data = useLoaderData(props); + const err = useLoaderError(props); + + return ( +
+

About

+

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

+
+ ) +}; const Topic = ({ match }) => (
@@ -77,7 +95,7 @@ const MyWebsite = () => (
- + fetch(new URL('/api/about', BACKEND_HOST))} />
diff --git a/packages/inferno-router/__tests__/BrowserRouter.spec.jsx b/packages/inferno-router/__tests__/BrowserRouter.spec.tsx similarity index 70% rename from packages/inferno-router/__tests__/BrowserRouter.spec.jsx rename to packages/inferno-router/__tests__/BrowserRouter.spec.tsx index fcb081e11..0c1f563bf 100644 --- a/packages/inferno-router/__tests__/BrowserRouter.spec.jsx +++ b/packages/inferno-router/__tests__/BrowserRouter.spec.tsx @@ -5,7 +5,7 @@ describe('BrowserRouter (jsx)', () => { it('puts history on context.router', () => { const node = document.createElement('div'); let history; - const ContextChecker = (props, context) => { + const ContextChecker = (_props, context) => { history = context.router.history; return null; }; @@ -24,18 +24,19 @@ describe('BrowserRouter (jsx)', () => { const node = document.createElement('div'); const history = {}; - spyOn(console, 'error'); + const consoleSpy = spyOn(console, 'error'); + // @ts-ignore render(, node); - expect(console.error).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledTimes(1); // browser only? - expect(console.error.calls.argsFor(0)[0]).toContain(' ignores the history prop'); + expect(consoleSpy.calls.argsFor(0)[0]).toContain(' ignores the history prop'); // node only? - //expect(console.error).toHaveBeenCalledWith( + // expect(console.error).toHaveBeenCalledWith( // expect.stringContaining(' ignores the history prop') - //) + // ) }); }); diff --git a/packages/inferno-router/__tests__/HashRouter.spec.jsx b/packages/inferno-router/__tests__/HashRouter.spec.tsx similarity index 70% rename from packages/inferno-router/__tests__/HashRouter.spec.jsx rename to packages/inferno-router/__tests__/HashRouter.spec.tsx index f244c8e2c..8d2457d8e 100644 --- a/packages/inferno-router/__tests__/HashRouter.spec.jsx +++ b/packages/inferno-router/__tests__/HashRouter.spec.tsx @@ -4,7 +4,7 @@ import { HashRouter } from 'inferno-router'; describe('A ', () => { it('puts history on context.router', () => { let history; - const ContextChecker = (props, context) => { + const ContextChecker = (_props, context) => { history = context.router.history; return null; }; @@ -25,11 +25,12 @@ describe('A ', () => { const history = {}; const node = document.createElement('div'); - spyOn(console, 'error'); + const consoleSpy = spyOn(console, 'error'); + // @ts-ignore render(, node); - expect(console.error).toHaveBeenCalledTimes(1); - expect(console.error.calls.argsFor(0)[0]).toContain(' ignores the history prop'); + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy.calls.argsFor(0)[0]).toContain(' ignores the history prop'); }); }); diff --git a/packages/inferno-router/__tests__/Link.ext.spec.jsx b/packages/inferno-router/__tests__/Link.ext.spec.tsx similarity index 100% rename from packages/inferno-router/__tests__/Link.ext.spec.jsx rename to packages/inferno-router/__tests__/Link.ext.spec.tsx diff --git a/packages/inferno-router/__tests__/Link.spec.jsx b/packages/inferno-router/__tests__/Link.spec.tsx similarity index 93% rename from packages/inferno-router/__tests__/Link.spec.jsx rename to packages/inferno-router/__tests__/Link.spec.tsx index b54acf42e..301630c4f 100644 --- a/packages/inferno-router/__tests__/Link.spec.jsx +++ b/packages/inferno-router/__tests__/Link.spec.tsx @@ -14,7 +14,7 @@ describe('Link (jsx)', () => { document.body.removeChild(node); }); - it('accepts a location "to" prop', () => { + it('accepts a location `to` prop', () => { render( link @@ -34,7 +34,7 @@ describe('Link (jsx)', () => { }); it('exposes its ref via an innerRef prop', (done) => { - const node = document.createElement('div'); + const testNode = document.createElement('div'); const refCallback = (n) => { expect(n.tagName).toEqual('A'); done(); @@ -46,10 +46,10 @@ describe('Link (jsx)', () => { link , - node + testNode ); - expect(node.textContent).toEqual('link'); + expect(testNode.textContent).toEqual('link'); }); }); @@ -103,9 +103,9 @@ describe('A underneath a ', () => { it('accepts an object `to` prop', () => { const to = { + hash: '#the-hash', pathname: '/the/path', search: 'the=query', - hash: '#the-hash' }; render( @@ -129,14 +129,14 @@ describe('A underneath a ', () => { const clickHandler = jasmine.createSpy(); const to = { + hash: '#the-hash', pathname: '/the/path', search: 'the=query', - hash: '#the-hash', state: { test: 'ok' } }; class ContextChecker extends Component { - getChildContext() { + public getChildContext() { const { context } = this; context.router.history = memoryHistoryFoo; @@ -145,7 +145,7 @@ describe('A underneath a ', () => { }; } - render({ children }) { + public render({ children }) { return children; } } diff --git a/packages/inferno-router/__tests__/MemoryRouter.spec.jsx b/packages/inferno-router/__tests__/MemoryRouter.spec.tsx similarity index 89% rename from packages/inferno-router/__tests__/MemoryRouter.spec.jsx rename to packages/inferno-router/__tests__/MemoryRouter.spec.tsx index 08440fa2e..1472b7b1c 100644 --- a/packages/inferno-router/__tests__/MemoryRouter.spec.jsx +++ b/packages/inferno-router/__tests__/MemoryRouter.spec.tsx @@ -17,7 +17,7 @@ describe('A ', () => { it('puts history on context.router', () => { let history; - const ContextChecker = (props, context) => { + const ContextChecker = (_props, context) => { history = context.router.history; return null; }; @@ -42,12 +42,13 @@ describe('A ', () => { const history = {}; const node = document.createElement('div'); - spyOn(console, 'error'); + const consoleSpy = spyOn(console, 'error'); + // @ts-ignore render(, node); - expect(console.error).toHaveBeenCalledTimes(1); - expect(console.error.calls.argsFor(0)[0]).toContain(' ignores the history prop'); + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy.calls.argsFor(0)[0]).toContain(' ignores the history prop'); }); it('Should be possible to render multiple sub routes, Github #1360', () => { diff --git a/packages/inferno-router/__tests__/Prompt.spec.jsx b/packages/inferno-router/__tests__/Prompt.spec.tsx similarity index 89% rename from packages/inferno-router/__tests__/Prompt.spec.jsx rename to packages/inferno-router/__tests__/Prompt.spec.tsx index 0f6b2bf76..788b07a34 100644 --- a/packages/inferno-router/__tests__/Prompt.spec.jsx +++ b/packages/inferno-router/__tests__/Prompt.spec.tsx @@ -40,29 +40,32 @@ describe('A ', () => { let promptWhen; class App extends Component { + private ref: any; + public state: any; + constructor() { super(); this.state = { when: true }; promptWhen = this._setActive = this._setActive.bind(this); } - _setActive() { + private _setActive() { this.setState({ when: false }); } - componentWillUpdate(nextProps, nextState) { + public componentWillUpdate(_nextProps, nextState) { expect(this.ref.unblock).toBeTruthy(); expect(this.state.when).toBe(true); expect(nextState.when).toBe(false); } - componentDidUpdate() { + public componentDidUpdate() { expect(this.ref.unblock).toBeFalsy(); } - render() { + public render() { return (this.ref = c)} />; } } @@ -83,6 +86,7 @@ describe('A ', () => { const node = document.createElement('div'); expect(() => { + // @ts-ignore render(, node); }).toThrow(); }); diff --git a/packages/inferno-router/__tests__/Route.spec.jsx b/packages/inferno-router/__tests__/Route.spec.tsx similarity index 90% rename from packages/inferno-router/__tests__/Route.spec.jsx rename to packages/inferno-router/__tests__/Route.spec.tsx index 40cddd72b..ba56bf56a 100644 --- a/packages/inferno-router/__tests__/Route.spec.jsx +++ b/packages/inferno-router/__tests__/Route.spec.tsx @@ -125,19 +125,19 @@ describe('', () => { it('renders its return value', () => { const TEXT = 'Mrs. Kato'; - const node = document.createElement('div'); + const testNode = document.createElement('div'); render(
{TEXT}
} />
, - node + testNode ); - expect(node.innerHTML).toContain(TEXT); + expect(testNode.innerHTML).toContain(TEXT); }); it('receives { match, location, history } props', () => { - let actual = null; + let actual: any = null; render( @@ -146,9 +146,9 @@ describe('', () => { node ); - expect(actual.history).toBe(history); - expect(typeof actual.match).toBe('object'); - expect(typeof actual.location).toBe('object'); + expect(actual?.history).toBe(history); + expect(typeof actual?.match).toBe('object'); + expect(typeof actual?.location).toBe('object'); }); }); @@ -158,20 +158,20 @@ describe('', () => { it('renders the component', () => { const TEXT = 'Mrs. Kato'; - const node = document.createElement('div'); + const testNode = document.createElement('div'); const Home = () =>
{TEXT}
; render( , - node + testNode ); - expect(node.innerHTML).toContain(TEXT); + expect(testNode.innerHTML).toContain(TEXT); }); it('receives { match, location, history } props', () => { - let actual = null; + let actual: any = null; const Component = (props) => (actual = props) && null; render( @@ -181,9 +181,9 @@ describe('', () => { node ); - expect(actual.history).toBe(history); - expect(typeof actual.match).toBe('object'); - expect(typeof actual.location).toBe('object'); + expect(actual?.history).toBe(history); + expect(typeof actual?.match).toBe('object'); + expect(typeof actual?.location).toBe('object'); }); }); @@ -193,34 +193,34 @@ describe('', () => { it('renders a function', () => { const TEXT = 'Mrs. Kato'; - const node = document.createElement('div'); + const testNode = document.createElement('div'); render(
{TEXT}
} />
, - node + testNode ); - expect(node.innerHTML).toContain(TEXT); + expect(testNode.innerHTML).toContain(TEXT); }); it('renders a child element', () => { const TEXT = 'Mrs. Kato'; - const node = document.createElement('div'); + const testNode = document.createElement('div'); render(
{TEXT}
, - node + testNode ); - expect(node.innerHTML).toContain(TEXT); + expect(testNode.innerHTML).toContain(TEXT); }); it('receives { match, location, history } props', () => { - let actual = null; + let actual: any = null; render( @@ -229,9 +229,9 @@ describe('', () => { node ); - expect(actual.history).toBe(history); - expect(typeof actual.match).toBe('object'); - expect(typeof actual.location).toBe('object'); + expect(actual?.history).toBe(history); + expect(typeof actual?.match).toBe('object'); + expect(typeof actual?.location).toBe('object'); }); }); diff --git a/packages/inferno-router/__tests__/Router.spec.jsx b/packages/inferno-router/__tests__/Router.spec.tsx similarity index 97% rename from packages/inferno-router/__tests__/Router.spec.jsx rename to packages/inferno-router/__tests__/Router.spec.tsx index d482e84a7..e3a24a69c 100644 --- a/packages/inferno-router/__tests__/Router.spec.jsx +++ b/packages/inferno-router/__tests__/Router.spec.tsx @@ -21,6 +21,7 @@ describe('A ', () => { it('does not throw an error', () => { const node = document.createElement('div'); expect(() => { + // @ts-ignore render(, node); }).not.toThrow(); }); @@ -28,7 +29,7 @@ describe('A ', () => { describe('context', () => { let rootContext; - const ContextChecker = (props, context) => { + const ContextChecker = (_props, context) => { rootContext = context; return null; }; diff --git a/packages/inferno-router/__tests__/Switch.spec.tsx b/packages/inferno-router/__tests__/Switch.spec.tsx index 104e6826b..6e6fbf8db 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 { + public 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/__tests__/SwitchMount.spec.jsx b/packages/inferno-router/__tests__/SwitchMount.spec.tsx similarity index 92% rename from packages/inferno-router/__tests__/SwitchMount.spec.jsx rename to packages/inferno-router/__tests__/SwitchMount.spec.tsx index 23e632ab4..2b5adebeb 100644 --- a/packages/inferno-router/__tests__/SwitchMount.spec.jsx +++ b/packages/inferno-router/__tests__/SwitchMount.spec.tsx @@ -9,11 +9,11 @@ describe('A ', () => { let mountCount = 0; class App extends Component { - componentWillMount() { + public componentWillMount() { mountCount++; } - render() { + public render() { return
; } } @@ -45,14 +45,15 @@ describe('A ', () => { it('Should be possible to have multiple children in Route', () => { const node = document.createElement('div'); + // @ts-ignore let mountCount = 0; class App extends Component { - componentWillMount() { + public componentWillMount() { mountCount++; } - render() { + public render() { return
; } } diff --git a/packages/inferno-router/__tests__/integration.spec.jsx b/packages/inferno-router/__tests__/integration.spec.tsx similarity index 100% rename from packages/inferno-router/__tests__/integration.spec.jsx rename to packages/inferno-router/__tests__/integration.spec.tsx diff --git a/packages/inferno-router/__tests__/issue1322.spec.jsx b/packages/inferno-router/__tests__/issue1322.spec.tsx similarity index 100% rename from packages/inferno-router/__tests__/issue1322.spec.jsx rename to packages/inferno-router/__tests__/issue1322.spec.tsx diff --git a/packages/inferno-router/__tests__/loaderOnRoute.spec.tsx b/packages/inferno-router/__tests__/loaderOnRoute.spec.tsx new file mode 100644 index 000000000..15a6325c4 --- /dev/null +++ b/packages/inferno-router/__tests__/loaderOnRoute.spec.tsx @@ -0,0 +1,636 @@ +import { render } from 'inferno'; +import { BrowserRouter, MemoryRouter, StaticRouter, Route, NavLink, useLoaderData, useLoaderError, resolveLoaders, traverseLoaders } from 'inferno-router'; +// Cherry picked relative import so we don't get node-stuff from inferno-server in browser test +import { createEventGuard, createResponse } 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) => { + 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 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); + 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('Should recieve params in loader', async () => { + const TEXT = 'bubblegum'; + const Component = (props) => { + const res = useLoaderData(props); + return

{res?.message}

{res?.slug}

+ } + const loaderFunc = async ({params: paramsIn}: any) => { + return { message: TEXT, slug: paramsIn?.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'); + }); + + it('Can abort fetch', async () => { + const abortCalls = { + nrofCalls: 0 + }; + const _abortFn = AbortController.prototype.abort; + AbortController.prototype.abort = () => { + abortCalls.nrofCalls++; + } + 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); + 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(abortCalls.nrofCalls).toEqual(1); + expect(container.querySelector('#create')).toBeNull(); + AbortController.prototype.abort = _abortFn; + }); +}); + +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, '', '/'); + }); + + 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) => { + const res = useLoaderData(props); + return

{res?.message}

+ } + + const loaderFunc = async () => { return { message: TEXT }} + + const initialData = { + '/flowers': { res: await loaderFunc(), err: undefined, } + } + + history.replaceState(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); + 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('calls json() when response is received', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "ok"; + const loaderFunc = async () => { + setDone(); + const data = { message: TEXT }; + return createResponse(data, 'json', 200); + } + + 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('calls text() when response is received', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + const TEXT = "ok"; + const loaderFunc = async () => { + setDone(); + const data = TEXT; + return createResponse(data, 'text', 200); + } + + render( + + { + const data = useLoaderData(props); + return

{data}

+ }} loader={loaderFunc} /> +
, + container + ); + + // Wait until async loader has completed + await waitForRerender(); + + expect(container.innerHTML).toContain(TEXT); + }); + +}); + +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, '', '/'); + }); + + 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) => { + 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); + }); +}); + +describe('Resolve loaders during server side rendering', () => { + it('Can resolve with single route', async () => { + const TEXT = 'bubblegum'; + const Component = (props) => { + const res = useLoaderData(props); + return

{res?.message}

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

{res?.message}

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

{res?.message}

+ } + + const loaderFuncNoHit = async () => { return { message: 'no' }} + const loaderFunc = async () => { return { message: TEXT }} + + const initialData = { + '/flowers': { res: await loaderFunc() }, + '/flowers/birds': { res: await loaderFunc() } + } + + const app = + + + + {null} + + ; + + const loaderEntries = traverseLoaders('/flowers/birds', app); + const result = await resolveLoaders(loaderEntries); + expect(result).toEqual(initialData); + }); +}) diff --git a/packages/inferno-router/__tests__/loaderWithSwitch.spec.tsx b/packages/inferno-router/__tests__/loaderWithSwitch.spec.tsx new file mode 100644 index 000000000..15a3b7681 --- /dev/null +++ b/packages/inferno-router/__tests__/loaderWithSwitch.spec.tsx @@ -0,0 +1,362 @@ +import { render, rerender } 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 { 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) => { + 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); + 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) => { + 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); + 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(); + }); + + 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(); + + // Complete any pending render and make sure we don't + // prematurely update view + rerender(); + 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); + }); + + + it('Should only render one (1) component after click with subclass of Switch', async () => { + const [setDone, waitForRerender] = createEventGuard(); + + class SubSwitch extends Switch {} + + const TEST = "ok"; + const loaderFunc = async () => { + setDone(); + return { message: TEST } + }; + + function RootComp() { + return
ROOT
; + } + + function CreateComp(props) { + const res = useLoaderData(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/__tests__/matchPath.spec.js b/packages/inferno-router/__tests__/matchPath.spec.ts similarity index 84% rename from packages/inferno-router/__tests__/matchPath.spec.js rename to packages/inferno-router/__tests__/matchPath.spec.ts index 71f709943..c1b5c0c74 100644 --- a/packages/inferno-router/__tests__/matchPath.spec.js +++ b/packages/inferno-router/__tests__/matchPath.spec.ts @@ -6,14 +6,14 @@ describe('matchPath', () => { const path = '/'; const pathname = '/'; const match = matchPath(pathname, path); - expect(match.url).toBe('/'); + expect(match?.url).toBe('/'); }); it('returns correct url at "/somewhere/else"', () => { const path = '/'; const pathname = '/somewhere/else'; const match = matchPath(pathname, path); - expect(match.url).toBe('/'); + expect(match?.url).toBe('/'); }); }); @@ -22,14 +22,14 @@ describe('matchPath', () => { const path = '/somewhere'; const pathname = '/somewhere'; const match = matchPath(pathname, path); - expect(match.url).toBe('/somewhere'); + expect(match?.url).toBe('/somewhere'); }); it('returns correct url at "/somewhere/else"', () => { const path = '/somewhere'; const pathname = '/somewhere/else'; const match = matchPath(pathname, path); - expect(match.url).toBe('/somewhere'); + expect(match?.url).toBe('/somewhere'); }); }); @@ -40,7 +40,7 @@ describe('matchPath', () => { }; const pathname = '/somewhere'; const match = matchPath(pathname, options); - expect(match.url).toBe('/somewhere'); + expect(match?.url).toBe('/somewhere'); }); it('returns sensitive url', () => { @@ -57,10 +57,10 @@ describe('matchPath', () => { describe('with no path', () => { it('matches the root URL', () => { const match = matchPath('/test-location/7', {}); - expect(match.path).toBe('/'); - expect(match.url).toBe('/'); - expect(match.isExact).toBe(false); - expect(match.params).toEqual({}); + expect(match?.path).toBe('/'); + expect(match?.url).toBe('/'); + expect(match?.isExact).toBe(false); + expect(match?.params).toEqual({}); }); }); @@ -68,13 +68,13 @@ describe('matchPath', () => { it('creates a cache entry for each exact/strict pair', () => { // true/false and false/true will collide when adding booleans const trueFalse = matchPath('/one/two', { - path: '/one/two/', exact: true, + path: '/one/two/', strict: false }); const falseTrue = matchPath('/one/two', { - path: '/one/two/', exact: false, + path: '/one/two/', strict: true }); expect(!!trueFalse).toBe(true); diff --git a/packages/inferno-router/__tests__/mobx-router.spec.jsx b/packages/inferno-router/__tests__/mobx-router.spec.tsx similarity index 84% rename from packages/inferno-router/__tests__/mobx-router.spec.jsx rename to packages/inferno-router/__tests__/mobx-router.spec.tsx index abfceccdc..523bd6846 100644 --- a/packages/inferno-router/__tests__/mobx-router.spec.jsx +++ b/packages/inferno-router/__tests__/mobx-router.spec.tsx @@ -22,17 +22,17 @@ describe('Github #1236', () => { /* This is pre-compiled from old decorator pattern */ - var _createClass = (function () { + const _createClass = (function () { function defineProperties(target, props) { - for (var i = 0; i < props.length; i++) { - var descriptor = props[i]; + for (let i = 0; i < props.length; i++) { + const descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } - return function (Constructor, protoProps, staticProps) { + return function (Constructor, protoProps, staticProps = undefined) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; @@ -42,10 +42,10 @@ describe('Github #1236', () => { function _initDefineProp(target, property, descriptor, context) { if (!descriptor) return; Object.defineProperty(target, property, { - enumerable: descriptor.enumerable, configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + value: descriptor.initializer ? descriptor.initializer.call(context) : void 0, writable: descriptor.writable, - value: descriptor.initializer ? descriptor.initializer.call(context) : void 0 }); } @@ -55,8 +55,8 @@ describe('Github #1236', () => { } } - function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) { - var desc = {}; + function _applyDecoratedDescriptor(target, property, decorators, descriptor, context?) { + let desc: any = {}; Object['ke' + 'ys'](descriptor).forEach(function (key) { desc[key] = descriptor[key]; }); @@ -70,8 +70,8 @@ describe('Github #1236', () => { desc = decorators .slice() .reverse() - .reduce(function (desc, decorator) { - return decorator(target, property, desc) || desc; + .reduce(function (descIn, decorator) { + return decorator(target, property, descIn) || descIn; }, desc); if (context && desc.initializer !== void 0) { @@ -86,16 +86,17 @@ describe('Github #1236', () => { return desc; } - var _desc, _value, _class, _descriptor; - var SearchStore = + let _class; + let _descriptor; + const SearchStore = ((_class = (function () { - function SearchStore() { - _classCallCheck(this, SearchStore); + function TestSearchStore() { + _classCallCheck(this, TestSearchStore); _initDefineProp(this, 'query', _descriptor, this); } - _createClass(SearchStore, [ + _createClass(TestSearchStore, [ { key: 'doSearch', value: function doSearch(search) { @@ -104,7 +105,7 @@ describe('Github #1236', () => { } ]); - return SearchStore; + return TestSearchStore; })()), ((_descriptor = _applyDecoratedDescriptor(_class.prototype, 'query', [observable], { enumerable: true, @@ -116,24 +117,24 @@ describe('Github #1236', () => { _class); let SearchPage = observer( - class SearchPage extends Component { + class TestSearchPage extends Component { constructor(props) { super(props); this.doSearch = this.doSearch.bind(this); } - componentWillReceiveProps(nextProps) { + public componentWillReceiveProps(nextProps) { nextProps.searchStore.doSearch(nextProps.location.search); } - doSearch(e) { + public doSearch(e) { e.preventDefault(); const nextLoc = this.context.router.history.location.pathname + '?q=test'; this.context.router.history.push(nextLoc); } - render({ searchStore }) { - let showView = searchStore['query'] ? 'results' : 'default'; + public render({ searchStore: searchStoreIn }: any) { + const showView = searchStoreIn.query ? 'results' : 'default'; return (
@@ -151,7 +152,7 @@ describe('Github #1236', () => { SearchPage = inject('searchStore')(SearchPage); class SearchResult extends Component { - render() { + public render() { return
results
; } } diff --git a/packages/inferno-router/__tests__/testUtils.ts b/packages/inferno-router/__tests__/testUtils.ts new file mode 100644 index 000000000..56ab6b255 --- /dev/null +++ b/packages/inferno-router/__tests__/testUtils.ts @@ -0,0 +1,94 @@ +import { rerender } from 'inferno'; + +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 resolving promises to finish + await new Promise((resolved) => setTimeout(resolved, 0)); + + // Allow render loop to complete + rerender() + } + + return [ + markEventCompleted, + waitForEventToTriggerRender + ] +} + +export function createResponse(data, type, status) { + switch (type) { + case 'json': + return createJsonResponse(data, status); + case 'text': + if (typeof data !== 'string') throw new Error('Type "text" requires string as data'); + return createTextResponse(data, status); + default: + throw new Error('Unknown value for param "type"'); + } +} + +function createJsonResponse(data, status = 200) { + return new MockResponse(data, 'application/json', status); +} + +function createTextResponse(data, status = 200) { + return new MockResponse(data, 'text/plain', status); +} + +class MockResponseHeaders { + private _headers; + + constructor(headers) { + this._headers = headers; + } + + public get(key) { + return this._headers[key]; + } +} + +class MockResponse { + private _data: any; + private _contentType: string; + private _statusCode: number; + + constructor(data, contentType = 'application/json', statusCode = 200) { + this._data = data; + this._contentType = contentType; + this._statusCode = statusCode; + } + + get headers() { + return new MockResponseHeaders({ + 'Content-Type': this._contentType + }); + } + + get ok() { + return this._statusCode >= 200 && this._statusCode < 300; + } + + get type () { + return 'basic'; + } + + get status () { + return this._statusCode; + } + + public json() { + return Promise.resolve(this._data); + } + + public text() { + return Promise.resolve(this._data); + } +} \ No newline at end of file diff --git a/packages/inferno-router/__tests__/withRouter.spec.jsx b/packages/inferno-router/__tests__/withRouter.spec.tsx similarity index 96% rename from packages/inferno-router/__tests__/withRouter.spec.jsx rename to packages/inferno-router/__tests__/withRouter.spec.tsx index e13b40635..f9aac9bf3 100644 --- a/packages/inferno-router/__tests__/withRouter.spec.jsx +++ b/packages/inferno-router/__tests__/withRouter.spec.tsx @@ -78,7 +78,7 @@ describe('withRouter', () => { it('exposes the instance of the wrapped component via wrappedComponentRef', () => { class WrappedComponent extends Component { - render() { + public render() { return null; } } @@ -97,15 +97,16 @@ describe('withRouter', () => { it('hoists non-react statics from the wrapped component', () => { class TestComponent extends Component { - static foo() { + public static hello: string = 'world'; + + public static foo() { return 'bar'; } - render() { + public render() { return null; } } - TestComponent.hello = 'world'; const decorated = withRouter(TestComponent); 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/BrowserRouter.ts b/packages/inferno-router/src/BrowserRouter.ts index e580f9534..4a886e120 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 { + initialData?: 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(); } public render(): VNode { return createComponentVNode(VNodeFlags.ComponentClass, Router, { children: this.props.children, - history: this.history + history: this.history, + initialData: this.props.initialData, }); } } diff --git a/packages/inferno-router/src/Link.ts b/packages/inferno-router/src/Link.ts index adcb3cc29..0314e922c 100644 --- a/packages/inferno-router/src/Link.ts +++ b/packages/inferno-router/src/Link.ts @@ -14,7 +14,7 @@ export interface ILinkProps { target?: string; className?: string; replace?: boolean; - to?: string | Location; + to?: string | Partial; // INVESTIGATE: Should Partial be Partial instead since this is what is returned by history.parsePath? innerRef?: any; } diff --git a/packages/inferno-router/src/MemoryRouter.ts b/packages/inferno-router/src/MemoryRouter.ts index 0841a0f56..64d7817e1 100644 --- a/packages/inferno-router/src/MemoryRouter.ts +++ b/packages/inferno-router/src/MemoryRouter.ts @@ -1,12 +1,13 @@ 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[]; initialIndex?: number; + initialData?: Record; getUserConfirmation?: () => {}; keyLength?: number; children: Component[]; @@ -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, + initialData: this.props.initialData, }); } } diff --git a/packages/inferno-router/src/Route.ts b/packages/inferno-router/src/Route.ts index a50bd1366..ab05fbe97 100644 --- a/packages/inferno-router/src/Route.ts +++ b/packages/inferno-router/src/Route.ts @@ -2,17 +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, isNullOrUndef, isUndefined } from 'inferno-shared'; import type { History, Location } from 'history'; +import type { RouterContext, TContextRouter, 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; @@ -20,14 +23,15 @@ export interface RouteComponentProps

{ } export interface IRouteProps { - computedMatch?: any; // private, from + computedMatch?: Match | null; // private, from path?: string; 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; + location?: Pick; children?: ((props: RouteComponentProps) => InfernoNode) | InfernoNode; } @@ -35,47 +39,53 @@ export interface IRouteProps { * The public API for matching a single path and rendering. */ type RouteState = { - match: boolean; -}; + 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 + constructor(props: IRouteProps, context: RouterContext) { + super(props, context); + const match = this.computeMatch(props, context.router); + this.state = { + __loaderData__: match?.loaderData, + match, }; + } - return { - router: childContext + 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, }; - } - constructor(props?: any, context?: any) { - super(props, context); - this.state = { - match: this.computeMatch(props, context.router) + return { + router }; } - public computeMatch({ computedMatch, location, path, strict, exact, sensitive }, router) { - if (computedMatch) { + public computeMatch({ computedMatch, ...props }: IRouteProps, router: TContextRouter): Match | null { + if (!isNullOrUndef(computedMatch)) { // already computed the match for us return computedMatch; } + 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 } = router; - const pathname = (location || route.location).pathname; + const { route, initialData } = router; // This is the parent route + const pathname = (props.location || route.location).pathname; - return path ? matchPath(pathname, { path, strict, exact, sensitive }) : route.match; + return path ? matchPath(pathname, { path, strict, exact, sensitive, loader, initialData }) : route.match; } - public componentWillReceiveProps(nextProps, nextContext) { + public componentWillReceiveProps(nextProps, nextContext: { router: TContextRouter }) { if (process.env.NODE_ENV !== 'production') { warning( !(nextProps.location && !this.props.location), @@ -87,18 +97,25 @@ 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); this.setState({ - match: this.computeMatch(nextProps, nextContext.router) + __loaderData__: match?.loaderData, + match, }); } - public render() { - const { match } = this.state!; - const { children, component, render } = this.props; - const { history, route, staticContext } = this.context.router; - const location = this.props.location || route.location; - const props = { match, location, history, staticContext }; + 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 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') { @@ -106,16 +123,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 50cfb9cf9..62c654d10 100644 --- a/packages/inferno-router/src/Router.ts +++ b/packages/inferno-router/src/Router.ts @@ -1,46 +1,90 @@ import { Component, InfernoNode } from 'inferno'; +import { combineFrom, isUndefined } from 'inferno-shared'; +import type { History, Location } from 'history'; import { warning } from './utils'; -import { combineFrom } from 'inferno-shared'; -import type { History } from 'history'; +import { Match } from './Route'; +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; +} + +/** + * Loader returns Response and throws error + */ +export type TLoader

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

) => Promise; + +export type TLoaderData = { + res?: Res; + err?: Err; +} + +type TInitialData = Record; // key is route path to allow resolving export interface IRouterProps { history: History; children: InfernoNode; + 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; + private _loaderFetchControllers: AbortController[] = []; + private _loaderIteration = 0; - 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) + initialData: this.props.initialData, + match, }; } - public getChildContext() { - const childContext: any = combineFrom(this.context.router, null); - - childContext.history = this.props.history; - childContext.route = { - location: childContext.history.location, - match: this.state?.match + 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. }; - + router.initialData = this.state?.initialData; // this is a dictionary of all data available return { - router: childContext + router }; } - public computeMatch(pathname) { + + public computeMatch(pathname): Match<{}> { return { isExact: pathname === '/', + loader: undefined, params: {}, path: '/', - url: '/' + url: '/', }; } @@ -51,10 +95,50 @@ 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) - }); + const match = this.computeMatch(history.location.pathname); + this._matchAndResolveLoaders(match); }); + + // First execution of loaders + if (isUndefined(this.props.initialData)) { + this._matchAndResolveLoaders(this.state?.match); + } + } + + private _matchAndResolveLoaders(match?: Match) { + // Keep track of invokation order + // Bumping the counter needs to be done first because calling abort + // triggers promise to resolve with "aborted" + this._loaderIteration = (this._loaderIteration + 1) % 10000; + const currentIteration = this._loaderIteration; + + + 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); + + resolveLoaders(loaderEntries) + .then((initialData) => { + // On multiple pending navigations, only update interface with last + // in case they resolve out of order + if (currentIteration === this._loaderIteration) { + this.setState({ + initialData, + match, + }); + } + }); } public componentWillUnmount() { diff --git a/packages/inferno-router/src/StaticRouter.ts b/packages/inferno-router/src/StaticRouter.ts index 9933173aa..590ada929 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 { + initialData?: 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 + initialData: this.props.initialData, + staticContext: this.props.context, } }; } @@ -70,7 +72,7 @@ export class StaticRouter extends Component, S> { location: stripBasename(basename, createLocation(location)), push: this.handlePush, replace: this.handleReplace - } + }, }) as any ); } @@ -101,7 +103,7 @@ function addBasename(basename, location) { return combineFrom(location, { pathname: addLeadingSlash(basename) + location.pathname }); } -function stripBasename(basename, location) { +function stripBasename(basename: string, location) { if (!basename) { return location; } @@ -112,7 +114,7 @@ function stripBasename(basename, location) { return location; } - return combineFrom(location, { pathname: location.pathname.substr(base.length) }); + return combineFrom(location, { pathname: location.pathname.substring(base.length) }); } function createLocation(location) { diff --git a/packages/inferno-router/src/Switch.ts b/packages/inferno-router/src/Switch.ts index ce7577353..fb8ee071a 100644 --- a/packages/inferno-router/src/Switch.ts +++ b/packages/inferno-router/src/Switch.ts @@ -2,76 +2,88 @@ 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 { IRouteProps, Match } from './Route'; +import { RouterContext } from './Router'; -function getMatch({ path, exact, strict, sensitive, from }, route, location) { - const pathProp = path || from; +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 }) : route.match; + return path ? matchPath(pathname, { path, exact, strict, sensitive, loader, initialData }) : route.match; } -function extractMatchFromChildren(children, route, location) { - let match; - let _child: any; - +function extractFirstMatchFromChildren(pathname: string, children, 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 = extractFirstMatchFromChildren(pathname, children[i], router); + if (nestedMatch.match) return nestedMatch; } - } else { - match = getMatch((children as any).props, route, location); - _child = children; + return {}; } - return { match, _child }; + return { + _child: children, + match: getMatch(pathname, (children as any).props, router), + } } -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; +type SwitchState = { + match: Match; + _child: any; +} + +export class Switch extends Component { + constructor(props, context: RouterContext) { + super(props, context); + + if (process.env.NODE_ENV !== 'production') { + invariant(context.router, 'You should not use outside a '); + } + + const { router } = context; + const { location, children } = props; + const pathname = (location || router.route.location).pathname; + const { match, _child } = extractFirstMatchFromChildren(pathname, children, router); + + this.state = { + _child, + match, + } + } + + public componentWillReceiveProps(nextProps: IRouteProps, nextContext: RouterContext): 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.' + ); + } + + const { router } = nextContext; + const { location, children } = nextProps; + const pathname = (location || router.route.location).pathname; + const { match, _child } = extractFirstMatchFromChildren(pathname, children, router); + + this.setState({ match, _child }) + } + + public render({ children, location }: IRouteProps, { match, _child }: SwitchState, context: RouterContext): VNode | null { if (isInvalid(children)) { return null; } - const { match, _child } = extractMatchFromChildren(children, route, location); - + if (match) { + location ??= context.router.route.location; return createComponentVNode(_child.flags, _child.type, combineFrom(_child.props, { location, computedMatch: match })); } 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.' - ); - }; -} diff --git a/packages/inferno-router/src/helpers.ts b/packages/inferno-router/src/helpers.ts new file mode 100644 index 000000000..b4809c73e --- /dev/null +++ b/packages/inferno-router/src/helpers.ts @@ -0,0 +1,9 @@ +import { TLoaderData } from "./Router"; + +export function useLoaderData(props: { __loaderData__: TLoaderData }): Res | undefined { + return props.__loaderData__?.res; +} + +export function useLoaderError(props: { __loaderData__: TLoaderData }): Err | undefined { + 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..1e2ce2b7d 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 './resolveLoaders'; +export * from './helpers'; 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..b6894a9da 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, initialData = {} } = 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 loaderData = initialData[path]; + const [url, ...values] = match; const isExact = pathname === url; @@ -49,11 +52,13 @@ export function matchPath(pathname, options: any) { return { isExact, // whether or not we matched exactly + loader, + loaderData, params: keys.reduce((memo, key, index) => { memo[key.name] = values[index]; 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 }; } diff --git a/packages/inferno-router/src/resolveLoaders.ts b/packages/inferno-router/src/resolveLoaders.ts new file mode 100644 index 000000000..7450e8ee6 --- /dev/null +++ b/packages/inferno-router/src/resolveLoaders.ts @@ -0,0 +1,235 @@ +import { isNullOrUndef, isUndefined } from "inferno-shared"; +import { matchPath } from "./matchPath"; +import type { TLoaderData, TLoaderProps } from "./Router"; +import { Switch } from "./Switch"; + +export function resolveLoaders(loaderEntries: TLoaderEntry[]): Promise> { + const promises = loaderEntries.map((({ path, params, request, loader }) => { + return resolveEntry(path, params, request, loader); + })); + return Promise.all(promises).then((result) => { + return Object.fromEntries(result); + }); +} + +type TLoaderEntry = { + path: string, + params: Record, + request: Request, + controller: AbortController, + loader: (TLoaderProps) => Promise, +} + +export function traverseLoaders(location: string, tree: any, base?: string): TLoaderEntry[] { + return _traverseLoaders(location, tree, base, false); +} + +// Optionally pass base param during SSR to get fully qualified request URI passed to loader in request param +function _traverseLoaders(location: string, tree: any, base?: string, parentIsSwitch = false): TLoaderEntry[] { + // Make sure tree isn't null + if (isNullOrUndef(tree)) return []; + + if (Array.isArray(tree)) { + let hasMatch = false; + const entriesOfArr = tree.reduce((res, node) => { + if (parentIsSwitch && hasMatch) return res; + + const outpArr = _traverseLoaders(location, node, base, node?.type?.prototype instanceof Switch); + if (parentIsSwitch && outpArr.length > 0) { + hasMatch = true; + } + return [...res, ...outpArr]; + }, []); + return entriesOfArr; + } + + + const outp: TLoaderEntry[] = []; + let isRouteButNotMatch = false; + if (tree.props) { + // 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, { + exact, + path, + sensitive, + strict, + }); + + // So we can bail out of recursion it this was a Route which didn't match + isRouteButNotMatch = !match; + + // Add any loader on this node (but only on the VNode) + if (match && !tree.context && tree.props?.loader && tree.props?.path) { + const { params } = match; + const controller = new AbortController(); + const request = createClientSideRequest(location, controller.signal, base); + + outp.push({ + controller, + loader: tree.props.loader, + params, + path, + request, + }) + } + } + + // Traverse ends here + if (isRouteButNotMatch) return outp; + + // Traverse children + const entries = _traverseLoaders(location, tree.children || tree.props?.children, base, tree.type?.prototype instanceof Switch); + return [...outp, ...entries]; +} + +function resolveEntry(path, params, request, loader): Promise { + return loader({ params, request }) + .then((res: any) => { + // This implementation is based on: + // https://github.com/remix-run/react-router/blob/4f3ad7b96e6e0228cc952cd7eafe2c265c7393c7/packages/router/router.ts#L2787-L2879 + + // Check if regular data object (from tests or initialData) + if (typeof res.json !== 'function') { + return [path, { res }]; + } + + const contentType = res.headers.get("Content-Type"); + let dataPromise: Promise; + // Check between word boundaries instead of startsWith() due to the last + // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type + if (contentType && /\bapplication\/json\b/.test(contentType)) { + dataPromise = res.json() + } else { + dataPromise = res.text(); + } + + return dataPromise.then((body) => { + // We got a JSON error + if (!res.ok) { + return [path, { err: body }] + } + // We got JSON response + return [path, { res: body }] + }) + // Could not parse JSON + .catch((err) => [path, { err }]) + }) + // Could not fetch data + .catch((err) => [path, { err }]); +} + +// 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; +} + + +const inBrowser = typeof window === 'undefined'; +function createClientSideRequest( + location: string | Location, + signal: AbortSignal, + // submission?: Submission + base?: string +): Request { + const url = inBrowser || !isUndefined(base) ? createClientSideURL(location, base) : location.toString(); + const 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 (process.env.NODE_ENV === 'test' && typeof Request === 'undefined') { + // @ts-ignore + global.Request = class Request { + public url; + public signal; + constructor(_url: URL | string, _init: RequestInit) { + this.url = _url; + this.signal = _init.signal; + } + } + } + + // Content-Type is inferred (https://fetch.spec.whatwg.org/#dom-request) + return new Request(url, init); +} + +/** + * Parses a string URL path into its separate pathname, search, and hash components. + */ + +export function createClientSideURL(location: Location | string, base?: string): URL { + if (base === undefined && typeof window !== 'undefined') { + // 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 + base = window?.location?.origin !== "null" + ? window.location.origin + : window.location.href; + } + + const url = new URL(location.toString(), base); + url.hash = ''; + return url; +} + +// 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 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..6ae9f67a4 --- /dev/null +++ b/packages/inferno-server/__tests__/loaderOnRoute.spec.server.jsx @@ -0,0 +1,49 @@ +import { render } from 'inferno'; +import { renderToString } from 'inferno-server'; +import { BrowserRouter, StaticRouter, Route, useLoaderData, resolveLoaders, traverseLoaders } 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 routes = [ + , + , + , + ] + + const loaderEntries = traverseLoaders('/birds', routes); + const initialData = await resolveLoaders(loaderEntries); + + // 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/__tests__/forward-ref.spec.tsx b/packages/inferno/__tests__/forward-ref.spec.tsx index 9fd2d28ba..f37cc9f15 100644 --- a/packages/inferno/__tests__/forward-ref.spec.tsx +++ b/packages/inferno/__tests__/forward-ref.spec.tsx @@ -128,7 +128,7 @@ describe('Forward Ref', () => { describe('Validations', () => { it('Should log error if input is: Component, vNode or invalid value', () => { - const spy = spyOn(console, 'error'); + const consoleSpy = spyOn(console, 'error'); class Foobar extends Component {} @@ -136,40 +136,40 @@ describe('Forward Ref', () => { // @ts-expect-error forwardRef(false); - expect(spy.calls.count()).toEqual(++i); + expect(consoleSpy.calls.count()).toEqual(++i); // @ts-expect-error forwardRef(true); - expect(spy.calls.count()).toEqual(++i); + expect(consoleSpy.calls.count()).toEqual(++i); // @ts-expect-error forwardRef({}); - expect(spy.calls.count()).toEqual(++i); + expect(consoleSpy.calls.count()).toEqual(++i); // @ts-expect-error forwardRef('asd'); - expect(spy.calls.count()).toEqual(++i); + expect(consoleSpy.calls.count()).toEqual(++i); // @ts-expect-error forwardRef(undefined); - expect(spy.calls.count()).toEqual(++i); + expect(consoleSpy.calls.count()).toEqual(++i); // @ts-expect-error forwardRef(8); - expect(spy.calls.count()).toEqual(++i); + expect(consoleSpy.calls.count()).toEqual(++i); // TODO: improve forward ref typings forwardRef(
1
); - expect(spy.calls.count()).toEqual(++i); + expect(consoleSpy.calls.count()).toEqual(++i); forwardRef(); - expect(spy.calls.count()).toEqual(++i); + expect(consoleSpy.calls.count()).toEqual(++i); // This is ok forwardRef(function () { return
1
; }); - expect(spy.calls.count()).toEqual(i); + expect(consoleSpy.calls.count()).toEqual(i); }); }); diff --git a/packages/inferno/__tests__/link.spec.tsx b/packages/inferno/__tests__/link.spec.tsx index 468b11cdc..359a2403e 100644 --- a/packages/inferno/__tests__/link.spec.tsx +++ b/packages/inferno/__tests__/link.spec.tsx @@ -17,37 +17,37 @@ describe('Links', () => { describe('javascript href', function () { it('Should log warning when rendering link starting with javascript::', function () { - spyOn(console, 'error'); + const consoleSpy = spyOn(console, 'error'); render(test, container); - expect(console.error).toHaveBeenCalledTimes(1); - expect(console.error).toHaveBeenCalledWith( + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledWith( 'Rendering links with javascript: URLs is not recommended. Use event handlers instead if you can. Inferno was passed "javascript:foobar".' ); expect(container.innerHTML).toEqual('test'); }); it('Should allow patching link to null', function () { - spyOn(console, 'error'); + const consoleSpy = spyOn(console, 'error'); render(test, container); - expect(console.error).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledTimes(1); render(test, container); - expect(console.error).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledTimes(1); expect(container.innerHTML).toEqual('test'); }); it('Should not log warning when rendering regular link', function () { - spyOn(console, 'error'); + const consoleSpy = spyOn(console, 'error'); render(test, container); - expect(console.error).toHaveBeenCalledTimes(0); + expect(consoleSpy).toHaveBeenCalledTimes(0); expect(container.innerHTML).toEqual('test'); }); }); diff --git a/packages/inferno/__tests__/rendering.spec.tsx b/packages/inferno/__tests__/rendering.spec.tsx index 0d9ebf336..1611f9578 100644 --- a/packages/inferno/__tests__/rendering.spec.tsx +++ b/packages/inferno/__tests__/rendering.spec.tsx @@ -99,19 +99,19 @@ describe('rendering routine', () => { }); it('should not warn when rendering into an empty container', () => { - const spy = spyOn(console, 'error'); + const consoleSpy = spyOn(console, 'error'); render(
foo
, container); expect(container.innerHTML).toBe('
foo
'); render(null, container); expect(container.innerHTML).toBe(''); - expect(spy.calls.count()).toBe(0); + expect(consoleSpy.calls.count()).toBe(0); render(
bar
, container); expect(container.innerHTML).toBe('
bar
'); - expect(spy.calls.count()).toBe(0); + expect(consoleSpy.calls.count()).toBe(0); }); it('Should be possible to render Immutable datastructures', () => { @@ -121,7 +121,7 @@ describe('rendering routine', () => { return
{array}
; } - const spy = spyOn(console, 'error'); + const consoleSpy = spyOn(console, 'error'); render(, container); expect(container.innerHTML).toBe('
Inferno version:
2
'); @@ -131,12 +131,12 @@ describe('rendering routine', () => { render(, container); expect(container.innerHTML).toBe('
Inferno version:
4
'); - expect(spy.calls.count()).toBe(0); + expect(consoleSpy.calls.count()).toBe(0); render(null, container); expect(container.innerHTML).toBe(''); - expect(spy.calls.count()).toBe(0); + expect(consoleSpy.calls.count()).toBe(0); }); describe('createTextVNode', () => {