From 352d9d2068f9633a47218036998adaedfdf61501 Mon Sep 17 00:00:00 2001 From: James K Nelson Date: Sun, 23 May 2021 23:10:00 +0900 Subject: [PATCH] remote retil-router, retil-history, move remaining (failing) tests to retil-nav --- packages/retil-history/README.md | 3 - packages/retil-history/jest.config.js | 15 - packages/retil-history/package.json | 36 -- .../retil-history/src/defaultHistories.ts | 17 - packages/retil-history/src/historyServices.ts | 140 ----- packages/retil-history/src/historyTypes.ts | 90 --- packages/retil-history/src/historyUtils.ts | 203 ------ packages/retil-history/src/index.ts | 4 - .../retil-history/test/historyUtils.test.ts | 25 - .../retil-history/test/memoryHistory.test.ts | 91 --- packages/retil-history/tsconfig.build.json | 6 - packages/retil-interaction/package.json | 2 +- packages/retil-issues/package.json | 2 +- packages/retil-mount/package.json | 2 +- packages/retil-mount/src/mount.ts | 7 +- packages/retil-mount/src/serverMount.tsx | 12 +- .../retil-mount/test/serverMount.test.tsx | 63 ++ packages/retil-nav/package.json | 2 +- packages/retil-nav/src/browserNavService.ts | 34 +- packages/retil-nav/src/getServerNavEnv.ts | 66 ++ packages/retil-nav/src/getStaticNavEnv.ts | 66 +- packages/retil-nav/src/index.ts | 1 + packages/retil-nav/src/loaders/match.tsx | 4 +- packages/retil-nav/src/loaders/notFound.tsx | 10 +- packages/retil-nav/src/navContext.tsx | 5 +- packages/retil-nav/src/navTypes.ts | 11 +- packages/retil-nav/src/navUtils.ts | 20 +- .../test/match.test.ts} | 0 packages/retil-nav/test/notFound.test.tsx | 40 ++ packages/retil-nav/test/redirect.test.ts | 32 + .../test/useNavMatch.test.ts} | 0 packages/retil-operation/package.json | 2 +- packages/retil-router/README.md | 195 ------ packages/retil-router/jest.config.js | 15 - packages/retil-router/package.json | 43 -- .../browserNavigationEnvironmentService.ts | 139 ----- packages/retil-router/src/components/link.tsx | 72 --- .../retil-router/src/components/router.tsx | 27 - .../src/components/routerContent.tsx | 6 - .../src/components/routerProvider.tsx | 46 -- packages/retil-router/src/historyStuff.ts | 580 ------------------ .../src/hooks/useBlockNavigation.ts | 8 - packages/retil-router/src/hooks/useLink.tsx | 103 ---- .../retil-router/src/hooks/useMatchRoute.ts | 25 - .../retil-router/src/hooks/useNavigate.ts | 8 - .../retil-router/src/hooks/usePrecache.ts | 8 - .../retil-router/src/hooks/useResolveRoute.ts | 29 - packages/retil-router/src/hooks/useRouter.tsx | 74 --- .../src/hooks/useRouterContent.ts | 7 - .../src/hooks/useRouterPending.tsx | 10 - .../src/hooks/useRouterRequest.tsx | 11 - .../src/hooks/useRouterScroller.ts | 139 ----- .../src/hooks/useRouterService.ts | 77 --- .../retil-router/src/hooks/useRouterSource.ts | 77 --- .../src/hooks/useRouterSourceBlocking.ts | 172 ------ .../src/hooks/useRouterSourceCommon.ts | 21 - .../src/hooks/useRouterSourceConcurrent.ts | 61 -- .../hooks/useWaitUntilNavigationCompletes.ts | 11 - packages/retil-router/src/index.ts | 43 -- packages/retil-router/src/locationUtils.ts | 203 ------ .../src/memoryNavigationService.ts | 161 ----- packages/retil-router/src/precache.ts | 17 - packages/retil-router/src/requestService.ts | 188 ------ packages/retil-router/src/routerContext.tsx | 15 - packages/retil-router/src/routerService.ts | 225 ------- packages/retil-router/src/routerTypes.ts | 118 ---- packages/retil-router/src/routerUtils.ts | 96 --- .../retil-router/src/routers/routeAsync.tsx | 99 --- .../src/routers/routeByPattern.tsx | 67 -- .../retil-router/src/routers/routeLazy.tsx | 23 - .../src/routers/routeNormalize.tsx | 39 -- .../src/routers/routeNotFound.tsx | 26 - .../src/routers/routeNotFoundBoundary.tsx | 97 --- .../retil-router/src/routers/routeProvide.tsx | 34 - .../src/routers/routeRedirect.tsx | 44 -- .../retil-router/src/routingEnvironment.ts | 71 --- .../test/getInitialStateAndResponse.test.ts | 26 - .../test/routeNotFoundBoundary.test.tsx | 41 -- .../retil-router/test/routeRedirect.test.tsx | 25 - packages/retil-router/test/useRouter.test.tsx | 474 -------------- packages/retil-router/tsconfig.build.json | 6 - packages/retil-source/package.json | 2 +- packages/retil-style/package.json | 2 +- packages/retil-support/package.json | 2 +- packages/retil/package.json | 4 +- site/server.ts | 26 +- site/src/entry-server.tsx | 12 +- yarn.lock | 24 +- 88 files changed, 337 insertions(+), 4848 deletions(-) delete mode 100644 packages/retil-history/README.md delete mode 100644 packages/retil-history/jest.config.js delete mode 100644 packages/retil-history/package.json delete mode 100644 packages/retil-history/src/defaultHistories.ts delete mode 100644 packages/retil-history/src/historyServices.ts delete mode 100644 packages/retil-history/src/historyTypes.ts delete mode 100644 packages/retil-history/src/historyUtils.ts delete mode 100644 packages/retil-history/src/index.ts delete mode 100644 packages/retil-history/test/historyUtils.test.ts delete mode 100644 packages/retil-history/test/memoryHistory.test.ts delete mode 100644 packages/retil-history/tsconfig.build.json create mode 100644 packages/retil-mount/test/serverMount.test.tsx create mode 100644 packages/retil-nav/src/getServerNavEnv.ts rename packages/{retil-router/test/routeByPattern.test.tsx => retil-nav/test/match.test.ts} (100%) create mode 100644 packages/retil-nav/test/notFound.test.tsx create mode 100644 packages/retil-nav/test/redirect.test.ts rename packages/{retil-router/test/useMatchRoute.test.tsx => retil-nav/test/useNavMatch.test.ts} (100%) delete mode 100644 packages/retil-router/README.md delete mode 100644 packages/retil-router/jest.config.js delete mode 100644 packages/retil-router/package.json delete mode 100644 packages/retil-router/src/browserNavigationEnvironmentService.ts delete mode 100644 packages/retil-router/src/components/link.tsx delete mode 100644 packages/retil-router/src/components/router.tsx delete mode 100644 packages/retil-router/src/components/routerContent.tsx delete mode 100644 packages/retil-router/src/components/routerProvider.tsx delete mode 100644 packages/retil-router/src/historyStuff.ts delete mode 100644 packages/retil-router/src/hooks/useBlockNavigation.ts delete mode 100644 packages/retil-router/src/hooks/useLink.tsx delete mode 100644 packages/retil-router/src/hooks/useMatchRoute.ts delete mode 100644 packages/retil-router/src/hooks/useNavigate.ts delete mode 100644 packages/retil-router/src/hooks/usePrecache.ts delete mode 100644 packages/retil-router/src/hooks/useResolveRoute.ts delete mode 100644 packages/retil-router/src/hooks/useRouter.tsx delete mode 100644 packages/retil-router/src/hooks/useRouterContent.ts delete mode 100644 packages/retil-router/src/hooks/useRouterPending.tsx delete mode 100644 packages/retil-router/src/hooks/useRouterRequest.tsx delete mode 100644 packages/retil-router/src/hooks/useRouterScroller.ts delete mode 100644 packages/retil-router/src/hooks/useRouterService.ts delete mode 100644 packages/retil-router/src/hooks/useRouterSource.ts delete mode 100644 packages/retil-router/src/hooks/useRouterSourceBlocking.ts delete mode 100644 packages/retil-router/src/hooks/useRouterSourceCommon.ts delete mode 100644 packages/retil-router/src/hooks/useRouterSourceConcurrent.ts delete mode 100644 packages/retil-router/src/hooks/useWaitUntilNavigationCompletes.ts delete mode 100644 packages/retil-router/src/index.ts delete mode 100644 packages/retil-router/src/locationUtils.ts delete mode 100644 packages/retil-router/src/memoryNavigationService.ts delete mode 100644 packages/retil-router/src/precache.ts delete mode 100644 packages/retil-router/src/requestService.ts delete mode 100644 packages/retil-router/src/routerContext.tsx delete mode 100644 packages/retil-router/src/routerService.ts delete mode 100644 packages/retil-router/src/routerTypes.ts delete mode 100644 packages/retil-router/src/routerUtils.ts delete mode 100644 packages/retil-router/src/routers/routeAsync.tsx delete mode 100644 packages/retil-router/src/routers/routeByPattern.tsx delete mode 100644 packages/retil-router/src/routers/routeLazy.tsx delete mode 100644 packages/retil-router/src/routers/routeNormalize.tsx delete mode 100644 packages/retil-router/src/routers/routeNotFound.tsx delete mode 100644 packages/retil-router/src/routers/routeNotFoundBoundary.tsx delete mode 100644 packages/retil-router/src/routers/routeProvide.tsx delete mode 100644 packages/retil-router/src/routers/routeRedirect.tsx delete mode 100644 packages/retil-router/src/routingEnvironment.ts delete mode 100644 packages/retil-router/test/getInitialStateAndResponse.test.ts delete mode 100644 packages/retil-router/test/routeNotFoundBoundary.test.tsx delete mode 100644 packages/retil-router/test/routeRedirect.test.tsx delete mode 100644 packages/retil-router/test/useRouter.test.tsx delete mode 100644 packages/retil-router/tsconfig.build.json diff --git a/packages/retil-history/README.md b/packages/retil-history/README.md deleted file mode 100644 index 8e613950..00000000 --- a/packages/retil-history/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# retil-history - -Retil sources for consuming and interacting with browser history. diff --git a/packages/retil-history/jest.config.js b/packages/retil-history/jest.config.js deleted file mode 100644 index b6a178c5..00000000 --- a/packages/retil-history/jest.config.js +++ /dev/null @@ -1,15 +0,0 @@ -const { pathsToModuleNameMapper } = require('ts-jest/utils') -// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file -// which contains the path mapping (ie the `compilerOptions.paths` option): -const { compilerOptions } = require('../../tsconfig') - -module.exports = { - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { - prefix: '/../', - }), - modulePaths: ['/src/'], - modulePathIgnorePatterns: ['/demo/'], - preset: 'ts-jest', - testEnvironment: 'jsdom', - testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', -} diff --git a/packages/retil-history/package.json b/packages/retil-history/package.json deleted file mode 100644 index 2fcfa074..00000000 --- a/packages/retil-history/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "retil-history", - "version": "0.20.1", - "description": "Retil sources for consuming and interacting with browser history.", - "author": "James K Nelson ", - "license": "MIT", - "main": "dist/commonjs/index.js", - "module": "dist/es/index.js", - "types": "dist/types/index.d.ts", - "scripts": { - "clean": "rimraf dist", - "build:commonjs": "tsc -p tsconfig.build.json --module commonjs --outDir dist/commonjs", - "build:es": "tsc -p tsconfig.build.json --module es2015 --outDir dist/es", - "build:types": "tsc -p tsconfig.build.json --declaration --emitDeclarationOnly --outDir dist/types --isolatedModules false", - "build": "yarn run clean && yarn build:es && yarn build:commonjs && yarn build:types", - "build:watch": "yarn run clean && yarn build:es -- --types --watch", - "lint": "eslint --ext ts,tsx src", - "prepare": "yarn test && yarn build", - "test": "jest", - "test:watch": "jest --watch" - }, - "dependencies": { - "history": "^5.0.0", - "querystring": "^0.2.0", - "retil-source": "^0.20.1", - "retil-support": "^0.20.1", - "tslib": "2.0.1" - }, - "devDependencies": { - "typescript": "4.2.4" - }, - "files": [ - "dist" - ], - "gitHead": "c3d07b313e425c572bf9b7bce2ca8ff09fb0f446" -} diff --git a/packages/retil-history/src/defaultHistories.ts b/packages/retil-history/src/defaultHistories.ts deleted file mode 100644 index 7f11f6af..00000000 --- a/packages/retil-history/src/defaultHistories.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createBrowserHistory } from './historyServices' -import { HistoryService } from './historyTypes' - -export const getDefaultBrowserHistory: { - (window?: Window): HistoryService - history?: HistoryService - window?: Window -} = (window?): HistoryService => { - if ( - !getDefaultBrowserHistory.history || - getDefaultBrowserHistory.window !== window - ) { - getDefaultBrowserHistory.history = createBrowserHistory({ window }) - getDefaultBrowserHistory.window = window - } - return getDefaultBrowserHistory.history -} diff --git a/packages/retil-history/src/historyServices.ts b/packages/retil-history/src/historyServices.ts deleted file mode 100644 index 437147c3..00000000 --- a/packages/retil-history/src/historyServices.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - BrowserHistory, - BrowserHistoryOptions, - History, - MemoryHistory, - createBrowserHistory as baseCreateBrowserHistory, - createMemoryHistory as baseCreateMemoryHistory, -} from 'history' -import { act, observe } from 'retil-source' - -import { - HistoryController, - HistoryLocation, - HistoryLocationReducer, - HistoryService, - HistorySnapshot, - HistoryState, - PrecachedSnapshot, -} from './historyTypes' -import { createActionMap, parseLocation, resolveAction } from './historyUtils' - -const defaultLocationReducer: HistoryLocationReducer = ( - location, - action, -) => resolveAction(action, location.pathname) - -export function createBrowserHistory( - options?: BrowserHistoryOptions, -): HistoryService { - return createHistoryService( - baseCreateBrowserHistory(options) as BrowserHistory, - ) -} - -export function createMemoryHistory( - initialLocation: string | HistoryLocation, -): HistoryService { - return createHistoryService( - baseCreateMemoryHistory({ - initialEntries: [parseLocation(initialLocation)], - }) as MemoryHistory, - ) -} - -export function createHistoryService( - history: History, - locationReducer: HistoryLocationReducer = defaultLocationReducer, -): HistoryService { - let forceChange = false - let lastRequest = { - ...parseLocation(history.location), - historyKey: history.location.key, - } as HistorySnapshot - - const precachedRequests = createActionMap< - HistorySnapshot & PrecachedSnapshot - >() - - const source = observe>((next) => { - next(lastRequest) - return history.listen(({ location }) => { - const parsedLocation = parseLocation(location) - const precachedRequest = precachedRequests.get(parsedLocation) - lastRequest = { - ...(precachedRequest || parsedLocation), - historyKey: location.key, - } - precachedRequests.clear() - next(lastRequest) - }) - }) - - const runMaybeBlockedAction = (callback: () => any): Promise => { - const key = history.location.key - return new Promise((resolve) => - act(source, callback).finally(() => { - resolve(history.location.key !== key) - }), - ) - } - - const controller: HistoryController = { - back: (): Promise => - runMaybeBlockedAction(() => { - history.back() - }), - - block: (predicate) => { - const unblock = history.block((tx) => { - if (forceChange) { - unblock() - tx.retry() - } else { - const location = parseLocation(tx.location) - act(source, () => { - return predicate(location, tx.action).then((shouldBlock) => { - if (!shouldBlock) { - unblock() - tx.retry() - } - }) - }) - } - }) - return unblock - }, - - navigate: (action, options): Promise => { - const { replace = false } = options || {} - let location: HistoryLocation - return runMaybeBlockedAction(() => { - location = locationReducer(lastRequest, action) - forceChange = !!options?.force - try { - history[replace ? 'replace' : 'push'](location, location.state) - } finally { - forceChange = false - } - }) - }, - - precache: (action): Promise & PrecachedSnapshot> => { - const location = locationReducer(lastRequest, action) - - let precachedRequest = precachedRequests.get(location) - if (!precachedRequest) { - precachedRequest = { - ...location, - precacheKey: Symbol(), - } - delete precachedRequest.historyKey - precachedRequests.set(location, precachedRequest) - } - - return Promise.resolve(precachedRequest) - }, - } - - return [source, controller] -} diff --git a/packages/retil-history/src/historyTypes.ts b/packages/retil-history/src/historyTypes.ts deleted file mode 100644 index a8199fd5..00000000 --- a/packages/retil-history/src/historyTypes.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Source } from 'retil-source' -import { ParsedUrlQuery } from 'querystring' - -export type HistoryTrigger = 'PUSH' | 'REPLACE' | 'POP' - -export type HistoryState = object - -export type HistoryAction = - | string - | HistoryActionObject - -export interface HistoryActionObject { - hash?: string - pathname?: string - query?: ParsedUrlQuery - search?: string - state?: S | null -} - -export interface HistoryLocation { - hash: string - pathname: string - query: ParsedUrlQuery - search: string - state: S | null -} - -export interface HistorySnapshot - extends HistoryLocation { - /** - * This is applied to individual requests that have actually been added to - * the browser history. - */ - historyKey?: string - - /** - * If this context was precached by the `precache` function, then this will - * will contain a symbol that is referentially equal to the value in the - * orbject returned by that function. - */ - precacheKey?: symbol -} - -export interface PrecachedSnapshot { - precacheKey: symbol -} - -export type HistorySource = Source< - HistorySnapshot -> - -export type HistoryService = readonly [ - HistorySource, - HistoryController, -] - -export interface HistoryController< - State extends HistoryState = HistoryState, - Snapshot extends HistorySnapshot = HistorySnapshot -> { - back(): Promise - - block(blocker: HistoryBlockPredicate): Unblock - - navigate( - action: HistoryAction, - options?: { - /** - * Bypass any blocks and navigate immediately. Useful for redirects, which - * should not be blocked. - */ - force?: boolean - replace?: boolean - }, - ): Promise - - precache(action: HistoryAction): Promise -} - -export type HistoryBlockPredicate = ( - location: HistoryLocation, - action: HistoryTrigger, -) => Promise - -export type Unblock = () => void - -export type HistoryLocationReducer = - // This returns a partial request, as a key and cache still need to be added - // by the router itself. - (location: HistoryLocation, action: HistoryAction) => HistoryLocation diff --git a/packages/retil-history/src/historyUtils.ts b/packages/retil-history/src/historyUtils.ts deleted file mode 100644 index 6827d6a5..00000000 --- a/packages/retil-history/src/historyUtils.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { parsePath } from 'history' -import { parse as parseQuery, stringify as stringifyQuery } from 'querystring' - -import { - HistoryAction, - HistoryActionObject, - HistoryLocation, - HistoryState, -} from './historyTypes' - -export function createHref(request: HistoryActionObject): string { - return ( - encodeURI(normalizePathname(request.pathname || '')) + - (request.search || '') + - (request.hash || '') - ) -} - -export function isExternalHref(href: string | HistoryAction) { - // If this is an external link, return undefined so that the native - // response will be used. - return ( - !href || - (typeof href === 'string' && - (href.indexOf('://') !== -1 || href.indexOf('mailto:') === 0)) - ) -} - -// users/789/, profile => users/789/profile/ -// /users/123, . => /users/123 -// /users/123, .. => /users -// /users/123, ../.. => / -// /a/b/c/d, ../../one => /a/b/one -// /a/b/c/d, .././one/ => /a/b/c/one/ -export function joinPaths(base: string, ...paths: string[]): string { - let allSegments = splitPath(base) - for (let i = 0; i < paths.length; i++) { - allSegments.push(...splitPath(paths[i])) - } - - let pathSegments: string[] = [] - let lastSegmentIndex = allSegments.length - 1 - for (let i = 0; i <= lastSegmentIndex; i++) { - let segment = allSegments[i] - if (segment === '..') { - pathSegments.pop() - } - // Allow empty segments on the first character, so that leading - // slashes will not be affected. - else if (segment !== '.' && (segment !== '' || i === 0)) { - pathSegments.push(segment) - } - } - - return pathSegments.join('/') -} - -function splitPath(path: string): string[] { - if (path === '') { - return [] - } - return path.split('/') -} - -export function normalizePathname(pathname: string): string { - return decodeURI( - pathname - .replace(/\/+/g, '/') - .replace(/(.)\/$/, '$1') - .normalize(), - ) -} - -export function resolveAction( - action: string | HistoryAction, - currentPathname: string, -): HistoryLocation { - if (isExternalHref(action)) { - throw new Error( - 'retil-router: applyAction cannot be applied to external URLs', - ) - } - - const parsedAction = parseAction(action) - - let pathname = parsedAction.pathname - - // If no relativity specifier is provided, use the browser default of - // replacing the last segment. - if (pathname) { - pathname = - pathname[0] === '/' - ? pathname - : joinPaths( - currentPathname, - /^\.\.?\//.test(pathname) ? '.' : '..', - pathname, - ) - } - - return { - hash: parsedAction.hash || '', - pathname: normalizePathname(pathname || currentPathname), - query: parsedAction.query || {}, - search: parsedAction.search || '', - state: parsedAction.state || null, - } -} - -export function parseAction( - input: string | HistoryAction, - state?: S, -): Exclude, string> { - const action: HistoryAction = - typeof input === 'string' ? parsePath(input) : { ...input } - - if (state) { - action.state = state - } - - if (action.search) { - if (!action.query) { - action.query = parseQuery(action.search.slice(1)) - } else if (process.env.NODE_ENV !== 'production') { - const stringifiedActionQuery = stringifyQuery(action.query) - if (stringifiedActionQuery !== action.search.slice(1)) { - console.error( - `A path was provided with differing "search" and "query" parameters. Ignoring "search" in favor of "query".`, - ) - } - } - } - if (action.query) { - const stringifiedQuery = stringifyQuery(action.query) - action.search = stringifiedQuery ? '?' + stringifiedQuery : '' - } - - if (action.pathname) { - action.pathname = decodeURI(action.pathname) - } - - return action -} - -export function parseLocation( - input: string | HistoryAction, -): HistoryLocation { - return { - hash: '', - pathname: '', - query: {}, - search: '', - state: null, - ...parseAction(input), - } -} - -export function getActionKey(action: HistoryAction): [any, string] { - const parsedAction = parseAction(action) - return [parsedAction.state, createHref(parsedAction)] -} - -export interface ActionMap { - clear(): void - delete(action: HistoryAction): void - get(action: HistoryAction): T | undefined - set(action: HistoryAction, value: T): void -} - -export function createActionMap(): ActionMap { - const map = new Map() - - const clear = () => map.clear() - - const del = (action: HistoryAction): void => { - const [state, url] = getActionKey(action) - const innerMap = map.get(state) - if (innerMap) { - delete innerMap[url] - if (!Object.keys(innerMap).length) { - map.delete(state) - } - } - } - - const get = (action: HistoryAction): T | undefined => { - const [state, url] = getActionKey(action) - const innerMap = map.get(state) - return innerMap && innerMap[url] - } - - const set = (action: HistoryAction, value: T): void => { - const [state, url] = getActionKey(action) - const innerMap = map.get(state) - if (!innerMap) { - map.set(state, { [url]: value }) - } else { - innerMap[url] = value - } - } - - return { clear, delete: del, get, set } -} diff --git a/packages/retil-history/src/index.ts b/packages/retil-history/src/index.ts deleted file mode 100644 index 3ebf22ac..00000000 --- a/packages/retil-history/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './defaultHistories' -export * from './historyServices' -export * from './historyTypes' -export * from './historyUtils' diff --git a/packages/retil-history/test/historyUtils.test.ts b/packages/retil-history/test/historyUtils.test.ts deleted file mode 100644 index 230adb9b..00000000 --- a/packages/retil-history/test/historyUtils.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { resolveAction, createHref, parseAction } from '../src' - -describe(`history utils`, () => { - test(`createHref(parseAction()) is a noop`, () => { - const location = '/test?param=1&another=two#id' - expect(createHref(parseAction(location))).toBe(location) - }) - - test(`parseAction() generates correct searches from queries`, () => { - expect(parseAction({ query: { param: '1', another: 'two' } }).search).toBe( - '?param=1&another=two', - ) - - expect(parseAction({ query: {} }).search).toBe('') - }) - - test(`resolveAction() works in pathnames with no leading . or /`, () => { - const pathname = '/browse/deck/word' - - expect(resolveAction('test', pathname).pathname).toBe('/browse/deck/test') - expect(resolveAction({ pathname: 'test' }, pathname).pathname).toBe( - '/browse/deck/test', - ) - }) -}) diff --git a/packages/retil-history/test/memoryHistory.test.ts b/packages/retil-history/test/memoryHistory.test.ts deleted file mode 100644 index c6c94c63..00000000 --- a/packages/retil-history/test/memoryHistory.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Source, getSnapshot, hasSnapshot, subscribe } from 'retil-source' - -import { createMemoryHistory } from '../src' - -export function sendToArray(source: Source): T[] { - const array = [] as T[] - if (hasSnapshot(source)) { - array.unshift(getSnapshot(source)) - } - subscribe(source, () => array.unshift(getSnapshot(source))) - return array -} - -describe(`createMemoryHistory`, () => { - test(`outputs its initial location`, () => { - const [historySource] = createMemoryHistory('/test') - const snapshots = sendToArray(historySource) - expect(snapshots[0].pathname).toEqual('/test') - }) - - test(`navigates`, async () => { - const [historySource, historyController] = createMemoryHistory('/test') - const snapshots = sendToArray(historySource) - - const done = await historyController.navigate('/') - - expect(snapshots[0].pathname).toEqual('/') - expect(snapshots.length).toBe(2) - expect(done).toBe(true) - }) - - test(`supports relative navigation`, async () => { - const [historySource, historyController] = createMemoryHistory('/test') - const snapshots = sendToArray(historySource) - - await historyController.navigate('./test') - - expect(snapshots[0].pathname).toEqual('/test/test') - }) - - test(`by default, replaces the last segment of urls with no relativity information`, async () => { - const [historySource, historyController] = createMemoryHistory('/test') - const snapshots = sendToArray(historySource) - - await historyController.navigate('test2') - - expect(snapshots[0].pathname).toEqual('/test2') - }) - - test(`can navigate backwards`, async () => { - const [historySource, historyController] = createMemoryHistory('/test-1') - const snapshots = sendToArray(historySource) - - await historyController.navigate('/test-2') - await historyController.back() - - expect(snapshots[0].pathname).toEqual('/test-1') - }) - - test(`can block and unblock navigation`, async () => { - const [historySource, historyController] = createMemoryHistory('/test-1') - const snapshots = sendToArray(historySource) - - const unblock = historyController.block(async () => true) - - const couldNavigate1 = await historyController.navigate('/test-2') - expect(couldNavigate1).toBe(false) - expect(snapshots[0].pathname).toBe('/test-1') - - unblock() - - const couldNavigate2 = await historyController.navigate('/test-2') - expect(couldNavigate2).toBe(true) - expect(snapshots[0].pathname).toBe('/test-2') - }) - - test(`suspends while blocked`, async () => { - const [historySource, historyController] = createMemoryHistory('/test-1') - sendToArray(historySource) - - historyController.block(async () => false) - - const navigatedPromise = historyController.navigate('/test-2') - - expect(hasSnapshot(historySource)).toBe(false) - - await navigatedPromise - - expect(hasSnapshot(historySource)).toBe(true) - }) -}) diff --git a/packages/retil-history/tsconfig.build.json b/packages/retil-history/tsconfig.build.json deleted file mode 100644 index 03ff0ce8..00000000 --- a/packages/retil-history/tsconfig.build.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../../tsconfig.build.json", - "include": [ - "src/**/*" - ] -} \ No newline at end of file diff --git a/packages/retil-interaction/package.json b/packages/retil-interaction/package.json index 2b30eb81..7e54f311 100644 --- a/packages/retil-interaction/package.json +++ b/packages/retil-interaction/package.json @@ -26,7 +26,7 @@ "retil-source": "^0.20.1", "retil-style": "^0.20.1", "retil-support": "^0.20.1", - "tslib": "2.0.1" + "tslib": "^2.2.0" }, "devDependencies": { "@types/styled-components": "^5.1.7", diff --git a/packages/retil-issues/package.json b/packages/retil-issues/package.json index c09573d2..4821f6fb 100644 --- a/packages/retil-issues/package.json +++ b/packages/retil-issues/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "retil-support": "^0.20.1", - "tslib": "2.0.1" + "tslib": "^2.2.0" }, "devDependencies": { "typescript": "4.2.4" diff --git a/packages/retil-mount/package.json b/packages/retil-mount/package.json index 9686d0c1..55ee4252 100644 --- a/packages/retil-mount/package.json +++ b/packages/retil-mount/package.json @@ -23,7 +23,7 @@ "abort-controller": "^3.0.0", "retil-source": "^0.20.1", "retil-support": "^0.20.1", - "tslib": "2.0.1" + "tslib": "^2.2.0" }, "devDependencies": { "typescript": "4.2.4" diff --git a/packages/retil-mount/src/mount.ts b/packages/retil-mount/src/mount.ts index ceea227a..d6df27dc 100644 --- a/packages/retil-mount/src/mount.ts +++ b/packages/retil-mount/src/mount.ts @@ -1,4 +1,4 @@ -import { VectorFusor, map, vectorFuse } from 'retil-source' +import { Source, Vector, VectorFusor, map, vectorFuse } from 'retil-source' import { EnvType, @@ -24,12 +24,13 @@ export function mount( abortSignal: undefined as any as AbortSignal, dependencies, } - const envSnapshot = + const envSnapshot = ( typeof env === 'function' ? (env as VectorFusor)(use) : Array.isArray(env) - ? use(env) + ? use(env as Source>) : env + ) as Env const content = loader({ ...envSnapshot, ...mountEnv, diff --git a/packages/retil-mount/src/serverMount.tsx b/packages/retil-mount/src/serverMount.tsx index eaacec4b..ad526364 100644 --- a/packages/retil-mount/src/serverMount.tsx +++ b/packages/retil-mount/src/serverMount.tsx @@ -2,7 +2,13 @@ import React, { ReactElement } from 'react' import { getSnapshot } from 'retil-source' import { mount } from './mount' -import { EnvType, Loader, MountEnv, MountSource } from './mountTypes' +import { + EnvType, + Loader, + MountEnv, + MountSnapshot, + MountSource, +} from './mountTypes' import { ServerMountContext } from './serverMountContext' export class ServerMount { @@ -15,7 +21,7 @@ export class ServerMount { this.env = env } - preload(): Promise { + preload(): Promise> { if (this.source) { throw new Error( `The "preload" method of ServerMount may only be called once.`, @@ -24,7 +30,7 @@ export class ServerMount { this.source = mount(this.loader, this.env) const snapshot = getSnapshot(this.source) - return snapshot.dependencies.resolve() + return snapshot.dependencies.resolve().then(() => snapshot) } provide(element: ReactElement): ReactElement { diff --git a/packages/retil-mount/test/serverMount.test.tsx b/packages/retil-mount/test/serverMount.test.tsx new file mode 100644 index 00000000..e2bf01a4 --- /dev/null +++ b/packages/retil-mount/test/serverMount.test.tsx @@ -0,0 +1,63 @@ +import '@testing-library/jest-dom/extend-expect' +import React, { StrictMode } from 'react' +import { render } from '@testing-library/react' +import { Deferred } from 'retil-support' + +import { ServerMount, lazy, useMount } from '../src' + +describe('ServerMount', () => { + test('returns a mount snapshot from preload()', async () => { + const loader = (env: any) => env.pathname + const mount = new ServerMount(loader, { pathname: '/test' }) + const { content } = await mount.preload() + expect(content).toBe('/test') + }) + + test('works with async routes', async () => { + const deferred = new Deferred() + + const loader = lazy(async (env: any) => { + env.asyncRef.current = await deferred.promise + return 'done' + }) + + const env = { asyncRef: { current: undefined } } + const mount = new ServerMount(loader, env) + const mountSnapshotPromise = mount.preload() + expect(env.asyncRef.current).toBe(undefined) + deferred.resolve('/async') + await mountSnapshotPromise + expect(env.asyncRef.current).toBe('/async') + }) + + test('makes preloaded async content synchronously available using provide()', async () => { + let loadCount = 0 + + const deferred = new Deferred() + const loader = lazy(async (env: any) => { + loadCount++ + return env.pathname + (await deferred.promise) + }) + + const env = { pathname: '/success' } + const mount = new ServerMount(loader, env) + + const preloadPromise = mount.preload() + + deferred.resolve('/async') + + await preloadPromise + + const Test = () => <>{useMount(loader, env).content} + const { container } = render( + mount.provide( + + + , + ), + ) + + expect(loadCount).toBe(1) + expect(container).toHaveTextContent('/success/async') + }) +}) diff --git a/packages/retil-nav/package.json b/packages/retil-nav/package.json index a830b5c3..4090d933 100644 --- a/packages/retil-nav/package.json +++ b/packages/retil-nav/package.json @@ -23,7 +23,7 @@ "retil-mount": "^0.20.1", "retil-source": "^0.20.1", "retil-support": "^0.20.1", - "tslib": "2.0.1" + "tslib": "^2.2.0" }, "devDependencies": { "typescript": "4.2.4" diff --git a/packages/retil-nav/src/browserNavService.ts b/packages/retil-nav/src/browserNavService.ts index 43b707ed..673a5a51 100644 --- a/packages/retil-nav/src/browserNavService.ts +++ b/packages/retil-nav/src/browserNavService.ts @@ -16,7 +16,6 @@ import { getSnapshot, observe, } from 'retil-source' -import { noop } from 'retil-support' import { NavAction, @@ -33,14 +32,6 @@ import { createHref, parseLocation, resolveAction } from './navUtils' const defaultNavReducer: NavReducer = (location, action) => resolveAction(action, location.pathname) -const noopResponse = { - getHeaders: () => ({}), - setHeader: noop, - get statusCode() { - return 200 - }, -} - const defaultMaxRedirectDepth = 10 const BeforeUnloadEventType = 'beforeunload' @@ -171,6 +162,21 @@ export function createBrowserNavService( const { key = createKey(), redirectDepth = 0 } = options + const headers = {} as Record + const getHeaders = () => headers + const setHeader = ( + name: string, + value: number | string | string[] | undefined, + ) => { + headers[name] = value + } + + let statusCode = 200 + const getStatusCode = () => statusCode + const setStatusCode = (newStatusCode: number) => { + statusCode = newStatusCode + } + const redirect = ( statusOrAction: number | string, action?: string, @@ -185,7 +191,7 @@ export function createBrowserNavService( `A redirect was attempted from a location that is not currently active` + ` – from ${createHref(location)} to ${createHref( to, - )}. This is often` + + )}. This may be` + `due to precaching a link that points to a redirect.`, ) cancelPrecacheLocation(env) @@ -205,6 +211,9 @@ export function createBrowserNavService( redirectDepth: redirectDepth + 1, }) + statusCode = typeof statusOrAction === 'number' ? statusOrAction : 302 + headers['Location'] = createHref(to) + write(redirectEnv, { replace: true }) }) } @@ -212,10 +221,13 @@ export function createBrowserNavService( const env = { ...location, basename, + getHeaders, + getStatusCode, navKey: key, params: {}, redirect, - response: noopResponse, + setHeader, + setStatusCode, } return env diff --git a/packages/retil-nav/src/getServerNavEnv.ts b/packages/retil-nav/src/getServerNavEnv.ts new file mode 100644 index 00000000..8cc20d89 --- /dev/null +++ b/packages/retil-nav/src/getServerNavEnv.ts @@ -0,0 +1,66 @@ +import { NavEnv, NavParams, NavQuery } from './navTypes' +import { createHref, parseLocation, resolveAction } from './navUtils' + +export interface ServerNavRequest { + baseUrl?: string + originalUrl?: string + params?: NavParams + query?: NavQuery + url: string +} + +export interface ServerNavResponse { + getHeaders(): { [name: string]: number | string | string[] | undefined } + setHeader(name: string, value: number | string | string[] | undefined): void + statusCode: number +} + +export interface ServerNavEnv< + TRequest extends ServerNavRequest, + TResponse extends ServerNavResponse, +> extends NavEnv { + request: TRequest + response: TResponse +} + +export function getServerNavEnv< + TRequest extends ServerNavRequest, + TResponse extends ServerNavResponse, +>(request: TRequest, response: TResponse): ServerNavEnv { + const originalUrl = + request.originalUrl ?? (request.baseUrl ?? '') + request.url + const location = parseLocation(originalUrl) + const redirect = async ( + statusOrAction: number | string, + action?: string, + ): Promise => { + const to = createHref( + resolveAction(action || (statusOrAction as string), location.pathname), + ) + + response.setHeader('Location', to) + response.statusCode = + typeof statusOrAction === 'number' ? statusOrAction : 302 + } + + return { + ...location, + basename: request.baseUrl ?? '', + getHeaders: () => response.getHeaders(), + getStatusCode: () => response.statusCode, + navKey: 'ssr', + params: request.params || {}, + request, + response, + redirect, + setHeader: ( + name: string, + value: number | string | string[] | undefined, + ) => { + response.setHeader(name, value) + }, + setStatusCode: (code: number) => { + response.statusCode = code + }, + } +} diff --git a/packages/retil-nav/src/getStaticNavEnv.ts b/packages/retil-nav/src/getStaticNavEnv.ts index 1ed8a93d..6aa10271 100644 --- a/packages/retil-nav/src/getStaticNavEnv.ts +++ b/packages/retil-nav/src/getStaticNavEnv.ts @@ -1,29 +1,37 @@ -import { NavEnv, NavParams, NavQuery, NavResponse } from './navTypes' +import { NavEnv } from './navTypes' import { createHref, parseLocation, resolveAction } from './navUtils' -export interface StaticNavRequest { - baseUrl?: string - originalUrl?: string - params?: NavParams - query?: NavQuery - url: string +export interface StaticNavContext { + headers: Record + statusCode: number } -export interface StaticNavEnv< - TRequest extends StaticNavRequest, - TResponse extends NavResponse, -> extends NavEnv { - request: TRequest - response: TResponse -} +export function getStaticNavEnv( + url: string, + context?: Partial, +): NavEnv { + if (!context) { + context = {} + } + if (!context.headers) { + context.headers = {} + } + + const location = parseLocation(url) + + const getHeaders = () => context!.headers! + const setHeader = ( + name: string, + value: number | string | string[] | undefined, + ) => { + context!.headers![name] = value + } + + const getStatusCode = () => context!.statusCode || 200 + const setStatusCode = (newStatusCode: number) => { + context!.statusCode = newStatusCode + } -export function getStaticNavEnv< - TRequest extends StaticNavRequest, - TResponse extends NavResponse, ->(request: TRequest, response: TResponse): StaticNavEnv { - const originalUrl = - request.originalUrl ?? (request.baseUrl ?? '') + request.url - const location = parseLocation(originalUrl) const redirect = async ( statusOrAction: number | string, action?: string, @@ -32,18 +40,20 @@ export function getStaticNavEnv< resolveAction(action || (statusOrAction as string), location.pathname), ) - response.setHeader('Location', to) - response.statusCode = + setHeader('Location', to) + context!.statusCode = typeof statusOrAction === 'number' ? statusOrAction : 302 } return { ...location, - basename: request.baseUrl ?? '', - navKey: 'ssr', - params: request.params || {}, - request, - response, + basename: '', + getHeaders, + getStatusCode, + navKey: 'static', + params: {}, redirect, + setHeader, + setStatusCode, } } diff --git a/packages/retil-nav/src/index.ts b/packages/retil-nav/src/index.ts index a3745d48..48219a9e 100644 --- a/packages/retil-nav/src/index.ts +++ b/packages/retil-nav/src/index.ts @@ -9,6 +9,7 @@ export * from './loaders/redirect' export * from './browserNavService' export * from './getDefaultBrowserNavService' +export * from './getServerNavEnv' export * from './getStaticNavEnv' export * from './navContext' export * from './navTypes' diff --git a/packages/retil-nav/src/loaders/match.tsx b/packages/retil-nav/src/loaders/match.tsx index 086d97f2..89793112 100644 --- a/packages/retil-nav/src/loaders/match.tsx +++ b/packages/retil-nav/src/loaders/match.tsx @@ -8,13 +8,13 @@ import { joinPathnames } from '../navUtils' import { notFoundLoader } from './notFound' export interface MatchOptions< - TEnv extends object = object, + TEnv extends NavEnv = NavEnv, TContent = ReactNode, > { [pattern: string]: Loader | TContent } -export function match( +export function match( handlers: MatchOptions, ): Loader { const tests: [Matcher, Loader][] = [] diff --git a/packages/retil-nav/src/loaders/notFound.tsx b/packages/retil-nav/src/loaders/notFound.tsx index ea172333..4035ea25 100644 --- a/packages/retil-nav/src/loaders/notFound.tsx +++ b/packages/retil-nav/src/loaders/notFound.tsx @@ -3,13 +3,13 @@ import { Loader } from 'retil-mount' import { NavEnv } from '../navTypes' -export interface NotFoundBoundaryProps { +export interface NotFoundBoundaryProps { children: React.ReactNode env: TEnv notFoundLoader: Loader } -function NotFoundBoundary( +function NotFoundBoundary( props: NotFoundBoundaryProps, ) { return @@ -62,14 +62,14 @@ class InnerNotFoundBoundary extends React.Component< // As SSR doesn't support state, and thus can't recover using error // boundaries, we'll also check for a 404 on the response object (as // during SSR, the response will always be complete before rendering). - if (this.state.error || this.props.env.response.statusCode === 404) { + if (this.state.error || this.props.env.getStatusCode() === 404) { return this.props.notFoundLoader(this.props.env) } return this.props.children } } -export const notFoundBoundary = ( +export const notFoundBoundary = ( mainLoader: Loader, notFoundLoader: Loader, ): Loader => { @@ -95,7 +95,7 @@ export const NotFound: React.FunctionComponent = (props) => { export const notFoundLoader: Loader = (env) => { const error = new NotFoundError(env) - env.response.statusCode = 404 + env.setStatusCode(404) return } diff --git a/packages/retil-nav/src/navContext.tsx b/packages/retil-nav/src/navContext.tsx index c995932d..dafa86c9 100644 --- a/packages/retil-nav/src/navContext.tsx +++ b/packages/retil-nav/src/navContext.tsx @@ -40,9 +40,10 @@ export function useNavController() { const waitForStableMount = useWaitForStableMount() const contextController = useContext(NavControllerContext) const controller = - contextController || typeof window === 'undefined' + contextController || + (typeof window === 'undefined' ? noopNavController - : getDefaultBrowserNavService()[1] + : getDefaultBrowserNavService()[1]) return useMemo( (): NavController => ({ ...controller, diff --git a/packages/retil-nav/src/navTypes.ts b/packages/retil-nav/src/navTypes.ts index cd5e545b..9800e0d2 100644 --- a/packages/retil-nav/src/navTypes.ts +++ b/packages/retil-nav/src/navTypes.ts @@ -25,21 +25,18 @@ export interface NavRedirectFunction { (statusCode: number, action: string): Promise } -export interface NavResponse { - getHeaders(): { [name: string]: number | string | string[] | undefined } - setHeader(name: string, value: number | string | string[] | undefined): void - statusCode: number -} - export type NavQuery = { [name: string]: string | string[] } export type NavParams = { [name: string]: string | string[] } export interface NavEnv extends NavLocation { basename: string + getHeaders(): { [name: string]: number | string | string[] | undefined } + getStatusCode(): number navKey: string params: NavParams redirect: NavRedirectFunction - response: NavResponse + setHeader(name: string, value: number | string | string[] | undefined): void + setStatusCode(code: number): void } export type NavSource = VectorSource diff --git a/packages/retil-nav/src/navUtils.ts b/packages/retil-nav/src/navUtils.ts index 6501d342..c4b84c14 100644 --- a/packages/retil-nav/src/navUtils.ts +++ b/packages/retil-nav/src/navUtils.ts @@ -39,22 +39,38 @@ export function joinPathnames(base: string, ...paths: string[]): string { } export function normalizePathname(pathname: string): string { - return decodeURI( + const intermediate = decodeURI( pathname .replace(/\/+/g, '/') .replace(/(.)\/$/, '$1') .normalize(), ) + + if (intermediate === '/' || intermediate === '') { + return '/' + } else { + const pathnameWithLeadingSlash = + intermediate[0] !== '/' ? '/' + intermediate : intermediate + return pathnameWithLeadingSlash[pathnameWithLeadingSlash.length - 1] === '/' + ? pathnameWithLeadingSlash.slice(0, pathnameWithLeadingSlash.length - 1) + : pathnameWithLeadingSlash + } } export function parseLocation(input: NavAction, state?: object): NavLocation { + const parsedAction = parseAction(input, state) + + if (parsedAction?.pathname !== undefined) { + parsedAction.pathname = normalizePathname(parsedAction.pathname) + } + return { hash: '', pathname: '', query: {}, search: '', state: null, - ...parseAction(input, state), + ...parsedAction, } } diff --git a/packages/retil-router/test/routeByPattern.test.tsx b/packages/retil-nav/test/match.test.ts similarity index 100% rename from packages/retil-router/test/routeByPattern.test.tsx rename to packages/retil-nav/test/match.test.ts diff --git a/packages/retil-nav/test/notFound.test.tsx b/packages/retil-nav/test/notFound.test.tsx new file mode 100644 index 00000000..4d6c69eb --- /dev/null +++ b/packages/retil-nav/test/notFound.test.tsx @@ -0,0 +1,40 @@ +import '@testing-library/jest-dom/extend-expect' +import React from 'react' +import { renderToString } from 'react-dom/server' +import { MountEnv, ServerMount, lazy, useMount } from 'retil-mount' +import { Deferred } from 'retil-support' + +import { NavEnv, getStaticNavEnv, match, notFoundBoundary } from '../src' + +describe('notFoundBoundary', () => { + test(`works during SSR with async routes`, async () => { + const deferred = new Deferred() + const innerLoader = match({ + '/found': (request) => 'found' + request.pathname, + }) + const loader = notFoundBoundary( + lazy(async (req: NavEnv & MountEnv) => { + await deferred.promise + return innerLoader(req) + }), + (request) => 'not-found' + request.pathname, + ) + const env = getStaticNavEnv('/test-1') + const mount = new ServerMount(loader, env) + + const mountPromise = mount.preload() + + deferred.resolve('test') + + await mountPromise + + const Test = () => { + const route = useMount(loader, env) + return <>{route.content} + } + + const html = renderToString(mount.provide()) + + expect(html).toEqual('not-found/test-1') + }) +}) diff --git a/packages/retil-nav/test/redirect.test.ts b/packages/retil-nav/test/redirect.test.ts new file mode 100644 index 00000000..b80d9fef --- /dev/null +++ b/packages/retil-nav/test/redirect.test.ts @@ -0,0 +1,32 @@ +import { mount } from 'retil-mount' +import { getSnapshot } from 'retil-source' + +import { StaticNavContext, getStaticNavEnv, redirect } from '../src' + +describe('routeByRedirect', () => { + test(`supports relative redirects`, async () => { + const context = {} as StaticNavContext + const loader = redirect('./acquisition') + const env = { + ...getStaticNavEnv('/browse/deck', context), + basename: '/browse/deck', + } + + await getSnapshot(mount(loader, env)).dependencies.resolve() + + expect(context.headers.Location).toBe('/browse/deck/acquisition') + }) + + test(`supports absolute redirects`, async () => { + const context = {} as StaticNavContext + const loader = redirect('/test') + const env = { + ...getStaticNavEnv('/browse/deck', context), + basename: '/browse/deck', + } + + await getSnapshot(mount(loader, env)).dependencies.resolve() + + expect(context.headers.Location).toBe('/test') + }) +}) diff --git a/packages/retil-router/test/useMatchRoute.test.tsx b/packages/retil-nav/test/useNavMatch.test.ts similarity index 100% rename from packages/retil-router/test/useMatchRoute.test.tsx rename to packages/retil-nav/test/useNavMatch.test.ts diff --git a/packages/retil-operation/package.json b/packages/retil-operation/package.json index 49efbb65..e8da19a8 100644 --- a/packages/retil-operation/package.json +++ b/packages/retil-operation/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "retil-support": "^0.20.1", - "tslib": "2.0.1" + "tslib": "^2.2.0" }, "devDependencies": { "typescript": "4.2.4" diff --git a/packages/retil-router/README.md b/packages/retil-router/README.md deleted file mode 100644 index 7fcabecc..00000000 --- a/packages/retil-router/README.md +++ /dev/null @@ -1,195 +0,0 @@ -

- retil-router -

- -

- Superpowers for React Developers -

- -

- NPM -

- - -## Getting Started - -```bash -yarn add retil-router -``` - -- [**Read the 2-minute primer**](#2-minute-primer) - - -- [View the API reference »](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md) - - - -## 2-minute primer - -**Your router just is a function.** - -With retil-router, a **router** is a function that maps a request to an element. - -```ts -type RouterFunction = (request: RouterRequest) => ReactNode -``` - -You've seen this before -- its a lot like a React component. - -```tsx -const router = request => { - switch (request.pathname) { - case '/': - return

Home

- - case '/about': - return

About

- - default: - return

Not Found

- } -} -``` - -Once you have a router function, just pass it to `useRouter` to get your `route` -- your current route's content is available on the `content` property. - -```tsx -import { useRouter } from 'retil-router' - -export default function App() { - const route = useRouter(router) - return route.content -} -``` - -Routers-as-functions is the underlying secret that makes retil-router so powerful. Most of the time though, it's easier to let retil-router create the router functions for you. For example, the above router could be created with `routeByPattern()`. - -```tsx -import { routeByPattern } from 'retil-router' - -const router = routeByPattern({ - '/':

Home

, - '/about':

About

-}) -``` - -If you want to use retil-router's built in `` component and redirect routes, you'll need to make sure they can access the current route and `navigate()` function. You can do this by wrapping your app with a ``. - -```tsx -import { RouterProvider, useRouter } from 'retil-router' - -export default function App() { - const route = useRouter(router) - - return ( - - {route.content} - - ) -} -``` - -Naturally, your `route.content` element can be nested inside other elements. This lets you easily add layout elements -- like a site-wide navigation bar. And hey presto -- you've now built a simple app with push-state routing! - -[*View this example live at CodeSandbox »*](https://codesandbox.io/s/rrl-minimal-vsdsd) - -```tsx -import { Link, RouterProvider, useRouter } from 'retil-router' - -function AppLayout({ children }) { - return ( - <> -
- Home - About -
-
- {children} -
- - ) -} - -export default function App() { - const route = useRouter(router) - - return ( - - - {route.content} - - - ) -} -``` - - - - -## [API Docs](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md) - -[**Components**](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#components) - -- [``](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#link) -- [``](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routerprovider) - -[**Hooks**](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#hooks) - -- [`useLink()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#uselink) -- [`useMatch()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#usematch) -- [`useRequest()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#userequest) -- [`useRouter()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#userouter) -- [`useRouterController()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#useroutercontroller) - -[**Router function helpers**](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#router-function-helpers) - -- [`routeAsync()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routeasync) -- [`routeByPattern()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routebypattern) -- [`routeLazy()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routelazy) -- [`routeNotFound()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routenotfound) -- [`routeNotFoundBoundary()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routenotfoundboundary) -- [`routeRedirect()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routeredirect) - -[**Functions**](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#functions) - -- [`createHref()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#createhref) -- [`getInitialStateAndResponse()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#getinitialstateandresponse) -- [`parseAction()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#parseaction) -- [`resolveAction()`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#resolveaction) - -[**Types**](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#types) - -- [`RouterAction`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routeraction) -- [`RouterController`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routercontroller) -- [`RouterFunction`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routerfunction) -- [`RouterRequest`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routerrequest) -- [`RouterResponse`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routerresponse) -- [`RouterState`](https://github.com/jamesknelson/retil/blob/master/docs/router-api.md#routerstate) - - -## License - -MIT License, Copyright © 2020 James K. Nelson diff --git a/packages/retil-router/jest.config.js b/packages/retil-router/jest.config.js deleted file mode 100644 index b6a178c5..00000000 --- a/packages/retil-router/jest.config.js +++ /dev/null @@ -1,15 +0,0 @@ -const { pathsToModuleNameMapper } = require('ts-jest/utils') -// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file -// which contains the path mapping (ie the `compilerOptions.paths` option): -const { compilerOptions } = require('../../tsconfig') - -module.exports = { - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { - prefix: '/../', - }), - modulePaths: ['/src/'], - modulePathIgnorePatterns: ['/demo/'], - preset: 'ts-jest', - testEnvironment: 'jsdom', - testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', -} diff --git a/packages/retil-router/package.json b/packages/retil-router/package.json deleted file mode 100644 index 69eca2d4..00000000 --- a/packages/retil-router/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "retil-router", - "version": "0.20.1", - "description": "Simple, powerful routing that grows with your app.", - "author": "James K Nelson ", - "license": "MIT", - "main": "dist/commonjs/index.js", - "module": "dist/es/index.js", - "types": "dist/types/index.d.ts", - "scripts": { - "clean": "rimraf dist", - "build:commonjs": "tsc -p tsconfig.build.json --module commonjs --outDir dist/commonjs", - "build:es": "tsc -p tsconfig.build.json --module es2015 --outDir dist/es", - "build:types": "tsc -p tsconfig.build.json --declaration --emitDeclarationOnly --outDir dist/types --isolatedModules false", - "build": "yarn run clean && yarn build:es && yarn build:commonjs && yarn build:types", - "build:watch": "yarn run clean && yarn build:es -- --types --watch", - "lint": "eslint --ext ts,tsx src", - "prepare": "yarn test && yarn build", - "test": "jest", - "test:watch": "jest --watch" - }, - "dependencies": { - "abort-controller": "^3.0.0", - "path-to-regexp": "^6.1.0", - "retil-history": "^0.20.1", - "retil-source": "^0.20.1", - "retil-support": "^0.20.1", - "tslib": "2.0.1" - }, - "devDependencies": { - "typescript": "4.2.4" - }, - "files": [ - "dist" - ], - "keywords": [ - "react", - "routing", - "router", - "navigation" - ], - "gitHead": "c3d07b313e425c572bf9b7bce2ca8ff09fb0f446" -} diff --git a/packages/retil-router/src/browserNavigationEnvironmentService.ts b/packages/retil-router/src/browserNavigationEnvironmentService.ts deleted file mode 100644 index a861573e..00000000 --- a/packages/retil-router/src/browserNavigationEnvironmentService.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - BrowserHistory, - BrowserHistoryOptions, - History, - MemoryHistory, - createBrowserHistory as baseCreateBrowserHistory, - createMemoryHistory as baseCreateMemoryHistory, -} from 'history' -import { act, observe } from 'retil-source' - -import { - HistoryController, - HistoryLocation, - HistoryLocationReducer, - HistoryService, - HistorySnapshot, - HistoryState, - PrecachedSnapshot, -} from './historyTypes' -import { createActionMap, parseLocation, resolveAction } from './historyUtils' - -const defaultLocationReducer: HistoryLocationReducer = ( - location, - action, -) => resolveAction(action, location.pathname) - -export function createBrowserHistory( - options?: BrowserHistoryOptions, -): HistoryService { - return createHistoryService( - baseCreateBrowserHistory(options) as BrowserHistory, - ) -} - -export function createMemoryHistory( - initialLocation: string | HistoryLocation, -): HistoryService { - return createHistoryService( - baseCreateMemoryHistory({ - initialEntries: [parseLocation(initialLocation)], - }) as MemoryHistory, - ) -} - -export function createHistoryService( - history: History, - locationReducer: HistoryLocationReducer = defaultLocationReducer, -): HistoryService { - let forceChange = false - let lastRequest = { - ...parseLocation(history.location), - historyKey: history.location.key, - } as HistorySnapshot - - const precachedRequests = - createActionMap & PrecachedSnapshot>() - - const source = observe>((next) => { - next(lastRequest) - return history.listen(({ location }) => { - const parsedLocation = parseLocation(location) - const precachedRequest = precachedRequests.get(parsedLocation) - lastRequest = { - ...(precachedRequest || parsedLocation), - historyKey: location.key, - } - precachedRequests.clear() - next(lastRequest) - }) - }) - - const runMaybeBlockedAction = (callback: () => any): Promise => { - const key = history.location.key - return new Promise((resolve) => - act(source, callback).finally(() => { - resolve(history.location.key !== key) - }), - ) - } - - const controller: HistoryController = { - back: (): Promise => - runMaybeBlockedAction(() => { - history.back() - }), - - block: (predicate) => { - const unblock = history.block((tx) => { - if (forceChange) { - unblock() - tx.retry() - } else { - const location = parseLocation(tx.location) - act(source, () => { - return predicate(location, tx.action).then((shouldBlock) => { - if (!shouldBlock) { - unblock() - tx.retry() - } - }) - }) - } - }) - return unblock - }, - - navigate: (action, options): Promise => { - const { replace = false } = options || {} - let location: HistoryLocation - return runMaybeBlockedAction(() => { - location = locationReducer(lastRequest, action) - forceChange = !!options?.force - try { - history[replace ? 'replace' : 'push'](location, location.state) - } finally { - forceChange = false - } - }) - }, - - precache: (action): Promise & PrecachedSnapshot> => { - const location = locationReducer(lastRequest, action) - - let precachedRequest = precachedRequests.get(location) - if (!precachedRequest) { - precachedRequest = { - ...location, - precacheKey: Symbol(), - } - delete precachedRequest.historyKey - precachedRequests.set(location, precachedRequest) - } - - return Promise.resolve(precachedRequest) - }, - } - - return [source, controller] -} diff --git a/packages/retil-router/src/components/link.tsx b/packages/retil-router/src/components/link.tsx deleted file mode 100644 index 79de560e..00000000 --- a/packages/retil-router/src/components/link.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import * as React from 'react' - -import { useMatchRoute } from '../hooks/useMatchRoute' -import { UseLinkOptions, useLink } from '../hooks/useLink' -import { useResolveRoute } from '../hooks/useResolveRoute' -import { RouterAction } from '../routerTypes' - -export interface LinkProps - extends UseLinkOptions, - Omit, 'href'> { - active?: boolean - activeClassName?: string - activeStyle?: object - children: React.ReactNode - exact?: boolean - ref?: React.Ref - to: RouterAction -} - -// Need to include this type definition, as the automatically generated one -// is incompatible with some versions of the react typings. -export const Link: React.FunctionComponent = React.forwardRef( - (props: LinkProps, anchorRef: React.Ref) => { - const { - active: activeProp, - activeClassName = '', - activeStyle = {}, - children, - className = '', - disabled, - exact, - onClick: onClickProp, - onMouseEnter: onMouseEnterProp, - prefetchOn, - replace, - state, - style = {}, - to, - ...rest - } = props - - const action = useResolveRoute(to, state) - - const { onClick, onMouseEnter, href } = useLink(action, { - disabled, - onClick: onClickProp, - onMouseEnter: onMouseEnterProp, - prefetchOn, - replace, - }) - - const activeMatch = useMatchRoute(action.pathname + (exact ? '' : '*')) - const active = activeProp ?? activeMatch - - return ( - - ) - }, -) diff --git a/packages/retil-router/src/components/router.tsx b/packages/retil-router/src/components/router.tsx deleted file mode 100644 index cbf496d5..00000000 --- a/packages/retil-router/src/components/router.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react' - -import { UseRouterOptions, useRouter } from '../hooks/useRouter' -import { - RouterFunction, - RouterRouteSnapshot, - RouterResponse, -} from '../routerTypes' - -import { RouterProvider } from './routerProvider' - -export interface RouterProps< - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse -> extends UseRouterOptions { - children: React.ReactNode - fn: RouterFunction -} - -export function Router< - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse ->(props: RouterProps) { - const { children, fn, ...routerOptions } = props - const router = useRouter(fn, routerOptions) - return {children} -} diff --git a/packages/retil-router/src/components/routerContent.tsx b/packages/retil-router/src/components/routerContent.tsx deleted file mode 100644 index eca896fe..00000000 --- a/packages/retil-router/src/components/routerContent.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import * as React from 'react' -import { useRouterContent } from '../hooks/useRouterContent' - -export function RouterContent() { - return <>{useRouterContent()} -} diff --git a/packages/retil-router/src/components/routerProvider.tsx b/packages/retil-router/src/components/routerProvider.tsx deleted file mode 100644 index 28c31e07..00000000 --- a/packages/retil-router/src/components/routerProvider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from 'react' -import { useMemo } from 'react' - -import { - RouterContentContext, - RouterControllerContext, - RouterPendingContext, - RouterRequestContext, -} from '../routerContext' -import { MountedRouterState } from '../routerTypes' - -export interface RouterProviderProps { - children: React.ReactNode - - // Pending is optional, as passing pending will cause changes to context that - // may be undesirable in some applications. - value: Omit & { - pending?: MountedRouterState['pending'] - } -} - -export function RouterProvider({ children, value }: RouterProviderProps) { - const { block, navigate, precache, waitUntilNavigationCompletes } = value - - const controller = useMemo( - () => ({ - block, - navigate, - precache, - waitUntilNavigationCompletes, - }), - [block, navigate, precache, waitUntilNavigationCompletes], - ) - - return ( - - - - - {children} - - - - - ) -} diff --git a/packages/retil-router/src/historyStuff.ts b/packages/retil-router/src/historyStuff.ts deleted file mode 100644 index 0706de28..00000000 --- a/packages/retil-router/src/historyStuff.ts +++ /dev/null @@ -1,580 +0,0 @@ -/** - * A URL pathname, beginning with a /. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.pathname - */ -export type Pathname = string - -/** - * A URL search string, beginning with a ?. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.search - */ -export type Search = string - -/** - * A URL fragment identifier, beginning with a #. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.hash - */ -export type Hash = string - -/** - * An object that is used to associate some arbitrary data with a location, but - * that does not appear in the URL path. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.state - */ -export type State = object | null - -/** - * A unique string associated with a location. May be used to safely store - * and retrieve data in some other storage API, like `localStorage`. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.key - */ -export type Key = string - -/** - * The pathname, search, and hash values of a URL. - */ -export interface Path { - /** - * A URL pathname, beginning with a /. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.pathname - */ - pathname: Pathname - - /** - * A URL search string, beginning with a ?. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.search - */ - search: Search - - /** - * A URL fragment identifier, beginning with a #. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.hash - */ - hash: Hash -} - -/** - * An entry in a history stack. A location contains information about the - * URL path, as well as possibly some arbitrary state and a key. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location - */ -export interface Location extends Path { - /** - * An object of arbitrary data associated with this location. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.state - */ - state: S - - /** - * A unique string associated with this location. May be used to safely store - * and retrieve data in some other storage API, like `localStorage`. - * - * Note: This value is always "default" on the initial location. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.key - */ - key: Key -} - -/** - * A partial Path object that may be missing some properties. - */ -export interface PartialPath { - /** - * The URL pathname, beginning with a /. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.pathname - */ - pathname?: Pathname - - /** - * The URL search string, beginning with a ?. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.search - */ - search?: Search - - /** - * The URL fragment identifier, beginning with a #. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.hash - */ - hash?: Hash -} - -/** - * A partial Location object that may be missing some properties. - */ -export interface PartialLocation extends PartialPath { - /** - * An object of arbitrary data associated with this location. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.state - */ - state?: S - - /** - * A unique string associated with this location. May be used to safely store - * and retrieve data in some other storage API, like `localStorage`. - * - * Note: This value is always "default" on the initial location. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#location.key - */ - key?: Key -} - -/** - * A change to the current location. - */ -export interface Update { - /** - * The action that triggered the change. - */ - action: Action - - /** - * The new location. - */ - location: Location -} - -/** - * A function that receives notifications about location changes. - */ -export interface Listener { - (update: Update): void -} - -/** - * A change to the current location that was blocked. May be retried - * after obtaining user confirmation. - */ -export interface Transition extends Update { - /** - * Retries the update to the current location. - */ - retry(): void -} - -/** - * A function that receives transitions when navigation is blocked. - */ -export interface Blocker { - (tx: Transition): void -} - -/** - * Describes a location that is the destination of some navigation, either via - * `history.push` or `history.replace`. May be either a URL or the pieces of a - * URL path. - */ -export type To = string | PartialPath - -/** - * A history is an interface to the navigation stack. The history serves as the - * source of truth for the current location, as well as provides a set of - * methods that may be used to change it. - * - * It is similar to the DOM's `window.history` object, but with a smaller, more - * focused API. - */ -export interface History { - /** - * The last action that modified the current location. This will always be - * Action.Pop when a history instance is first created. This value is mutable. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.action - */ - readonly action: Action - - /** - * The current location. This value is mutable. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.location - */ - readonly location: Location - - /** - * Returns a valid href for the given `to` value that may be used as - * the value of an attribute. - * - * @param to - The destination URL - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.createHref - */ - createHref(to: To): string - - /** - * Pushes a new location onto the history stack, increasing its length by one. - * If there were any entries in the stack after the current one, they are - * lost. - * - * @param to - The new URL - * @param state - Data to associate with the new location - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.push - */ - push(to: To, state?: S): void - - /** - * Replaces the current location in the history stack with a new one. The - * location that was replaced will no longer be available. - * - * @param to - The new URL - * @param state - Data to associate with the new location - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.replace - */ - replace(to: To, state?: S): void - - /** - * Navigates `n` entries backward/forward in the history stack relative to the - * current index. For example, a "back" navigation would use go(-1). - * - * @param delta - The delta in the stack index - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.go - */ - go(delta: number): void - - /** - * Navigates to the previous entry in the stack. Identical to go(-1). - * - * Warning: if the current location is the first location in the stack, this - * will unload the current document. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.back - */ - back(): void - - /** - * Navigates to the next entry in the stack. Identical to go(1). - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.forward - */ - forward(): void - - /** - * Sets up a listener that will be called whenever the current location - * changes. - * - * @param listener - A function that will be called when the location changes - * @returns unlisten - A function that may be used to stop listening - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.listen - */ - listen(listener: Listener): () => void - - /** - * Prevents the current location from changing and sets up a listener that - * will be called instead. - * - * @param blocker - A function that will be called when a transition is blocked - * @returns unblock - A function that may be used to stop blocking - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#history.block - */ - block(blocker: Blocker): () => void -} - -/** - * A browser history stores the current location in regular URLs in a web - * browser environment. This is the standard for most web apps and provides the - * cleanest URLs the browser's address bar. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#browserhistory - */ -export interface BrowserHistory extends History {} - -/** - * A memory history stores locations in memory. This is useful in stateful - * environments where there is no web browser, such as node tests or React - * Native. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#memoryhistory - */ -export interface MemoryHistory extends History { - index: number -} - -const readOnly: (obj: T) => T = __DEV__ - ? (obj) => Object.freeze(obj) - : (obj) => obj - -//////////////////////////////////////////////////////////////////////////////// -// BROWSER -//////////////////////////////////////////////////////////////////////////////// - -type HistoryState = { - usr: State - key?: string - idx: number -} - -const BeforeUnloadEventType = 'beforeunload' -const PopStateEventType = 'popstate' - -export type BrowserHistoryOptions = { window?: Window } - -/** - * Browser history stores the location in regular URLs. This is the standard for - * most web apps, but it requires some configuration on the server to ensure you - * serve the same app at multiple URLs. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#createbrowserhistory - */ -export function createBrowserHistory( - options: BrowserHistoryOptions = {}, -): BrowserHistory { - let { window = document.defaultView! } = options - let globalHistory = window.history - - function getIndexAndLocation(): [number, Location] { - let { pathname, search, hash } = window.location - let state = globalHistory.state || {} - return [ - state.idx, - readOnly({ - pathname, - search, - hash, - state: state.usr || null, - key: state.key || 'default', - }), - ] - } - - let blockedPopTx: Transition | null = null - function handlePop() { - if (blockedPopTx) { - blockers.call(blockedPopTx) - blockedPopTx = null - } else { - let nextAction = Action.Pop - let [nextIndex, nextLocation] = getIndexAndLocation() - - if (blockers.length) { - if (nextIndex != null) { - let delta = index - nextIndex - if (delta) { - // Revert the POP - blockedPopTx = { - action: nextAction, - location: nextLocation, - retry() { - go(delta * -1) - }, - } - - go(delta) - } - } else { - // Trying to POP to a location with no index. We did not create - // this location, so we can't effectively block the navigation. - warning( - false, - // TODO: Write up a doc that explains our blocking strategy in - // detail and link to it here so people can understand better what - // is going on and how to avoid it. - `You are trying to block a POP navigation to a location that was not ` + - `created by the history library. The block will fail silently in ` + - `production, but in general you should do all navigation with the ` + - `history library (instead of using window.history.pushState directly) ` + - `to avoid this situation.`, - ) - } - } else { - applyTx(nextAction) - } - } - } - - window.addEventListener(PopStateEventType, handlePop) - - let action = Action.Pop - let [index, location] = getIndexAndLocation() - let listeners = createEvents() - let blockers = createEvents() - - if (index == null) { - index = 0 - globalHistory.replaceState({ ...globalHistory.state, idx: index }, '') - } - - function createHref(to: To) { - return typeof to === 'string' ? to : createPath(to) - } - - function getNextLocation(to: To, state: State = null): Location { - return readOnly({ - ...location, - ...(typeof to === 'string' ? parsePath(to) : to), - state, - key: createKey(), - }) - } - - function getHistoryStateAndUrl( - nextLocation: Location, - index: number, - ): [HistoryState, string] { - return [ - { - usr: nextLocation.state, - key: nextLocation.key, - idx: index, - }, - createHref(nextLocation), - ] - } - - function allowTx(action: Action, location: Location, retry: () => void) { - return ( - !blockers.length || (blockers.call({ action, location, retry }), false) - ) - } - - function applyTx(nextAction: Action) { - action = nextAction - ;[index, location] = getIndexAndLocation() - listeners.call({ action, location }) - } - - function push(to: To, state?: State) { - let nextAction = Action.Push - let nextLocation = getNextLocation(to, state) - function retry() { - push(to, state) - } - - if (allowTx(nextAction, nextLocation, retry)) { - let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1) - - // TODO: Support forced reloading - // try...catch because iOS limits us to 100 pushState calls :/ - try { - globalHistory.pushState(historyState, '', url) - } catch (error) { - // They are going to lose state here, but there is no real - // way to warn them about it since the page will refresh... - window.location.assign(url) - } - - applyTx(nextAction) - } - } - - function replace(to: To, state?: State) { - let nextAction = Action.Replace - let nextLocation = getNextLocation(to, state) - function retry() { - replace(to, state) - } - - if (allowTx(nextAction, nextLocation, retry)) { - let [historyState, url] = getHistoryStateAndUrl(nextLocation, index) - - // TODO: Support forced reloading - globalHistory.replaceState(historyState, '', url) - - applyTx(nextAction) - } - } - - function go(delta: number) { - globalHistory.go(delta) - } - - let history: BrowserHistory = { - get action() { - return action - }, - get location() { - return location - }, - createHref, - push, - replace, - go, - back() { - go(-1) - }, - forward() { - go(1) - }, - listen(listener) { - return listeners.push(listener) - }, - block(blocker) { - let unblock = blockers.push(blocker) - - if (blockers.length === 1) { - window.addEventListener(BeforeUnloadEventType, promptBeforeUnload) - } - - return function () { - unblock() - - // Remove the beforeunload listener so the document may - // still be salvageable in the pagehide event. - // See https://html.spec.whatwg.org/#unloading-documents - if (!blockers.length) { - window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload) - } - } - }, - } - - return history -} - -//////////////////////////////////////////////////////////////////////////////// -// UTILS -//////////////////////////////////////////////////////////////////////////////// - -function promptBeforeUnload(event: BeforeUnloadEvent) { - // Cancel the event. - event.preventDefault() - // Chrome (and legacy IE) requires returnValue to be set. - event.returnValue = '' -} - -type Events = { - length: number - push: (fn: F) => () => void - call: (arg: any) => void -} - -function createEvents(): Events { - let handlers: F[] = [] - - return { - get length() { - return handlers.length - }, - push(fn: F) { - handlers.push(fn) - return function () { - handlers = handlers.filter((handler) => handler !== fn) - } - }, - call(arg) { - handlers.forEach((fn) => fn && fn(arg)) - }, - } -} - -function createKey() { - return Math.random().toString(36).substr(2, 8) -} diff --git a/packages/retil-router/src/hooks/useBlockNavigation.ts b/packages/retil-router/src/hooks/useBlockNavigation.ts deleted file mode 100644 index 6e7c8322..00000000 --- a/packages/retil-router/src/hooks/useBlockNavigation.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useContext } from 'react' - -import { RouterControllerContext } from '../routerContext' -import { MountedRouterController } from '../routerTypes' - -export function useBlockNavigation(): MountedRouterController['block'] { - return useContext(RouterControllerContext).block -} diff --git a/packages/retil-router/src/hooks/useLink.tsx b/packages/retil-router/src/hooks/useLink.tsx deleted file mode 100644 index eb95de9d..00000000 --- a/packages/retil-router/src/hooks/useLink.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useCallback, useEffect, useMemo } from 'react' -import { createHref } from 'retil-history' - -import { useResolveRoute } from './useResolveRoute' -import { useNavigate } from './useNavigate' -import { usePrecache } from './usePrecache' -import { RouterAction } from '../routerTypes' - -export interface UseLinkOptions { - disabled?: boolean - replace?: boolean - prefetchOn?: 'hover' | 'mount' - state?: object - onClick?: React.MouseEventHandler - onMouseEnter?: React.MouseEventHandler -} - -export const useLink = (to: RouterAction, options: UseLinkOptions = {}) => { - const { - disabled, - prefetchOn, - replace, - state, - onClick, - onMouseEnter, - } = options - const navigate = useNavigate() - const precache = usePrecache() - const action = useResolveRoute(to, state) - - const doPrecache = useMemo(() => { - let hasPrecached = false - - return () => { - if (!hasPrecached && action && precache) { - hasPrecached = true - precache(action) - } - } - }, [action, precache]) - - // Prefetch on mount if required, or if `prefetch` becomes `true`. - useEffect(() => { - if (prefetchOn === 'mount') { - doPrecache() - } - }, [prefetchOn, doPrecache]) - - let handleMouseEnter = useCallback( - (event: React.MouseEvent) => { - if (prefetchOn === 'hover') { - if (onMouseEnter) { - onMouseEnter(event) - } - - if (disabled) { - event.preventDefault() - return - } - - if (!event.defaultPrevented) { - doPrecache() - } - } - }, - [disabled, doPrecache, onMouseEnter, prefetchOn], - ) - - let handleClick = useCallback( - (event: React.MouseEvent) => { - // Let the browser handle the event directly if: - // - The user used the middle/right mouse button - // - The user was holding a modifier key - // - A `target` property is set (which may cause the browser to open the - // link in another tab) - if ( - event.button === 0 && - !(event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) - ) { - if (disabled) { - event.preventDefault() - return - } - - if (onClick) { - onClick(event) - } - - if (!event.defaultPrevented && action) { - event.preventDefault() - navigate(action, { replace }) - } - } - }, - [disabled, action, navigate, onClick, replace], - ) - - return { - onClick: handleClick, - onMouseEnter: handleMouseEnter, - href: action ? createHref(action) : (to as string), - } -} diff --git a/packages/retil-router/src/hooks/useMatchRoute.ts b/packages/retil-router/src/hooks/useMatchRoute.ts deleted file mode 100644 index 91c43352..00000000 --- a/packages/retil-router/src/hooks/useMatchRoute.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useMemo } from 'react' - -import { createMatcher } from '../routerUtils' - -import { useRouterRequest } from './useRouterRequest' - -/** - * Returns a boolean that indicates whether the user is currently - * viewing the specified pattern. - * @param pattern - */ -export const useMatchRoute = (patterns: string | string[]): boolean => { - const matcher = useMemo( - () => - typeof patterns === 'string' - ? createMatcher(patterns) - : (pathname: string) => { - const matchers = patterns.map(createMatcher) - return matchers.some((matcher) => matcher(pathname)) - }, - [patterns], - ) - const request = useRouterRequest() - return useMemo(() => !!matcher(request.pathname), [matcher, request]) -} diff --git a/packages/retil-router/src/hooks/useNavigate.ts b/packages/retil-router/src/hooks/useNavigate.ts deleted file mode 100644 index 5195ce52..00000000 --- a/packages/retil-router/src/hooks/useNavigate.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useContext } from 'react' - -import { RouterControllerContext } from '../routerContext' -import { MountedRouterController } from '../routerTypes' - -export function useNavigate(): MountedRouterController['navigate'] { - return useContext(RouterControllerContext).navigate -} diff --git a/packages/retil-router/src/hooks/usePrecache.ts b/packages/retil-router/src/hooks/usePrecache.ts deleted file mode 100644 index 8f5f5c27..00000000 --- a/packages/retil-router/src/hooks/usePrecache.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useContext } from 'react' - -import { RouterControllerContext } from '../routerContext' -import { MountedRouterController } from '../routerTypes' - -export function usePrecache(): MountedRouterController['precache'] { - return useContext(RouterControllerContext).precache -} diff --git a/packages/retil-router/src/hooks/useResolveRoute.ts b/packages/retil-router/src/hooks/useResolveRoute.ts deleted file mode 100644 index 320ec0ab..00000000 --- a/packages/retil-router/src/hooks/useResolveRoute.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useMemo } from 'react' -import { parseAction, resolveAction } from 'retil-history' - -import { RouterAction, RouterLocation } from '../routerTypes' - -import { useRouterRequest } from './useRouterRequest' - -export const useResolveRoute = ( - action: RouterAction, - state?: object, -): RouterLocation => { - const { pathname } = useRouterRequest() - const resolved = useMemo( - () => resolveAction(parseAction(action, state), pathname), - [action, pathname, state], - ) - - // Memoize by action parts so that we'll output the same location object - // until something actually changes. - const deps = [ - resolved?.pathname, - resolved?.search, - resolved?.hash, - resolved?.state, - ] - - // eslint-disable-next-line react-hooks/exhaustive-deps - return useMemo(() => resolved, deps) -} diff --git a/packages/retil-router/src/hooks/useRouter.tsx b/packages/retil-router/src/hooks/useRouter.tsx deleted file mode 100644 index c4b42dc0..00000000 --- a/packages/retil-router/src/hooks/useRouter.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useMemo, useRef } from 'react' -import { FusorUse } from 'retil-source' - -import { createRequestService } from '../requestService' -import { - RouterFunction, - RouterRequestService, - RouterHistorySnapshot, - RouterResponse, - RouterRouteSnapshot, - MountedRouterState, - RouterSnapshotExtension, -} from '../routerTypes' -import { createRouter } from '../routerService' - -import { useRouterService } from './useRouterService' - -export interface UseRouterOptions< - RouteExtension extends object = {}, - HistorySnapshot extends RouterHistorySnapshot = RouterHistorySnapshot -> { - basename?: string - extend?: ( - request: HistorySnapshot & RouterSnapshotExtension, - use: FusorUse, - ) => RouteExtension - historyService?: RouterRequestService - onResponseComplete?: (response: Response, request: RouteExtension) => void - transitionTimeoutMs?: number - unstable_isConcurrent?: boolean -} - -export function useRouter< - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse ->( - routerFunction: RouterFunction, - options: UseRouterOptions = {}, -): MountedRouterState { - const { - requestService: requestServiceProp, - initialSnapshot, - onResponseComplete, - transitionTimeoutMs = Infinity, - unstable_isConcurrent, - } = options - - const requestServiceRef = useRef>( - requestServiceProp!, - ) - if (requestServiceProp) { - requestServiceRef.current = requestServiceProp - } - if (!requestServiceRef.current && typeof window !== 'undefined') { - requestServiceRef.current = createRequestService() as RouterRequestService - } - if (!requestServiceRef.current) { - throw new Error( - `On the server, you must provide a history object to useRouter.`, - ) - } - - const requestService = requestServiceRef.current - const routerService = useMemo( - () => createRouter(routerFunction, requestService, { initialSnapshot }), - [requestService, routerFunction, initialSnapshot], - ) - - return useRouterService(routerService, { - onResponseComplete, - transitionTimeoutMs, - unstable_isConcurrent, - }) -} diff --git a/packages/retil-router/src/hooks/useRouterContent.ts b/packages/retil-router/src/hooks/useRouterContent.ts deleted file mode 100644 index ea107588..00000000 --- a/packages/retil-router/src/hooks/useRouterContent.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ReactNode, useContext } from 'react' - -import { RouterContentContext } from '../routerContext' - -export function useRouterContent(): ReactNode { - return useContext(RouterContentContext) -} diff --git a/packages/retil-router/src/hooks/useRouterPending.tsx b/packages/retil-router/src/hooks/useRouterPending.tsx deleted file mode 100644 index 19804a5f..00000000 --- a/packages/retil-router/src/hooks/useRouterPending.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useContext } from 'react' - -import { RouterPendingContext } from '../routerContext' -import { RouterRouteSnapshot } from '../routerTypes' - -export function useRouterPending< - Request extends RouterRouteSnapshot = RouterRouteSnapshot ->(): Request | boolean { - return useContext(RouterPendingContext) as Request -} diff --git a/packages/retil-router/src/hooks/useRouterRequest.tsx b/packages/retil-router/src/hooks/useRouterRequest.tsx deleted file mode 100644 index 14ba8be2..00000000 --- a/packages/retil-router/src/hooks/useRouterRequest.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from 'react' - -import { RouterRequestContext } from '../routerContext' -import { RouterRouteSnapshot } from '../routerTypes' - -export function useRouterRequest< - Request extends RouterRouteSnapshot = RouterRouteSnapshot ->(forceRequest?: Request): Request { - const contextRequest = useContext(RouterRequestContext) as Request - return forceRequest || contextRequest -} diff --git a/packages/retil-router/src/hooks/useRouterScroller.ts b/packages/retil-router/src/hooks/useRouterScroller.ts deleted file mode 100644 index d8614889..00000000 --- a/packages/retil-router/src/hooks/useRouterScroller.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { useLayoutEffect, useRef } from 'react' -import { noop } from 'retil-support' - -import { RouterRouteSnapshot } from '../routerTypes' - -import { useRouterRequest } from './useRouterRequest' -import { useWaitUntilNavigationCompletes } from './useWaitUntilNavigationCompletes' - -// React currently throws a warning when using useLayoutEffect on the server. -// To get around it, we can conditionally useEffect on the server (no-op) and -// useLayoutEffect in the browser. We need useLayoutEffect because we want -// `connect` to perform sync updates to a ref to save the latest props after -// a render is actually committed to the DOM. -const useClientSideOnlyLayoutEffect = - typeof window !== 'undefined' ? useLayoutEffect : noop - -export interface UseRouterScrollerOptions< - Request extends RouterRouteSnapshot = RouterRouteSnapshot -> { - // Useful for nested layouts with suspense wrappers, where you might want - // to leave scrolling to be handled by a child component when the inner - // suspense finishes loading. - getWillChildHandleScroll?: () => boolean - getShouldScroll?: (prevRequest: Request, nextRequest: Request) => boolean - scrollToRequest?: (request: Request) => boolean -} - -let hasHydrated = false - -export function useRouterScroller< - Request extends RouterRouteSnapshot = RouterRouteSnapshot ->(options: UseRouterScrollerOptions = {}) { - const { - getWillChildHandleScroll, - getShouldScroll = defaultGetShouldScroll, - scrollToRequest = defaultScrollToRequest, - } = options - - const request = useRouterRequest() - const waitUntilNavigationCompletes = useWaitUntilNavigationCompletes() - const scrollRequestRef = useRef(request) - - if (request !== scrollRequestRef.current) { - const nextRequest = request - const prevRequest = scrollRequestRef.current - const shouldScroll = getShouldScroll(prevRequest, nextRequest) - - if (shouldScroll) { - scrollRequestRef.current = request - - try { - // Save the scroll position before the update actually occurs - sessionStorage.setItem( - '__retil_scroll_' + prevRequest.historyKey!, - JSON.stringify({ x: window.pageXOffset, y: window.pageYOffset }), - ) - } catch {} - } - } - - const scrollRequest = scrollRequestRef.current - - useClientSideOnlyLayoutEffect(() => { - let unmounted = false - - if (!getWillChildHandleScroll || !getWillChildHandleScroll()) { - if (!hasHydrated) { - window.history.scrollRestoration = 'manual' - hasHydrated = true - } else { - const didScroll = scrollToRequest(scrollRequest) - if (!didScroll) { - waitUntilNavigationCompletes().then(() => { - if ( - !unmounted && - (!getWillChildHandleScroll || !getWillChildHandleScroll()) - ) { - scrollToRequest(scrollRequest) - } - }) - } - } - } - - return () => { - unmounted = true - } - }, [scrollRequest]) -} - -const defaultGetShouldScroll = ( - prev: RouterRouteSnapshot, - next: RouterRouteSnapshot, -) => prev.hash !== next.hash || prev.pathname !== next.pathname - -export const defaultScrollToRequest = (request: RouterRouteSnapshot) => { - // TODO: if scrolling to a hash within the same page, ignore - // the scroll history and just scroll directly there - - let scrollCoords: { x: number; y: number } - try { - scrollCoords = JSON.parse( - sessionStorage.getItem('__retil_scroll_' + request.historyKey)!, - ) || { x: 0, y: 0 } - } catch { - if (!request.hash) { - scrollCoords = { x: 0, y: 0 } - } else { - const id = document.getElementById(request.hash.slice(1)) - if (!id) { - return false - } - const { top, left } = id.getBoundingClientRect() - scrollCoords = { - x: left + window.pageXOffset, - y: top + window.pageYOffset, - } - } - } - - // Check that the element we want to scroll to is in view - const maxScrollTop = Math.max( - 0, - document.documentElement.scrollHeight - - document.documentElement.clientHeight, - ) - const maxScrollLeft = Math.max( - 0, - document.documentElement.scrollWidth - document.documentElement.clientWidth, - ) - - if (scrollCoords.x > maxScrollLeft || scrollCoords.y > maxScrollTop) { - return false - } - - window.scroll(scrollCoords.x, scrollCoords.y) - - return true -} diff --git a/packages/retil-router/src/hooks/useRouterService.ts b/packages/retil-router/src/hooks/useRouterService.ts deleted file mode 100644 index e6cef495..00000000 --- a/packages/retil-router/src/hooks/useRouterService.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useEffect, useMemo, useRef } from 'react' -import { - MountedRouterState, - RouterRouteSnapshot, - RouterResponse, - RouterService, -} from '../routerTypes' - -import { useRouterSource } from './useRouterSource' - -export interface UseRouterServiceOptions< - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse -> { - /** - * Called when a complete response object becomes available. - */ - onResponseComplete?: (response: Response, request: Request) => void - - transitionTimeoutMs?: number - - unstable_isConcurrent?: boolean -} - -export function useRouterService< - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse ->( - routerService: RouterService, - options: UseRouterServiceOptions = {}, -): MountedRouterState { - const { - onResponseComplete, - transitionTimeoutMs, - unstable_isConcurrent, - } = options - const [source, routerController] = routerService - const [ - { content, request, response }, - pending, - waitUntilNavigationCompletes, - ] = useRouterSource(source, { - transitionTimeoutMs, - unstable_isConcurrent, - }) - - const onResponseCompleteRef = useRef(onResponseComplete) - useEffect(() => { - onResponseCompleteRef.current = onResponseComplete - }, [onResponseComplete]) - - useEffect(() => { - waitUntilNavigationCompletes().then((snapshot) => { - const handler = onResponseCompleteRef.current - if (handler && snapshot.request === request) { - handler(snapshot.response, snapshot.request) - } - }) - }, [request, response, waitUntilNavigationCompletes]) - - return useMemo( - () => ({ - ...routerController, - content, - navigate: (...args) => - routerController - .navigate(...args) - .then((navigated) => - navigated ? waitUntilNavigationCompletes().then(() => true) : false, - ), - pending, - request, - waitUntilNavigationCompletes, - }), - [content, routerController, pending, request, waitUntilNavigationCompletes], - ) -} diff --git a/packages/retil-router/src/hooks/useRouterSource.ts b/packages/retil-router/src/hooks/useRouterSource.ts deleted file mode 100644 index 32bf405f..00000000 --- a/packages/retil-router/src/hooks/useRouterSource.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react' -import { getSnapshotPromise } from 'retil-source' -import { Deferred } from 'retil-support' - -import { - RouterRouteSnapshot, - RouterResponse, - RouterSource, - RouterRouteSnapshot, -} from '../routerTypes' -import { waitForResponse } from '../routerUtils' - -import { UseRouterSourceOptions } from './useRouterSourceCommon' - -// Avoid the `use` name to disable the no conditional hooks lint check. -import { useRouterSourceBlocking as _useRouterSourceBlocking } from './useRouterSourceBlocking' -import { useRouterSourceConcurrent as _useRouterSourceConcurrent } from './useRouterSourceConcurrent' - -export const useRouterSource = < - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse ->( - source: RouterSource, - options: UseRouterSourceOptions = {}, -): readonly [ - RouterRouteSnapshot, - Request | boolean, - () => Promise>, -] => { - const [snapshot, pending] = options.unstable_isConcurrent - ? _useRouterSourceConcurrent(source, options) - : _useRouterSourceBlocking(source, options) - - const request = snapshot.request - const latestSourceRef = useRef(source) - const latestRequestRef = useRef(request) - const waitingDeferredsRef = useRef[]>([]) - - // Only change the source once it's commited, as we don't want to wait for - // changes from sources that never subscribed to. - useEffect(() => { - latestRequestRef.current = request - latestSourceRef.current = source - - const deferreds = waitingDeferredsRef.current.slice() - waitingDeferredsRef.current = [] - for (const deferred of deferreds) { - deferred.resolve() - } - }, [request, source]) - - const waitUntilNavigationCompletes = useCallback((): Promise< - RouterRouteSnapshot - > => { - const source = latestSourceRef.current - return getSnapshotPromise(source).then((snapshot) => - waitForResponse(snapshot.response).then(() => { - if (source !== latestSourceRef.current) { - // The source has updated, so start waiting with the new source - return waitUntilNavigationCompletes() - } else if (snapshot.request !== latestRequestRef.current) { - // The latest snapshot hasn't been rendered yet, so wait until - // the effect where it is first rendered. - const deferred = new Deferred() - waitingDeferredsRef.current.push(deferred) - return deferred.promise.then(waitUntilNavigationCompletes) - } else { - return snapshot - } - }), - ) - }, []) - - return [snapshot, pending, waitUntilNavigationCompletes] -} - -export * from './useRouterSourceCommon' diff --git a/packages/retil-router/src/hooks/useRouterSourceBlocking.ts b/packages/retil-router/src/hooks/useRouterSourceBlocking.ts deleted file mode 100644 index 90c36d8a..00000000 --- a/packages/retil-router/src/hooks/useRouterSourceBlocking.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * For the blocking mode hook, we can't just `useSource()` if we want to - * get route transitions which wait for the next route to load, because - * useSource() (and useSubscription()) always immediately sets state -- - * even if we provide a `startTransition` function. - * - * In order to subscribe to a source *and maybe wait before saving the received - * value to React state*, we'll need custom subscription logic. That's what - * this hook provides. - */ - -import { useEffect, useMemo, useState } from 'react' -import { delay, areObjectsShallowEqual } from 'retil-support' -import { - Source, - getSnapshot, - hasSnapshot, - mergeLatest, - subscribe, -} from 'retil-source' - -import { - RouterRouteSnapshot, - RouterResponse, - RouterRouteSnapshot, - RouterSource, -} from '../routerTypes' -import { waitForResponse } from '../routerUtils' - -import { - UseRouterSourceFunction, - UseRouterSourceOptions, -} from './useRouterSourceCommon' - -export const useRouterSourceBlocking: UseRouterSourceFunction = < - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse ->( - source: RouterSource, - options: UseRouterSourceOptions = {}, -): readonly [RouterRouteSnapshot, Request | boolean] => { - const { transitionTimeoutMs = Infinity } = options - - const mergedSource = useMemo( - () => - mergeLatest( - source, - (latestSnapshot, isSuspended) => [latestSnapshot, isSuspended] as const, - ), - [source], - ) - - const [state, setState] = useState<{ - currentSnapshot: RouterRouteSnapshot - pendingSnapshot: RouterRouteSnapshot | null - mergedSource: Source | null - sourcePending: boolean - }>(() => { - const initialSnapshot = getSnapshot(mergedSource)[0] - return { - currentSnapshot: initialSnapshot, - pendingSnapshot: initialSnapshot.response.pendingSuspenses.length - ? initialSnapshot - : null, - mergedSource, - sourcePending: false, - } - }) - - const handleNewSnapshot = useMemo(() => { - let hasGotInitialSnapshot = false - - return (force?: boolean, newSource?: boolean) => { - hasGotInitialSnapshot = - !!force || hasGotInitialSnapshot || hasSnapshot(mergedSource) - let [newSnapshot, sourcePending] = - hasGotInitialSnapshot || transitionTimeoutMs === 0 - ? getSnapshot(mergedSource) - : [undefined, true] - const pendingSnapshot = - newSnapshot?.response.pendingSuspenses.length && - transitionTimeoutMs !== 0 - ? newSnapshot - : null - setState((state) => { - const nextState = { - currentSnapshot: - !sourcePending && !pendingSnapshot - ? newSnapshot! - : state.currentSnapshot, - pendingSnapshot, - mergedSource, - sourcePending, - } - return (newSource || state.mergedSource === mergedSource) && - !areObjectsShallowEqual(state, nextState) - ? nextState - : state - }) - } - }, [mergedSource, transitionTimeoutMs]) - - if (mergedSource !== state.mergedSource) { - handleNewSnapshot(false, true) - } - - // Don't waitForResponse until an effect has run, as this can trigger - // lazily loaded promises that we only want to run once. - const pendingSnapshot = state.pendingSnapshot - useEffect(() => { - let unsubscribed = false - if (pendingSnapshot) { - Promise.race( - [waitForResponse(pendingSnapshot.response)].concat( - transitionTimeoutMs === Infinity ? [] : [delay(transitionTimeoutMs)], - ), - ).then(() => { - if (!unsubscribed) { - setState((state) => - pendingSnapshot === state.pendingSnapshot - ? { - currentSnapshot: pendingSnapshot, - pendingSnapshot: null, - mergedSource, - sourcePending: false, - } - : state, - ) - } - }) - } - - return () => { - unsubscribed = true - } - }, [pendingSnapshot, mergedSource, transitionTimeoutMs]) - - useEffect(() => { - let unsubscribed = false - - // If there's no initial snapshot, then calling getSnapshot() will throw - // a Suspense. But if we've given a transition timeout, we'll do it anyway. - if ( - !hasSnapshot(mergedSource) && - transitionTimeoutMs !== Infinity && - transitionTimeoutMs === 0 - ) { - delay(transitionTimeoutMs).then(() => { - if (!unsubscribed) { - handleNewSnapshot(true) - } - }) - } - - // It's possible that something has changed between the new source being - // first rendered, and React calling this effect. So we'll call this - // again just in case. - handleNewSnapshot() - - const unsubscribe = subscribe(mergedSource, handleNewSnapshot) - - return () => { - unsubscribed = true - unsubscribe() - } - }, [mergedSource, handleNewSnapshot, transitionTimeoutMs]) - - return [ - state.currentSnapshot, - pendingSnapshot ? pendingSnapshot.request : state.sourcePending, - ] -} diff --git a/packages/retil-router/src/hooks/useRouterSourceCommon.ts b/packages/retil-router/src/hooks/useRouterSourceCommon.ts deleted file mode 100644 index 1ab1e450..00000000 --- a/packages/retil-router/src/hooks/useRouterSourceCommon.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - RouterRouteSnapshot, - RouterResponse, - RouterSource, - RouterRouteSnapshot, -} from '../routerTypes' - -export interface UseRouterSourceOptions { - transitionTimeoutMs?: number - unstable_isConcurrent?: boolean -} - -export interface UseRouterSourceFunction { - < - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse - >( - source: RouterSource, - options?: UseRouterSourceOptions, - ): readonly [RouterRouteSnapshot, Request | boolean] -} diff --git a/packages/retil-router/src/hooks/useRouterSourceConcurrent.ts b/packages/retil-router/src/hooks/useRouterSourceConcurrent.ts deleted file mode 100644 index 4d029fd2..00000000 --- a/packages/retil-router/src/hooks/useRouterSourceConcurrent.ts +++ /dev/null @@ -1,61 +0,0 @@ -/// - -import * as React from 'react' -import { useMemo, useRef } from 'react' -import { mergeLatest, useSource } from 'retil-source' - -import { - RouterRouteSnapshot, - RouterResponse, - RouterRouteSnapshot, - RouterSource, -} from '../routerTypes' - -import { - UseRouterSourceFunction, - UseRouterSourceOptions, -} from './useRouterSourceCommon' - -const { unstable_useTransition: useTransition } = React - -export const useRouterSourceConcurrent: UseRouterSourceFunction = < - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse ->( - source: RouterSource, - options: UseRouterSourceOptions = {}, -): readonly [RouterRouteSnapshot, boolean] => { - const [startTransition, pending] = useTransition() - const latestSource = useMemo( - () => - mergeLatest( - source, - (latestSnapshot, isSuspended) => [latestSnapshot, isSuspended] as const, - ), - [source], - ) - - const lastSnapshot = useRef | null>( - null, - ) - const [currentSnapshot, isSuspended] = - useSource(latestSource, { - defaultValue: null, - startTransition, - }) || ([null, false] as const) - - const snapshotPending = - pending || (currentSnapshot ? isSuspended : !!lastSnapshot.current) - - const snapshot = currentSnapshot || lastSnapshot.current - - // Changing routers isn't covered by a transition, and we don't want an - // initially empty new router to trigger a loading screen, so we'll use - // the last snapshot from the previous router until the first snapshot - // is available. - if (currentSnapshot) { - lastSnapshot.current = currentSnapshot - } - - return [snapshot!, snapshotPending] -} diff --git a/packages/retil-router/src/hooks/useWaitUntilNavigationCompletes.ts b/packages/retil-router/src/hooks/useWaitUntilNavigationCompletes.ts deleted file mode 100644 index 62328467..00000000 --- a/packages/retil-router/src/hooks/useWaitUntilNavigationCompletes.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from 'react' - -import { RouterControllerContext } from '../routerContext' -import { MountedRouterController } from '../routerTypes' - -export function useWaitUntilNavigationCompletes( - forceValue?: MountedRouterController['waitUntilNavigationCompletes'], -): MountedRouterController['waitUntilNavigationCompletes'] { - const contextController = useContext(RouterControllerContext) - return forceValue || contextController.waitUntilNavigationCompletes -} diff --git a/packages/retil-router/src/index.ts b/packages/retil-router/src/index.ts deleted file mode 100644 index 36ad27e2..00000000 --- a/packages/retil-router/src/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -export { - resolveAction, - createBrowserHistory, - createHref, - createMemoryHistory, - isExternalHref, - joinPaths, - normalizePathname, - parseAction, - parseLocation, -} from 'retil-history' - -export * from './requestService' -export * from './routerService' -export * from './routerTypes' -export * from './routerUtils' - -export * from './components/link' -export * from './components/router' -export * from './components/routerContent' -export * from './components/routerProvider' - -export * from './hooks/useBlockNavigation' -export * from './hooks/useLink' -export * from './hooks/useMatchRoute' -export * from './hooks/useNavigate' -export * from './hooks/usePrecache' -export * from './hooks/useResolveRoute' -export * from './hooks/useRouter' -export * from './hooks/useRouterContent' -export * from './hooks/useRouterPending' -export * from './hooks/useRouterRequest' -export * from './hooks/useRouterScroller' -export * from './hooks/useRouterService' -export * from './hooks/useWaitUntilNavigationCompletes' - -export * from './routers/routeAsync' -export * from './routers/routeByPattern' -export * from './routers/routeLazy' -export * from './routers/routeNotFound' -export * from './routers/routeNotFoundBoundary' -export * from './routers/routeRedirect' -export * from './routers/routeProvide' diff --git a/packages/retil-router/src/locationUtils.ts b/packages/retil-router/src/locationUtils.ts deleted file mode 100644 index 6827d6a5..00000000 --- a/packages/retil-router/src/locationUtils.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { parsePath } from 'history' -import { parse as parseQuery, stringify as stringifyQuery } from 'querystring' - -import { - HistoryAction, - HistoryActionObject, - HistoryLocation, - HistoryState, -} from './historyTypes' - -export function createHref(request: HistoryActionObject): string { - return ( - encodeURI(normalizePathname(request.pathname || '')) + - (request.search || '') + - (request.hash || '') - ) -} - -export function isExternalHref(href: string | HistoryAction) { - // If this is an external link, return undefined so that the native - // response will be used. - return ( - !href || - (typeof href === 'string' && - (href.indexOf('://') !== -1 || href.indexOf('mailto:') === 0)) - ) -} - -// users/789/, profile => users/789/profile/ -// /users/123, . => /users/123 -// /users/123, .. => /users -// /users/123, ../.. => / -// /a/b/c/d, ../../one => /a/b/one -// /a/b/c/d, .././one/ => /a/b/c/one/ -export function joinPaths(base: string, ...paths: string[]): string { - let allSegments = splitPath(base) - for (let i = 0; i < paths.length; i++) { - allSegments.push(...splitPath(paths[i])) - } - - let pathSegments: string[] = [] - let lastSegmentIndex = allSegments.length - 1 - for (let i = 0; i <= lastSegmentIndex; i++) { - let segment = allSegments[i] - if (segment === '..') { - pathSegments.pop() - } - // Allow empty segments on the first character, so that leading - // slashes will not be affected. - else if (segment !== '.' && (segment !== '' || i === 0)) { - pathSegments.push(segment) - } - } - - return pathSegments.join('/') -} - -function splitPath(path: string): string[] { - if (path === '') { - return [] - } - return path.split('/') -} - -export function normalizePathname(pathname: string): string { - return decodeURI( - pathname - .replace(/\/+/g, '/') - .replace(/(.)\/$/, '$1') - .normalize(), - ) -} - -export function resolveAction( - action: string | HistoryAction, - currentPathname: string, -): HistoryLocation { - if (isExternalHref(action)) { - throw new Error( - 'retil-router: applyAction cannot be applied to external URLs', - ) - } - - const parsedAction = parseAction(action) - - let pathname = parsedAction.pathname - - // If no relativity specifier is provided, use the browser default of - // replacing the last segment. - if (pathname) { - pathname = - pathname[0] === '/' - ? pathname - : joinPaths( - currentPathname, - /^\.\.?\//.test(pathname) ? '.' : '..', - pathname, - ) - } - - return { - hash: parsedAction.hash || '', - pathname: normalizePathname(pathname || currentPathname), - query: parsedAction.query || {}, - search: parsedAction.search || '', - state: parsedAction.state || null, - } -} - -export function parseAction( - input: string | HistoryAction, - state?: S, -): Exclude, string> { - const action: HistoryAction = - typeof input === 'string' ? parsePath(input) : { ...input } - - if (state) { - action.state = state - } - - if (action.search) { - if (!action.query) { - action.query = parseQuery(action.search.slice(1)) - } else if (process.env.NODE_ENV !== 'production') { - const stringifiedActionQuery = stringifyQuery(action.query) - if (stringifiedActionQuery !== action.search.slice(1)) { - console.error( - `A path was provided with differing "search" and "query" parameters. Ignoring "search" in favor of "query".`, - ) - } - } - } - if (action.query) { - const stringifiedQuery = stringifyQuery(action.query) - action.search = stringifiedQuery ? '?' + stringifiedQuery : '' - } - - if (action.pathname) { - action.pathname = decodeURI(action.pathname) - } - - return action -} - -export function parseLocation( - input: string | HistoryAction, -): HistoryLocation { - return { - hash: '', - pathname: '', - query: {}, - search: '', - state: null, - ...parseAction(input), - } -} - -export function getActionKey(action: HistoryAction): [any, string] { - const parsedAction = parseAction(action) - return [parsedAction.state, createHref(parsedAction)] -} - -export interface ActionMap { - clear(): void - delete(action: HistoryAction): void - get(action: HistoryAction): T | undefined - set(action: HistoryAction, value: T): void -} - -export function createActionMap(): ActionMap { - const map = new Map() - - const clear = () => map.clear() - - const del = (action: HistoryAction): void => { - const [state, url] = getActionKey(action) - const innerMap = map.get(state) - if (innerMap) { - delete innerMap[url] - if (!Object.keys(innerMap).length) { - map.delete(state) - } - } - } - - const get = (action: HistoryAction): T | undefined => { - const [state, url] = getActionKey(action) - const innerMap = map.get(state) - return innerMap && innerMap[url] - } - - const set = (action: HistoryAction, value: T): void => { - const [state, url] = getActionKey(action) - const innerMap = map.get(state) - if (!innerMap) { - map.set(state, { [url]: value }) - } else { - innerMap[url] = value - } - } - - return { clear, delete: del, get, set } -} diff --git a/packages/retil-router/src/memoryNavigationService.ts b/packages/retil-router/src/memoryNavigationService.ts deleted file mode 100644 index df32b5fb..00000000 --- a/packages/retil-router/src/memoryNavigationService.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * A user-supplied object that describes a location. Used when providing - * entries to `createMemoryHistory` via its `initialEntries` option. - */ -export type InitialEntry = string | PartialLocation - -export type MemoryHistoryOptions = { - initialEntries?: InitialEntry[] - initialIndex?: number -} - -/** - * Memory history stores the current location in memory. It is designed for use - * in stateful non-browser environments like tests and React Native. - * - * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#creatememoryhistory - */ -export function createMemoryHistory( - options: MemoryHistoryOptions = {}, -): MemoryHistory { - let { initialEntries = ['/'], initialIndex } = options - let entries: Location[] = initialEntries.map((entry) => { - let location = readOnly({ - pathname: '/', - search: '', - hash: '', - state: null, - key: createKey(), - ...(typeof entry === 'string' ? parsePath(entry) : entry), - }) - - warning( - location.pathname.charAt(0) === '/', - `Relative pathnames are not supported in createMemoryHistory({ initialEntries }) (invalid entry: ${JSON.stringify( - entry, - )})`, - ) - - return location - }) - let index = clamp( - initialIndex == null ? entries.length - 1 : initialIndex, - 0, - entries.length - 1, - ) - - let action = Action.Pop - let location = entries[index] - let listeners = createEvents() - let blockers = createEvents() - - function createHref(to: To) { - return typeof to === 'string' ? to : createPath(to) - } - - function getNextLocation(to: To, state: State = null): Location { - return readOnly({ - ...location, - ...(typeof to === 'string' ? parsePath(to) : to), - state, - key: createKey(), - }) - } - - function allowTx(action: Action, location: Location, retry: () => void) { - return ( - !blockers.length || (blockers.call({ action, location, retry }), false) - ) - } - - function applyTx(nextAction: Action, nextLocation: Location) { - action = nextAction - location = nextLocation - listeners.call({ action, location }) - } - - function push(to: To, state?: State) { - let nextAction = Action.Push - let nextLocation = getNextLocation(to, state) - function retry() { - push(to, state) - } - - warning( - location.pathname.charAt(0) === '/', - `Relative pathnames are not supported in memory history.push(${JSON.stringify( - to, - )})`, - ) - - if (allowTx(nextAction, nextLocation, retry)) { - index += 1 - entries.splice(index, entries.length, nextLocation) - applyTx(nextAction, nextLocation) - } - } - - function replace(to: To, state?: State) { - let nextAction = Action.Replace - let nextLocation = getNextLocation(to, state) - function retry() { - replace(to, state) - } - - warning( - location.pathname.charAt(0) === '/', - `Relative pathnames are not supported in memory history.replace(${JSON.stringify( - to, - )})`, - ) - - if (allowTx(nextAction, nextLocation, retry)) { - entries[index] = nextLocation - applyTx(nextAction, nextLocation) - } - } - - function go(delta: number) { - let nextIndex = clamp(index + delta, 0, entries.length - 1) - let nextAction = Action.Pop - let nextLocation = entries[nextIndex] - function retry() { - go(delta) - } - - if (allowTx(nextAction, nextLocation, retry)) { - index = nextIndex - applyTx(nextAction, nextLocation) - } - } - - let history: MemoryHistory = { - get index() { - return index - }, - get action() { - return action - }, - get location() { - return location - }, - createHref, - push, - replace, - go, - back() { - go(-1) - }, - forward() { - go(1) - }, - listen(listener) { - return listeners.push(listener) - }, - block(blocker) { - return blockers.push(blocker) - }, - } - - return history -} diff --git a/packages/retil-router/src/precache.ts b/packages/retil-router/src/precache.ts deleted file mode 100644 index 35c02431..00000000 --- a/packages/retil-router/src/precache.ts +++ /dev/null @@ -1,17 +0,0 @@ -export class Precache { - isReady(): boolean {} - - /** - * Wait until all suspenses added to the response have resolved. This is - * useful when using renderToString – but not necessary for the streaming - * renderer. - */ - waitUntilReady(): Promise {} - - /** - * Allows a router to indicate that the content will currently suspend, - * and if it is undesirable to render suspending content, the router should - * wait until there are no more pending promises. - */ - requires(promise: PromiseLike): void {} -} diff --git a/packages/retil-router/src/requestService.ts b/packages/retil-router/src/requestService.ts deleted file mode 100644 index dcc60b3c..00000000 --- a/packages/retil-router/src/requestService.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - HistoryAction, - HistorySnapshot, - createActionMap, - getDefaultBrowserHistory, -} from 'retil-history' -import { - FusorUse, - Source, - fuse, - getSnapshotPromise, - subscribe, -} from 'retil-source' -import { createMemo } from 'retil-support' - -import { - MaybePrecachedContext, - PrecachedSnapshot, - RouterInputController, - RouterRequestExtension, - RouterHistoryService as RouterRequestService, -} from './routerTypes' - -export interface CreateRouterRequestServiceOptions< - ContextSnapshot extends object, - RequestSnapshot extends MaybePrecachedContext = HistorySnapshot -> { - basename?: string - fuseContext?: (request: RequestSnapshot, use: FusorUse) => ContextSnapshot - requestService?: RouterRequestService -} - -export function createRequestService< - Ext extends object, - Request extends MaybePrecachedContext = HistorySnapshot ->( - options: CreateRouterRequestServiceOptions = {}, -): RouterRequestService { - const { - basename = '', - requestService: historyService = (getDefaultBrowserHistory() as any) as [ - Source, - RouterInputController, - ], - fuseContext: extend, - } = options - const [baseSource, baseController] = historyService - - const precachingActions = createActionMap<{ - promise: Promise - done: boolean - }>() - const precachedActions = new Map< - symbol, - Request & RouterRequestExtension & Ext & MaybePrecachedContext - >() - const precacheUnsubscribes = new Set<() => void>() - - const requestMemo = createMemo< - Request & RouterRequestExtension & Ext & MaybePrecachedContext - >() - - const source = fuse((use) => { - const historyRequest = use(baseSource) - const precachedRequest = - historyRequest.precacheId && - precachedActions.get(historyRequest.precacheId) - - // Clear our precache, as any change from now on should result in a new - // request. - precachedActions.clear() - precachingActions.clear() - precacheUnsubscribes.forEach((unsubscribe) => unsubscribe()) - precacheUnsubscribes.clear() - - // Get the extension even if we have a precached request, as we want to - // make sure that any changes to sources that the extension uses will - // trigger another execution of the fusor function. - const extension = (extend && extend(historyRequest, use)) as Ext - - return requestMemo( - () => - precachedRequest || { - params: {}, - basename, - ...historyRequest, - ...extension, - }, - [ - historyRequest, - ...([] as (string | any)[]).concat(...Object.entries(extension || {})), - ], - ) - }) - - const precacheRequest = async ( - action: HistoryAction, - ): Promise => { - const precachedHistoryRequest = await baseController.precache(action) - const historyPrecacheId = precachedHistoryRequest.precacheId - - if (!extend) { - return { - basename, - params: {}, - ...precachedHistoryRequest, - } as Request & RouterRequestExtension & Ext & PrecachedSnapshot - } - - const extensionSource = fuse((use) => extend(precachedHistoryRequest, use)) - const extension = await getSnapshotPromise(extensionSource) - - const precachedRequest = { - basename, - params: {}, - ...precachedHistoryRequest, - ...extension, - - // Override the history's precacheId with a new one that differs between - // extensions. - precacheId: Symbol(), - } - - precachedActions.set(historyPrecacheId, precachedRequest) - - // If the extension source emits a new snapshot, it'll invalidate our - // precached request. - const unsubscribe = subscribe(extensionSource, () => { - precachedActions.delete(precachedRequest.precacheId) - precachingActions.delete(action) - precacheUnsubscribes.delete(unsubscribe) - unsubscribe() - }) - precacheUnsubscribes.add(unsubscribe) - - // TODO: throw a cancellation exception and set `context.stale` to true if - // the extension changes - - return precachedRequest - } - - const controller: RouterInputController< - Request & RouterRequestExtension & Ext - > = { - block: baseController.block, - - navigate: async (action, options) => { - // If we're currently precaching this action, then wait until precaching - // is complete before navigating, as otherwise we'll ignore the precache - // and re-start the request from scratch. - const currentlyPrecaching = precachingActions.get(action) - if (currentlyPrecaching && !currentlyPrecaching.done) { - let hasChanged = false - - // Cancel navigation if something else causes navigation in the - // meantime. - const unsubscribe = subscribe(baseSource, () => { - hasChanged = true - }) - await currentlyPrecaching - unsubscribe() - if (hasChanged) { - return false - } - } - - return baseController.navigate(action, options) - }, - - precache: (action) => { - const precachingRequest = precachingActions.get(action) - if (precachingRequest) { - return precachingRequest.promise - } - - const promise = precacheRequest(action) - const cache = { promise, done: false } - precachingActions.set(action, cache) - promise.then(() => { - cache.done = true - }) - - return promise - }, - } - - return [source, controller] -} diff --git a/packages/retil-router/src/routerContext.tsx b/packages/retil-router/src/routerContext.tsx deleted file mode 100644 index a6f829eb..00000000 --- a/packages/retil-router/src/routerContext.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext } from 'react' -import { MountedRouterController, RouterRouteSnapshot } from './routerTypes' - -export const RouterContentContext = createContext( - undefined as any, -) -export const RouterControllerContext = createContext( - undefined as any, -) -export const RouterPendingContext = createContext< - RouterRouteSnapshot | boolean | undefined ->(false) -export const RouterRequestContext = createContext( - undefined as any, -) diff --git a/packages/retil-router/src/routerService.ts b/packages/retil-router/src/routerService.ts deleted file mode 100644 index 7c32358f..00000000 --- a/packages/retil-router/src/routerService.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { PrecachedSnapshot, createActionMap } from 'retil-history' -import { - FusorUse, - createState, - fuse, - getSnapshotPromise, - subscribe, -} from 'retil-source' -import { createMemo } from 'retil-support' - -import { - RouterController, - RouterFunction, - RouterHistorySnapshot, - RouterRequestService, - RouterResponse, - RouterService, - RouterRouteSnapshot, - RouterSnapshotExtension, -} from './routerTypes' -import { isRedirect, waitForResponse } from './routerUtils' - -import { routeNormalize } from './routers/routeNormalize' - -export interface RouterOptions< - TContextSnapshot extends object, - TRequestSnapshot extends RequestSnapshot = RequestSnapshot -> { - basename?: string - followRedirects?: boolean - fuseContext?: (request: TRequestSnapshot, use: FusorUse) => TContextSnapshot - maxRedirects?: number - normalizePathname?: boolean - requestService?: RequestService -} - -export function createRouter< - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse ->( - router: RouterFunction, - options: RouterOptions = {}, -): RouterService { - const { - followRedirects = true, - maxRedirects = 5, - normalizePathname = true, - initialSnapshot, - } = options - const normalizedRouter = normalizePathname ? routeNormalize(router) : router - const [requestSource, requestController] = inputService - - const precachingActions = createActionMap<{ - promise: Promise> - done: boolean - }>() - const precachedActions = new Map< - symbol, - RouterRouteSnapshot - >() - const precacheUnsubscribes = new Set<() => void>() - - const contextMemo = createMemo< - RouterRouteSnapshot - >() - - let initialRequest: Request | null = null - - let redirectCounter = 0 - const redirect = async ( - response: Response, - statusOrURL: number | string, - url?: string, - ): Promise => { - const location = url || (statusOrURL as string) - - response.headers.Location = location - response.status = typeof statusOrURL === 'number' ? statusOrURL : 302 - - if (followRedirects) { - if (++redirectCounter > maxRedirects) { - throw new Error('Possible redirect loop detected') - } - - // Navigate in a microtask so that we don't cause any synchronous updates to - // components listening to the history. - await Promise.resolve() - - // Redirects should never be blocked, so we'll force immediate - // navigation. - await requestController.navigate(location, { - force: true, - replace: true, - }) - } - } - - const source = fuse>((use) => { - const request = use(requestSource) - - // If an initial snapshot is provided, use it until a new request is - // available. - if (initialSnapshot && (!initialRequest || initialRequest === request)) { - initialRequest = request - return initialSnapshot - } - - // TODO: signal an abort on any still precaching actions, and on any - // currently working router. - - const precachedRouterSnapshot = - request.precacheKey && precachedActions.get(request.precacheKey) - - // Clear our precache, as any change from now on should result in a new - // request. - precachedActions.clear() - precachingActions.clear() - precacheUnsubscribes.forEach((unsubscribe) => unsubscribe()) - precacheUnsubscribes.clear() - - // Memozie the snapshot by request, in case the fusor is re-run due to - // a suspense. - const snapshot = contextMemo( - () => precachedRouterSnapshot || createSnapshot(request), - [request], - ) - - return snapshot - }) - - const createSnapshot = ( - request: R, - ): RouterRouteSnapshot => { - const response: Response = { - head: [] as any[], - headers: {}, - pendingSuspenses: [] as PromiseLike[], - } as Response - - response.redirect = redirect.bind(null, response) - - const snapshot: RouterRouteSnapshot = { - content: normalizedRouter(request, response), - response, - request, - } - - if (!isRedirect(snapshot.response)) { - redirectCounter = 0 - } - - return snapshot - } - - const controller: RouterController = { - block: requestController.block, - - navigate: async (action, options) => { - // If we're currently precaching this action, then wait until precaching - // is complete before navigating, as otherwise we'll ignore the precache - // and re-start the request from scratch. - const currentlyPrecaching = precachingActions.get(action) - if (currentlyPrecaching && !currentlyPrecaching.done) { - let hasChanged = false - - // Cancel navigation if something else causes navigation in the - // meantime. - const unsubscribe = subscribe(requestSource, () => { - hasChanged = true - }) - await currentlyPrecaching - unsubscribe() - if (hasChanged) { - return false - } - } - - return requestController.navigate(action, options) - }, - - async precache( - action, - ): Promise> { - // TODO: once request precache cancellation on change is implemented, - // cache this so that we don't end up precaching the same action twice. - const precachedRouterRequest = await requestController.precache(action) - const precachedSnapshot = createSnapshot(precachedRouterRequest) - await waitForResponse(precachedSnapshot.response) - precachedActions.set( - precachedRouterRequest.precacheKey, - precachedSnapshot, - ) - return precachedSnapshot - }, - } - - return [source, controller] -} - -export interface GetRouteOptions { - normalizePathname?: boolean -} - -export async function getInitialSnapshot< - Request extends RouterRouteSnapshot = RouterRouteSnapshot, - Response extends RouterResponse = RouterResponse ->( - router: RouterFunction, - request: Request, - options: GetRouteOptions = {}, -): Promise> { - const [requestSource] = createState(request) - const requestService = [requestSource, {} as any] as const - const [routerSource] = createRouter( - router, - requestService, - { - followRedirects: false, - ...options, - }, - ) - const snapshot = await getSnapshotPromise(routerSource) - await waitForResponse(snapshot.response) - return snapshot -} diff --git a/packages/retil-router/src/routerTypes.ts b/packages/retil-router/src/routerTypes.ts deleted file mode 100644 index c72fec77..00000000 --- a/packages/retil-router/src/routerTypes.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { ReactNode } from 'react' -import { - HistoryAction, - HistoryBlockPredicate, - HistoryController, - HistoryLocation, - HistorySnapshot, -} from 'retil-history' -import { Source } from 'retil-source' - -export type RouterAction = HistoryAction -export type RouterBlockPredicate = HistoryBlockPredicate -export type RouterLocation = HistoryLocation - -export type RouterFunction< - Snapshot extends RouterRouteSnapshot = RouterRouteSnapshot -> = (snapshot: Readonly) => ReactNode - -export interface RouterSnapshotExtension { - /** - * This is unique for each time the router service passes a context to the - * root-level router function. - */ - routerKey: symbol - - // how do we define that the parent context can supply default values for - // these for us? - basename: string - params: { [name: string]: string | string[] } - - // This will be set by the router that wraps the content in a react context - // provider - content?: any - - // Note: this will be extracted from the context passed to the react app - // itself, as it's mutable and only meant to be accessed by the router - // functions. - response: RouterResponse -} - -export interface RouterResponse { - // if there's an error, it can be stored here - error?: any - - headers?: { [name: string]: string } - - /** - * can be used to specify redirects, not found, etc. - **/ - status?: number - - isReady(): boolean - - /** - * Helper to set the response status and headers as required for a redirect. - */ - redirect(url: string): Promise - redirect(statusCode: number, url: string): Promise - - /** - * Wait until all suspenses added to the response have resolved. This is - * useful when using renderToString – but not necessary for the streaming - * renderer. - */ - waitUntilReady(): Promise - - /** - * Allows a router to indicate that the content will currently suspend, - * and if it is undesirable to render suspending content, the router should - * wait until there are no more pending promises. - */ - willNotBeReadyUntil(promise: PromiseLike): void -} - -export interface RouterRouteSnapshot - extends HistorySnapshot, - RouterSnapshotExtension {} - -export type RouterSource< - RouteSnapshot extends RouterRouteSnapshot = RouterRouteSnapshot -> = Source - -export type RouterService< - RouteSnapshot extends RouterRouteSnapshot = RouterRouteSnapshot -> = readonly [RouterSource, RouterController] - -export interface RouterHistorySnapshot - extends HistorySnapshot, - Partial {} - -export type RouterRequestService< - HistorySnapshot extends RouterHistorySnapshot = RouterHistorySnapshot -> = readonly [ - Source, - HistoryController, -] - -export type RouterController< - RouteSnapshot extends RouterRouteSnapshot = RouterRouteSnapshot -> = HistoryController - -export interface MountedRouterController< - RouteSnapshot extends RouterRouteSnapshot = RouterRouteSnapshot -> extends RouterController { - /** - * Waits until navigation is no longer in progress, and return the snapshot - * at that time. - */ - waitUntilNavigationCompletes: () => Promise -} - -export type MountedRouterState< - Snapshot extends RouterRouteSnapshot = RouterRouteSnapshot -> = readonly [ - snapshot: Omit & { content: ReactNode }, - controller: MountedRouterController, - pendingSnapshot: RouterRouteSnapshot | boolean, -] diff --git a/packages/retil-router/src/routerUtils.ts b/packages/retil-router/src/routerUtils.ts deleted file mode 100644 index 538bfb13..00000000 --- a/packages/retil-router/src/routerUtils.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - match as createMatchFunction, - parse as parsePattern, -} from 'path-to-regexp' -import { normalizePathname, parseLocation } from 'retil-history' - -import { - RouterAction, - RouterRouteSnapshot, - RouterResponse, -} from './routerTypes' - -// Wait for a list of promises that may have grown by the time the first -// promises resolves. -export async function waitForResponse(response: RouterResponse) { - const promises = response.pendingSuspenses - while (promises.length) { - const waitingPromises = promises.slice(0) - // Use `Promise.all` to eagerly start any lazy promises - await Promise.all(waitingPromises) - for (let i = 0; i < waitingPromises.length; i++) { - const promise = waitingPromises[i] - const pendingIndex = promises.indexOf(promise) - if (pendingIndex !== -1) { - promises.splice(pendingIndex, 1) - } - } - } -} - -export function createRequest( - action: RouterAction, - ext?: Ext, -): RouterRouteSnapshot & Ext { - return Object.assign(parseLocation(action), { basename: '', params: {} }, ext) -} - -export type Matcher = (pathname: string) => MatcherResult -export type MatcherResult = false | Match -export interface Match { - /** - * Excludes any final wildcards - */ - pathname: string - - params: { [name: string]: string | string[] } -} -export function createMatcher(rawPattern: string): Matcher { - let normalizedPattern = normalizePathname(rawPattern.replace(/^\.?\/?/, '/')) - - if (normalizedPattern.slice(normalizedPattern.length - 2) === '/*') { - normalizedPattern = - normalizedPattern.slice(0, normalizedPattern.length - 2) + '/(.*)?' - } else if (normalizedPattern.slice(normalizedPattern.length - 1) === '*') { - normalizedPattern = - normalizedPattern.slice(0, normalizedPattern.length - 1) + '/(.*)?' - } else if (normalizedPattern === '/*') { - normalizedPattern = '/(.*)?' - } - - const lastToken = parsePattern(normalizedPattern).pop() - const wildcardParamName = - lastToken && typeof lastToken !== 'string' && lastToken.pattern === '.*' - ? String(lastToken.name) - : undefined - - const matchFunction = createMatchFunction(normalizedPattern, { - encode: encodeURI, - decode: decodeURIComponent, - }) - - const matcher = (pathname: string) => { - const match = matchFunction(pathname) - if (match) { - const params = match.params as any - let wildcard = '' - if (wildcardParamName) { - wildcard = params[wildcardParamName] || '' - delete params[wildcardParamName] - } - return { - params, - pathname: pathname - .slice(0, pathname.length - wildcard.length) - .replace(/\/$/, ''), - } - } - return false - } - - return matcher -} - -export function isRedirect(response: RouterResponse) { - return response.status && response.status >= 300 && response.status < 400 -} diff --git a/packages/retil-router/src/routers/routeAsync.tsx b/packages/retil-router/src/routers/routeAsync.tsx deleted file mode 100644 index b82c2621..00000000 --- a/packages/retil-router/src/routers/routeAsync.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import * as React from 'react' -import { ReactNode } from 'react' - -import { - RouterFunction, - RouterRouteSnapshot, - RouterResponse, -} from '../routerTypes' - -import { routeProvide } from './routeProvide' - -interface ResultRef { - current: - | null - | { - type: 'error' - error: any - } - | { - type: 'value' - value: ReactNode - } -} - -export interface AsyncResponseContentProps { - promisedContent: PromiseLike - resultRef: ResultRef -} - -export const AsyncContentWrapper: React.FunctionComponent = ({ - promisedContent, - resultRef, -}) => { - const result = resultRef.current - if (!result) { - throw Promise.resolve(promisedContent) - } else if (result.type === 'error') { - throw result.error - } - return <>{result.value} -} - -export function routeAsync< - Request extends RouterRouteSnapshot, - Response extends RouterResponse ->( - asyncRouter: (request: Request, response: Response) => PromiseLike, -): RouterFunction { - return routeProvide((request, response) => { - const resultRef: ResultRef = { - current: null, - } - - // Defer this promise until we're ready to actually render the content - // that would have existed at this page. - let promisedContent: Promise | undefined - const lazyPromise: PromiseLike = { - then: (...args) => { - if (!promisedContent) { - promisedContent = Promise.resolve(asyncRouter(request, response)) - .then((value) => { - resultRef.current = { - type: 'value', - value, - } - return value - }) - .catch((error) => { - resultRef.current = { - type: 'error', - error, - } - - response.error = error - response.status = 500 - - console.error( - 'An async router failed with the following error:', - error, - ) - - throw error - }) - } - - return promisedContent.then(...args) - }, - } - - response.pendingSuspenses.push(lazyPromise) - - return ( - - ) - }) -} diff --git a/packages/retil-router/src/routers/routeByPattern.tsx b/packages/retil-router/src/routers/routeByPattern.tsx deleted file mode 100644 index 0baa445f..00000000 --- a/packages/retil-router/src/routers/routeByPattern.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import * as React from 'react' -import { joinPaths } from 'retil-history' - -import { - RouterFunction, - RouterRouteSnapshot, - RouterResponse, -} from '../routerTypes' -import { Matcher, createMatcher } from '../routerUtils' - -import { routeNotFound } from './routeNotFound' -import { routeProvide } from './routeProvide' - -const notFoundRouter = routeNotFound() - -export interface CreatePatternRouterOptions< - Request extends RouterRouteSnapshot, - Response extends RouterResponse -> { - [pattern: string]: React.ReactNode | RouterFunction -} - -export function routeByPattern< - Request extends RouterRouteSnapshot, - Response extends RouterResponse ->( - handlers: CreatePatternRouterOptions, -): RouterFunction { - const tests: [Matcher, RouterFunction][] = [] - - const patterns = Object.keys(handlers) - for (const rawPattern of patterns) { - const handler = handlers[rawPattern] - const router = routeProvide( - typeof handler === 'function' - ? (handler as RouterFunction) - : () => <>{handler}, - ) - - const matcher = createMatcher(rawPattern) - tests.push([matcher, router]) - } - - return (request, response) => { - const { basename, pathname } = request - const unmatchedPathname = - (pathname.slice(0, basename.length) === basename - ? pathname.slice(basename.length) - : pathname) || '/' - - for (const [matcher, router] of tests) { - const match = matcher(unmatchedPathname) - if (match) { - return router( - { - ...request, - basename: joinPaths(basename, match.pathname), - params: { ...request.params, ...match.params }, - }, - response, - ) - } - } - - return notFoundRouter(request, response) - } -} diff --git a/packages/retil-router/src/routers/routeLazy.tsx b/packages/retil-router/src/routers/routeLazy.tsx deleted file mode 100644 index b79bdca7..00000000 --- a/packages/retil-router/src/routers/routeLazy.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { - RouterFunction, - RouterRouteSnapshot, - RouterResponse, -} from '../routerTypes' - -import { routeAsync } from './routeAsync' - -export function routeLazy< - Request extends RouterRouteSnapshot, - Response extends RouterResponse ->( - load: () => PromiseLike<{ default: RouterFunction }>, -): RouterFunction { - let router: RouterFunction | undefined - - return routeAsync(async (request, response) => { - if (!router) { - router = (await load()).default - } - return router(request, response) - }) -} diff --git a/packages/retil-router/src/routers/routeNormalize.tsx b/packages/retil-router/src/routers/routeNormalize.tsx deleted file mode 100644 index 0c82b6e6..00000000 --- a/packages/retil-router/src/routers/routeNormalize.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { normalizePathname, parseAction } from 'retil-history' - -import { - RouterFunction, - RouterRouteSnapshot, - RouterResponse, -} from '../routerTypes' - -import { routeRedirect } from './routeRedirect' - -// TODO: move this into the location source -export function routeNormalize< - Request extends RouterRouteSnapshot, - Response extends RouterResponse ->( - router: RouterFunction, -): RouterFunction { - return (request: Request, response: Response) => { - let pathname = normalizePathname(request.pathname) - - if (pathname === '/' || pathname === '') { - pathname = '/' - } else { - pathname = pathname[0] !== '/' ? '/' + pathname : pathname - pathname = - pathname[pathname.length - 1] === '/' - ? pathname.slice(0, pathname.length - 1) - : pathname - } - - if (pathname !== request.pathname) { - return routeRedirect(parseAction({ ...request, pathname }), 301)( - request, - response, - ) - } - return router(request, response) - } -} diff --git a/packages/retil-router/src/routers/routeNotFound.tsx b/packages/retil-router/src/routers/routeNotFound.tsx deleted file mode 100644 index 26d1b016..00000000 --- a/packages/retil-router/src/routers/routeNotFound.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from 'react' - -import { RouterFunction, RouterRouteSnapshot } from '../routerTypes' - -export class NotFoundError { - constructor(readonly request: RouterRouteSnapshot) {} -} - -export interface NotFoundProps { - error: NotFoundError -} - -export const NotFound: React.FunctionComponent = (props) => { - throw props.error -} - -export const routeNotFound = (): RouterFunction => { - return (request, response) => { - const error = new NotFoundError(request) - - response.error = error - response.status = 404 - - return - } -} diff --git a/packages/retil-router/src/routers/routeNotFoundBoundary.tsx b/packages/retil-router/src/routers/routeNotFoundBoundary.tsx deleted file mode 100644 index d9d62782..00000000 --- a/packages/retil-router/src/routers/routeNotFoundBoundary.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import * as React from 'react' - -import { - RouterFunction, - RouterRouteSnapshot, - RouterResponse, -} from '../routerTypes' - -import { NotFoundError } from './routeNotFound' - -export interface NotFoundBoundaryProps< - Request extends RouterRouteSnapshot, - Response extends RouterResponse -> { - children: React.ReactNode - request: Request - response: Response - notFoundRouter: RouterFunction -} - -function NotFoundBoundary< - Request extends RouterRouteSnapshot, - Response extends RouterResponse ->(props: NotFoundBoundaryProps) { - return -} - -interface InnerNotFoundBoundaryProps extends NotFoundBoundaryProps {} - -interface InnerNotFoundBoundaryState { - error?: NotFoundError - errorPathname?: string - errorInfo?: any -} - -class InnerNotFoundBoundary extends React.Component< - InnerNotFoundBoundaryProps, - InnerNotFoundBoundaryState -> { - static getDerivedStateFromProps( - props: InnerNotFoundBoundaryProps, - state: InnerNotFoundBoundaryState, - ): Partial | null { - if (state.error && props.request.pathname !== state.errorPathname) { - return { - error: undefined, - errorPathname: undefined, - errorInfo: undefined, - } - } - return null - } - - constructor(props: InnerNotFoundBoundaryProps) { - super(props) - this.state = {} - } - - componentDidCatch(error: any, errorInfo: any) { - if (error instanceof NotFoundError) { - this.setState({ - error, - errorInfo, - errorPathname: this.props.request.pathname, - }) - } else { - throw error - } - } - - render() { - // As SSR doesn't support state, and thus can't recover using error - // boundaries, we'll also check for a 404 on the response object (as - // during SSR, the response will always be complete before rendering). - if (this.state.error || this.props.response.status === 404) { - return this.props.notFoundRouter(this.props.request, this.props.response) - } - return this.props.children - } -} - -export const routeNotFoundBoundary = < - Request extends RouterRouteSnapshot, - Response extends RouterResponse ->( - initialRouter: RouterFunction, - notFoundRouter: RouterFunction, -): RouterFunction => { - return (request, response) => ( - - {initialRouter(request, response)} - - ) -} diff --git a/packages/retil-router/src/routers/routeProvide.tsx b/packages/retil-router/src/routers/routeProvide.tsx deleted file mode 100644 index 8c488734..00000000 --- a/packages/retil-router/src/routers/routeProvide.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react' - -import { RouterRequestContext } from '../routerContext' -import { - RouterFunction, - RouterRouteSnapshot, - RouterResponse, -} from '../routerTypes' - -export function routeProvide< - Request extends RouterRouteSnapshot, - Response extends RouterResponse ->( - router: RouterFunction, -): RouterFunction { - return (request, response) => { - const content = router(request, response) - - return - } -} - -interface RouterContentWrapperProps { - content: React.ReactNode - request: RouterRouteSnapshot -} - -function RouterContentWrapper({ content, request }: RouterContentWrapperProps) { - return ( - - {content} - - ) -} diff --git a/packages/retil-router/src/routers/routeRedirect.tsx b/packages/retil-router/src/routers/routeRedirect.tsx deleted file mode 100644 index 0fa090c6..00000000 --- a/packages/retil-router/src/routers/routeRedirect.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from 'react' -import { resolveAction, createHref, parseAction } from 'retil-history' - -import { - RouterAction, - RouterFunction, - RouterRouteSnapshot, -} from '../routerTypes' - -export interface RedirectProps { - redirectPromise: PromiseLike -} - -export const Redirect: React.FunctionComponent = (props) => { - throw Promise.resolve(props.redirectPromise) -} - -export function routeRedirect< - Request extends RouterRouteSnapshot = RouterRouteSnapshot ->( - to: RouterAction | ((request: Request) => RouterAction), - status = 302, -): RouterFunction { - return (fromRequest, response) => { - const toAction = parseAction( - typeof to === 'function' ? to(fromRequest) : to, - ) - const href = createHref(resolveAction(toAction, fromRequest.basename)) - - // Defer this promise until we're ready to actually render the content - // that would have existed at this page. - let redirectPromise: Promise | undefined - const lazyPromise: PromiseLike = { - then: (...args) => { - redirectPromise = redirectPromise || response.redirect(status, href) - return redirectPromise.then(...args) - }, - } - - response.pendingSuspenses.push(lazyPromise) - - return - } -} diff --git a/packages/retil-router/src/routingEnvironment.ts b/packages/retil-router/src/routingEnvironment.ts deleted file mode 100644 index 0ec7c2ba..00000000 --- a/packages/retil-router/src/routingEnvironment.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ParsedUrlQuery } from 'querystring' -import { - FuseEffectSymbol, - Fusor, - Source, - filter, - fuse, - mergeLatest, - select, -} from 'retil-source' - -export interface RoutingRedirectFunction { - redirect(url: string): Promise - redirect(statusCode: number, url: string): Promise -} - -export interface RoutingEnvironmentResponse { - getHeaders(): { [name: string]: number | string | string[] } - setHeader(name: string, value: number | string | string[]): void - statusCode: number -} - -// TODO: this doesn't exactly work, as "basename" needs to differ at different -// points in the request tree. - -export interface Location { - hash: string - pathname: string - query: ParsedUrlQuery - search: string - state: object | null -} - -export interface HistoryLocation extends Location { - historyKey: string -} - -export interface RetilEnvironment extends HistoryLocation { - basename: string - params: { [name: string]: string | string[] } - redirect: RoutingRedirectFunction - response: RoutingEnvironmentResponse -} - -export interface InjectedRouteProps { - abortSignal: AbortSignal - routeKey: symbol - suspenseTracker: SuspenseTracker -} - -export interface RetilRouteProps extends RetilEnvironment, InjectedRouteProps {} - -export type RouterFunction< - Snapshot extends RouterRouteSnapshot = RouterRouteSnapshot, -> = (snapshot: Readonly) => ReactNode - -export function extendEnvironmentSource( - environmentSource, - getExtension: ( - environmentSnapshot: Environment, - ) => MaybePrecachedFusor | object, -) { - return fuseMaybePrecached((use) => { - const environmentSnapshot = use(environmentSource) - const extension = getExtension(environmentSnapshot) - return { - ...extension, - ...(typeof extension === 'function' ? extension(use) : extension), - } - }) -} diff --git a/packages/retil-router/test/getInitialStateAndResponse.test.ts b/packages/retil-router/test/getInitialStateAndResponse.test.ts deleted file mode 100644 index 5b088cd7..00000000 --- a/packages/retil-router/test/getInitialStateAndResponse.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - RouterFunction, - createRequest, - getInitialSnapshot, - routeAsync, -} from '../src' - -describe('getInitialSnapshot()', () => { - test('works', async () => { - const router: RouterFunction = (request) => request.pathname - const route = await getInitialSnapshot(router, createRequest('/test')) - - expect(route.content).toBe('/test') - }) - - test('works with async routes', async () => { - const router = routeAsync(async (request, response) => { - response.headers['async-test'] = 'async-test' - return 'done' - }) - - const route = await getInitialSnapshot(router, createRequest('/test')) - - expect(route.response.headers['async-test']).toBe('async-test') - }) -}) diff --git a/packages/retil-router/test/routeNotFoundBoundary.test.tsx b/packages/retil-router/test/routeNotFoundBoundary.test.tsx deleted file mode 100644 index 3fe94faa..00000000 --- a/packages/retil-router/test/routeNotFoundBoundary.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import '@testing-library/jest-dom/extend-expect' -import React from 'react' -import { renderToString } from 'react-dom/server' -import { delay } from 'retil-support' - -import { - createRequest, - getInitialSnapshot, - routeByPattern, - routeLazy, - routeNotFoundBoundary, - useRouter, -} from '../src' - -describe('routeNotFoundBoundary', () => { - test(`works during SSR with async routes`, async () => { - const innerRouter = routeByPattern({ - '/found': (request) => 'found' + request.pathname, - }) - const router = routeNotFoundBoundary( - routeLazy(async () => { - await delay(10) - return { default: innerRouter } - }), - (request) => 'not-found' + request.pathname, - ) - - const initialSnapshot = await getInitialSnapshot( - router, - createRequest('/test-1'), - ) - const Test = () => { - const route = useRouter(router, { initialSnapshot }) - return <>{route.content} - } - - const html = renderToString() - - expect(html).toEqual('not-found/test-1') - }) -}) diff --git a/packages/retil-router/test/routeRedirect.test.tsx b/packages/retil-router/test/routeRedirect.test.tsx deleted file mode 100644 index bfd46be1..00000000 --- a/packages/retil-router/test/routeRedirect.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { createRequest, getInitialSnapshot, routeRedirect } from '../src' - -describe('routeByRedirect', () => { - test(`supports relative redirects`, async () => { - const router = routeRedirect('./acquisition') - const route = await getInitialSnapshot( - router, - createRequest('/browse/deck', { - basename: '/browse/deck', - }), - ) - expect(route.response.headers.Location).toBe('/browse/deck/acquisition') - }) - - test(`supports absolute redirects`, async () => { - const router = routeRedirect('/test') - const route = await getInitialSnapshot( - router, - createRequest('/browse/deck', { - basename: '/browse/deck', - }), - ) - expect(route.response.headers.Location).toBe('/test') - }) -}) diff --git a/packages/retil-router/test/useRouter.test.tsx b/packages/retil-router/test/useRouter.test.tsx deleted file mode 100644 index e5b82ddd..00000000 --- a/packages/retil-router/test/useRouter.test.tsx +++ /dev/null @@ -1,474 +0,0 @@ -import '@testing-library/jest-dom/extend-expect' -import React, { StrictMode, Suspense, useState } from 'react' -import { createState } from 'retil-source' -import { delay } from 'retil-support' -import { createMemoryHistory } from 'retil-history' -import { act, render, waitFor } from '@testing-library/react' - -import { - CreateRouterRequestServiceOptions, - RouterFunction, - RouterProvider, - RouterRouteSnapshot, - MountedRouterState, - UseRouterOptions, - createRequest, - createRequestService, - getInitialSnapshot, - routeAsync, - routeByPattern, - routeLazy, - routeRedirect, - useRouter as _useRouter, -} from '../src' - -function createTestRequestService( - path: string, - options: CreateRouterRequestServiceOptions = {}, -) { - const historyService = createMemoryHistory(path) - return createRequestService({ - requestService: historyService, - ...options, - }) -} - -function testUseRouter(useRouter: typeof _useRouter) { - test(`returns content`, () => { - const router: RouterFunction = () => 'success' - const Test = () => <>{useRouter(router).content} - const { container } = render( - - - , - ) - expect(container).toHaveTextContent('success') - }) - - test(`only runs the router once`, () => { - let runCount = 0 - const router: RouterFunction = () => { - runCount++ - return 'test' - } - const Test = () => <>{useRouter(router).content} - render() - expect(runCount).toBe(1) - }) - - test(`only runs async routes once even in strict mode`, async () => { - let runCount = 0 - const router: RouterFunction = routeAsync(async () => { - runCount++ - return 'test' - }) - const Test = () => <>{useRouter(router).content} - const { container } = render( - - - - - , - ) - expect(container).toHaveTextContent('loading') - await waitFor(() => { - expect(runCount).toBe(1) - expect(container).toHaveTextContent('test') - }) - }) - - test(`can redirect at startup`, async () => { - const requestService = createTestRequestService('/test-1') - const router = routeByPattern({ - '/test-1': routeRedirect('/test-2'), - '/test-2': () => 'done', - }) - const Test = () => ( - - {useRouter(router, { requestService }).content} - - ) - const { container } = render( - - - , - ) - expect(container).toHaveTextContent('loading') - await waitFor(() => { - expect(container).toHaveTextContent('done') - }) - }) - - test(`accepts and transitions via custom history services`, () => { - const requestService = createTestRequestService('/test-1') - const router: RouterFunction = (request) => request.pathname - const Test = () => <>{useRouter(router, { requestService }).content} - const { container } = render() - expect(container).toHaveTextContent('/test-1') - act(() => { - requestService[1].navigate('/test-2') - }) - expect(container).toHaveTextContent('/test-2') - }) - - test(`can use an extended request`, () => { - const requestService = createTestRequestService('/test-1', { - fuseContext: () => ({ - currentUser: 'james', - }), - }) - const router: RouterFunction< - RouterRouteSnapshot & { currentUser: string } - > = (request) => request.currentUser - const Test = () => <>{useRouter(router, { requestService }).content} - const { container } = render() - expect(container).toHaveTextContent('james') - }) - - test(`can change routers`, () => { - const router1: RouterFunction = () => 'router-1' - const router2: RouterFunction = () => 'router-2' - let setState!: any - const Test = () => { - const [state, _setState] = useState({ router: router1 }) - setState = _setState - return <>{useRouter(state.router).content} - } - const { container } = render( - - - , - ) - expect(container).toHaveTextContent('router-1') - act(() => { - setState({ router: router2 }) - }) - expect(container).toHaveTextContent('router-2') - }) - - test(`changing request services immediately recomputes synchronous routes`, () => { - const requestService1 = createTestRequestService('/test-1') - const requestService2 = createTestRequestService('/test-2') - const router: RouterFunction = (request) => request.pathname - let setState!: any - const Test = () => { - const [state, _setState] = useState({ requestService: requestService1 }) - setState = _setState - return <>{useRouter(router, state).content} - } - const { container } = render( - - - , - ) - expect(container).toHaveTextContent('/test-1') - act(() => { - setState({ requestService: requestService2 }) - }) - expect(container).toHaveTextContent('/test-2') - }) - - test(`when changing to a pending source, if can detect a change non-pending before the first effect`, async () => { - const [pendingSource, setPendingSource] = createState() - const requestService1 = createTestRequestService('/test-1') - const requestService2 = createTestRequestService('/test-2', { - fuseContext: (_request, use) => { - return { - asyncValue: use(pendingSource), - } - }, - }) - const router: RouterFunction = (request) => request.pathname - let setState!: any - const Test = () => { - const [state, _setState] = useState({ requestService: requestService1 }) - setState = _setState - return <>{useRouter(router, state).content} - } - const { container } = render( - - - , - ) - expect(container).toHaveTextContent('/test-1') - await act(async () => { - await setState({ requestService: requestService2 }) - setPendingSource('received') - }) - await waitFor(() => { - expect(container).toHaveTextContent('/test-2') - }) - }) -} - -describe('useRouter (in concurrent mode)', () => { - const useRouter = ( - router: RouterFunction, - options: UseRouterOptions, - ) => _useRouter(router, { ...options, unstable_isConcurrent: true }) - - testUseRouter(useRouter as any) - - test(`can specify an initial snapshot to avoid initial loading`, async () => { - const innerRouter: RouterFunction = (request) => request.pathname - const router = routeLazy(async () => { - await delay(10) - return { default: innerRouter } - }) - - const initialSnapshot = await getInitialSnapshot( - router, - createRequest('/test-1'), - ) - const Test = () => { - const route = useRouter(router, { initialSnapshot }) - return <>{route.content} - } - const { container } = render() - expect(container).toHaveTextContent('/test-1') - }) - - // FIXME: Doesn't work right now because the test environment doesn't support - // concurrent mode - test.skip(`doesn't resolve controller actions until the new route is loaded`, async () => { - const requestService = createTestRequestService('/test-1') - const innerRouter: RouterFunction = (request) => request.pathname - const router = routeByPattern({ - '/test-1': innerRouter, - '/test-2': routeLazy(async () => { - await delay(10) - return { default: innerRouter } - }), - }) - let route!: MountedRouterState - const Test = () => { - route = useRouter(router, { requestService }) - return ( - <> - {route.pending ? 'pending' : ''} - {route.content} - - ) - } - const { container } = render() - let didNavigatePromise!: Promise - expect(container).toHaveTextContent('/test-1') - act(() => { - didNavigatePromise = route.navigate('/test-2') - }) - expect(container).toHaveTextContent('pending/test-1') - let didNavigate!: boolean - await act(async () => { - didNavigate = await didNavigatePromise - }) - expect(didNavigate).toBe(true) - expect(container).toHaveTextContent('/test-2') - }) -}) - -describe('useRouter (in blocking mode)', () => { - const useRouter = ( - router: RouterFunction, - options: UseRouterOptions, - ) => _useRouter(router, { ...options, unstable_isConcurrent: false }) - - testUseRouter(useRouter as any) - - test(`can specify an initial snapshot to avoid initial loading`, async () => { - const requestService = createTestRequestService('/test-1') - const innerRouter: RouterFunction = (request) => request.pathname - const router = routeLazy(async () => { - await delay(10) - return { default: innerRouter } - }) - - const initialSnapshot = await getInitialSnapshot( - router, - createRequest('/test-1'), - ) - const Test = () => { - const route = useRouter(router, { - requestService, - initialSnapshot, - }) - return <>{route.content} - } - const { container } = render( - - - , - ) - expect(container).toHaveTextContent('/test-1') - await act(async () => {}) - expect(container).toHaveTextContent('/test-1') - }) - - test(`doesn't resolve controller actions until the new route is loaded`, async () => { - const requestService = createTestRequestService('/test-1') - const innerRouter: RouterFunction = (request) => request.pathname - const router = routeByPattern({ - '/test-1': innerRouter, - '/test-2': routeLazy(async () => { - await delay(10) - return { default: innerRouter } - }), - }) - let route!: MountedRouterState - - const Test = () => { - route = useRouter(router, { requestService }) - return ( - <> - {route.pending ? 'pending' : ''} - {route.content} - - ) - } - const { container } = render() - let didNavigatePromise!: Promise - expect(container).toHaveTextContent('/test-1') - act(() => { - didNavigatePromise = route.navigate('/test-2') - }) - expect(container).toHaveTextContent('pending/test-1') - let didNavigate!: boolean - await act(async () => { - didNavigate = await didNavigatePromise - }) - expect(didNavigate).toBe(true) - expect(container).toHaveTextContent('/test-2') - }) - - test(`can change routers to an initial redirect without seeing a loading screen`, async () => { - const requestService = createTestRequestService('/test') - const router1: RouterFunction = (request) => request.pathname - const router2 = routeByPattern({ - '/test': routeRedirect('/success'), - '/success': router1, - }) - let setState!: any - const Test = () => { - const [state, _setState] = useState({ router: router1 }) - setState = _setState - return <>{useRouter(state.router, { requestService }).content} - } - const { container } = render( - - - , - ) - expect(container).toHaveTextContent('/test') - await act(async () => { - setState({ router: router2 }) - }) - expect(container).toHaveTextContent('/success') - }) - - test(`doesn't cause redirects on outdated routers`, async () => { - const historyService = createMemoryHistory('/login') - const router = routeByPattern({ - '/login': (req: { auth: boolean }, res) => - req.auth ? routeRedirect('/dashboard')(req as any, res) : 'login', - '/dashboard': (req: { auth: boolean }, res) => - req.auth ? 'dashboard' : routeRedirect('/login')(req as any, res), - }) - - let setRequestService!: any - const TestRedirectLoop = () => { - const [requestService, _setRequestService] = useState(() => - createRequestService({ - requestService: historyService, - fuseContext: () => ({ auth: false }), - }), - ) - setRequestService = _setRequestService - return ( - <> - { - useRouter(router, { - requestService, - }).content - } - - ) - } - const { container } = render( - - - , - ) - expect(container).toHaveTextContent('login') - act(() => { - setRequestService(() => - createRequestService({ - requestService: historyService, - fuseContext: () => ({ auth: true }), - }), - ) - }) - await waitFor(() => { - expect(container).toHaveTextContent('dashboard') - }) - }) - - test(`supports waitUntilStable() with redirects`, async () => { - const requestService = createTestRequestService('/test-1') - const innerRouter: RouterFunction = (request) => request.pathname - const router = routeByPattern({ - '/test-1': innerRouter, - '/test-2': routeLazy(async () => { - await delay(10) - return { default: routeRedirect('/test-1') } - }), - }) - let route!: MountedRouterState - - const Test = () => { - route = useRouter(router, { requestService }) - return ( - - {route.pending ? 'pending' : ''} - {route.content} - - ) - } - const { container } = render() - expect(container).toHaveTextContent(/^\/test-1/) - act(() => { - route.navigate('/test-2') - }) - await act(async () => { - expect(container).toHaveTextContent('pending/test-1') - await route.waitUntilNavigationCompletes() - expect(container).toHaveTextContent(/^\/test-1/) - }) - }) - - test(`calls onResponseComplete when response is complete`, async () => { - const requestService = createTestRequestService('/test-1') - const innerRouter: RouterFunction = (request) => request.pathname - const router = routeByPattern({ - '/test-1': innerRouter, - '/test-2': routeLazy(async () => { - await delay(10) - return { default: routeRedirect('/test-1') } - }), - }) - - const onResponseComplete = jest.fn() - - const Test = () => { - const route = useRouter(router, { requestService, onResponseComplete }) - return ( - - {route.pending ? 'pending' : ''} - {route.content} - - ) - } - render() - await waitFor(() => { - expect(onResponseComplete).toBeCalled() - }) - }) -}) diff --git a/packages/retil-router/tsconfig.build.json b/packages/retil-router/tsconfig.build.json deleted file mode 100644 index 03ff0ce8..00000000 --- a/packages/retil-router/tsconfig.build.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../../tsconfig.build.json", - "include": [ - "src/**/*" - ] -} \ No newline at end of file diff --git a/packages/retil-source/package.json b/packages/retil-source/package.json index 0d7e88fd..016747a3 100644 --- a/packages/retil-source/package.json +++ b/packages/retil-source/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "retil-support": "^0.20.1", - "tslib": "2.0.1", + "tslib": "^2.2.0", "use-subscription": "0.0.0-experimental-79740da4c" }, "devDependencies": { diff --git a/packages/retil-style/package.json b/packages/retil-style/package.json index 09ed995f..8a75308c 100644 --- a/packages/retil-style/package.json +++ b/packages/retil-style/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "retil-support": "^0.20.1", - "tslib": "2.0.1" + "tslib": "^2.2.0" }, "devDependencies": { "typescript": "4.2.4" diff --git a/packages/retil-support/package.json b/packages/retil-support/package.json index 01ab5d81..0cff92c9 100644 --- a/packages/retil-support/package.json +++ b/packages/retil-support/package.json @@ -22,7 +22,7 @@ "dependencies": { "lodash": "^4.17.21", "memoize-one": "^5.1.1", - "tslib": "2.0.1" + "tslib": "^2.2.0" }, "devDependencies": { "@types/lodash": "^4.14.168", diff --git a/packages/retil/package.json b/packages/retil/package.json index 74a056ee..4c6ae442 100644 --- a/packages/retil/package.json +++ b/packages/retil/package.json @@ -19,13 +19,11 @@ "prepare": "yarn build" }, "dependencies": { - "retil-history": "^0.20.1", "retil-issues": "^0.20.1", "retil-operation": "^0.20.1", - "retil-router": "^0.20.1", "retil-source": "^0.20.1", "retil-support": "^0.20.1", - "tslib": "2.0.1" + "tslib": "^2.2.0" }, "devDependencies": { "typescript": "4.2.4" diff --git a/site/server.ts b/site/server.ts index def17927..2e110d65 100644 --- a/site/server.ts +++ b/site/server.ts @@ -65,30 +65,22 @@ async function createServer( render = require('./dist/server/entry-server.js').render } - const { - appHTML, - headHTML, - responseHeaders = {} as Record, - responseStatus = 200, - } = await render(req, res) + const result = await render(req, res) if ( res.statusCode >= 300 && res.statusCode < 400 && - res.getHeaders().Location + res.getHeader('Location') ) { - return res.redirect(responseStatus, responseHeaders.Location) + return res.send() } - responseHeaders['Content-Type'] = 'text/html' - res - .status(responseStatus) - .set(responseHeaders) - .end( - template - .replace(``, appHTML) - .replace('', headHTML), - ) + res.setHeader('Content-Type', 'text/html') + res.end( + template + .replace(``, result.appHTML) + .replace('', result.headHTML), + ) } catch (e) { if (viteDevServer) { viteDevServer.ssrFixStacktrace(e) diff --git a/site/src/entry-server.tsx b/site/src/entry-server.tsx index 4ee2621f..76d82051 100644 --- a/site/src/entry-server.tsx +++ b/site/src/entry-server.tsx @@ -8,7 +8,7 @@ import { Request, Response } from 'express' import React from 'react' import { renderToString } from 'react-dom/server' import { ServerMount } from 'retil-mount' -import { getStaticNavEnv } from 'retil-nav' +import { createHref, getServerNavEnv } from 'retil-nav' import Root from './root' import rootLoader from './screens/rootLoader' @@ -17,9 +17,15 @@ export async function render( request: Omit, response: Response, ) { - const env = getStaticNavEnv(request, response) - const mount = new ServerMount(rootLoader, env) + const env = getServerNavEnv(request, response) + + if (request.path !== env.pathname) { + response.statusCode = 308 + response.setHeader('Location', createHref(env)) + return null + } + const mount = new ServerMount(rootLoader, env) const styleCache = createStyleCache({ key: 'sskk' }) const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(styleCache) diff --git a/yarn.lock b/yarn.lock index 0b193e98..9f0e03a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8413,31 +8413,16 @@ react-error-boundary@^3.1.0: dependencies: "@babel/runtime" "^7.12.5" -react-is@0.0.0-experimental-1a2d79250: +react-is@0.0.0-experimental-1a2d79250, react-is@^16.12.0, "react-is@^16.12.0 || ^17.0.0", react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^17.0.1: version "0.0.0-experimental-1a2d79250" resolved "https://registry.yarnpkg.com/react-is/-/react-is-0.0.0-experimental-1a2d79250.tgz#344cb83e231f284632c114bb86cc474ee34dfeb0" integrity sha512-CfmVgp8wfpC1t+AWyYOH0Y9BTngHDkHJALFTnHtheskBCWWqFnl+dO1hX5D2V2xagJM38dTo+yP3bV6uYo5Hcg== -react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" - integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== - -"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" - integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== - -react-refresh@0.0.0-experimental-1a2d79250: +react-refresh@0.0.0-experimental-1a2d79250, react-refresh@^0.9.0: version "0.0.0-experimental-1a2d79250" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.0.0-experimental-1a2d79250.tgz#a384043ecfdf6e699f1229b3cebcf76dda0283cf" integrity sha512-KKqklawtW9g8EVAKyN3CgTq8a2P7Rw3dnEBSky0wcUvZ7REf7w0OZv6tFOeiVqa6FQNdakiPBB+T289fVV5nzQ== -react-refresh@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf" - integrity sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ== - react-shallow-renderer@^16.13.1: version "16.14.1" resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.14.1.tgz#bf0d02df8a519a558fd9b8215442efa5c840e124" @@ -9931,11 +9916,6 @@ tsconfig-paths@^3.9.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e" - integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ== - tslib@^1.8.1, tslib@^1.9.0: version "1.13.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"