Fix persisted state being dropped on enhanced navigation - #68088
Fix persisted state being dropped on enhanced navigation#68088javiercn wants to merge 2 commits into
Conversation
Persisted component state was not restored when interactive components were activated as a result of an enhanced navigation, unless they were the first interactive components activated on the page. refreshRootComponents collected the set of renderer IDs used to decide which persisted state to look for in the document *before* calling determinePendingOperation, which is what assigns the renderer ID to a component that is being activated for the first time. During enhanced navigation we defer activating new components until the navigation completes, so a component is first activated on the very refreshRootComponents call that is supposed to discover the new state. At that point its renderer ID was not in the set yet, so we never read the state from the document and passed an empty string to the runtime, causing components to re-run their initialization logic. The first enhanced navigation to an interactive page happened to work because it goes through the initial root component update, which reads the persisted state independently. The bug only reproduced from the second activation onwards. Fixes #63895 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf9db46f-bcb3-45c5-b3eb-9683cf87ee13
There was a problem hiding this comment.
Pull request overview
Fixes a Blazor Web persisted-state regression during enhanced navigation by ensuring renderer IDs are collected after pending operations are determined, so newly-activated components contribute their renderer and the correct persisted state is discovered and passed to the runtime. Adds an E2E regression test and a test-app switch to keep server circuits alive long enough to deterministically exercise the second-activation path.
Changes:
- Update
WebRootComponentManager.refreshRootComponentsto add renderer IDs afterdeterminePendingOperationassigns them for first-time activations. - Add a regression E2E test that navigates away and back to ensure persisted state is restored on subsequent enhanced navigations (server + wasm).
- Add a test-app sessionStorage flag to increase server circuit inactivity timeout for tests that require circuit continuity.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Components/Web.JS/src/Services/WebRootComponentManager.ts | Fixes renderer-ID collection ordering so persisted state is discovered on subsequent enhanced navigations. |
| src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor | Adds a sessionStorage-controlled circuit inactivity timeout override for deterministic server-side testing. |
| src/Components/test/E2ETest/Tests/StatePersistenceTest.cs | Adds a regression test covering repeated enhanced navigations to pages with interactive components. |
…ression test Covers the case where the persisted state is only emitted on a streaming update at the end of the response, after the interactive components have already been discovered. Uses the declarative [PersistentState] components, since the state is restored by the framework during component initialization. The non declarative streaming test component reads the state after an await, which is only guaranteed to work during the first render. Verified that all four rows fail without the fix and pass with it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf9db46f-bcb3-45c5-b3eb-9683cf87ee13
ilonatommy
left a comment
There was a problem hiding this comment.
Looks logical but adds the AI-generated comments in places that are self-descriptive already.
| // Regression test for https://github.com/dotnet/aspnetcore/issues/63895 | ||
| // The first enhanced navigation to a page with interactive components goes through the initial | ||
| // root component update, which reads the persisted state independently. Subsequent enhanced | ||
| // navigations activate components through 'updateRootComponents' instead, and used to drop the | ||
| // persisted state because the renderer had not been assigned to the component yet at the time | ||
| // we decided which persisted state to look for. |
There was a problem hiding this comment.
| // Regression test for https://github.com/dotnet/aspnetcore/issues/63895 | |
| // The first enhanced navigation to a page with interactive components goes through the initial | |
| // root component update, which reads the persisted state independently. Subsequent enhanced | |
| // navigations activate components through 'updateRootComponents' instead, and used to drop the | |
| // persisted state because the renderer had not been assigned to the component yet at the time | |
| // we decided which persisted state to look for. |
| const operationsByRendererId = new Map<WebRendererId, RootComponentOperation[]>(); | ||
| const rendererIds: Set<WebRendererId> = new Set<WebRendererId>(); | ||
| for (const component of components) { | ||
| const operation = this.determinePendingOperation(component); |
There was a problem hiding this comment.
| const operation = this.determinePendingOperation(component); | |
| // Makes sure a component has a render ID assigned | |
| const operation = this.determinePendingOperation(component); |
| // This must happen after determining the pending operation, because a component | ||
| // that is being activated for the first time only gets assigned a renderer ID | ||
| // as part of computing its 'add' operation. |
There was a problem hiding this comment.
| // This must happen after determining the pending operation, because a component | |
| // that is being activated for the first time only gets assigned a renderer ID | |
| // as part of computing its 'add' operation. |
| // We use a short timeout so tests don't take too long, except for tests that | ||
| // need the circuit to survive navigating through pages with no interactive | ||
| // components so that components get reactivated on the existing circuit. |
There was a problem hiding this comment.
| // We use a short timeout so tests don't take too long, except for tests that | |
| // need the circuit to survive navigating through pages with no interactive | |
| // components so that components get reactivated on the existing circuit. |
| // Same regression as above, but with streaming rendering, where the persisted state only reaches | ||
| // the document on a streaming update at the end of the response, after the interactive components | ||
| // have already been discovered. |
There was a problem hiding this comment.
| // Same regression as above, but with streaming rendering, where the persisted state only reaches | |
| // the document on a streaming update at the end of the response, after the interactive components | |
| // have already been discovered. |
Fix persisted state being dropped on enhanced navigation
Fixes #63895
Root Cause Analysis
Persisted component state is not restored when interactive components are activated as a result of an enhanced navigation, unless they happen to be the first interactive components activated on the page.
In
WebRootComponentManager.refreshRootComponents, the set of renderer IDs used to decide which persisted state to look for in the document was collected before callingdeterminePendingOperation:determinePendingOperationis what assignsassignedRendererId, as part of producing theaddoperation for a component that is being activated for the first time.During enhanced navigation we deliberately defer activating new components (
isPageLoading()short-circuits the activation), so a component is first activated on the veryrefreshRootComponents(discoverNewState: true)call triggered byonEnhancedNavigationCompleted(). On that call its renderer ID was not inrendererIdsyet, sodiscoverServerPersistedState/discoverWebAssemblyPersistedStatewere never called and we passed an empty state string to the runtime. The component then re-ran its initialization logic, re-fetching data that was already persisted.The reason this was not caught earlier is that the first enhanced navigation to a page with interactive components goes through
resolveInitialUpdate, which reads the state independently viaBlazor._internal.getPersistedState. The bug only reproduces from the second activation onwards, when components are activated throughupdateRootComponents. The existing E2E tests only covered the first hop.Fix
Move the
rendererIds.add(...)bookkeeping afterdeterminePendingOperation, so that a component being activated on this pass contributes its renderer ID. This applies equally to Server and WebAssembly.Verification
Using the repro from the issue, instrumenting
Blazor._internal.updateRootComponentsandfetch, and navigating Home ↔ Counter:api/counterstaterequestsThe state was present in the document the whole time and simply not picked up:
After the fix
updateRootComponentsreceivesstateLen: 1280on every navigation.Test Coverage
Added
StateIsRestoredOnSubsequentEnhancedNavigationsToPagesWithComponentstoStatePersistenceTest, covering bothserverandwasm. It navigates to a page with components, away, and back, so the second activation goes through a root component update rather than the initial update.The test app disposes idle circuits after 100 ms, which would make the server case go through circuit creation (which reads the state independently) and silently not exercise the regression. Added an opt-in
keep-circuit-alivesession storage flag to the test app that raises the inactivity timeout, following the existing flag pattern, so the server case is deterministic.Validated as a genuine regression test:
Expected: State found:True / Actual: State found:False) for bothserverandwasmRegression runs, all green:
StatePersistenceTest— 30/30InteractivityTest+EnhancedNavigation— 258/258