diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/index.ts b/src/platform/packages/shared/kbn-typed-react-router-config/index.ts index 0ab7a48b1b159..4c4bc77b6af64 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/index.ts +++ b/src/platform/packages/shared/kbn-typed-react-router-config/index.ts @@ -8,6 +8,7 @@ */ export * from './src/create_router'; +export * from './src/errors'; export * from './src/encode_path'; export type * from './src/types'; export * from './src/outlet'; diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.test.tsx b/src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.test.tsx index a389e9fdf21c7..f8925d1f4430e 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.test.tsx +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; import * as t from 'io-ts'; import { toNumberRt } from '@kbn/io-ts-utils'; import { createRouter } from './create_router'; +import { InvalidRouteParamsException } from './errors/invalid_route_params_exception'; import { createMemoryHistory } from 'history'; import { last } from 'lodash'; @@ -439,4 +440,229 @@ describe('createRouter', () => { ).toBe('/services/{serviceName}/errors'); }); }); + + describe('invalid query params recovery', () => { + it('throws InvalidRouteParamsException when a query param has null value and a default exists', () => { + // ?rangeFrom (bare key, parsed as null by query-string) + valid rangeTo + history.push('/services?rangeFrom&rangeTo=now&transactionType=request'); + + expect(() => { + router.getParams('/services', history.location); + }).toThrow(InvalidRouteParamsException); + + try { + router.getParams('/services', history.location); + } catch (e) { + const error = e as InvalidRouteParamsException; + // rangeFrom should be replaced with default, rangeTo and transactionType preserved + expect(error.patched.query).toEqual( + expect.objectContaining({ + rangeFrom: 'now-30m', + rangeTo: 'now', + transactionType: 'request', + }) + ); + } + }); + + it('throws InvalidRouteParamsException preserving valid params when a codec fails and param is optional', () => { + const recoverableRoutes = { + '/': { + element: <>, + params: t.type({ + query: t.intersection([ + t.type({ + rangeFrom: t.string, + rangeTo: t.string, + }), + t.partial({ + page: toNumberRt, + }), + ]), + }), + }, + }; + + const recoverableRouter = createRouter(recoverableRoutes); + + // page=abc will fail toNumberRt; rangeFrom and rangeTo are valid + history.push('/?rangeFrom=now-15m&rangeTo=now&page=abc'); + + expect(() => { + recoverableRouter.getParams('/', history.location); + }).toThrow(InvalidRouteParamsException); + + try { + recoverableRouter.getParams('/', history.location); + } catch (e) { + const error = e as InvalidRouteParamsException; + // page has no default so it should be removed; rangeFrom and rangeTo preserved + expect(error.patched.query).toEqual( + expect.objectContaining({ + rangeFrom: 'now-15m', + rangeTo: 'now', + }) + ); + expect(error.patched.query).not.toHaveProperty('page'); + } + }); + + it('throws a plain Error when recovery with defaults also fails', () => { + // rangeTo is required with no default — removing the invalid param still won't satisfy the codec + history.push('/services?transactionType=request'); + + expect(() => { + router.getParams('/services', history.location); + }).not.toThrow(InvalidRouteParamsException); + + expect(() => { + router.getParams('/services', history.location); + }).toThrow(Error); + }); + + it('does not throw when all query params are valid', () => { + history.push('/services?rangeFrom=now-15m&rangeTo=now&transactionType=request'); + + expect(() => { + router.getParams('/services', history.location); + }).not.toThrow(); + }); + + it('throws InvalidParamsException when a child route param is null and the child has its own default', () => { + const parentChildRoutes = { + '/': { + element: <>, + params: t.type({ + query: t.type({ + rangeFrom: t.string, + rangeTo: t.string, + }), + }), + defaults: { + query: { + rangeFrom: 'now-30m', + }, + }, + children: { + '/inventory': { + element: <>, + params: t.type({ + query: t.type({ + sortField: t.string, + }), + }), + defaults: { + query: { + sortField: 'name', + }, + }, + }, + }, + }, + }; + + const parentChildRouter = createRouter(parentChildRoutes); + + // sortField is null (bare key); parent params are valid + history.push('/inventory?rangeFrom=now-15m&rangeTo=now&sortField'); + + expect(() => { + parentChildRouter.getParams('/inventory', history.location); + }).toThrow(InvalidRouteParamsException); + + try { + parentChildRouter.getParams('/inventory', history.location); + } catch (e) { + const error = e as InvalidRouteParamsException; + // sortField should be replaced with child's default; parent params preserved in query + expect(error.patched.query).toEqual( + expect.objectContaining({ + rangeFrom: 'now-15m', + rangeTo: 'now', + sortField: 'name', + }) + ); + } + }); + + it('recovers both parent and child null params in a single InvalidParamsException', () => { + const parentChildRoutes = { + '/': { + element: <>, + params: t.type({ + query: t.type({ + rangeFrom: t.string, + rangeTo: t.string, + }), + }), + defaults: { + query: { + rangeFrom: 'now-30m', + }, + }, + children: { + '/inventory': { + element: <>, + params: t.type({ + query: t.type({ + sortField: t.string, + }), + }), + defaults: { + query: { + sortField: 'name', + }, + }, + }, + }, + }, + }; + + const parentChildRouter = createRouter(parentChildRoutes); + + // Both rangeFrom (parent) and sortField (child) are null bare keys + history.push('/inventory?rangeFrom&rangeTo=now&sortField'); + + expect(() => { + parentChildRouter.getParams('/inventory', history.location); + }).toThrow(InvalidRouteParamsException); + + try { + parentChildRouter.getParams('/inventory', history.location); + } catch (e) { + const error = e as InvalidRouteParamsException; + // Both should be recovered: rangeFrom from parent default, sortField from child default + expect(error.patched.query).toEqual( + expect.objectContaining({ + rangeFrom: 'now-30m', + rangeTo: 'now', + sortField: 'name', + }) + ); + } + }); + + it('respects codecs that accept null as a valid value', () => { + const nullableRoutes = { + '/': { + element: <>, + params: t.type({ + query: t.type({ + filter: t.union([t.string, t.null]), + }), + }), + }, + }; + + const nullableRouter = createRouter(nullableRoutes); + + // ?filter (bare key, parsed as null) should pass because t.null is in the union + history.push('/?filter'); + const params = nullableRouter.getParams('/', history.location); + expect(params).toEqual({ + path: {}, + query: { filter: null }, + }); + }); + }); }); diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.ts b/src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.ts index 4451603f33fa6..9c597c8dad6cb 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.ts +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.ts @@ -8,8 +8,9 @@ */ import { deepExactRt, mergeRt } from '@kbn/io-ts-utils'; -import { isLeft } from 'fp-ts/lib/Either'; -import { Location } from 'history'; +import { isLeft, isRight } from 'fp-ts/Either'; +import type { Location } from 'history'; +import type { Errors } from 'io-ts'; import { PathReporter } from 'io-ts/lib/PathReporter'; import { compact, findLastIndex, mapValues, merge, orderBy } from 'lodash'; import qs from 'query-string'; @@ -20,15 +21,33 @@ import { } from 'react-router-config'; import { FlattenRoutesOf, Route, RouteMap, Router, RouteWithPath } from './types'; import { encodePath } from './encode_path'; +import { InvalidRouteParamsException } from './errors/invalid_route_params_exception'; +import { NotFoundRouteException } from './errors'; function toReactRouterPath(path: string) { return path.replace(/(?:{([^\/]+)})/g, ':$1'); } -export class NotFoundRouteException extends Error { - constructor(message: string) { - super(message); +function extractFailingQueryKeys(errors: Errors): Set { + const keys = new Set(); + for (const error of errors) { + const { context } = error; + let foundQuery = false; + for (let i = 0; i < context.length; i++) { + if (!foundQuery) { + if (context[i].key === 'query') { + foundQuery = true; + } + } else { + // Skip numeric keys from intersection/union wrappers + if (context[i].key && !Number.isInteger(Number(context[i].key))) { + keys.add(context[i].key); + break; + } + } + } } + return keys; } export function createRouter(routes: TRoutes): Router { @@ -134,43 +153,96 @@ export function createRouter(routes: TRoutes): Router< throw new NotFoundRouteException('No route was matched'); } - return matches.slice(0, matchIndex + 1).map((matchedRoute) => { + const parsedQuery = qs.parse(location.search, { decode: true }); + const results: Array<{ match: any; route: Route | undefined }> = []; + const allPatchedKeys = new Map(); + const errorMessages: string[] = []; + let hasUnrecoverableError = false; + + for (const matchedRoute of matches.slice(0, matchIndex + 1)) { const route = routesByReactRouterConfig.get(matchedRoute.route); - if (route?.params) { - const decoded = deepExactRt(route.params).decode( - merge({}, route.defaults ?? {}, { - path: mapValues(matchedRoute.match.params, (value) => { - return decodeURIComponent(value); - }), - query: qs.parse(location.search, { decode: true }), - }) - ); - - if (isLeft(decoded)) { - throw new Error(PathReporter.report(decoded).join('\n')); + if (!route?.params) { + results.push({ + match: { ...matchedRoute.match, params: { path: {}, query: {} } }, + route, + }); + continue; + } + + const pathParams = mapValues(matchedRoute.match.params, (value) => { + return decodeURIComponent(value); + }); + + const decoded = deepExactRt(route.params).decode( + merge({}, route.defaults ?? {}, { + path: pathParams, + query: parsedQuery, + }) + ); + + if (isRight(decoded)) { + results.push({ + match: { ...matchedRoute.match, params: decoded.right }, + route, + }); + continue; + } + + const failingKeys = extractFailingQueryKeys(decoded.left); + const defaultQuery = (route.defaults?.query as Record) ?? {}; + const patchedQuery: Record = { ...parsedQuery }; + + for (const key of failingKeys) { + if (key in defaultQuery) { + patchedQuery[key] = defaultQuery[key]; + } else { + delete patchedQuery[key]; } + } + + const retryDecoded = deepExactRt(route.params).decode( + merge({}, route.defaults ?? {}, { + path: pathParams, + query: patchedQuery, + }) + ); - return { - match: { - ...matchedRoute.match, - params: decoded.right, - }, + if (isRight(retryDecoded)) { + errorMessages.push(PathReporter.report(decoded).join('\n')); + for (const key of failingKeys) { + allPatchedKeys.set(key, patchedQuery[key]); + } + results.push({ + match: { ...matchedRoute.match, params: retryDecoded.right }, route, - }; + }); + } else { + hasUnrecoverableError = true; + errorMessages.push(PathReporter.report(decoded).join('\n')); } + } - return { - match: { - ...matchedRoute.match, - params: { - path: {}, - query: {}, - }, - }, - route, - }; - }); + if (hasUnrecoverableError) { + throw new Error(errorMessages.join('\n')); + } + + if (allPatchedKeys.size > 0) { + const mergedQuery: Record = { ...parsedQuery }; + for (const [key, value] of allPatchedKeys) { + if (value === undefined) { + delete mergedQuery[key]; + } else { + mergedQuery[key] = value; + } + } + throw new InvalidRouteParamsException(errorMessages.join('\n'), { + path: results[results.length - 1]?.match.params.path ?? {}, + query: mergedQuery, + }); + } + + return results; }; const link = (path: string, ...args: any[]) => { diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/index.ts b/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/index.ts new file mode 100644 index 0000000000000..0bdb010718b36 --- /dev/null +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export * from './invalid_route_params_exception'; +export * from './not_found_route_exception'; diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/invalid_route_params_exception.ts b/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/invalid_route_params_exception.ts new file mode 100644 index 0000000000000..86563473510f7 --- /dev/null +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/invalid_route_params_exception.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export class InvalidRouteParamsException extends Error { + constructor( + message: string, + public readonly patched: { path: Record; query: Record } + ) { + super(message); + this.name = 'InvalidRouteParamsException'; + } +} diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/not_found_route_exception.ts b/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/not_found_route_exception.ts new file mode 100644 index 0000000000000..dd70fe88efe62 --- /dev/null +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/not_found_route_exception.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export class NotFoundRouteException extends Error { + constructor(message: string) { + super(message); + this.name = 'NotFoundRouteException'; + } +} diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.test.tsx b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.test.tsx new file mode 100644 index 0000000000000..d942692e9e567 --- /dev/null +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { useHistory, useLocation } from 'react-router-dom'; +import qs from 'query-string'; +import { RouteSelfHealErrorBoundary } from './route_self_heal_error_boundary'; +import { InvalidRouteParamsException } from './errors'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: jest.fn(), + useLocation: jest.fn(), +})); + +// Captures errors that propagate out of RouteSelfHealErrorBoundary. +let caughtError: Error | null = null; + +class CatchAllBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: Error) { + caughtError = error; + } + + render() { + return this.state.hasError ? null : this.props.children; + } +} + +describe('RouteSelfHealErrorBoundary', () => { + const mockReplace = jest.fn(); + const baseLocation = { pathname: '/test', search: '', hash: '' }; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + mockReplace.mockClear(); + caughtError = null; + (useHistory as jest.Mock).mockReturnValue({ replace: mockReplace }); + (useLocation as jest.Mock).mockReturnValue({ ...baseLocation }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('catches InvalidRouteParamsException and calls history.replace with the patched query', () => { + const patchedQuery = { rangeFrom: 'now-30m', rangeTo: 'now' }; + const error = new InvalidRouteParamsException('invalid params', { + path: {}, + query: patchedQuery, + }); + + const ThrowingComponent = () => { + throw error; + }; + + render( + + + + + + ); + + expect(mockReplace).toHaveBeenCalledTimes(1); + expect(mockReplace).toHaveBeenCalledWith( + expect.objectContaining({ search: qs.stringify(patchedQuery) }) + ); + expect(caughtError).toBeNull(); + }); + + it('re-throws non-InvalidRouteParamsException errors without calling history.replace', () => { + const nonRouteError = new Error('some unrelated error'); + + const ThrowingComponent = () => { + throw nonRouteError; + }; + + render( + + + + + + ); + + expect(caughtError).toBe(nonRouteError); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.tsx b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.tsx new file mode 100644 index 0000000000000..e5046d522169f --- /dev/null +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useCallback } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import qs from 'query-string'; +import { InvalidRouteParamsException } from './errors'; + +class ErrorBoundary extends React.Component<{ + children: React.ReactNode; + pathname: string; + search: string; + onError: (error: Error) => void; +}> { + state = { hasError: false }; + + componentDidUpdate(prevProps: { pathname: string; search: string }) { + if ( + (this.props.pathname !== prevProps.pathname || this.props.search !== prevProps.search) && + this.state.hasError + ) { + this.setState({ hasError: false }); + } + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: Error) { + this.props.onError(error); + } + + render() { + return this.state.hasError ? null : this.props.children; + } +} + +/** + * Error boundary that intercepts {@link InvalidRouteParamsException} thrown by + * `router.matchRoutes` (or the `useMatchRoutes` hook) and attempts to self-heal + * the URL by replacing malformed query parameters with their route-defined defaults. + * + * When an `InvalidRouteParamsException` is caught, the component calls + * `history.replace` with the corrected query string carried in the exception's + * `patched` payload, triggering a re-render with valid params. + * + * Errors that are **not** `InvalidRouteParamsException` (including unrecoverable + * decode failures) are always re-thrown upward. + */ +export function RouteSelfHealErrorBoundary({ children }: { children: React.ReactNode }) { + const history = useHistory(); + const location = useLocation(); + + const handleError = useCallback( + (error: Error) => { + if (error instanceof InvalidRouteParamsException) { + history.replace({ + ...location, + search: qs.stringify(error.patched.query), + }); + } else { + throw error; + } + }, + [history, location] + ); + + return ( + + {children} + + ); +} diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/router_provider.tsx b/src/platform/packages/shared/kbn-typed-react-router-config/src/router_provider.tsx index 329bf46046769..9483741e088d7 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/src/router_provider.tsx +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/router_provider.tsx @@ -13,6 +13,7 @@ import { Router as ReactRouter } from '@kbn/shared-ux-router'; import { RouteMap, Router } from './types'; import { RouterContextProvider } from './use_router'; +import { RouteSelfHealErrorBoundary } from './route_self_heal_error_boundary'; export function RouterProvider({ children, @@ -25,7 +26,9 @@ export function RouterProvider({ }) { return ( - {children} + + {children} + ); } diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/use_route_path.tsx b/src/platform/packages/shared/kbn-typed-react-router-config/src/use_route_path.tsx index 30dbad9c50450..5f866bbcd2b20 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/src/use_route_path.tsx +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/use_route_path.tsx @@ -8,9 +8,9 @@ */ import { last } from 'lodash'; -import { NotFoundRouteException } from './create_router'; import { useMatchRoutes } from './use_match_routes'; import { useRouter } from './use_router'; +import { NotFoundRouteException } from './errors'; export function useRoutePath() { const lastRouteMatch = last(useMatchRoutes());