Skip to content

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

Merged
AlejandroFrndz merged 1 commit intoelastic:8.19from
AlejandroFrndz:backport/8.19/pr-257245
Apr 15, 2026
Merged

[8.19] [Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)#263473
AlejandroFrndz merged 1 commit intoelastic:8.19from
AlejandroFrndz:backport/8.19/pr-257245

Conversation

@AlejandroFrndz
Copy link
Copy Markdown
Contributor

Backport

This will backport the following commits from main to 8.19:

Questions ?

Please refer to the Backport tool documentation

…ormed urls (elastic#257245)

## Summary

Closes elastic#256295

### The problem

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

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

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

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

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

---

### The approach: self-healing route decode

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

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

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

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

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

---

### Self-healing, not a silver bullet

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

This is left up to consuming plugins to resolve. If proper route
configuration is done, required parameters should have defaults register
in the route codec. When registering proper defaults via codec is not
possible, there are other solutions to ensure required parameters are
always present and contain valid values. For instance, the APM plugin
uses a custom component
[RedirectWithDefaultDateRange](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/apm/public/components/routing/app_root/redirect_with_default_date_range/index.tsx)
that handles setting required URL params (rangeFrom, rangeTo) with
default values that can be customised via ui settings in Kibana. Each
consuming plugin should determine the approach that better suits their
needs in a case by case basis for each parameters as the business logic
in each case can vary.

While this change in the package will benefit from defaults registered
at codec level, this is not enforced and plugins could opt to take other
approaches as shown for APM. Even both approaches are valid (APM does
so) where some parameters can be fixed automatically via defaults and
others can be handled with custom redirects

---

### Design decisions and safety measures

#### Accumulated patching across parent + child routes

Routes in `@kbn/typed-react-router-config` are hierarchical — a URL like
`/services/foo` matches both a parent route `/` (with
`rangeFrom`/`rangeTo` params) and a child route
`/services/{serviceName}` (with `transactionType`/`environment` params).
Each route segment is decoded independently.

The initial implementation threw on the first failing route segment
(parent), which meant:
1. Parent fails → error boundary redirects, fixing only parent's params
2. Re-render → child fails → error boundary's `retried` flag is `true` →
re-throw → crash

We fixed this by refactoring the decode loop from `.map()` (which throws
immediately) to a `for` loop that **accumulates all recoverable patches
across all route segments** before throwing a single
`InvalidRouteParamsException` with a merged query. This guarantees the
error boundary only needs one redirect cycle.

---

#### Handling io-ts intersection types

io-ts intersection types (e.g., `t.intersection([t.type({...}),
t.partial({...})])`) insert numeric branch indices in the validation
error context path:

```
Expected: ['', 'query', 'page']
Actual:   ['', 'query', '1', 'page']
                         ↑ intersection branch index
```

The `extractFailingQueryKeys` helper handles this by skipping numeric
keys when walking the context path after the `'query'` key.

---

#### Codecs that accept `null`

Although we currently don't have any route parameter that can have
`null` as a valid value, this solution also future proofs the system to
allow these to exists. If I had went with the route of stripping `nulls`
this situation would have been problematic should it ever present itself
in the future (unlikely as it may be however)

If a route codec explicitly accepts `null` (e.g., `t.union([t.string,
t.null])`), the first decode attempt succeeds and no patching occurs.
The self-healing logic only activates when the codec actually rejects
the value.

---

### What changed

#### `@kbn/typed-react-router-config` (core package)

| File | Change |
|---|---|
| `src/errors/invalid_route_params_exception.ts` | New —
`InvalidRouteParamsException` class with `patched` payload |
| `src/errors/not_found_route_exception.ts` | Moved from inline class in
`create_router.ts` |
| `src/errors/index.ts` | New — barrel export for error classes |
| `src/create_router.ts` | Added `extractFailingQueryKeys` helper;
refactored `matchRoutes` decode loop to accumulate patches across
parent/child routes |
| `src/route_self_heal_error_boundary.tsx` | New —
`RouteSelfHealErrorBoundary` component with JSDoc documenting placement
requirements |
| `src/create_router.test.tsx` | 7 new test cases covering all recovery
and edge-case scenarios |

---

### Manual testing

#### How the test works

The self-healing mechanism activates when a query parameter cannot be
decoded by its io-ts codec. The simplest way to trigger this is to use a
**bare query key** (e.g., `?rangeFrom` without `=value`), which
`query-string` parses as `null`. If the route defines a default for that
parameter, the URL should automatically correct itself. If you see a
crash / white screen instead, the error boundary might not be working.
Keep in mind some parameters, by virtue of how they're configured at
route level, won't be able to be recovered.

If you're looking at the dev console in the browser, expect to see
errors regarding parameters being logged. This is just how React error
boundaries work. Even though the UI will be handled and even able to
self-heal React still reports the error event and thus you will see it
in the console and in error monitoring tools. While this could be worked
around with some hacking at the error boundary level, I decided to keep
it in as a high amount of errors in the same URL and parameter could
indicate issues somewhere in the app with how URLs for redirection are
being generated. It's not every day that users will mess their URLs up.
A couple of errors with different parameters might be accidental but a
high amount of errors for the same parameter would uncover deeper
issues.

---

#### Test matrix

For each test case:
1. Navigate to the URL in the browser address bar
2. **Expected:** the page loads normally and the URL is rewritten with
the default value applied
3. **Not expected:** blank page, crash, or infinite redirect

---

#### What to look for if something goes wrong

| Symptom | Likely cause |
|---|---|
| White screen / crash | `RouteSelfHealErrorBoundary` is not in the
right position in the component tree, or the error is not an
`InvalidRouteParamsException` |
| Infinite redirect (URL keeps changing) | The `retried` flag is not
resetting properly — check browser network tab for repeated navigation
entries |
| URL corrected but page shows stale data | The component tree
re-rendered but downstream hooks are caching the old query — unrelated
to this PR |
| Param removed instead of defaulted | The route definition is missing a
`defaults` block for that param — expected behavior, param is simply
dropped |

---

### Unit Test coverage

| Test | What it verifies |
|---|---|
| null value with existing default | Parent `rangeFrom` is `null`,
default `'now-30m'` applied, other params preserved |
| codec failure on optional param (intersection type) | `page=abc` fails
`toNumberRt` inside `t.intersection`, page removed, valid params
preserved |
| unrecoverable error | Required param missing with no default → plain
`Error`, not `InvalidRouteParamsException` |
| valid params | No error thrown when everything is correct |
| child route with own default | Child's `sortField` is `null`, child's
default `'name'` applied |
| parent + child simultaneous recovery | Both parent and child have
`null` params → single `InvalidRouteParamsException` with both defaults
merged |
| codec accepting null | `t.union([t.string, t.null])` — bare `?filter`
passes without error |

---

### Identify risks

| Risk | Severity | Mitigation |
|---|---|---|
| Recovery logic masks a genuinely broken URL, making it harder to debug
| Low | The original io-ts error message is preserved in the
`InvalidRouteParamsException` and logged. The URL is replaced (not
pushed) so it doesn't pollute browser history. |
| `extractFailingQueryKeys` fails to identify keys for an unusual codec
structure | Low | If key extraction fails, the retry also fails and we
fall through to the existing plain `Error` behavior — no regression. |

---

## Release note

Fixes an issue where some plugins would crash when receiving malformed
urls. This instances will now attempt to recover automatically avoiding
crashes whenever possible

(cherry picked from commit 0998bee)

# Conflicts:
#	src/platform/packages/shared/kbn-typed-react-router-config/src/create_router.ts
@elasticmachine
Copy link
Copy Markdown
Contributor

elasticmachine commented Apr 15, 2026

💔 Build Failed

Failed CI Steps

Test Failures

  • [job] [logs] Jest Tests #10 / Category renders the category field correctly

Metrics [docs]

Module Count

Fewer modules leads to a faster build time

id before after diff
aiAssistantManagementSelection 74 78 +4
apm 2055 2059 +4
observabilityAIAssistantApp 473 477 +4
observabilityAiAssistantManagement 427 431 +4
profiling 295 299 +4
streamsApp 498 502 +4
ux 188 192 +4
total +28

Async chunks

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

id before after diff
aiAssistantManagementSelection 93.6KB 95.7KB +2.1KB
apm 2.7MB 2.7MB +1.9KB
observabilityAIAssistantApp 296.0KB 298.1KB +2.1KB
observabilityAiAssistantManagement 129.2KB 131.1KB +1.9KB
profiling 389.0KB 390.8KB +1.9KB
streamsApp 585.4KB 587.3KB +1.9KB
ux 163.2KB 165.2KB +2.0KB
total +13.8KB

Page load bundle

Size of the bundles that are downloaded on every page load. Target size is below 100kb

id before after diff
observabilityAiAssistantManagement 6.4KB 6.4KB -1.0B

History

cc @AlejandroFrndz

@AlejandroFrndz
Copy link
Copy Markdown
Contributor Author

/ci

@AlejandroFrndz AlejandroFrndz merged commit f78f267 into elastic:8.19 Apr 15, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport This PR is a backport of another PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants