fix(core): wait for app stability before cleaning up dehydrated views by arturovt · Pull Request #70575 · angular/angular · GitHub
Skip to content

fix(core): wait for app stability before cleaning up dehydrated views - #70575

Draft
arturovt wants to merge 1 commit into
angular:mainfrom
arturovt:fix/defer-hydration-cleanup-race
Draft

fix(core): wait for app stability before cleaning up dehydrated views#70575
arturovt wants to merge 1 commit into
angular:mainfrom
arturovt:fix/defer-hydration-cleanup-race

Conversation

@arturovt

@arturovt arturovt commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This fixes a production crash seen on pages that dynamically create non-defer components:

TypeError: Cannot set properties of null (setting '__ngContext__')
...
at locateOrCreateElementNodeImpl

The crash was traced to triggerHydrationForBlockQueue(). When a @defer block finishes hydrating, it removes its own PendingTasks entry and immediately calls cleanupHydratedDeferBlocks() -> cleanupDehydratedViews(). That cleanup walks all LViews registered with ApplicationRef and removes dehydrated views that aren't associated with a defer block. This isn't safe while other async hydration work is still pending.

For example, a dynamically created component can have its own PendingTasks entry while its ViewContainerRef.createComponent() call is still waiting to run. If an unrelated defer block finishes first, its cleanup can remove that component's dehydrated view before the component gets a chance to claim it. The later creation then sees stale hydration state and can fail with the crash above.

This was reproduced with a test in incremental_hydration_spec.ts that combines an immediately hydrated @defer block with an unrelated PendingTasks-guarded ViewContainerRef.createComponent() whose creation is delayed until after the defer block hydrates. The test fails with the original code and passes with this fix.

The fix waits for ApplicationRef.whenStable() before running the cleanup, but only when there are other pending tasks. This keeps the usual case synchronous while making sure cleanup doesn't run while other hydration work is still in progress.

The wait is intentionally conditional. An unconditional whenStable() introduces another subscription to the shared stability signal. Because this subscription is created after this function removes its own pending task, callers that are already waiting on whenStable() can resume one microtask before this cleanup does. That changes the existing timing and breaks the immediate and should create an IntersectionObserver... tests. Checking hasPendingTasks first avoids creating that extra subscription when there is nothing else to wait for, preserving the existing behavior.

This fixes a production crash seen on pages that dynamically create
non-defer components:

    TypeError: Cannot set properties of null (setting '__ngContext__')
    ...
    at locateOrCreateElementNodeImpl

The crash was traced to `triggerHydrationForBlockQueue()`. When a
`@defer` block finishes hydrating, it removes its own `PendingTasks`
entry and immediately calls `cleanupHydratedDeferBlocks()` ->
`cleanupDehydratedViews()`. That cleanup walks all LViews registered
with `ApplicationRef` and removes dehydrated views that aren't
associated with a defer block. This isn't safe while other async
hydration work is still pending.

For example, a dynamically created component can have its own
`PendingTasks` entry while its `ViewContainerRef.createComponent()`
call is still waiting to run. If an unrelated defer block finishes
first, its cleanup can remove that component's dehydrated view before
the component gets a chance to claim it. The later creation then sees
stale hydration state and can fail with the crash above.

This was reproduced with a test in `incremental_hydration_spec.ts`
that combines an immediately hydrated `@defer` block with an unrelated
`PendingTasks`-guarded `ViewContainerRef.createComponent()` whose
creation is delayed until after the defer block hydrates. The test
fails with the original code and passes with this fix.

The fix waits for `ApplicationRef.whenStable()` before running the
cleanup, but only when there are other pending tasks. This keeps the
usual case synchronous while making sure cleanup doesn't run while
other hydration work is still in progress.

The wait is intentionally conditional. An unconditional `whenStable()`
introduces another subscription to the shared stability signal.
Because this subscription is created after this function removes its
own pending task, callers that are already waiting on `whenStable()`
can resume one microtask before this cleanup does. That changes the
existing timing and breaks the `immediate` and `should create an
IntersectionObserver...` tests. Checking `hasPendingTasks` first
avoids creating that extra subscription when there is nothing else to
wait for, preserving the existing behavior.
@angular-robot angular-robot Bot added the area: core Issues related to the framework runtime label Sep 4, 2026
@ngbot ngbot Bot added this to the Backlog milestone Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core Issues related to the framework runtime

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant