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(signals): throw error in dev mode on state mutation #4526

Merged
merged 1 commit into from
Nov 18, 2024

Conversation

rainerhahnekamp
Copy link
Contributor

@rainerhahnekamp rainerhahnekamp commented Sep 18, 2024

Alternative version to protected the state from mutable changes via Object.freeze.

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

[ ] Bugfix
[x] Feature
[ ] Code style update (formatting, local variables)
[ ] Refactoring (no functional changes, no api changes)
[ ] Build related changes
[ ] CI related changes
[ ] Documentation content changes
[ ] Other... Please describe:

What is the current behavior?

Mutable changes in patchState don't trigger any kind of warning

Closes #4030

What is the new behavior?

In development mode (ngDevMode), Object.freeze is applied to the state, causing a runtime error on a mutable change.

Does this PR introduce a breaking change?

[x] Yes
[ ] No
BREAKING CHANGES:

The `patchState` function applies a deep freeze on the state in dev mode.

BEFORE:

const userState = signalState(initialState);
patchState(userState, (state) => {
  state.user.firstName = 'mutable change'; // mutable change which went through
  return state;
});

AFTER:

const userState = signalState(initialState);
patchState(userState, (state) => {
  state.user.firstName = 'mutable change'; // throws in dev mode
  return state;
});

Copy link

netlify bot commented Sep 18, 2024

Deploy Preview for ngrx-io canceled.

Built without sensitive environment variables

Name Link
🔨 Latest commit bff9d2c
🔍 Latest deploy log https://app.netlify.com/sites/ngrx-io/deploys/673a6b5605ee0600083ecb35

@rainerhahnekamp rainerhahnekamp changed the title feat: apply Object.freeze in patchState on state in dev mode feat(signals): apply Object.freeze in patchState on state in dev mode Sep 22, 2024
@samuelfernandez
Copy link
Contributor

That is great! Trying to modify the state would result in a runtime error. Any reasons for not returning a Readonly type that would catch it before on compile time?

@rainerhahnekamp
Copy link
Contributor Author

@samuelfernandez

Any reasons for not returning a Readonly type

Yes, experience. It has shown that if you start to bend the type system too much, you might end up in some unpredictable issues.

For example, if people provide some type, which could be any or maybe a very complicated type that we cannot predict, then we might run into issues where suddenly it stops working.

That's the reasoning behind this decision.

@samuelfernandez
Copy link
Contributor

samuelfernandez commented Oct 2, 2024

IMHO, I see it as complementary, don't mean type only can cover all use cases. Freezing state during development is great. But adding additional type safety can provide immediate feedback right in the IDE when writing the code.

FWIW, there are simple utils that would provide deep readonly types and would cover the majority of simple cases: microsoft/TypeScript#13923 (comment)

@samuelfernandez
Copy link
Contributor

Question: I understand that, since the freeze logic is applied in the patch function, it will also work for the signal store, is that correct?

If that is the case, I'd also recommend freezing the output of withComputed. In user land code I've faced the issue of trying to modify the state down in the component tree, which consumes derived state, not root state. Freezing the result of any read only signals coming from the store would be great.

@eneajaho thoughts on freezedComputed for ngxtension? 😉

@rainerhahnekamp
Copy link
Contributor Author

@samuelfernandez I think this is an issue for Angular. patchState is an NgRx feature, so we can improve that one. computed() and the Signal type comes from the framework itself.

Copy link
Member

@brandonroberts brandonroberts left a comment

Choose a reason for hiding this comment

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

We should add a note to the docs on this behavior

modules/signals/src/state-source.ts Outdated Show resolved Hide resolved
modules/signals/spec/signal-state.spec.ts Outdated Show resolved Hide resolved
@markostanimirovic
Copy link
Member

To mention new behavior, we can update this alert with an additional sentence that an error will be thrown in dev mode on state mutations: https://github.com/ngrx/platform/blob/main/projects/ngrx.io/content/guide/signals/signal-state.md?plain=1#L59

@markostanimirovic
Copy link
Member

Since this PR introduces a breaking change, we should add this to the PR description in the following format:

BREAKING CHANGES:

__description__

BEFORE:

__...__

AFTER:

__...__

Check this PR for more info: #4584

@rainerhahnekamp
Copy link
Contributor Author

@markostanimirovic, all your requests have been incorporated:

  • Breaking Changes in PR docs
  • consolidation of methods in `deep-freeze.ts``
  • own testing file for deep-freeze.ts
  • type-renaming in freezeInDevMode()

@markostanimirovic
Copy link
Member

@rainerhahnekamp recheck this comment: #4526 (comment)

Currently, the error will be thrown only if the state is mutated within the patchState updater. It would be good to do the full state freeze:

Also, in the following cases, errors should be thrown as well:

userState.user().firstName = 'mutable change 1'; // error

// ---

getState(userState).ngrx = 'mutable change 2'; // error

// ---

const s = { user: { firstName: 'M', lastName: 'S' } };
patchState(userState, s);
s.user.firstName = 'mutable change 3'; // error

Currently, there are no errors in these cases.

@markostanimirovic
Copy link
Member

@rainerhahnekamp It will also be useful to update documentation: #4526 (comment)

BREAKING CHANGE: the state of `signalStore` and `signalState`
is frozen during dev mode.

Mutable changes - not just in `patchState` - will throw an error.

BEFORE:

```typescript
const userState = signalState(initialState);
patchState(userState, (state) => {
  state.user.firstName = 'mutable change'; // mutable change went through
  return state;
});

getState(userState).user.firstName = 'mutable change'; // mutable change went through
```

AFTER:

```
const userState = signalState(initialState);
patchState(userState, (state) => {
  state.user.firstName = 'mutable change'; // throws in dev mode
  return state;
});

getState(userState).user.firstName = 'mutable change'; // throws in dev mode
```
@rainerhahnekamp
Copy link
Contributor Author

@markostanimirovic, as requested, the state itself is now frozen, not just the changes coming from patchState.
Thanks for pointing it out. This feature is became now much better.

I'll update the PR about the documentation as well.

Commits have been squashed into a "conventional commit"-friendly message.

Copy link
Member

@markostanimirovic markostanimirovic left a comment

Choose a reason for hiding this comment

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

LGTM 👍 Thanks Rainer!

@markostanimirovic markostanimirovic changed the title feat(signals): apply Object.freeze in patchState on state in dev mode feat(signals): throw error in dev mode on state mutation Nov 18, 2024
@markostanimirovic markostanimirovic merged commit 7a84209 into ngrx:main Nov 18, 2024
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

@ngrx/signals: Improve Developer Experience for Immutability
5 participants