From 21012b89be3ac888e35fa585a7743cdbe9ae2ce8 Mon Sep 17 00:00:00 2001
From: Daniel Murrmann <9040811+fancyDevelopment@users.noreply.github.com>
Date: Tue, 14 Jul 2026 20:59:00 +0200
Subject: [PATCH 1/3] Add withDeepWritableStateProjection feature and related
documentation
---
.../04-withDeepWritableStateProjection.md | 110 +++++++
doc/sidebars.ts | 3 +-
...ith-deep-writable-state-projection.spec.ts | 307 ++++++++++++++++++
.../with-deep-writable-state-projection.ts | 233 +++++++++++++
libs/ngrx-hateoas/src/public-api.ts | 3 +-
5 files changed, 654 insertions(+), 2 deletions(-)
create mode 100644 doc/docs/guide/05-state_mutation_features/04-withDeepWritableStateProjection.md
create mode 100644 libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.spec.ts
create mode 100644 libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.ts
diff --git a/doc/docs/guide/05-state_mutation_features/04-withDeepWritableStateProjection.md b/doc/docs/guide/05-state_mutation_features/04-withDeepWritableStateProjection.md
new file mode 100644
index 0000000..b50fc85
--- /dev/null
+++ b/doc/docs/guide/05-state_mutation_features/04-withDeepWritableStateProjection.md
@@ -0,0 +1,110 @@
+---
+sidebar_position: 4
+---
+
+# withDeepWritableStateProjection
+
+`withDeepWritableStateProjection` creates a writable view composed from arbitrary parts of the store state. Every property returned by the selector is a deep writable signal. Nested object nodes are signals as well, not just containers for their child signals.
+
+This makes it possible to create a form-shaped view without adding a synchronized copy of the selected state.
+
+## Usage
+
+```ts
+import { signalStore, withState } from '@ngrx/signals';
+import { withDeepWritableStateProjection } from '@angular-architects/ngrx-hateoas';
+
+const FlightStore = signalStore(
+ withState({
+ search: {
+ from: null as string | null,
+ to: null as string | null,
+ },
+ settings: {
+ travelClass: 'economy',
+ language: 'en',
+ },
+ }),
+ withDeepWritableStateProjection(store => ({
+ searchForm: {
+ route: {
+ from: store.search.from,
+ to: store.search.to,
+ },
+ travelClass: store.settings.travelClass,
+ },
+ })),
+);
+```
+
+The projection and all of its nodes can be read as signals:
+
+```ts
+store.searchForm();
+// {
+// route: { from: null, to: null },
+// travelClass: 'economy'
+// }
+
+store.searchForm.route();
+// { from: null, to: null }
+
+store.searchForm.route.from();
+// null
+```
+
+Every node can also be written:
+
+```ts
+store.searchForm.route.from.set('Graz');
+
+store.searchForm.route.set({
+ from: 'Graz',
+ to: 'Hamburg',
+});
+
+store.searchForm.update(form => ({
+ ...form,
+ travelClass: 'business',
+}));
+```
+
+Writing a structure node distributes its value to all selected state properties below that node. Writes spanning multiple state roots are applied with a single `patchState` operation. State properties that are not part of the projection remain unchanged.
+
+Changes made to the original state are immediately reflected by the projection. The projection therefore does not introduce an independent state copy and does not need explicit synchronization or reset behavior.
+
+## API
+
+```ts
+function withDeepWritableStateProjection<
+ Input extends SignalStoreFeatureResult,
+ Selection extends DeepWritableStateProjectionSelection,
+>(
+ stateMapFn: (store: MappedStoreState) => Selection,
+): SignalStoreFeature
+```
+
+The selector returns an object whose leaves are state signals. Objects may be nested to create the desired projection shape:
+
+```ts
+type DeepWritableStateProjectionSelection = {
+ [key: string]: Signal | DeepWritableStateProjectionSelection;
+};
+```
+
+For every top-level property, the feature adds a `DeepWritableSignal` containing the recursively unwrapped values:
+
+```ts
+type ProjectedValue =
+ Node extends Signal
+ ? Value
+ : Node extends DeepWritableStateProjectionSelection
+ ? { [Key in keyof Node]: ProjectedValue }
+ : never;
+
+type DeepWritableStateProjection = {
+ [Key in keyof Selection]: DeepWritableSignal>;
+};
+```
+
+The generated writable signals support `set`, `update`, and `asReadonly` at the projection root, at every structure node, and at every selected leaf.
diff --git a/doc/sidebars.ts b/doc/sidebars.ts
index 312315b..1b6dd91 100644
--- a/doc/sidebars.ts
+++ b/doc/sidebars.ts
@@ -43,7 +43,8 @@ const sidebars: SidebarsConfig = {
items: [
'guide/state_mutation_features/withWritableStateCopy',
'guide/state_mutation_features/withDeepWritableStateCopy',
- 'guide/state_mutation_features/withDeepWritableStateDelegate'
+ 'guide/state_mutation_features/withDeepWritableStateDelegate',
+ 'guide/state_mutation_features/withDeepWritableStateProjection'
],
},
{
diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.spec.ts b/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.spec.ts
new file mode 100644
index 0000000..1c15fc4
--- /dev/null
+++ b/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.spec.ts
@@ -0,0 +1,307 @@
+import {
+ Injector,
+ isSignal,
+ provideZonelessChangeDetection,
+ runInInjectionContext,
+} from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { patchState, signalMethod, signalStore, withMethods, withState } from '@ngrx/signals';
+import { firstValueFrom, timer } from 'rxjs';
+import { withDeepWritableStateProjection } from './with-deep-writable-state-projection';
+
+type TestState = {
+ person: {
+ firstName: string;
+ lastName: string;
+ address: {
+ city: string;
+ zipCode: string;
+ };
+ };
+ preferences: {
+ theme: 'light' | 'dark';
+ language: string;
+ };
+ counter: number;
+ untouched: string;
+};
+
+const initialState: TestState = {
+ person: {
+ firstName: 'Ada',
+ lastName: 'Lovelace',
+ address: {
+ city: 'London',
+ zipCode: 'SW1A',
+ },
+ },
+ preferences: {
+ theme: 'light',
+ language: 'en',
+ },
+ counter: 1,
+ untouched: 'keep me',
+};
+
+const TestStore = signalStore(
+ withState(initialState),
+ withDeepWritableStateProjection(store => ({
+ editor: {
+ identity: {
+ givenName: store.person.firstName,
+ familyName: store.person.lastName,
+ },
+ location: {
+ city: store.person.address.city,
+ },
+ appearance: {
+ theme: store.preferences.theme,
+ },
+ },
+ projectedCounter: store.counter,
+ projectedAddress: store.person.address,
+ })),
+ withMethods(store => ({
+ updateOriginalState(state: Partial) {
+ patchState(store, state);
+ },
+ })),
+);
+
+describe('withDeepWritableStateProjection', () => {
+ let store: InstanceType;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [provideZonelessChangeDetection(), TestStore],
+ });
+ store = TestBed.inject(TestStore);
+ });
+
+ it('creates a deep writable signal for every projected structure node and leaf', () => {
+ expect(isSignal(store.editor)).toBeTrue();
+ expect(store.editor.set).toBeDefined();
+ expect(store.editor.update).toBeDefined();
+ expect(store.editor.asReadonly).toBeDefined();
+
+ expect(isSignal(store.editor.identity)).toBeTrue();
+ expect(store.editor.identity.set).toBeDefined();
+ expect(store.editor.identity.update).toBeDefined();
+ expect(isSignal(store.editor.identity.givenName)).toBeTrue();
+ expect(store.editor.identity.givenName.set).toBeDefined();
+ expect(store.editor.identity.givenName.update).toBeDefined();
+
+ expect(isSignal(store.editor.location)).toBeTrue();
+ expect(isSignal(store.editor.location.city)).toBeTrue();
+ expect(isSignal(store.editor.appearance)).toBeTrue();
+ expect(isSignal(store.editor.appearance.theme)).toBeTrue();
+
+ expect(isSignal(store.projectedCounter)).toBeTrue();
+ expect(store.projectedCounter.set).toBeDefined();
+ expect(store.projectedCounter.update).toBeDefined();
+ expect(isSignal(store.projectedAddress)).toBeTrue();
+ expect(isSignal(store.projectedAddress.city)).toBeTrue();
+ expect(store.projectedAddress.city.set).toBeDefined();
+ });
+
+ it('reads a composed value from different parts of the state', () => {
+ expect(store.editor()).toEqual({
+ identity: {
+ givenName: 'Ada',
+ familyName: 'Lovelace',
+ },
+ location: {
+ city: 'London',
+ },
+ appearance: {
+ theme: 'light',
+ },
+ });
+
+ expect(store.editor.identity()).toEqual({
+ givenName: 'Ada',
+ familyName: 'Lovelace',
+ });
+ expect(store.editor.identity.givenName()).toBe('Ada');
+ expect(store.editor.location.city()).toBe('London');
+ expect(store.editor.appearance.theme()).toBe('light');
+ expect(store.projectedCounter()).toBe(1);
+ expect(store.projectedAddress()).toEqual({ city: 'London', zipCode: 'SW1A' });
+ });
+
+ it('sets a whole projection across multiple state roots and preserves unprojected state', () => {
+ store.editor.set({
+ identity: {
+ givenName: 'Grace',
+ familyName: 'Hopper',
+ },
+ location: {
+ city: 'New York',
+ },
+ appearance: {
+ theme: 'dark',
+ },
+ });
+
+ expect(store.person()).toEqual({
+ firstName: 'Grace',
+ lastName: 'Hopper',
+ address: {
+ city: 'New York',
+ zipCode: 'SW1A',
+ },
+ });
+ expect(store.preferences()).toEqual({
+ theme: 'dark',
+ language: 'en',
+ });
+ expect(store.counter()).toBe(1);
+ expect(store.untouched()).toBe('keep me');
+ expect(store.editor()).toEqual({
+ identity: {
+ givenName: 'Grace',
+ familyName: 'Hopper',
+ },
+ location: {
+ city: 'New York',
+ },
+ appearance: {
+ theme: 'dark',
+ },
+ });
+ });
+
+ it('sets a projected structure node without changing its siblings', () => {
+ store.editor.identity.set({
+ givenName: 'Katherine',
+ familyName: 'Johnson',
+ });
+
+ expect(store.person.firstName()).toBe('Katherine');
+ expect(store.person.lastName()).toBe('Johnson');
+ expect(store.person.address()).toEqual({ city: 'London', zipCode: 'SW1A' });
+ expect(store.preferences.theme()).toBe('light');
+ expect(store.editor.location.city()).toBe('London');
+ });
+
+ it('sets individual projected leaves in deeply nested and top-level projections', () => {
+ store.editor.location.city.set('Paris');
+ store.editor.appearance.theme.set('dark');
+ store.projectedCounter.set(10);
+
+ expect(store.person.address.city()).toBe('Paris');
+ expect(store.person.address.zipCode()).toBe('SW1A');
+ expect(store.preferences.theme()).toBe('dark');
+ expect(store.preferences.language()).toBe('en');
+ expect(store.counter()).toBe(10);
+ });
+
+ it('deeply sets and updates a directly selected object signal', () => {
+ store.projectedAddress.set({
+ city: 'Berlin',
+ zipCode: '10115',
+ });
+ store.projectedAddress.city.update(city => city.toUpperCase());
+
+ expect(store.projectedAddress()).toEqual({
+ city: 'BERLIN',
+ zipCode: '10115',
+ });
+ expect(store.person.address()).toEqual({
+ city: 'BERLIN',
+ zipCode: '10115',
+ });
+ expect(store.person.firstName()).toBe('Ada');
+ expect(store.person.lastName()).toBe('Lovelace');
+ });
+
+ it('updates structure nodes and leaves using their current projected value', () => {
+ store.editor.identity.update(identity => ({
+ givenName: identity.givenName.toUpperCase(),
+ familyName: identity.familyName.toUpperCase(),
+ }));
+ store.projectedCounter.update(counter => counter + 4);
+
+ expect(store.editor.identity()).toEqual({
+ givenName: 'ADA',
+ familyName: 'LOVELACE',
+ });
+ expect(store.person.firstName()).toBe('ADA');
+ expect(store.person.lastName()).toBe('LOVELACE');
+ expect(store.counter()).toBe(5);
+ });
+
+ it('reacts to changes made directly to the original state', () => {
+ store.updateOriginalState({
+ person: {
+ firstName: 'Dorothy',
+ lastName: 'Vaughan',
+ address: {
+ city: 'Hampton',
+ zipCode: '23666',
+ },
+ },
+ preferences: {
+ theme: 'dark',
+ language: 'en',
+ },
+ counter: 7,
+ });
+
+ expect(store.editor()).toEqual({
+ identity: {
+ givenName: 'Dorothy',
+ familyName: 'Vaughan',
+ },
+ location: {
+ city: 'Hampton',
+ },
+ appearance: {
+ theme: 'dark',
+ },
+ });
+ expect(store.projectedCounter()).toBe(7);
+ });
+
+ it('keeps fine-grained reactivity when one projected leaf changes', async () => {
+ const injector = TestBed.inject(Injector);
+ let editorNotifications = 0;
+ let identityNotifications = 0;
+ let givenNameNotifications = 0;
+ let familyNameNotifications = 0;
+ let locationNotifications = 0;
+
+ runInInjectionContext(injector, () => {
+ signalMethod(() => editorNotifications++)(store.editor);
+ signalMethod(() => identityNotifications++)(store.editor.identity);
+ signalMethod(() => givenNameNotifications++)(store.editor.identity.givenName);
+ signalMethod(() => familyNameNotifications++)(store.editor.identity.familyName);
+ signalMethod(() => locationNotifications++)(store.editor.location);
+ });
+
+ await firstValueFrom(timer(0));
+ editorNotifications = 0;
+ identityNotifications = 0;
+ givenNameNotifications = 0;
+ familyNameNotifications = 0;
+ locationNotifications = 0;
+
+ store.editor.identity.givenName.set('Augusta');
+ await firstValueFrom(timer(0));
+
+ expect(editorNotifications).toBe(1);
+ expect(identityNotifications).toBe(1);
+ expect(givenNameNotifications).toBe(1);
+ expect(familyNameNotifications).toBe(0);
+ expect(locationNotifications).toBe(0);
+ });
+
+ it('exposes a readonly view without writable operations', () => {
+ const readonlyEditor = store.editor.asReadonly();
+
+ expect(isSignal(readonlyEditor)).toBeTrue();
+ expect(readonlyEditor()).toEqual(store.editor());
+ expect((readonlyEditor as unknown as { set?: unknown }).set).toBeUndefined();
+ expect((readonlyEditor as unknown as { update?: unknown }).update).toBeUndefined();
+ });
+});
diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.ts b/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.ts
new file mode 100644
index 0000000..6d225c1
--- /dev/null
+++ b/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.ts
@@ -0,0 +1,233 @@
+import { computed, isSignal, Signal } from '@angular/core';
+import {
+ patchState,
+ signalStoreFeature,
+ SignalStoreFeature,
+ SignalStoreFeatureResult,
+ StateSignals,
+ withProps,
+ WritableStateSource,
+} from '@ngrx/signals';
+import { DeepWritableSignal } from '../util/deep-writeable-signal';
+
+export type DeepWritableStateProjectionSelection = {
+ [key: string]: Signal | DeepWritableStateProjectionSelection;
+};
+
+type ProjectedValue = Node extends Signal
+ ? Value
+ : Node extends DeepWritableStateProjectionSelection
+ ? { [Key in keyof Node]: ProjectedValue }
+ : never;
+
+export type DeepWritableStateProjection = {
+ [Key in keyof Selection]: DeepWritableSignal>;
+};
+
+type MappedStoreState = {
+ [Key in keyof StateSignals]: StateSignals[Key] extends Signal
+ ? DeepWritableSignal
+ : never;
+};
+
+type SelectStateFn<
+ Input extends SignalStoreFeatureResult,
+ Selection extends DeepWritableStateProjectionSelection,
+> = (store: MappedStoreState) => Selection;
+
+type StateRecord = Record;
+
+type StateWrite = {
+ path: PropertyKey[];
+ value: unknown;
+};
+
+const statePath = Symbol('deepWritableStateProjectionPath');
+
+type StateProjectionSignal = DeepWritableSignal & {
+ [statePath]: PropertyKey[];
+};
+
+function isStateRecord(value: unknown): value is StateRecord {
+ return value !== null && typeof value === 'object';
+}
+
+function setValueAtPath(currentValue: unknown, path: readonly PropertyKey[], value: unknown): unknown {
+ if (path.length === 0) {
+ return value;
+ }
+
+ const [key, ...remainingPath] = path;
+ const currentRecord = isStateRecord(currentValue) ? currentValue : {};
+
+ return {
+ ...currentRecord,
+ [key]: setValueAtPath(currentRecord[key], remainingPath, value),
+ };
+}
+
+function createStateProjectionSignal(
+ source: Signal,
+ path: PropertyKey[],
+ commit: (writes: StateWrite[]) => void,
+): StateProjectionSignal {
+ const children = new Map>();
+
+ return new Proxy(source, {
+ get(target, property, receiver) {
+ if (property === statePath) {
+ return path;
+ }
+
+ if (property === 'set') {
+ return (value: T) => commit([{ path, value }]);
+ }
+
+ if (property === 'update') {
+ return (updateFn: (value: T) => T) => commit([{ path, value: updateFn(target()) }]);
+ }
+
+ if (property === 'asReadonly') {
+ return () => target;
+ }
+
+ if (typeof property !== 'symbol') {
+ const currentValue = target();
+ if (isStateRecord(currentValue) && property in currentValue) {
+ let child = children.get(property);
+ if (!child) {
+ child = createStateProjectionSignal(
+ computed(() => (target() as StateRecord)[property]),
+ [...path, property],
+ commit,
+ );
+ children.set(property, child);
+ }
+ return child;
+ }
+ }
+
+ return Reflect.get(target, property, receiver);
+ },
+ }) as StateProjectionSignal;
+}
+
+function collectWrites(
+ selection: Signal | DeepWritableStateProjectionSelection,
+ value: unknown,
+ writes: StateWrite[],
+): void {
+ if (isSignal(selection)) {
+ writes.push({
+ path: (selection as StateProjectionSignal)[statePath],
+ value,
+ });
+ return;
+ }
+
+ const valueRecord = isStateRecord(value) ? value : {};
+ for (const key of Object.keys(selection)) {
+ collectWrites(selection[key], valueRecord[key], writes);
+ }
+}
+
+function createCompositeProjectionSignal(
+ selection: Selection,
+ commit: (writes: StateWrite[]) => void,
+): DeepWritableSignal> {
+ const children = Object.fromEntries(
+ Object.entries(selection).map(([key, child]) => [
+ key,
+ isSignal(child) ? child : createCompositeProjectionSignal(child, commit),
+ ]),
+ ) as Record>;
+
+ const source = computed(() =>
+ Object.fromEntries(Object.entries(children).map(([key, child]) => [key, child()])),
+ ) as Signal>;
+
+ const set = (value: ProjectedValue) => {
+ const writes: StateWrite[] = [];
+ collectWrites(selection, value, writes);
+ commit(writes);
+ };
+
+ return new Proxy(source, {
+ get(target, property, receiver) {
+ if (property === 'set') {
+ return set;
+ }
+
+ if (property === 'update') {
+ return (updateFn: (value: ProjectedValue) => ProjectedValue) =>
+ set(updateFn(target()));
+ }
+
+ if (property === 'asReadonly') {
+ return () => target;
+ }
+
+ if (typeof property === 'string' && property in children) {
+ return children[property];
+ }
+
+ return Reflect.get(target, property, receiver);
+ },
+ }) as DeepWritableSignal>;
+}
+
+function createProjection(
+ selection: Selection,
+ commit: (writes: StateWrite[]) => void,
+): DeepWritableStateProjection {
+ return Object.fromEntries(
+ Object.entries(selection).map(([key, node]) => [
+ key,
+ isSignal(node) ? node : createCompositeProjectionSignal(node, commit),
+ ]),
+ ) as DeepWritableStateProjection;
+}
+
+export function withDeepWritableStateProjection<
+ Input extends SignalStoreFeatureResult,
+ Selection extends DeepWritableStateProjectionSelection,
+>(
+ stateMapFn: SelectStateFn,
+): SignalStoreFeature<
+ Input,
+ Input & {
+ props: DeepWritableStateProjection;
+ }
+>;
+export function withDeepWritableStateProjection<
+ Input extends SignalStoreFeatureResult,
+ Selection extends DeepWritableStateProjectionSelection,
+>(stateMapFn: SelectStateFn) {
+ return signalStoreFeature(
+ withProps((store: WritableStateSource