From 271560379e97e7f7ceeeccb56bd8c0e922ad4904 Mon Sep 17 00:00:00 2001 From: Alex Fernandez <47327793+AlejandroFrndz@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:16:34 +0200 Subject: [PATCH] [Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes #256295 ### The problem The APM app (and all other plugins using `@kbn/typed-react-router-config`) can crash at runtime when a URL contains malformed query parameters — specifically bare keys like `?rangeFrom` (no `=value`). **Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for bare keys: ``` URL: /services?rangeFrom&rangeTo=now Parsed: { rangeFrom: null, rangeTo: 'now' } ``` Route definitions validate query params using io-ts codecs (typically `t.type({ rangeFrom: t.string })`), which expect `string`. When `null` arrives, io-ts decode fails, an unhandled error is thrown inside `matchRoutes`, and the entire React tree crashes with no recovery path. Users would be stuck in a crash loop unless they navigate away from the broken URL because even when an application-level error boundary catches the error, the usual recovery path offered is to reload the page. But, given that the error is in the URL itself, reloading will only lead to another crash This can happen via: - Bookmarks or shared links with truncated/corrupted query strings - Browser extensions or tools that strip query values - Manual URL editing in the address bar - Redirects from external systems that don't preserve full query parameters --- ### The approach: self-healing route decode My first instinct was to, during parameter parsing, strip out any `null` properties essentially replacing their values for `undefined` which would be handled better by the io-ts codecs. Any parameter marked optional (`t.partial`) or required but that has defaults defined wouldn't break when using `undefined` instead of `null`. But then I realised we just *happened* to record the bug in the case of `null` values. But the problem would still be the same for any other malformed URL values that wouldn't necessarily have to be `null` Think a parameter that is expected to be a number but somehow ends up with a malformed value that cannot be coerced into a valid number. Validation would fail as well and the stripping `nulls` approach wouldn't help here. Rather than stripping `null` values at the parsing layer, we implemented a **two-attempt decode with selective patching** strategy: 1. **First attempt**: decode params as normal through the io-ts codec 2. **On failure**: inspect the io-ts validation errors to identify which specific query keys failed 3. **Patch**: for each failing key, replace it with the route's declared default (if one exists) or remove it entirely 4. **Retry**: decode again with the patched query 5. **If retry succeeds**: the URL is recoverable → throw `InvalidRouteParamsException` carrying the corrected query 6. **If retry also fails**: the URL is truly broken → throw a plain `Error` (existing behavior) An error boundary (`RouteSelfHealErrorBoundary`) catches `InvalidRouteParamsException` and performs a `history.replace` with the corrected query string, effectively healing the URL in a single redirect. --- ### Self-healing, not a silver bullet As detailed in the process explanation above, this self-heal mechanism is not infalible. Although the route will do it best to recover, some situations might just be impossible to get out from. If a required parameter is declared that does not provide a default value registered via io-ts codecs it will never be able to recover. This is left up to consuming plugins to resolve. If proper route configuration is done, required parameters should have defaults register in the route codec. When registering proper defaults via codec is not possible, there are other solutions to ensure required parameters are always present and contain valid values. For instance, the APM plugin uses a custom component [RedirectWithDefaultDateRange](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/redirect_with_default_date_range/index.tsx) that handles setting required URL params (rangeFrom, rangeTo) with default values that can be customised via ui settings in Kibana. Each consuming plugin should determine the approach that better suits their needs in a case by case basis for each parameters as the business logic in each case can vary. While this change in the package will benefit from defaults registered at codec level, this is not enforced and plugins could opt to take other approaches as shown for APM. Even both approaches are valid (APM does so) where some parameters can be fixed automatically via defaults and others can be handled with custom redirects --- ### Design decisions and safety measures #### Accumulated patching across parent + child routes Routes in `@kbn/typed-react-router-config` are hierarchical — a URL like `/services/foo` matches both a parent route `/` (with `rangeFrom`/`rangeTo` params) and a child route `/services/{serviceName}` (with `transactionType`/`environment` params). Each route segment is decoded independently. The initial implementation threw on the first failing route segment (parent), which meant: 1. Parent fails → error boundary redirects, fixing only parent's params 2. Re-render → child fails → error boundary's `retried` flag is `true` → re-throw → crash We fixed this by refactoring the decode loop from `.map()` (which throws immediately) to a `for` loop that **accumulates all recoverable patches across all route segments** before throwing a single `InvalidRouteParamsException` with a merged query. This guarantees the error boundary only needs one redirect cycle. --- #### Handling io-ts intersection types io-ts intersection types (e.g., `t.intersection([t.type({...}), t.partial({...})])`) insert numeric branch indices in the validation error context path: ``` Expected: ['', 'query', 'page'] Actual: ['', 'query', '1', 'page'] ↑ intersection branch index ``` The `extractFailingQueryKeys` helper handles this by skipping numeric keys when walking the context path after the `'query'` key. --- #### Codecs that accept `null` Although we currently don't have any route parameter that can have `null` as a valid value, this solution also future proofs the system to allow these to exists. If I had went with the route of stripping `nulls` this situation would have been problematic should it ever present itself in the future (unlikely as it may be however) If a route codec explicitly accepts `null` (e.g., `t.union([t.string, t.null])`), the first decode attempt succeeds and no patching occurs. The self-healing logic only activates when the codec actually rejects the value. --- ### What changed #### `@kbn/typed-react-router-config` (core package) | File | Change | |---|---| | `src/errors/invalid_route_params_exception.ts` | New — `InvalidRouteParamsException` class with `patched` payload | | `src/errors/not_found_route_exception.ts` | Moved from inline class in `create_router.ts` | | `src/errors/index.ts` | New — barrel export for error classes | | `src/create_router.ts` | Added `extractFailingQueryKeys` helper; refactored `matchRoutes` decode loop to accumulate patches across parent/child routes | | `src/route_self_heal_error_boundary.tsx` | New — `RouteSelfHealErrorBoundary` component with JSDoc documenting placement requirements | | `src/create_router.test.tsx` | 7 new test cases covering all recovery and edge-case scenarios | --- ### Manual testing #### How the test works The self-healing mechanism activates when a query parameter cannot be decoded by its io-ts codec. The simplest way to trigger this is to use a **bare query key** (e.g., `?rangeFrom` without `=value`), which `query-string` parses as `null`. If the route defines a default for that parameter, the URL should automatically correct itself. If you see a crash / white screen instead, the error boundary might not be working. Keep in mind some parameters, by virtue of how they're configured at route level, won't be able to be recovered. If you're looking at the dev console in the browser, expect to see errors regarding parameters being logged. This is just how React error boundaries work. Even though the UI will be handled and even able to self-heal React still reports the error event and thus you will see it in the console and in error monitoring tools. While this could be worked around with some hacking at the error boundary level, I decided to keep it in as a high amount of errors in the same URL and parameter could indicate issues somewhere in the app with how URLs for redirection are being generated. It's not every day that users will mess their URLs up. A couple of errors with different parameters might be accidental but a high amount of errors for the same parameter would uncover deeper issues. --- #### Test matrix For each test case: 1. Navigate to the URL in the browser address bar 2. **Expected:** the page loads normally and the URL is rewritten with the default value applied 3. **Not expected:** blank page, crash, or infinite redirect --- #### What to look for if something goes wrong | Symptom | Likely cause | |---|---| | White screen / crash | `RouteSelfHealErrorBoundary` is not in the right position in the component tree, or the error is not an `InvalidRouteParamsException` | | Infinite redirect (URL keeps changing) | The `retried` flag is not resetting properly — check browser network tab for repeated navigation entries | | URL corrected but page shows stale data | The component tree re-rendered but downstream hooks are caching the old query — unrelated to this PR | | Param removed instead of defaulted | The route definition is missing a `defaults` block for that param — expected behavior, param is simply dropped | --- ### Unit Test coverage | Test | What it verifies | |---|---| | null value with existing default | Parent `rangeFrom` is `null`, default `'now-30m'` applied, other params preserved | | codec failure on optional param (intersection type) | `page=abc` fails `toNumberRt` inside `t.intersection`, page removed, valid params preserved | | unrecoverable error | Required param missing with no default → plain `Error`, not `InvalidRouteParamsException` | | valid params | No error thrown when everything is correct | | child route with own default | Child's `sortField` is `null`, child's default `'name'` applied | | parent + child simultaneous recovery | Both parent and child have `null` params → single `InvalidRouteParamsException` with both defaults merged | | codec accepting null | `t.union([t.string, t.null])` — bare `?filter` passes without error | --- ### Identify risks | Risk | Severity | Mitigation | |---|---|---| | Recovery logic masks a genuinely broken URL, making it harder to debug | Low | The original io-ts error message is preserved in the `InvalidRouteParamsException` and logged. The URL is replaced (not pushed) so it doesn't pollute browser history. | | `extractFailingQueryKeys` fails to identify keys for an unusual codec structure | Low | If key extraction fails, the retry also fails and we fall through to the existing plain `Error` behavior — no regression. | --- ## Release note Fixes an issue where some plugins would crash when receiving malformed urls. This instances will now attempt to recover automatically avoiding crashes whenever possible (cherry picked from commit 0998beebffd5fc8e288b2e2bec7a3475ead547ba) --- .../kbn-typed-react-router-config/index.ts | 1 + .../src/create_router.test.tsx | 226 ++++++++++++++++++ .../src/create_router.ts | 140 ++++++++--- .../src/errors/index.ts | 11 + .../errors/invalid_route_params_exception.ts | 18 ++ .../src/errors/not_found_route_exception.ts | 15 ++ .../route_self_heal_error_boundary.test.tsx | 105 ++++++++ .../src/route_self_heal_error_boundary.tsx | 80 +++++++ .../src/router_provider.tsx | 5 +- .../src/use_route_path.tsx | 2 +- 10 files changed, 567 insertions(+), 36 deletions(-) create mode 100644 src/platform/packages/shared/kbn-typed-react-router-config/src/errors/index.ts create mode 100644 src/platform/packages/shared/kbn-typed-react-router-config/src/errors/invalid_route_params_exception.ts create mode 100644 src/platform/packages/shared/kbn-typed-react-router-config/src/errors/not_found_route_exception.ts create mode 100644 src/platform/packages/shared/kbn-typed-react-router-config/src/route_self_heal_error_boundary.test.tsx 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 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 2303294193c1d..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 @@ -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,15 +18,33 @@ 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 { 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 { @@ -131,43 +150,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 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/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());