Skip to content

[Typed React Router Config] Implement self-healing mechanism for malformed urls#257245

Merged
AlejandroFrndz merged 8 commits intoelastic:mainfrom
AlejandroFrndz:256295-fix-null-query-params-crashing-apm
Apr 14, 2026
Merged

[Typed React Router Config] Implement self-healing mechanism for malformed urls#257245
AlejandroFrndz merged 8 commits intoelastic:mainfrom
AlejandroFrndz:256295-fix-null-query-params-crashing-apm

Conversation

@AlejandroFrndz
Copy link
Copy Markdown
Contributor

@AlejandroFrndz AlejandroFrndz commented Mar 11, 2026

Summary

Closes #256295

The problem

The APM app (and all other plugins using @kbn/typed-react-router-config) can crash at runtime when a URL contains malformed query parameters — specifically bare keys like ?rangeFrom (no =value).

Root cause: query-string v6.13.2's qs.parse() returns null for bare keys:

URL: /services?rangeFrom&rangeTo=now
Parsed: { rangeFrom: null, rangeTo: 'now' }

Route definitions validate query params using io-ts codecs (typically t.type({ rangeFrom: t.string })), which expect string. When null arrives, io-ts decode fails, an unhandled error is thrown inside matchRoutes, and the entire React tree crashes with no recovery path. Users would be stuck in a crash loop unless they navigate away from the broken URL because even when an application-level error boundary catches the error, the usual recovery path offered is to reload the page. But, given that the error is in the URL itself, reloading will only lead to another crash

This can happen via:

  • Bookmarks or shared links with truncated/corrupted query strings
  • Browser extensions or tools that strip query values
  • Manual URL editing in the address bar
  • Redirects from external systems that don't preserve full query parameters

The approach: self-healing route decode

My first instinct was to, during parameter parsing, strip out any null properties essentially replacing their values for undefined which would be handled better by the io-ts codecs. Any parameter marked optional (t.partial) or required but that has defaults defined wouldn't break when using undefined instead of null.

But then I realised we just happened to record the bug in the case of null values. But the problem would still be the same for any other malformed URL values that wouldn't necessarily have to be null Think a parameter that is expected to be a number but somehow ends up with a malformed value that cannot be coerced into a valid number. Validation would fail as well and the stripping nulls approach wouldn't help here.

Rather than stripping null values at the parsing layer, we implemented a two-attempt decode with selective patching strategy:

  1. First attempt: decode params as normal through the io-ts codec
  2. On failure: inspect the io-ts validation errors to identify which specific query keys failed
  3. Patch: for each failing key, replace it with the route's declared default (if one exists) or remove it entirely
  4. Retry: decode again with the patched query
  5. If retry succeeds: the URL is recoverable → throw InvalidRouteParamsException carrying the corrected query
  6. If retry also fails: the URL is truly broken → throw a plain Error (existing behavior)

An error boundary (RouteSelfHealErrorBoundary) catches InvalidRouteParamsException and performs a history.replace with the corrected query string, effectively healing the URL in a single redirect.


Self-healing, not a silver bullet

As detailed in the process explanation above, this self-heal mechanism is not infalible. Although the route will do it best to recover, some situations might just be impossible to get out from. If a required parameter is declared that does not provide a default value registered via io-ts codecs it will never be able to recover.

This is left up to consuming plugins to resolve. If proper route configuration is done, required parameters should have defaults register in the route codec. When registering proper defaults via codec is not possible, there are other solutions to ensure required parameters are always present and contain valid values. For instance, the APM plugin uses a custom component RedirectWithDefaultDateRange 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

@AlejandroFrndz AlejandroFrndz self-assigned this Mar 11, 2026
@AlejandroFrndz AlejandroFrndz requested review from a team as code owners March 11, 2026 17:08
@AlejandroFrndz AlejandroFrndz requested review from a team as code owners March 11, 2026 17:08
@AlejandroFrndz AlejandroFrndz added backport:all-open Backport to all branches that could still receive a release Team:obs-presentation Focus: APM UI, Infra UI, Hosts UI, Universal Profiling, Obs Overview and left Navigation labels Mar 11, 2026
@botelastic botelastic Bot added the ci:project-deploy-observability Create an Observability project label Mar 11, 2026
@github-actions
Copy link
Copy Markdown
Contributor

🤖 GitHub comments

Expand to view the GitHub comments

Just comment with:

  • /oblt-deploy : Deploy a Kibana instance using the Observability test environments.
  • run docs-build : Re-trigger the docs validation. (use unformatted text in the comment!)

@AlejandroFrndz
Copy link
Copy Markdown
Contributor Author

/oblt-deploy

@AlejandroFrndz AlejandroFrndz changed the title 256295 fix null query params crashing apm Mar 11, 2026
Copy link
Copy Markdown
Member

@qn895 qn895 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Infra LGTM

Copy link
Copy Markdown
Contributor

@CoenWarmer CoenWarmer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streams app changed LGTM

@AlejandroFrndz AlejandroFrndz force-pushed the 256295-fix-null-query-params-crashing-apm branch from 8b274d1 to 7d79f3c Compare March 23, 2026 10:56
@AlejandroFrndz
Copy link
Copy Markdown
Contributor Author

/ci

Copy link
Copy Markdown
Contributor

@rmyz rmyz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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', () => {
Copy link
Copy Markdown
Contributor Author

@AlejandroFrndz AlejandroFrndz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@AlejandroFrndz AlejandroFrndz force-pushed the 256295-fix-null-query-params-crashing-apm branch from 7d79f3c to f2133af Compare April 14, 2026 12:05
@AlejandroFrndz AlejandroFrndz removed request for a team April 14, 2026 12:06
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented Apr 14, 2026

Approvability

Verdict: 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.

@AlejandroFrndz AlejandroFrndz requested a review from rmyz April 14, 2026 14:12
Copy link
Copy Markdown
Contributor

@rmyz rmyz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for applying the suggestions!

@AlejandroFrndz AlejandroFrndz enabled auto-merge (squash) April 14, 2026 14:48
@elasticmachine
Copy link
Copy Markdown
Contributor

elasticmachine commented Apr 14, 2026

💛 Build succeeded, but was flaky

  • Buildkite Build
  • Commit: 1c50df6
  • Kibana Serverless Image: docker.elastic.co/kibana-ci/kibana-serverless:pr-257245-1c50df6b9774

Failed CI Steps

Test Failures

  • [job] [logs] Jest Tests #10 / should update rule when save button is clicked

Metrics [docs]

Module Count

Fewer modules leads to a faster build time

id before after diff
aiAssistantManagementSelection 89 93 +4
apm 2128 2132 +4
observabilityAIAssistantApp 904 908 +4
observabilityAiAssistantManagement 370 374 +4
profiling 274 278 +4
streamsApp 1828 1832 +4
ux 161 165 +4
total +28

Public APIs missing comments

Total count of every public API that lacks a comment. Target amount is 0. Run node scripts/build_api_docs --plugin [yourplugin] --stats comments for more detailed information.

id before after diff
@kbn/typed-react-router-config 95 101 +6

Async chunks

Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app

id before after diff
aiAssistantManagementSelection 96.9KB 98.8KB +1.9KB
apm 2.7MB 2.7MB +1.8KB
observabilityAIAssistantApp 667.9KB 669.7KB +1.9KB
observabilityAiAssistantManagement 104.5KB 106.4KB +1.9KB
profiling 362.7KB 364.5KB +1.8KB
streamsApp 2.0MB 2.0MB +1.9KB
ux 134.8KB 136.8KB +1.9KB
total +13.1KB
Unknown metric groups

API count

id before after diff
@kbn/typed-react-router-config 95 101 +6

History

cc @AlejandroFrndz

@AlejandroFrndz AlejandroFrndz merged commit 0998bee into elastic:main Apr 14, 2026
24 of 25 checks passed
@kibanamachine
Copy link
Copy Markdown
Contributor

Starting backport for target branches: 8.19, 9.2, 9.3, 9.4

https://github.com/elastic/kibana/actions/runs/24407239952

kibanamachine pushed a commit to kibanamachine/kibana that referenced this pull request Apr 14, 2026
…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)
kibanamachine pushed a commit to kibanamachine/kibana that referenced this pull request Apr 14, 2026
…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)
kibanamachine pushed a commit to kibanamachine/kibana that referenced this pull request Apr 14, 2026
…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)
@kibanamachine
Copy link
Copy Markdown
Contributor

💔 Some backports could not be created

Status Branch Result
8.19 Backport failed because of merge conflicts
9.2
9.3
9.4

Note: Successful backport PRs will be merged automatically after passing CI.

Manual backport

To create the backport manually run:

node scripts/backport --pr 257245

Questions ?

Please refer to the Backport tool documentation

@AlejandroFrndz
Copy link
Copy Markdown
Contributor Author

💚 All backports created successfully

Status Branch Result
8.19

Note: Successful backport PRs will be merged automatically after passing CI.

Questions ?

Please refer to the Backport tool documentation

AlejandroFrndz added a commit to AlejandroFrndz/kibana that referenced this pull request Apr 15, 2026
…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
kibanamachine added a commit that referenced this pull request Apr 15, 2026
…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>
AlejandroFrndz added a commit that referenced this pull request Apr 15, 2026
…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-->
kibanamachine added a commit that referenced this pull request Apr 15, 2026
…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>
kibanamachine added a commit that referenced this pull request Apr 15, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:all-open Backport to all branches that could still receive a release ci:project-deploy-observability Create an Observability project release_note:fix Team:obs-presentation Focus: APM UI, Infra UI, Hosts UI, Universal Profiling, Obs Overview and left Navigation v8.19.15 v9.2.9 v9.3.4 v9.4.0 v9.5.0

8 participants