From 3ae6393f715de2842f8ea97c0649aca65d6cff86 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Wed, 1 Jul 2026 02:12:56 +0200 Subject: [PATCH] fix(signals): `patchState` does not block on error Signals can throw an error if they are accessed. `patchState` needs to call `getState` in order to provide the current state to an updater function. This fix catches errors in the internal `getState` call and keeps the errored signals in a proxy, where accessing them throws again. That means `patchState` does not block unrelated updates, and the user can apply the normal error handling features like `try/catch`, `ErrorHandler`, or the upcoming error boundaries. --- modules/signals/spec/state-source.spec.ts | 40 ++++++++++++++++ modules/signals/src/state-source.ts | 58 +++++++++++++++++------ 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/modules/signals/spec/state-source.spec.ts b/modules/signals/spec/state-source.spec.ts index 7de0b29a34..f91dd340e1 100644 --- a/modules/signals/spec/state-source.spec.ts +++ b/modules/signals/spec/state-source.spec.ts @@ -15,6 +15,7 @@ import { StateSource, watchState, withHooks, + withLinkedState, withMethods, withState, } from '../src'; @@ -246,6 +247,45 @@ describe('StateSource', () => { TestBed.tick(); expect(userChangedCount).toBe(2); }); + + describe('error behavior', () => { + function setupStore() { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState({ name: '' }), + withLinkedState(() => ({ + value: () => { + throw new Error('Failed to read value.'); + }, + })) + ); + + return TestBed.inject(Store); + } + + it('patches an unrelated state slice without throwing', () => { + const store = setupStore(); + + expect(() => patchState(store, { name: 'foo' })).not.toThrow(); + expect(store.name()).toBe('foo'); + }); + + it('throws when an updater reads the errored state slice', () => { + const store = setupStore(); + + expect(() => + patchState(store, ({ value }) => ({ name: String(value) })) + ).toThrow('Failed to read value.'); + }); + + it('throws when patching the errored state slice', () => { + const store = setupStore(); + + expect(() => patchState(store, { value: undefined })).toThrow( + 'Failed to read value.' + ); + }); + }); }); describe('getState', () => { diff --git a/modules/signals/src/state-source.ts b/modules/signals/src/state-source.ts index fdff85abd8..cd0bbfbdaf 100644 --- a/modules/signals/src/state-source.ts +++ b/modules/signals/src/state-source.ts @@ -82,24 +82,24 @@ export function patchState( Partial> | PartialStateUpdater> > ): void { - const currentState = untracked(() => getState(stateSource)); - const newState = updaters.reduce( - (nextState: State, updater) => ({ - ...nextState, - ...(typeof updater === 'function' ? updater(nextState) : updater), - }), - currentState - ); - const signals = stateSource[STATE_SOURCE]; const stateKeys = Reflect.ownKeys(stateSource[STATE_SOURCE]); + const draftState = untracked(() => getSafeState(stateSource)); + const touchedKeys = new Set(); + + for (const updater of updaters) { + const partial = + typeof updater === 'function' ? updater(draftState) : updater; - for (const key of Reflect.ownKeys(newState)) { - if (stateKeys.includes(key)) { - const signalKey = key as keyof State; - if (currentState[signalKey] !== newState[signalKey]) { - signals[signalKey].set(newState[signalKey]); - } + for (const key of Reflect.ownKeys(partial) as Array) { + touchedKeys.add(key); + draftState[key] = partial[key] as State[keyof State]; + } + } + + for (const key of touchedKeys) { + if (stateKeys.includes(key as string | symbol)) { + signals[key].set(draftState[key]); } else if (typeof ngDevMode !== 'undefined' && ngDevMode) { console.warn( `@ngrx/signals: patchState was called with an unknown state slice '${String( @@ -114,6 +114,34 @@ export function patchState( notifyWatchers(stateSource); } +function getSafeState( + stateSource: StateSource +): State { + const signals: Record> = stateSource[ + STATE_SOURCE + ]; + const state = {} as State; + + for (const key of Reflect.ownKeys(signals)) { + try { + (state as Record)[key] = signals[key](); + } catch (error) { + Object.defineProperty(state, key, { + get() { + throw error; + }, + set() { + throw error; + }, + enumerable: true, + configurable: true, + }); + } + } + + return state; +} + /** * @description *