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): apply Object.freeze in patchState on state in dev mode #4526

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

rainerhahnekamp
Copy link
Contributor

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

Other information

  • I might be better to reuse the existing freeze function from the global store. For that, we would have to move freeze to @ngrx/operators. Maybe a follow-up PR or should I include it in the current one?

Copy link

netlify bot commented Sep 18, 2024

Deploy Preview for ngrx-io canceled.

Built without sensitive environment variables

Name Link
🔨 Latest commit 59bd044
🔍 Latest deploy log https://app.netlify.com/sites/ngrx-io/deploys/66f02b761b987f00084f7f18

@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

Comment on lines +57 to +60
function freezeInDevMode<State extends object>(value: State): State {
return ngDevMode ? deepFreeze(value) : value;
}

Copy link
Member

Choose a reason for hiding this comment

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

freezeInDevMode (and ngDevMode) can be moved to the deep-freeze.ts file as well. Since it's not directly related to the state object, the generic can be named T:

Suggested change
function freezeInDevMode<State extends object>(value: State): State {
return ngDevMode ? deepFreeze(value) : value;
}
function freezeInDevMode<T>(target: T): T {
return ngDevMode ? deepFreeze(target) : target;
}

Comment on lines +198 to +220
describe('freezeInDevMode', () => {
it('throws on a mutable change', () => {
const userState = signalState(initialState);
expect(() =>
patchState(userState, (state) => {
state.ngrx = 'mutable change';
return state;
})
).toThrowError("Cannot assign to read only property 'ngrx' of object");
});

it('throws on a mutable change', () => {
const userState = signalState(initialState);
expect(() =>
patchState(userState, (state) => {
state.user.firstName = 'mutable change';
return state;
})
).toThrowError(
"Cannot assign to read only property 'firstName' of object"
);
});
});
Copy link
Member

@markostanimirovic markostanimirovic Nov 12, 2024

Choose a reason for hiding this comment

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

I suggest moving these tests to deep-freeze.spec.ts and checking the behavior for both - signalState and signalStore. Take a look at these tests for inspiration: https://github.com/ngrx/platform/blob/main/modules/signals/spec/state-source.spec.ts#L49

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

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

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