[8.19] [Typed React Router Config] Implement self-healing mechanism for malformed urls (#257245)#263473
Merged
AlejandroFrndz merged 1 commit intoelastic:8.19from Apr 15, 2026
Conversation
…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
jennypavlova
approved these changes
Apr 15, 2026
Contributor
💔 Build Failed
Failed CI StepsTest FailuresMetrics [docs]Module Count
Async chunks
Page load bundle
History |
Contributor
Author
|
/ci |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backport
This will backport the following commits from
mainto8.19:Questions ?
Please refer to the Backport tool documentation