[Typed React Router Config] Implement self-healing mechanism for malformed urls#257245
Conversation
🤖 GitHub commentsExpand to view the GitHub comments
Just comment with:
|
|
/oblt-deploy |
CoenWarmer
left a comment
There was a problem hiding this comment.
Streams app changed LGTM
8b274d1 to
7d79f3c
Compare
|
/ci |
rmyz
left a comment
There was a problem hiding this comment.
a few nits, also, we could consider making RouteSelfHealErrorBoundary part of RouterProvider, this way the consumer plugins wouldn't need to manually use RouteSelfHealErrorBoundary, and we get the same benefits in all places using @kbn/typed-react-router-config, wdyt?
| }); | ||
|
|
||
| describe('invalid query params recovery', () => { | ||
| it('throws InvalidParamsException when a query param has null value and a default exists', () => { |
There was a problem hiding this comment.
| 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', () => { |
| } | ||
| }); | ||
|
|
||
| it('throws InvalidParamsException preserving valid params when a codec fails and param is optional', () => { |
There was a problem hiding this comment.
| 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', () => { |
AlejandroFrndz
left a comment
There was a problem hiding this comment.
And about adding RouteSelfHealErrorBoundary to RouterProvider, you're absolutely right. Not sure why I didn't think of that but it's much cleaner, I'll merge the two together!
7d79f3c to
f2133af
Compare
ApprovabilityVerdict: Needs human review This PR introduces a new self-healing mechanism that automatically redirects users when URL query parameters fail validation - a significant runtime behavior change to shared router infrastructure. The author does not own any of the modified files (all owned by @elastic/obs-presentation-team), warranting review by the designated code owners. You can customize Macroscope's approvability policy. Learn more. |
rmyz
left a comment
There was a problem hiding this comment.
LGTM, thanks for applying the suggestions!
💛 Build succeeded, but was flaky
Failed CI StepsTest FailuresMetrics [docs]Module Count
Public APIs missing comments
Async chunks
History
|
|
Starting backport for target branches: 8.19, 9.2, 9.3, 9.4 |
…ormed urls (elastic#257245) ## Summary Closes elastic#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 0998bee)
…ormed urls (elastic#257245) ## Summary Closes elastic#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 0998bee)
…ormed urls (elastic#257245) ## Summary Closes elastic#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 0998bee)
💔 Some backports could not be created
Note: Successful backport PRs will be merged automatically after passing CI. Manual backportTo create the backport manually run: Questions ?Please refer to the Backport tool documentation |
💚 All backports created successfully
Note: Successful backport PRs will be merged automatically after passing CI. Questions ?Please refer to the Backport tool documentation |
…ormed urls (elastic#257245) ## Summary Closes elastic#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 0998bee) # Conflicts: # src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.ts
…r malformed urls (#257245) (#263115) # Backport This will backport the following commits from `main` to `9.4`: - [[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)](#257245) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Alex Fernandez","email":"47327793+AlejandroFrndz@users.noreply.github.com"},"sourceCommit":{"committedDate":"2026-04-14T15:16:34Z","message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba","branchLabelMapping":{"^v9.5.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","backport:all-open","ci:project-deploy-observability","Team:obs-presentation","v9.5.0"],"title":"[Typed React Router Config] Implement self-healing mechanism for malformed urls","number":257245,"url":"https://github.com/elastic/kibana/pull/257245","mergeCommit":{"message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.5.0","branchLabelMappingKey":"^v9.5.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/257245","number":257245,"mergeCommit":{"message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba"}}]}] BACKPORT--> Co-authored-by: Alex Fernandez <47327793+AlejandroFrndz@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
…or malformed urls (#257245) (#263473) # Backport This will backport the following commits from `main` to `8.19`: - [[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)](#257245) <!--- Backport version: 11.0.2 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Alex Fernandez","email":"47327793+AlejandroFrndz@users.noreply.github.com"},"sourceCommit":{"committedDate":"2026-04-14T15:16:34Z","message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba","branchLabelMapping":{"^v9.5.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","backport:all-open","ci:project-deploy-observability","Team:obs-presentation","v9.5.0"],"title":"[Typed React Router Config] Implement self-healing mechanism for malformed urls","number":257245,"url":"https://github.com/elastic/kibana/pull/257245","mergeCommit":{"message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.5.0","branchLabelMappingKey":"^v9.5.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/257245","number":257245,"mergeCommit":{"message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba"}},{"url":"https://github.com/elastic/kibana/pull/263112","number":263112,"branch":"9.2","state":"OPEN"},{"url":"https://github.com/elastic/kibana/pull/263113","number":263113,"branch":"9.3","state":"OPEN"},{"url":"https://github.com/elastic/kibana/pull/263115","number":263115,"branch":"9.4","state":"OPEN"}]}] BACKPORT-->
…r malformed urls (#257245) (#263112) # Backport This will backport the following commits from `main` to `9.2`: - [[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)](#257245) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Alex Fernandez","email":"47327793+AlejandroFrndz@users.noreply.github.com"},"sourceCommit":{"committedDate":"2026-04-14T15:16:34Z","message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba","branchLabelMapping":{"^v9.5.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","backport:all-open","ci:project-deploy-observability","Team:obs-presentation","v9.5.0"],"title":"[Typed React Router Config] Implement self-healing mechanism for malformed urls","number":257245,"url":"https://github.com/elastic/kibana/pull/257245","mergeCommit":{"message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.5.0","branchLabelMappingKey":"^v9.5.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/257245","number":257245,"mergeCommit":{"message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba"}}]}] BACKPORT--> Co-authored-by: Alex Fernandez <47327793+AlejandroFrndz@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
…r malformed urls (#257245) (#263113) # Backport This will backport the following commits from `main` to `9.3`: - [[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)](#257245) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Alex Fernandez","email":"47327793+AlejandroFrndz@users.noreply.github.com"},"sourceCommit":{"committedDate":"2026-04-14T15:16:34Z","message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba","branchLabelMapping":{"^v9.5.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","backport:all-open","ci:project-deploy-observability","Team:obs-presentation","v9.5.0"],"title":"[Typed React Router Config] Implement self-healing mechanism for malformed urls","number":257245,"url":"https://github.com/elastic/kibana/pull/257245","mergeCommit":{"message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render → child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.5.0","branchLabelMappingKey":"^v9.5.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/257245","number":257245,"mergeCommit":{"message":"[Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)\n\n## Summary\n\nCloses #256295\n\n### The problem\n\nThe APM app (and all other plugins using\n`@kbn/typed-react-router-config`) can crash at runtime when a URL\ncontains malformed query parameters — specifically bare keys like\n`?rangeFrom` (no `=value`).\n\n**Root cause:** `query-string` v6.13.2's `qs.parse()` returns `null` for\nbare keys:\n\n```\nURL: /services?rangeFrom&rangeTo=now\nParsed: { rangeFrom: null, rangeTo: 'now' }\n```\n\nRoute definitions validate query params using io-ts codecs (typically\n`t.type({ rangeFrom: t.string })`), which expect `string`. When `null`\narrives, io-ts decode fails, an unhandled error is thrown inside\n`matchRoutes`, and the entire React tree crashes with no recovery path.\nUsers would be stuck in a crash loop unless they navigate away from the\nbroken URL because even when an application-level error boundary catches\nthe error, the usual recovery path offered is to reload the page. But,\ngiven that the error is in the URL itself, reloading will only lead to\nanother crash\n\nThis can happen via:\n- Bookmarks or shared links with truncated/corrupted query strings\n- Browser extensions or tools that strip query values\n- Manual URL editing in the address bar\n- Redirects from external systems that don't preserve full query\nparameters\n\n---\n\n### The approach: self-healing route decode\n\nMy first instinct was to, during parameter parsing, strip out any `null`\nproperties essentially replacing their values for `undefined` which\nwould be handled better by the io-ts codecs. Any parameter marked\noptional (`t.partial`) or required but that has defaults defined\nwouldn't break when using `undefined` instead of `null`.\n\nBut then I realised we just *happened* to record the bug in the case of\n`null` values. But the problem would still be the same for any other\nmalformed URL values that wouldn't necessarily have to be `null` Think a\nparameter that is expected to be a number but somehow ends up with a\nmalformed value that cannot be coerced into a valid number. Validation\nwould fail as well and the stripping `nulls` approach wouldn't help\nhere.\n\nRather than stripping `null` values at the parsing layer, we implemented\na **two-attempt decode with selective patching** strategy:\n\n1. **First attempt**: decode params as normal through the io-ts codec\n2. **On failure**: inspect the io-ts validation errors to identify which\nspecific query keys failed\n3. **Patch**: for each failing key, replace it with the route's declared\ndefault (if one exists) or remove it entirely\n4. **Retry**: decode again with the patched query\n5. **If retry succeeds**: the URL is recoverable → throw\n`InvalidRouteParamsException` carrying the corrected query\n6. **If retry also fails**: the URL is truly broken → throw a plain\n`Error` (existing behavior)\n\nAn error boundary (`RouteSelfHealErrorBoundary`) catches\n`InvalidRouteParamsException` and performs a `history.replace` with the\ncorrected query string, effectively healing the URL in a single\nredirect.\n\n---\n\n### Self-healing, not a silver bullet\n\nAs detailed in the process explanation above, this self-heal mechanism\nis not infalible. Although the route will do it best to recover, some\nsituations might just be impossible to get out from. If a required\nparameter is declared that does not provide a default value registered\nvia io-ts codecs it will never be able to recover.\n\nThis is left up to consuming plugins to resolve. If proper route\nconfiguration is done, required parameters should have defaults register\nin the route codec. When registering proper defaults via codec is not\npossible, there are other solutions to ensure required parameters are\nalways present and contain valid values. For instance, the APM plugin\nuses a custom component\n[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)\nthat handles setting required URL params (rangeFrom, rangeTo) with\ndefault values that can be customised via ui settings in Kibana. Each\nconsuming plugin should determine the approach that better suits their\nneeds in a case by case basis for each parameters as the business logic\nin each case can vary.\n\nWhile this change in the package will benefit from defaults registered\nat codec level, this is not enforced and plugins could opt to take other\napproaches as shown for APM. Even both approaches are valid (APM does\nso) where some parameters can be fixed automatically via defaults and\nothers can be handled with custom redirects\n\n---\n\n### Design decisions and safety measures\n\n#### Accumulated patching across parent + child routes\n\nRoutes in `@kbn/typed-react-router-config` are hierarchical — a URL like\n`/services/foo` matches both a parent route `/` (with\n`rangeFrom`/`rangeTo` params) and a child route\n`/services/{serviceName}` (with `transactionType`/`environment` params).\nEach route segment is decoded independently.\n\nThe initial implementation threw on the first failing route segment\n(parent), which meant:\n1. Parent fails → error boundary redirects, fixing only parent's params\n2. Re-render �� child fails → error boundary's `retried` flag is `true` →\nre-throw → crash\n\nWe fixed this by refactoring the decode loop from `.map()` (which throws\nimmediately) to a `for` loop that **accumulates all recoverable patches\nacross all route segments** before throwing a single\n`InvalidRouteParamsException` with a merged query. This guarantees the\nerror boundary only needs one redirect cycle.\n\n---\n\n#### Handling io-ts intersection types\n\nio-ts intersection types (e.g., `t.intersection([t.type({...}),\nt.partial({...})])`) insert numeric branch indices in the validation\nerror context path:\n\n```\nExpected: ['', 'query', 'page']\nActual: ['', 'query', '1', 'page']\n ↑ intersection branch index\n```\n\nThe `extractFailingQueryKeys` helper handles this by skipping numeric\nkeys when walking the context path after the `'query'` key.\n\n---\n\n#### Codecs that accept `null`\n\nAlthough we currently don't have any route parameter that can have\n`null` as a valid value, this solution also future proofs the system to\nallow these to exists. If I had went with the route of stripping `nulls`\nthis situation would have been problematic should it ever present itself\nin the future (unlikely as it may be however)\n\nIf a route codec explicitly accepts `null` (e.g., `t.union([t.string,\nt.null])`), the first decode attempt succeeds and no patching occurs.\nThe self-healing logic only activates when the codec actually rejects\nthe value.\n\n---\n\n### What changed\n\n#### `@kbn/typed-react-router-config` (core package)\n\n| File | Change |\n|---|---|\n| `src/errors/invalid_route_params_exception.ts` | New —\n`InvalidRouteParamsException` class with `patched` payload |\n| `src/errors/not_found_route_exception.ts` | Moved from inline class in\n`create_router.ts` |\n| `src/errors/index.ts` | New — barrel export for error classes |\n| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;\nrefactored `matchRoutes` decode loop to accumulate patches across\nparent/child routes |\n| `src/route_self_heal_error_boundary.tsx` | New —\n`RouteSelfHealErrorBoundary` component with JSDoc documenting placement\nrequirements |\n| `src/create_router.test.tsx` | 7 new test cases covering all recovery\nand edge-case scenarios |\n\n---\n\n### Manual testing\n\n#### How the test works\n\nThe self-healing mechanism activates when a query parameter cannot be\ndecoded by its io-ts codec. The simplest way to trigger this is to use a\n**bare query key** (e.g., `?rangeFrom` without `=value`), which\n`query-string` parses as `null`. If the route defines a default for that\nparameter, the URL should automatically correct itself. If you see a\ncrash / white screen instead, the error boundary might not be working.\nKeep in mind some parameters, by virtue of how they're configured at\nroute level, won't be able to be recovered.\n\nIf you're looking at the dev console in the browser, expect to see\nerrors regarding parameters being logged. This is just how React error\nboundaries work. Even though the UI will be handled and even able to\nself-heal React still reports the error event and thus you will see it\nin the console and in error monitoring tools. While this could be worked\naround with some hacking at the error boundary level, I decided to keep\nit in as a high amount of errors in the same URL and parameter could\nindicate issues somewhere in the app with how URLs for redirection are\nbeing generated. It's not every day that users will mess their URLs up.\nA couple of errors with different parameters might be accidental but a\nhigh amount of errors for the same parameter would uncover deeper\nissues.\n\n---\n\n#### Test matrix\n\nFor each test case:\n1. Navigate to the URL in the browser address bar\n2. **Expected:** the page loads normally and the URL is rewritten with\nthe default value applied\n3. **Not expected:** blank page, crash, or infinite redirect\n\n---\n\n#### What to look for if something goes wrong\n\n| Symptom | Likely cause |\n|---|---|\n| White screen / crash | `RouteSelfHealErrorBoundary` is not in the\nright position in the component tree, or the error is not an\n`InvalidRouteParamsException` |\n| Infinite redirect (URL keeps changing) | The `retried` flag is not\nresetting properly — check browser network tab for repeated navigation\nentries |\n| URL corrected but page shows stale data | The component tree\nre-rendered but downstream hooks are caching the old query — unrelated\nto this PR |\n| Param removed instead of defaulted | The route definition is missing a\n`defaults` block for that param — expected behavior, param is simply\ndropped |\n\n---\n\n### Unit Test coverage\n\n| Test | What it verifies |\n|---|---|\n| null value with existing default | Parent `rangeFrom` is `null`,\ndefault `'now-30m'` applied, other params preserved |\n| codec failure on optional param (intersection type) | `page=abc` fails\n`toNumberRt` inside `t.intersection`, page removed, valid params\npreserved |\n| unrecoverable error | Required param missing with no default → plain\n`Error`, not `InvalidRouteParamsException` |\n| valid params | No error thrown when everything is correct |\n| child route with own default | Child's `sortField` is `null`, child's\ndefault `'name'` applied |\n| parent + child simultaneous recovery | Both parent and child have\n`null` params → single `InvalidRouteParamsException` with both defaults\nmerged |\n| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`\npasses without error |\n\n---\n\n### Identify risks\n\n| Risk | Severity | Mitigation |\n|---|---|---|\n| Recovery logic masks a genuinely broken URL, making it harder to debug\n| Low | The original io-ts error message is preserved in the\n`InvalidRouteParamsException` and logged. The URL is replaced (not\npushed) so it doesn't pollute browser history. |\n| `extractFailingQueryKeys` fails to identify keys for an unusual codec\nstructure | Low | If key extraction fails, the retry also fails and we\nfall through to the existing plain `Error` behavior — no regression. |\n\n---\n\n## Release note\n\nFixes an issue where some plugins would crash when receiving malformed\nurls. This instances will now attempt to recover automatically avoiding\ncrashes whenever possible","sha":"0998beebffd5fc8e288b2e2bec7a3475ead547ba"}}]}] BACKPORT--> Co-authored-by: Alex Fernandez <47327793+AlejandroFrndz@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
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-stringv6.13.2'sqs.parse()returnsnullfor bare keys:Route definitions validate query params using io-ts codecs (typically
t.type({ rangeFrom: t.string })), which expectstring. Whennullarrives, io-ts decode fails, an unhandled error is thrown insidematchRoutes, 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 crashThis can happen via:
The approach: self-healing route decode
My first instinct was to, during parameter parsing, strip out any
nullproperties essentially replacing their values forundefinedwhich 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 usingundefinedinstead ofnull.But then I realised we just happened to record the bug in the case of
nullvalues. But the problem would still be the same for any other malformed URL values that wouldn't necessarily have to benullThink 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 strippingnullsapproach wouldn't help here.Rather than stripping
nullvalues at the parsing layer, we implemented a two-attempt decode with selective patching strategy:InvalidRouteParamsExceptioncarrying the corrected queryError(existing behavior)An error boundary (
RouteSelfHealErrorBoundary) catchesInvalidRouteParamsExceptionand performs ahistory.replacewith 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 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-configare hierarchical — a URL like/services/foomatches both a parent route/(withrangeFrom/rangeToparams) and a child route/services/{serviceName}(withtransactionType/environmentparams). Each route segment is decoded independently.The initial implementation threw on the first failing route segment (parent), which meant:
retriedflag istrue→ re-throw → crashWe fixed this by refactoring the decode loop from
.map()(which throws immediately) to aforloop that accumulates all recoverable patches across all route segments before throwing a singleInvalidRouteParamsExceptionwith 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:The
extractFailingQueryKeyshelper handles this by skipping numeric keys when walking the context path after the'query'key.Codecs that accept
nullAlthough we currently don't have any route parameter that can have
nullas a valid value, this solution also future proofs the system to allow these to exists. If I had went with the route of strippingnullsthis 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)src/errors/invalid_route_params_exception.tsInvalidRouteParamsExceptionclass withpatchedpayloadsrc/errors/not_found_route_exception.tscreate_router.tssrc/errors/index.tssrc/create_router.tsextractFailingQueryKeyshelper; refactoredmatchRoutesdecode loop to accumulate patches across parent/child routessrc/route_self_heal_error_boundary.tsxRouteSelfHealErrorBoundarycomponent with JSDoc documenting placement requirementssrc/create_router.test.tsxManual 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.,
?rangeFromwithout=value), whichquery-stringparses asnull. 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:
What to look for if something goes wrong
RouteSelfHealErrorBoundaryis not in the right position in the component tree, or the error is not anInvalidRouteParamsExceptionretriedflag is not resetting properly — check browser network tab for repeated navigation entriesdefaultsblock for that param — expected behavior, param is simply droppedUnit Test coverage
rangeFromisnull, default'now-30m'applied, other params preservedpage=abcfailstoNumberRtinsidet.intersection, page removed, valid params preservedError, notInvalidRouteParamsExceptionsortFieldisnull, child's default'name'appliednullparams → singleInvalidRouteParamsExceptionwith both defaults mergedt.union([t.string, t.null])— bare?filterpasses without errorIdentify risks
InvalidRouteParamsExceptionand logged. The URL is replaced (not pushed) so it doesn't pollute browser history.extractFailingQueryKeysfails to identify keys for an unusual codec structureErrorbehavior — 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