An Angular invoice edit route shows stale data when only its ?revision query parameter changes; why does its resolver not re-run, and how do you fix it?
answer
- the component is reused
- the route's rerun policy default
- path params yes, query params no
- snapshot read once in the constructor
basics
~10 sThe router reuses the component and, under the default runGuardsAndResolvers of 'paramsChange', ignores query parameters, so the resolver is skipped and the old data kept. Set a query-aware policy and read ActivatedRoute.data reactively.
solid answer
~30 sGoing from `/invoices/42/edit?revision=3` to `?revision=4` matches the same route config, so the router reuses the `InvoiceEdit` instance. Whether guards and resolvers rerun on a reused route is decided by `runGuardsAndResolvers`; its default, `'paramsChange'`, reruns only when path or matrix parameters change, not query parameters, so the old resolved data is copied forward. Fix both halves: set `runGuardsAndResolvers: 'paramsOrQueryParamsChange'` (or `'pathParamsOrQueryParamsChange'`, or a predicate function) so the resolver runs again, and read `ActivatedRoute.data` as an Observable or through `toSignal` or input binding, because a `snapshot.data` read in the constructor will not see the new value on a reused component.
code
ts · 31 linesimport { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, ActivatedRouteSnapshot, ResolveFn, Routes } from '@angular/router';
import { map } from 'rxjs';
import { Invoice, InvoiceApi } from './invoice-api';
const invoiceRevisionResolver: ResolveFn<Invoice> = (route) =>
inject(InvoiceApi).getRevision(route.paramMap.get('id')!, route.queryParamMap.get('revision'));
@Component({
selector: 'app-invoice-edit',
template: `<h1>Invoice {{ invoice().number }}, revision {{ invoice().revision }}</h1>`,
})
export class InvoiceEdit {
// updates on a reused instance, unlike snapshot.data
readonly invoice = toSignal(
inject(ActivatedRoute).data.pipe(map((d) => d['invoice'] as Invoice)),
{ requireSync: true },
);
}
export const routes: Routes = [
{
path: 'invoices/:id/edit',
component: InvoiceEdit,
resolve: { invoice: invoiceRevisionResolver },
runGuardsAndResolvers: (from: ActivatedRouteSnapshot, to: ActivatedRouteSnapshot) =>
from.paramMap.get('id') !== to.paramMap.get('id') ||
from.queryParamMap.get('revision') !== to.queryParamMap.get('revision'),
},
];go deeper
Remember that the router can reuse a component when only part of the URL changes, so data read once may go stale.
Explain runGuardsAndResolvers and its default, and why ActivatedRoute.data must be read reactively on a reused component.
Diagnose which half failed, the rerun or the read, and choose the narrowest policy or predicate so unrelated parameters do not refetch.
Set conventions for URL-driven data so query-driven views declare their rerun policy, and weigh that against moving such data into reactive resources.
## The symptom An invoice edit page lets the user switch between revisions with links such as `?revision=3` and `?revision=4`. The resolver reads `route.queryParamMap.get('revision')` and fetches that revision. The first visit works. Switching revision changes the URL, but the form still shows revision 3. There are **two independent causes**, and both must be fixed. ## Cause 1: the resolver was never called When the new URL matches the same route configuration as the current one, the router **reuses** the activated route and its component rather than destroying and recreating them. For a reused route it asks one question: should guards and resolvers run again? The answer comes from the route's **`runGuardsAndResolvers`** property: | Value | Reruns guards and resolvers when | |---|---| | `'paramsChange'` (default) | path or matrix parameters change; query parameters are ignored | | `'pathParamsChange'` | path parameters change; matrix and query parameters are ignored | | `'pathParamsOrQueryParamsChange'` | path parameters or query parameters change; matrix parameters are ignored | | `'paramsOrQueryParamsChange'` | path, matrix or query parameters change | | `'always'` | on every navigation that reaches this route | | a function `(from, to) => boolean` | the function returns `true`; it runs in an injection context | With the default, a query-only change does not rerun the resolver. The router copies the previous resolved data onto the new snapshot, so `data['invoice']` is still revision 3. The fix is to choose a policy that includes query parameters: ```ts { path: 'invoices/:id/edit', component: InvoiceEdit, resolve: { invoice: invoiceRevisionResolver }, runGuardsAndResolvers: 'pathParamsOrQueryParamsChange', } ``` A predicate is the precise option when only one query parameter matters, for example `(from, to) => from.queryParamMap.get('revision') !== to.queryParamMap.get('revision')`, so that a change to an unrelated `?tab=` parameter does not refetch. Note that the policy applies to the route's **guards too**: widening it for the resolver also reruns that route's `canActivate` guards. ## Cause 2: the component read the data once Even when the resolver reruns, a component that did `this.invoice = inject(ActivatedRoute).snapshot.data['invoice']` in its constructor or `ngOnInit` will not update: the reused instance is not constructed again and `ngOnInit` does not fire again. The fresh value arrives through the **`ActivatedRoute.data` Observable**, which emits again when the resolved data changes. So the component should: - subscribe to `route.data` (or convert it with `toSignal`), or - use `withComponentInputBinding()` and an `invoice` input, which the router updates on the reused component. ## Why the default leaves query parameters out The default fits the common shape of URLs: path parameters such as `:id` identify **which record** a page shows, while query parameters often carry **view state** such as sorting, a selected tab or a page number. Rerunning every guard and resolver whenever a tab changed would add a blocking request to each click on that tab. The cost of that default is exactly this bug: when a query parameter does identify the data, as `?revision=` does here, the route has to say so explicitly. Treat a query parameter that selects data as a signal to declare a rerun policy on that route. ## A related case: the exact same URL A "Reload" button that navigates to the URL already displayed hits a different default first: the router's `onSameUrlNavigation` is `'ignore'`, so the navigation is skipped entirely. Setting it to `'reload'` (globally with `withRouterConfig`, or per call in `router.navigate(..., { onSameUrlNavigation: 'reload' })`) makes the router process the navigation, but on the reused route the resolver still reruns only if `runGuardsAndResolvers` says so, which for an identical URL means `'always'` or a predicate returning `true`. ## Diagnosis checklist 1. Log `ResolveStart` in `Router.events`, or put a breakpoint in the resolver: if it is not hit, the rerun policy is the cause. 2. If it is hit but the screen is stale, look for `snapshot` reads in a reused component. 3. Check whether the URL change is a query, matrix or path parameter change, because the default policy treats them differently. 4. Prefer the narrowest policy that covers the real dependency; `'always'` reruns guards and resolvers on every navigation through the route, including child-only changes.
- Does the default policy rerun the resolver when a matrix parameter such as ;mode=compact changes?Yes. The default `'paramsChange'` compares the route's parameters, which include matrix parameters, as well as its URL segments. `'pathParamsChange'` and `'pathParamsOrQueryParamsChange'` are the options that ignore matrix parameters.
- What is the downside of setting runGuardsAndResolvers to 'always' on a parent route?It reruns the parent's guards and resolvers on every navigation that passes through it, including child-to-child navigations that change nothing the parent depends on. That adds requests and blocking time to every click, and any guard with side effects fires repeatedly. A predicate or a narrower named policy targets the real dependency.
saying these in an interview costs you the question
- Resolvers rerun on every URL change by default
- The router always recreates the component when the URL changes
- snapshot.data updates itself on a reused component
- runGuardsAndResolvers only affects resolvers, not guards
- onSameUrlNavigation 'reload' alone reruns every resolver