Skip to content

Fix persisted state being dropped on enhanced navigation - #68088

Open
javiercn wants to merge 2 commits into
mainfrom
javiercn/fix-persistent-state-enhanced-nav
Open

Fix persisted state being dropped on enhanced navigation#68088
javiercn wants to merge 2 commits into
mainfrom
javiercn/fix-persistent-state-enhanced-nav

Conversation

@javiercn

Copy link
Copy Markdown
Member

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 calling determinePendingOperation:

for (const component of components) {
  if (discoverNewState && component.assignedRendererId !== undefined) {
    rendererIds.add(component.assignedRendererId);   // read before it is set
  }

  const operation = this.determinePendingOperation(component);  // assigns assignedRendererId
  ...
}

determinePendingOperation is what assigns assignedRendererId, as part of producing the add operation 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 very refreshRootComponents(discoverNewState: true) call triggered by onEnhancedNavigationCompleted(). On that call its renderer ID was not in rendererIds yet, so discoverServerPersistedState / discoverWebAssemblyPersistedState were 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 via Blazor._internal.getPersistedState. The bug only reproduces from the second activation onwards, when components are activated through updateRootComponents. The existing E2E tests only covered the first hop.

Fix

Move the rendererIds.add(...) bookkeeping after determinePendingOperation, 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.updateRootComponents and fetch, and navigating Home ↔ Counter:

client service calls api/counterstate requests
before 3 / 3 enhanced navs 3
after 0 0

The state was present in the document the whole time and simply not picked up:

stateLen: 0
stateCommentsInDom: ["Blazor-WebAssembly-Component-State:eyIvMXovUk5BNVJVYzdDeDVSS...len=1315"]

After the fix updateRootComponents receives stateLen: 1280 on every navigation.

Test Coverage

Added StateIsRestoredOnSubsequentEnhancedNavigationsToPagesWithComponents to StatePersistenceTest, covering both server and wasm. 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-alive session 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:

  • without the fix: 2 failed (Expected: State found:True / Actual: State found:False) for both server and wasm
  • with the fix: 2 passed

Regression runs, all green:

  • StatePersistenceTest — 30/30
  • InteractivityTest + EnhancedNavigation — 258/258

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
Copilot AI review requested due to automatic review settings July 29, 2026 15:47
@javiercn
javiercn requested a review from a team as a code owner July 29, 2026 15:47

Copilot AI left a comment

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.

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.refreshRootComponents to add renderer IDs after determinePendingOperation assigns 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.

@maraf maraf added the area-blazor Includes: Blazor, Razor Components label Jul 29, 2026
…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 ilonatommy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks logical but adds the AI-generated comments in places that are self-descriptive already.

Comment on lines +279 to +284
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
const operation = this.determinePendingOperation(component);
// Makes sure a component has a render ID assigned
const operation = this.determinePendingOperation(component);

Comment on lines +279 to +281
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// 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.

Comment on lines +225 to +227
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// 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.

Comment on lines +314 to +316
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components feature-blazor-enhanced-navigation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PersistentState does not work between enhanced navigation SSR and WASM pages

4 participants