Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add immerPatchState #24

Merged
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,44 @@ export class MoviesStore extends ImmerComponentStore<MoviesState> {
}
```

## `immerPatchState`

Provides an Immer-version of the `patchState` function from the `@ngrx/signals` package. In addition to partial state objects and updaters that update the state immutably, it accepts updater functions that update the state in a mutable manner. Similar to `patchState`, the `immerPatchState` function can be used to update the state of both SignalStore and SignalState.

```ts
const UserStore = signalStore(
withState({
user: { firstName: 'Konrad', lastName: 'Schultz' },
address: { city: 'Vienna', zip: '1010' },
}),
withMethods((store) => ({
setLastName(lastName: string): void {
immerPatchState(store, (state) => {
state.user.lastName = lastName;
});
},
setCity(city: string): void {
immerPatchState(store, (state) => {
state.address.city = city;
});
},
}))
);
```

Please note, that the updater function can only mutate a change without returning it or return an immutable
state without mutable change.

This one is going to throw a runtime error:

```ts
// will throw because of both returning and mutable change
immerPatchState(userStore, (state) => {
state.name.lastname = 'Sanders'; // mutable change
return state; // returning state
});
```

## `immerReducer`

Inspired by [Alex Okrushko](https://twitter.com/alexokrushko), `immerReducer` is a reducer method that uses the Immer `produce` method.
Expand Down
14 changes: 14 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** @type {import('jest').Config} */
const config = {
preset: 'jest-preset-angular',
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
testMatch: ['**/*.jest.ts'],
globalSetup: 'jest-preset-angular/global-setup',
modulePathIgnorePatterns: ['<rootDir>/src/package.json'],
moduleNameMapper: {
'ngrx-immer/signals': '<rootDir>/src/signals',
'ngrx-immer': '<rootDir>/src',
},
};

module.exports = config;
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "ngrx-immer",
"version": "0.0.0-development",
"scripts": {
"test": "uvu -r tsm -r tsconfig-paths/register src tests",
"test": "uvu -r tsm -r tsconfig-paths/register -i jest.ts src tests && jest",
"build": "ng-packagr -p src/ng-package.json",
"postbuild": "cpy README.md LICENSE dist && cpy schematics ../../dist --parents --cwd=src"
},
Expand All @@ -11,10 +11,13 @@
"@angular/compiler-cli": "^17.0.0",
"@angular/core": "^17.0.0",
"@ngrx/component-store": "^17.0.0",
"@ngrx/signals": "^17.0.0",
"@ngrx/signals": "^17.1.1",
"@ngrx/store": "^17.0.0",
"@types/jest": "^29.5.12",
"cpy-cli": "^5.0.0",
"immer": "^10.0.3",
"jest": "^29.7.0",
"jest-preset-angular": "^14.0.3",
"ng-packagr": "^17.0.0",
"prettier": "^3.2.5",
"rimraf": "^5.0.5",
Expand Down
1 change: 1 addition & 0 deletions setup-jest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import 'jest-preset-angular/setup-jest';
2 changes: 1 addition & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"immer": ">=7.0.0",
"@ngrx/component-store": ">=13.0.0",
"@ngrx/store": ">=13.0.0",
"@ngrx/signals": ">=17.0.0"
"@ngrx/signals": ">=17.1.1"
},
"peerDependenciesMeta": {
"@ngrx/component-store": {
Expand Down
27 changes: 24 additions & 3 deletions src/signals/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
export function placeholder() {
return 'placeholder';
}
import { PartialStateUpdater, patchState, StateSignal } from '@ngrx/signals';
import { immerReducer } from 'ngrx-immer';

export type ImmerStateUpdater<State extends object> = (state: State) => void;

function toFullStateUpdater<State extends object>(updater: PartialStateUpdater<State & {}> | ImmerStateUpdater<State & {}>): (state: State) => State | void {
return (state: State) => {
const patchedState = updater(state);
if (patchedState) {
return ({ ...state, ...patchedState });
}
return;
};
}

export function immerPatchState<State extends object>(state: StateSignal<State>, ...updaters: Array<Partial<State & {}> | PartialStateUpdater<State & {}> | ImmerStateUpdater<State & {}>>) {
Copy link
Owner

Choose a reason for hiding this comment

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

I wonder if we can update the generics to be able to do the following:

  • the second patch updates the state and knows there a request state
  • the first patch does the same but the state gets narrowed down because of the use of setAllEntities

image

Copy link
Owner

Choose a reason for hiding this comment

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

Something like the following seems to work:

export declare function immerPatchState<State extends object, T = State>(state: StateSignal<T>, ...updaters: Array<Partial<T & {}> | PartialStateUpdater<T & {}> | ImmerStateUpdater<T & {}>>): void;

image

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi Tim, I can't currently work on it, but please update my branch as you wish. I'll continue tomorrow.

Copy link
Owner

Choose a reason for hiding this comment

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

You can ignore this.
In WebStorm this was working, after setting the TS version of Visual Studio Code to the installed version it does work.
Sorry for the confusion.
Let's ship this! 🚀

const immerUpdaters = updaters.map(updater => {
if (typeof updater === 'function') {
return immerReducer(toFullStateUpdater(updater)) as unknown as PartialStateUpdater<State & {}>;
}
return updater;
});
patchState(state, ...immerUpdaters);
}
145 changes: 145 additions & 0 deletions src/signals/tests/immer-patch-state.jest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { signalStore, withComputed, withState } from '@ngrx/signals';
import { immerPatchState } from 'ngrx-immer/signals';
import { computed, effect } from '@angular/core';
import { TestBed } from '@angular/core/testing';

const UserState = signalStore(withState({
id: 1,
name: { firstname: 'Konrad', lastname: 'Schultz' },
address: { city: 'Vienna', zip: '1010' },
}), withComputed(({ name }) => ({ prettyName: computed(() => `${name.firstname()} ${name.lastname()}`) })));


describe('immerPatchState', () => {
const setup = () => {
return new UserState;
};

it('should do a sanity check', () => {
const userState = setup();
expect(userState.id()).toBe(1);
});

it('should be type-safe', () => {
const userState = setup();

//@ts-expect-error number is not a property
immerPatchState(userState, { number: 1 });

//@ts-expect-error number is not a property
immerPatchState(userState, state => ({ number: 1 }));
});

it('should allow patching with object literal', () => {
const userState = setup();
immerPatchState(userState, { name: { firstname: 'Lucy', lastname: 'Sanders' } });
expect(userState.prettyName()).toBe('Lucy Sanders');
});

describe('update with return value', () => {
it('should work with the default patch function', () => {
const userState = setup();
immerPatchState(userState, ({ name }) => ({ name: { firstname: name.firstname, lastname: 'Sanders' } }));
expect(userState.prettyName()).toBe('Konrad Sanders');
});

it('should not emit other signals', () => {
TestBed.runInInjectionContext(() => {
let effectCounter = 0;
const userState = setup();
effect(() => {
userState.id();
effectCounter++;
});
TestBed.flushEffects();

expect(effectCounter).toBe(1);
immerPatchState(userState, ({ name }) => ({ name: { firstname: name.firstname, lastname: 'Sanders' } }));

TestBed.flushEffects();
expect(effectCounter).toBe(1);
});
});

it('should throw if a mutated patched state is returned', () => {
const userState = setup();

expect(() =>
immerPatchState(userState, (state) => {
state.name.lastname = 'Sanders';
return state;
})).toThrow('[Immer] An immer producer returned a new value *and* modified its draft.');
});
});

describe('update without returning a value', () => {
it('should allow a mutable update', () => {
const userState = setup();
immerPatchState(userState, (state => {
state.name = { firstname: 'Lucy', lastname: 'Sanders' };
}));
expect(userState.prettyName()).toBe('Lucy Sanders');
});

it('should not emit other signals', () => {
TestBed.runInInjectionContext(() => {
let effectCounter = 0;
const userState = setup();
effect(() => {
userState.id();
effectCounter++;
});
TestBed.flushEffects();

expect(effectCounter).toBe(1);
immerPatchState(userState, (state => {
state.name = { firstname: 'Lucy', lastname: 'Sanders' };
}));

TestBed.flushEffects();
expect(effectCounter).toBe(1);
});
});
});

it('should check the Signal notification on multiple updates', () => {
TestBed.runInInjectionContext(() => {
// setup effects
let addressEffectCounter = 0;
let nameEffectCounter = 0;
let idEffectCounter = 0;
const userState = setup();
effect(() => {
userState.id();
idEffectCounter++;
});
effect(() => {
userState.address();
addressEffectCounter++
});
effect(() => {
userState.name();
nameEffectCounter++
});


// first run
TestBed.flushEffects();
expect(idEffectCounter).toBe(1);
expect(addressEffectCounter).toBe(1);
expect(nameEffectCounter).toBe(1);

// change
immerPatchState(userState, (state => {
state.name = { firstname: 'Lucy', lastname: 'Sanders' };
state.address.zip = '1020'
}));

// second run
TestBed.flushEffects();
expect(idEffectCounter).toBe(1);
expect(addressEffectCounter).toBe(2);
expect(nameEffectCounter).toBe(2);
});
});
});
9 changes: 0 additions & 9 deletions src/signals/tests/placeholder.test.ts

This file was deleted.

9 changes: 9 additions & 0 deletions tsconfig.jest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["jest"]
},
"include": ["**/tests/*.jest.ts"]
}
Loading