From e2c8f686b5769c94de9b16f5d25d46a0b4e41238 Mon Sep 17 00:00:00 2001 From: Alex Fernandez Date: Wed, 11 Mar 2026 16:42:28 +0100 Subject: [PATCH 1/8] Route self healing --- .../kbn-typed-react-router-config/index.ts | 1 + .../src/create_router.test.tsx | 226 ++++++++++++++++++ .../src/create_router.ts | 139 ++++++++--- .../src/invalid_params_exception.ts | 17 ++ .../src/route_renderer.tsx | 66 ++++- .../components/routing/app_root/index.tsx | 58 +++-- 6 files changed, 449 insertions(+), 58 deletions(-) create mode 100644 src/platform/packages/shared/kbn-typed-react-router-config/src/invalid_params_exception.ts 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..8e7b37e4aeff2 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/invalid_params_exception'; 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..e8a15609ff0ec 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 { InvalidParamsException } from './invalid_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 InvalidParamsException 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(InvalidParamsException); + + try { + router.getParams('/services', history.location); + } catch (e) { + const error = e as InvalidParamsException; + // rangeFrom should be replaced with default, rangeTo and transactionType preserved + expect(error.defaults.query).toEqual( + expect.objectContaining({ + rangeFrom: 'now-30m', + rangeTo: 'now', + transactionType: 'request', + }) + ); + } + }); + + it('throws InvalidParamsException 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(InvalidParamsException); + + try { + recoverableRouter.getParams('/', history.location); + } catch (e) { + const error = e as InvalidParamsException; + // page has no default so it should be removed; rangeFrom and rangeTo preserved + expect(error.defaults.query).toEqual( + expect.objectContaining({ + rangeFrom: 'now-15m', + rangeTo: 'now', + }) + ); + expect(error.defaults.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(InvalidParamsException); + + 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(InvalidParamsException); + + try { + parentChildRouter.getParams('/inventory', history.location); + } catch (e) { + const error = e as InvalidParamsException; + // sortField should be replaced with child's default; parent params preserved in query + expect(error.defaults.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(InvalidParamsException); + + try { + parentChildRouter.getParams('/inventory', history.location); + } catch (e) { + const error = e as InvalidParamsException; + // Both should be recovered: rangeFrom from parent default, sortField from child default + expect(error.defaults.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 2303294193c1d..59fdbfedbb837 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/Either'; +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'; @@ -17,6 +18,7 @@ import type { MatchedRoute, RouteConfig as ReactRouterConfig } from 'react-route import { matchRoutes as matchRoutesConfig } from 'react-router-config'; import type { FlattenRoutesOf, Route, RouteMap, Router, RouteWithPath } from './types'; import { encodePath } from './encode_path'; +import { InvalidParamsException } from './invalid_params_exception'; function toReactRouterPath(path: string) { return path.replace(/(?:{([^\/]+)})/g, ':$1'); @@ -28,6 +30,28 @@ export class NotFoundRouteException extends Error { } } +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 { const routesByReactRouterConfig = new Map(); const reactRouterConfigsByRoute = new Map(); @@ -131,43 +155,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 InvalidParamsException(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/invalid_params_exception.ts b/src/platform/packages/shared/kbn-typed-react-router-config/src/invalid_params_exception.ts new file mode 100644 index 0000000000000..e1f65e51609bc --- /dev/null +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/invalid_params_exception.ts @@ -0,0 +1,17 @@ +/* + * 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 InvalidParamsException extends Error { + constructor( + message: string, + public readonly defaults: { path: Record; query: Record } + ) { + super(message); + } +} diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx index c745064e67867..1f64c299e497d 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx @@ -7,10 +7,74 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import qs from 'query-string'; import { CurrentRouteContextProvider } from './use_current_route'; import type { RouteMatch } from './types'; import { useMatchRoutes } from './use_match_routes'; +import { InvalidParamsException } from './invalid_params_exception'; + +class ErrorCatcher 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; + } +} + +export function RouteRendererErrorBoundary({ children }: { children: React.ReactNode }) { + const history = useHistory(); + const location = useLocation(); + const [retried, setRetried] = useState(false); + + const handleError = useCallback( + (error: Error) => { + if (error instanceof InvalidParamsException && !retried) { + setRetried(true); + history.replace({ + ...location, + search: qs.stringify(error.defaults.query), + }); + } else { + throw error; + } + }, + [history, location, retried] + ); + + useEffect(() => { + setRetried(false); + }, [location.pathname, location.search]); + + return ( + + {children} + + ); +} export function RouteRenderer() { const matches: RouteMatch[] = useMatchRoutes(); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx index 14922924f9a2c..e69d18a7ced39 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx @@ -15,7 +15,11 @@ import { InspectorContextProvider, } from '@kbn/observability-shared-plugin/public'; import { Route } from '@kbn/shared-ux-router'; -import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; +import { + RouteRenderer, + RouterProvider, + RouteRendererErrorBoundary, +} from '@kbn/typed-react-router-config'; import React, { useEffect } from 'react'; import { EMPTY, skip } from 'rxjs'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; @@ -73,31 +77,33 @@ export function ApmAppRoot({ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + From 5af4d92adcb9744c48aea67e68e83f9fa308b92c Mon Sep 17 00:00:00 2001 From: Alex Fernandez Date: Wed, 11 Mar 2026 16:46:55 +0100 Subject: [PATCH 2/8] refactor route exceptions --- .../kbn-typed-react-router-config/index.ts | 2 +- .../src/create_router.test.tsx | 30 +++++++++---------- .../src/create_router.ts | 11 ++----- .../src/errors/index.ts | 11 +++++++ .../invalid_route_params_exception.ts} | 4 +-- .../src/errors/not_found_route_exception.ts | 14 +++++++++ .../src/route_renderer.tsx | 6 ++-- .../src/use_route_path.tsx | 2 +- 8 files changed, 50 insertions(+), 30 deletions(-) create mode 100644 src/platform/packages/shared/kbn-typed-react-router-config/src/errors/index.ts rename src/platform/packages/shared/kbn-typed-react-router-config/src/{invalid_params_exception.ts => errors/invalid_route_params_exception.ts} (78%) create mode 100644 src/platform/packages/shared/kbn-typed-react-router-config/src/errors/not_found_route_exception.ts 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 8e7b37e4aeff2..9a1874a6775a6 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,7 +8,7 @@ */ export * from './src/create_router'; -export * from './src/invalid_params_exception'; +export * from './src/errors/invalid_route_params_exception'; 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 e8a15609ff0ec..3352bcce0e177 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,7 +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 { InvalidParamsException } from './invalid_params_exception'; +import { InvalidRouteParamsException } from './errors/invalid_route_params_exception'; import { createMemoryHistory } from 'history'; import { last } from 'lodash'; @@ -448,14 +448,14 @@ describe('createRouter', () => { expect(() => { router.getParams('/services', history.location); - }).toThrow(InvalidParamsException); + }).toThrow(InvalidRouteParamsException); try { router.getParams('/services', history.location); } catch (e) { - const error = e as InvalidParamsException; + const error = e as InvalidRouteParamsException; // rangeFrom should be replaced with default, rangeTo and transactionType preserved - expect(error.defaults.query).toEqual( + expect(error.patched.query).toEqual( expect.objectContaining({ rangeFrom: 'now-30m', rangeTo: 'now', @@ -490,20 +490,20 @@ describe('createRouter', () => { expect(() => { recoverableRouter.getParams('/', history.location); - }).toThrow(InvalidParamsException); + }).toThrow(InvalidRouteParamsException); try { recoverableRouter.getParams('/', history.location); } catch (e) { - const error = e as InvalidParamsException; + const error = e as InvalidRouteParamsException; // page has no default so it should be removed; rangeFrom and rangeTo preserved - expect(error.defaults.query).toEqual( + expect(error.patched.query).toEqual( expect.objectContaining({ rangeFrom: 'now-15m', rangeTo: 'now', }) ); - expect(error.defaults.query).not.toHaveProperty('page'); + expect(error.patched.query).not.toHaveProperty('page'); } }); @@ -513,7 +513,7 @@ describe('createRouter', () => { expect(() => { router.getParams('/services', history.location); - }).not.toThrow(InvalidParamsException); + }).not.toThrow(InvalidRouteParamsException); expect(() => { router.getParams('/services', history.location); @@ -568,14 +568,14 @@ describe('createRouter', () => { expect(() => { parentChildRouter.getParams('/inventory', history.location); - }).toThrow(InvalidParamsException); + }).toThrow(InvalidRouteParamsException); try { parentChildRouter.getParams('/inventory', history.location); } catch (e) { - const error = e as InvalidParamsException; + const error = e as InvalidRouteParamsException; // sortField should be replaced with child's default; parent params preserved in query - expect(error.defaults.query).toEqual( + expect(error.patched.query).toEqual( expect.objectContaining({ rangeFrom: 'now-15m', rangeTo: 'now', @@ -625,14 +625,14 @@ describe('createRouter', () => { expect(() => { parentChildRouter.getParams('/inventory', history.location); - }).toThrow(InvalidParamsException); + }).toThrow(InvalidRouteParamsException); try { parentChildRouter.getParams('/inventory', history.location); } catch (e) { - const error = e as InvalidParamsException; + const error = e as InvalidRouteParamsException; // Both should be recovered: rangeFrom from parent default, sortField from child default - expect(error.defaults.query).toEqual( + expect(error.patched.query).toEqual( expect.objectContaining({ rangeFrom: 'now-30m', rangeTo: 'now', 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 59fdbfedbb837..2320a49cdcab5 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 @@ -18,18 +18,13 @@ import type { MatchedRoute, RouteConfig as ReactRouterConfig } from 'react-route import { matchRoutes as matchRoutesConfig } from 'react-router-config'; import type { FlattenRoutesOf, Route, RouteMap, Router, RouteWithPath } from './types'; import { encodePath } from './encode_path'; -import { InvalidParamsException } from './invalid_params_exception'; +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) { @@ -238,7 +233,7 @@ export function createRouter(routes: TRoutes): Router< mergedQuery[key] = value; } } - throw new InvalidParamsException(errorMessages.join('\n'), { + throw new InvalidRouteParamsException(errorMessages.join('\n'), { path: results[results.length - 1]?.match.params.path ?? {}, query: mergedQuery, }); 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/invalid_params_exception.ts b/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/invalid_route_params_exception.ts similarity index 78% rename from src/platform/packages/shared/kbn-typed-react-router-config/src/invalid_params_exception.ts rename to src/platform/packages/shared/kbn-typed-react-router-config/src/errors/invalid_route_params_exception.ts index e1f65e51609bc..c23a574fcef2e 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/src/invalid_params_exception.ts +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/invalid_route_params_exception.ts @@ -7,10 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export class InvalidParamsException extends Error { +export class InvalidRouteParamsException extends Error { constructor( message: string, - public readonly defaults: { path: Record; query: Record } + public readonly patched: { path: Record; query: Record } ) { super(message); } 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..16c9ee4f8b2bc --- /dev/null +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/errors/not_found_route_exception.ts @@ -0,0 +1,14 @@ +/* + * 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); + } +} diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx index 1f64c299e497d..1253244234312 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx @@ -13,7 +13,7 @@ import qs from 'query-string'; import { CurrentRouteContextProvider } from './use_current_route'; import type { RouteMatch } from './types'; import { useMatchRoutes } from './use_match_routes'; -import { InvalidParamsException } from './invalid_params_exception'; +import { InvalidRouteParamsException } from './errors/invalid_route_params_exception'; class ErrorCatcher extends React.Component<{ children: React.ReactNode; @@ -52,11 +52,11 @@ export function RouteRendererErrorBoundary({ children }: { children: React.React const handleError = useCallback( (error: Error) => { - if (error instanceof InvalidParamsException && !retried) { + if (error instanceof InvalidRouteParamsException && !retried) { setRetried(true); history.replace({ ...location, - search: qs.stringify(error.defaults.query), + search: qs.stringify(error.patched.query), }); } else { throw error; 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()); From 7d460f2aa301ad49f4cf91fd9041722bd58eb630 Mon Sep 17 00:00:00 2001 From: Alex Fernandez Date: Wed, 11 Mar 2026 16:47:45 +0100 Subject: [PATCH 3/8] missing export --- .../packages/shared/kbn-typed-react-router-config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9a1874a6775a6..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,7 +8,7 @@ */ export * from './src/create_router'; -export * from './src/errors/invalid_route_params_exception'; +export * from './src/errors'; export * from './src/encode_path'; export type * from './src/types'; export * from './src/outlet'; From 5d66bc1262653340364132c056517c1106f34b84 Mon Sep 17 00:00:00 2001 From: Alex Fernandez Date: Wed, 11 Mar 2026 17:05:46 +0100 Subject: [PATCH 4/8] rename error boundary and add JSDocs --- .../kbn-typed-react-router-config/index.ts | 1 + .../src/route_renderer.tsx | 66 +--------- .../src/route_self_heal_error_boundary.tsx | 117 ++++++++++++++++++ .../components/routing/app_root/index.tsx | 6 +- 4 files changed, 122 insertions(+), 68 deletions(-) create mode 100644 src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.tsx 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 4c4bc77b6af64..02e426d473ecb 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 @@ -20,3 +20,4 @@ export * from './src/use_params'; export * from './src/use_router'; export * from './src/use_route_path'; export * from './src/breadcrumbs'; +export * from './src/route_self_heal_error_boundary'; diff --git a/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx index 1253244234312..c745064e67867 100644 --- a/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_renderer.tsx @@ -7,74 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useEffect, useState } from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; -import qs from 'query-string'; +import React from 'react'; import { CurrentRouteContextProvider } from './use_current_route'; import type { RouteMatch } from './types'; import { useMatchRoutes } from './use_match_routes'; -import { InvalidRouteParamsException } from './errors/invalid_route_params_exception'; - -class ErrorCatcher 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; - } -} - -export function RouteRendererErrorBoundary({ children }: { children: React.ReactNode }) { - const history = useHistory(); - const location = useLocation(); - const [retried, setRetried] = useState(false); - - const handleError = useCallback( - (error: Error) => { - if (error instanceof InvalidRouteParamsException && !retried) { - setRetried(true); - history.replace({ - ...location, - search: qs.stringify(error.patched.query), - }); - } else { - throw error; - } - }, - [history, location, retried] - ); - - useEffect(() => { - setRetried(false); - }, [location.pathname, location.search]); - - return ( - - {children} - - ); -} export function RouteRenderer() { const matches: RouteMatch[] = useMatchRoutes(); 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..d7c0d29a514c5 --- /dev/null +++ b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.tsx @@ -0,0 +1,117 @@ +/* + * 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, useEffect, useState } 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. A `retried` flag + * prevents infinite redirect loops — if the re-render still fails, the error is + * re-thrown so an ancestor error boundary can handle it. The flag resets whenever + * `location.pathname` or `location.search` changes. + * + * Errors that are **not** `InvalidRouteParamsException` (including unrecoverable + * decode failures) are always re-thrown upward. + * + * ### Placement in the React tree + * + * 1. **Below `RouterProvider`** — this component uses `useHistory` and + * `useLocation`, which require a router context to be present above it. + * + * 2. **Below any application-level error boundary** — this component intercepts + * `InvalidRouteParamsException` to attempt self-healing and re-throws all + * other errors (as well as unrecoverable ones) upward. Application error + * boundaries must be placed above so they do not intercept + * `InvalidRouteParamsException` prematurely and so they can catch errors + * re-thrown from here. + * + * 3. **Above any component that calls `useMatchRoutes` or `router.matchRoutes` + * directly** — `InvalidRouteParamsException` is thrown from within + * `router.matchRoutes`, so any component invoking these methods must be a + * descendant of this boundary for self-healing to work. + * + * @example + * ```tsx + * + * + * + * + * + * + * + * ``` + */ +export function RouteSelfHealErrorBoundary({ children }: { children: React.ReactNode }) { + const history = useHistory(); + const location = useLocation(); + const [retried, setRetried] = useState(false); + + const handleError = useCallback( + (error: Error) => { + if (error instanceof InvalidRouteParamsException && !retried) { + setRetried(true); + history.replace({ + ...location, + search: qs.stringify(error.patched.query), + }); + } else { + throw error; + } + }, + [history, location, retried] + ); + + useEffect(() => { + setRetried(false); + }, [location.pathname, location.search]); + + return ( + + {children} + + ); +} diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx index e69d18a7ced39..317fec5664cdf 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx @@ -17,8 +17,8 @@ import { import { Route } from '@kbn/shared-ux-router'; import { RouteRenderer, + RouteSelfHealErrorBoundary, RouterProvider, - RouteRendererErrorBoundary, } from '@kbn/typed-react-router-config'; import React, { useEffect } from 'react'; import { EMPTY, skip } from 'rxjs'; @@ -77,7 +77,7 @@ export function ApmAppRoot({ - + @@ -103,7 +103,7 @@ export function ApmAppRoot({ - + From 1f6f0b7b748fa86ed40f685f30e38c407731df29 Mon Sep 17 00:00:00 2001 From: Alex Fernandez Date: Wed, 11 Mar 2026 17:24:06 +0100 Subject: [PATCH 5/8] add error boundary to consuming plugins --- .../management_section/mount_section.tsx | 10 +++- .../public/app.tsx | 10 +++- .../public/components/app_root/index.tsx | 25 ++++++---- .../public/application.tsx | 10 +++- .../plugins/profiling/public/app.tsx | 50 +++++++++++-------- .../plugins/ux/public/application/ux_app.tsx | 28 +++++++---- 6 files changed, 83 insertions(+), 50 deletions(-) diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/public/management_section/mount_section.tsx b/src/platform/plugins/shared/ai_assistant_management/selection/public/management_section/mount_section.tsx index b976d0d91053a..668c59c146de0 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/public/management_section/mount_section.tsx +++ b/src/platform/plugins/shared/ai_assistant_management/selection/public/management_section/mount_section.tsx @@ -9,7 +9,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; +import { + RouteRenderer, + RouterProvider, + RouteSelfHealErrorBoundary, +} from '@kbn/typed-react-router-config'; import { I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import type { CoreSetup } from '@kbn/core/public'; @@ -66,7 +70,9 @@ export const mountManagementSection = async ({ }} > - + + + diff --git a/x-pack/platform/plugins/private/observability_ai_assistant_management/public/app.tsx b/x-pack/platform/plugins/private/observability_ai_assistant_management/public/app.tsx index e6fd63e9da6ec..71b07edf6cc2f 100644 --- a/x-pack/platform/plugins/private/observability_ai_assistant_management/public/app.tsx +++ b/x-pack/platform/plugins/private/observability_ai_assistant_management/public/app.tsx @@ -8,7 +8,11 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import React from 'react'; import ReactDOM from 'react-dom'; -import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; +import { + RouteRenderer, + RouterProvider, + RouteSelfHealErrorBoundary, +} from '@kbn/typed-react-router-config'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import { I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -57,7 +61,9 @@ export const mountManagementSection = async ({ core, mountParams, config }: Moun history={history} router={aIAssistantManagementObservabilityRouter as any} > - + + + diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/app_root/index.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/app_root/index.tsx index 4b176688420e6..c8289b49f1b70 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/app_root/index.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/app_root/index.tsx @@ -11,6 +11,7 @@ import { BreadcrumbsContextProvider, RouteRenderer, RouterProvider, + RouteSelfHealErrorBoundary, } from '@kbn/typed-react-router-config'; import { PerformanceContextProvider } from '@kbn/ebt-tools'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; @@ -55,17 +56,19 @@ export function AppRoot({ {/* @ts-expect-error upgrade typescript v5.4.5 */} - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/application.tsx b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/application.tsx index 89da3a7dedd65..a9ae94d95743d 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/application.tsx +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/application.tsx @@ -5,7 +5,11 @@ * 2.0. */ import type { CoreStart, CoreTheme } from '@kbn/core/public'; -import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; +import { + RouteRenderer, + RouterProvider, + RouteSelfHealErrorBoundary, +} from '@kbn/typed-react-router-config'; import type { History } from 'history'; import React from 'react'; import type { Observable } from 'rxjs'; @@ -38,7 +42,9 @@ export function Application({ > {/* @ts-expect-error upgrade typescript v5.4.5 */} - + + + ); diff --git a/x-pack/solutions/observability/plugins/profiling/public/app.tsx b/x-pack/solutions/observability/plugins/profiling/public/app.tsx index 9d250ace11ba9..7c4572d1fd2bd 100644 --- a/x-pack/solutions/observability/plugins/profiling/public/app.tsx +++ b/x-pack/solutions/observability/plugins/profiling/public/app.tsx @@ -12,7 +12,11 @@ import { PerformanceContextProvider } from '@kbn/ebt-tools'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; -import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; +import { + RouteRenderer, + RouterProvider, + RouteSelfHealErrorBoundary, +} from '@kbn/typed-react-router-config'; import React, { useMemo } from 'react'; import ReactDOM from 'react-dom'; import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public'; @@ -94,27 +98,29 @@ function App({ - - - - - <> - - - - - - - - - - - - - + + + + + + <> + + + + + + + + + + + + + + diff --git a/x-pack/solutions/observability/plugins/ux/public/application/ux_app.tsx b/x-pack/solutions/observability/plugins/ux/public/application/ux_app.tsx index 7fec42620e4f0..a69a5cd00b24f 100644 --- a/x-pack/solutions/observability/plugins/ux/public/application/ux_app.tsx +++ b/x-pack/solutions/observability/plugins/ux/public/application/ux_app.tsx @@ -8,7 +8,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Redirect } from 'react-router-dom'; -import { RouterProvider, createRouter } from '@kbn/typed-react-router-config'; +import { + RouteSelfHealErrorBoundary, + RouterProvider, + createRouter, +} from '@kbn/typed-react-router-config'; import { i18n } from '@kbn/i18n'; import type { RouteComponentProps, RouteProps } from 'react-router-dom'; import type { AppMountParameters, CoreStart } from '@kbn/core/public'; @@ -151,16 +155,18 @@ export function UXAppRoot({ }} > - - - - - - - - - - + + + + + + + + + + + + From f2133af7475425f5f5fb6498662c7ad05033d423 Mon Sep 17 00:00:00 2001 From: Alex Fernandez Date: Tue, 14 Apr 2026 14:05:43 +0200 Subject: [PATCH 6/8] Review changes --- .../kbn-typed-react-router-config/index.ts | 1 - .../src/create_router.test.tsx | 4 +- .../errors/invalid_route_params_exception.ts | 1 + .../src/errors/not_found_route_exception.ts | 1 + .../src/route_self_heal_error_boundary.tsx | 28 --------- .../src/router_provider.tsx | 5 +- .../management_section/mount_section.tsx | 10 +--- .../public/app.tsx | 10 +--- .../public/components/app_root/index.tsx | 25 ++++---- .../components/routing/app_root/index.tsx | 58 +++++++++---------- .../public/application.tsx | 10 +--- .../plugins/profiling/public/app.tsx | 50 +++++++--------- .../plugins/ux/public/application/ux_app.tsx | 28 ++++----- 13 files changed, 84 insertions(+), 147 deletions(-) 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 02e426d473ecb..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 @@ -20,4 +20,3 @@ export * from './src/use_params'; export * from './src/use_router'; export * from './src/use_route_path'; export * from './src/breadcrumbs'; -export * from './src/route_self_heal_error_boundary'; 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 3352bcce0e177..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 @@ -442,7 +442,7 @@ describe('createRouter', () => { }); describe('invalid query params recovery', () => { - it('throws InvalidParamsException when a query param has null value and a default exists', () => { + 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'); @@ -465,7 +465,7 @@ describe('createRouter', () => { } }); - it('throws InvalidParamsException preserving valid params when a codec fails and param is optional', () => { + it('throws InvalidRouteParamsException preserving valid params when a codec fails and param is optional', () => { const recoverableRoutes = { '/': { element: <>, 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 index c23a574fcef2e..86563473510f7 100644 --- 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 @@ -13,5 +13,6 @@ export class InvalidRouteParamsException extends Error { 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 index 16c9ee4f8b2bc..dd70fe88efe62 100644 --- 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 @@ -10,5 +10,6 @@ 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.tsx b/src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.tsx index d7c0d29a514c5..b98d6efb20d36 100644 --- 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 @@ -56,34 +56,6 @@ class ErrorBoundary extends React.Component<{ * * Errors that are **not** `InvalidRouteParamsException` (including unrecoverable * decode failures) are always re-thrown upward. - * - * ### Placement in the React tree - * - * 1. **Below `RouterProvider`** — this component uses `useHistory` and - * `useLocation`, which require a router context to be present above it. - * - * 2. **Below any application-level error boundary** — this component intercepts - * `InvalidRouteParamsException` to attempt self-healing and re-throws all - * other errors (as well as unrecoverable ones) upward. Application error - * boundaries must be placed above so they do not intercept - * `InvalidRouteParamsException` prematurely and so they can catch errors - * re-thrown from here. - * - * 3. **Above any component that calls `useMatchRoutes` or `router.matchRoutes` - * directly** — `InvalidRouteParamsException` is thrown from within - * `router.matchRoutes`, so any component invoking these methods must be a - * descendant of this boundary for self-healing to work. - * - * @example - * ```tsx - * - * - * - * - * - * - * - * ``` */ export function RouteSelfHealErrorBoundary({ children }: { children: React.ReactNode }) { const history = useHistory(); 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 1577a809e56a3..b69b84e8792a9 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 type { 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/plugins/shared/ai_assistant_management/selection/public/management_section/mount_section.tsx b/src/platform/plugins/shared/ai_assistant_management/selection/public/management_section/mount_section.tsx index 668c59c146de0..b976d0d91053a 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/public/management_section/mount_section.tsx +++ b/src/platform/plugins/shared/ai_assistant_management/selection/public/management_section/mount_section.tsx @@ -9,11 +9,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { - RouteRenderer, - RouterProvider, - RouteSelfHealErrorBoundary, -} from '@kbn/typed-react-router-config'; +import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; import { I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import type { CoreSetup } from '@kbn/core/public'; @@ -70,9 +66,7 @@ export const mountManagementSection = async ({ }} > - - - + diff --git a/x-pack/platform/plugins/private/observability_ai_assistant_management/public/app.tsx b/x-pack/platform/plugins/private/observability_ai_assistant_management/public/app.tsx index 71b07edf6cc2f..e6fd63e9da6ec 100644 --- a/x-pack/platform/plugins/private/observability_ai_assistant_management/public/app.tsx +++ b/x-pack/platform/plugins/private/observability_ai_assistant_management/public/app.tsx @@ -8,11 +8,7 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import React from 'react'; import ReactDOM from 'react-dom'; -import { - RouteRenderer, - RouterProvider, - RouteSelfHealErrorBoundary, -} from '@kbn/typed-react-router-config'; +import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import { I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -61,9 +57,7 @@ export const mountManagementSection = async ({ core, mountParams, config }: Moun history={history} router={aIAssistantManagementObservabilityRouter as any} > - - - + diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/app_root/index.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/app_root/index.tsx index c8289b49f1b70..4b176688420e6 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/app_root/index.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/app_root/index.tsx @@ -11,7 +11,6 @@ import { BreadcrumbsContextProvider, RouteRenderer, RouterProvider, - RouteSelfHealErrorBoundary, } from '@kbn/typed-react-router-config'; import { PerformanceContextProvider } from '@kbn/ebt-tools'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; @@ -56,19 +55,17 @@ export function AppRoot({ {/* @ts-expect-error upgrade typescript v5.4.5 */} - - - - - - - - - - - - - + + + + + + + + + + + diff --git a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx index 317fec5664cdf..14922924f9a2c 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/index.tsx @@ -15,11 +15,7 @@ import { InspectorContextProvider, } from '@kbn/observability-shared-plugin/public'; import { Route } from '@kbn/shared-ux-router'; -import { - RouteRenderer, - RouteSelfHealErrorBoundary, - RouterProvider, -} from '@kbn/typed-react-router-config'; +import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; import React, { useEffect } from 'react'; import { EMPTY, skip } from 'rxjs'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; @@ -77,33 +73,31 @@ export function ApmAppRoot({ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/application.tsx b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/application.tsx index a9ae94d95743d..89da3a7dedd65 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/application.tsx +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/application.tsx @@ -5,11 +5,7 @@ * 2.0. */ import type { CoreStart, CoreTheme } from '@kbn/core/public'; -import { - RouteRenderer, - RouterProvider, - RouteSelfHealErrorBoundary, -} from '@kbn/typed-react-router-config'; +import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; import type { History } from 'history'; import React from 'react'; import type { Observable } from 'rxjs'; @@ -42,9 +38,7 @@ export function Application({ > {/* @ts-expect-error upgrade typescript v5.4.5 */} - - - + ); diff --git a/x-pack/solutions/observability/plugins/profiling/public/app.tsx b/x-pack/solutions/observability/plugins/profiling/public/app.tsx index 7c4572d1fd2bd..9d250ace11ba9 100644 --- a/x-pack/solutions/observability/plugins/profiling/public/app.tsx +++ b/x-pack/solutions/observability/plugins/profiling/public/app.tsx @@ -12,11 +12,7 @@ import { PerformanceContextProvider } from '@kbn/ebt-tools'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; -import { - RouteRenderer, - RouterProvider, - RouteSelfHealErrorBoundary, -} from '@kbn/typed-react-router-config'; +import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; import React, { useMemo } from 'react'; import ReactDOM from 'react-dom'; import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public'; @@ -98,29 +94,27 @@ function App({ - - - - - - <> - - - - - - - - - - - - - - + + + + + <> + + + + + + + + + + + + + diff --git a/x-pack/solutions/observability/plugins/ux/public/application/ux_app.tsx b/x-pack/solutions/observability/plugins/ux/public/application/ux_app.tsx index a69a5cd00b24f..7fec42620e4f0 100644 --- a/x-pack/solutions/observability/plugins/ux/public/application/ux_app.tsx +++ b/x-pack/solutions/observability/plugins/ux/public/application/ux_app.tsx @@ -8,11 +8,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Redirect } from 'react-router-dom'; -import { - RouteSelfHealErrorBoundary, - RouterProvider, - createRouter, -} from '@kbn/typed-react-router-config'; +import { RouterProvider, createRouter } from '@kbn/typed-react-router-config'; import { i18n } from '@kbn/i18n'; import type { RouteComponentProps, RouteProps } from 'react-router-dom'; import type { AppMountParameters, CoreStart } from '@kbn/core/public'; @@ -155,18 +151,16 @@ export function UXAppRoot({ }} > - - - - - - - - - - - - + + + + + + + + + + From ffbd5c1d3b190548867831f1c0b8074f3194c585 Mon Sep 17 00:00:00 2001 From: Alex Fernandez Date: Tue, 14 Apr 2026 16:02:32 +0200 Subject: [PATCH 7/8] remove retired flag --- .../src/route_self_heal_error_boundary.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) 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 index b98d6efb20d36..e5046d522169f 100644 --- 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 @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import qs from 'query-string'; import { InvalidRouteParamsException } from './errors'; @@ -49,10 +49,7 @@ class ErrorBoundary extends React.Component<{ * * 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. A `retried` flag - * prevents infinite redirect loops — if the re-render still fails, the error is - * re-thrown so an ancestor error boundary can handle it. The flag resets whenever - * `location.pathname` or `location.search` changes. + * `patched` payload, triggering a re-render with valid params. * * Errors that are **not** `InvalidRouteParamsException` (including unrecoverable * decode failures) are always re-thrown upward. @@ -60,12 +57,10 @@ class ErrorBoundary extends React.Component<{ export function RouteSelfHealErrorBoundary({ children }: { children: React.ReactNode }) { const history = useHistory(); const location = useLocation(); - const [retried, setRetried] = useState(false); const handleError = useCallback( (error: Error) => { - if (error instanceof InvalidRouteParamsException && !retried) { - setRetried(true); + if (error instanceof InvalidRouteParamsException) { history.replace({ ...location, search: qs.stringify(error.patched.query), @@ -74,13 +69,9 @@ export function RouteSelfHealErrorBoundary({ children }: { children: React.React throw error; } }, - [history, location, retried] + [history, location] ); - useEffect(() => { - setRetried(false); - }, [location.pathname, location.search]); - return ( {children} From 1c50df6b977493dbddaaf17f17f18c6afc08e5df Mon Sep 17 00:00:00 2001 From: Alex Fernandez Date: Tue, 14 Apr 2026 16:02:44 +0200 Subject: [PATCH 8/8] add unit tests --- .../route_self_heal_error_boundary.test.tsx | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.test.tsx 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(); + }); +});