Skip to content

fix(store-devtools): toSignal() in StoreDevtools writes to signal in reactive context → NG0600 #5147

@christophw-bird

Description

@christophw-bird

Which @ngrx/* package(s) are the source of the bug?

store-devtools

Minimal reproduction of the bug/regression with instructions

Any application that registers provideStoreDevtools() and dispatches an action synchronously during template evaluation or computed() evaluation will intermittently throw:

NG0600: Writing to signals is not allowed in a `computed`

Stack trace (Angular 21 + NgRx 21.1.0):

@ngrx/store-devtools/fesm2022/ngrx-store-devtools.mjs (liftedStateSubscription)
    liftedStateSubject.next(state);
↓
@angular/core/fesm2022/rxjs-interop.mjs (toSignal subscription)
    next: value => state.set({...})
↓
@angular/core/fesm2022/_effect-chunk.mjs
    signalSetFn → throwInvalidWriteToSignalError

This is a timing-dependent race condition. It is more likely to occur in production (optimized builds, tighter CD) but can happen in any environment.

Root cause

In modules/store-devtools/src/devtools.ts, the StateObservable's .state signal is created with toSignal():

Object.defineProperty(state$, 'state', {
  value: toSignal(state$, { manualCleanup: true, requireSync: true }),
});

state$ is derived from liftedStateSubject, which emits from a queueScheduler-scheduled subscription:

const liftedAction$ = merge(/* ... */).pipe(observeOn(queueScheduler));

// ...
.subscribe(({ state, action }) => {
  liftedStateSubject.next(state);   // ← triggers toSignal's signal write
  // ...
});

When an action is dispatched synchronously during template evaluation or inside a computed(), the queueScheduler processes the emission on the same call stack. The chain liftedStateSubject.next()state$toSignal()'s internal sub.nextsignal.set() fires within the reactive read context, and Angular throws NG0600.

The connectInZone / emitInZone option does not help here — it controls the extension communication path, not the liftedStateSubjecttoSignal path.

The logOnly option does not help either — even with logOnly: true, the full subscription chain and signal write still runs.

Suggested fix

Replace the bare toSignal() with a manual signal that writes via untracked():

import { signal, untracked } from '@angular/core';

// Instead of:
// Object.defineProperty(state$, 'state', {
//   value: toSignal(state$, { manualCleanup: true, requireSync: true }),
// });

// Use:
const stateSignal = signal<any>(unliftState(liftedInitialState));
state$.subscribe((value) => {
  untracked(() => stateSignal.set(value));
});
Object.defineProperty(state$, 'state', {
  value: stateSignal.asReadonly(),
});

This ensures the signal write never inherits a reactive context from the caller, while preserving the same observable-to-signal bridge behavior.

Expected behavior

provideStoreDevtools() should not throw NG0600, regardless of whether actions are dispatched synchronously during template evaluation or computed().

Versions of NgRx, Angular, Node, affected browser(s) and operating system(s)

NgRx: 21.1.0
Angular: 21.2.8
Node: 22
Browsers: Chrome, Safari (all browsers — framework-level error)
OS: All

Other information

I would be willing to submit a PR to fix this issue

  • Yes
  • No

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions