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

Discussion: atom values should be freezed/immutable #167

Closed
likern opened this issue Nov 6, 2020 · 25 comments · Fixed by #185
Closed

Discussion: atom values should be freezed/immutable #167

likern opened this issue Nov 6, 2020 · 25 comments · Fixed by #185

Comments

@likern
Copy link
Contributor

likern commented Nov 6, 2020

Based on discussion in #41

@dai-shi
Copy link
Member

dai-shi commented Nov 6, 2020

oops.

@dai-shi
Copy link
Member

dai-shi commented Nov 6, 2020

Do you agree the issue exists even with React.useState?

@dai-shi dai-shi changed the title Discussion: atoms should be freezed/immutable Discussion: atom values should be freezed/immutable Nov 6, 2020
@likern
Copy link
Contributor Author

likern commented Nov 6, 2020

Do you agree the issue exists even with React.useState?

I agree that if some object is shared between components, it's change changes all components. But because callbacks with updated value are used - UI is still valid.

Let's say I have that array of tags. If tag value is changed (isSelected = true) it doesn't cause a rerender.
But I get onPress(tagId: string, isSelected: boolean) callback fired. And it will rerender valid components.

@likern
Copy link
Contributor Author

likern commented Nov 6, 2020

If atom is changed inherently, there is no notification of that, we don't know that it's happened.

And if useAtom(atom) will be called in some other components we accidentally get wrong value. Without even knowing about it.

@likern
Copy link
Contributor Author

likern commented Nov 6, 2020

If I'm correct Recoil has immutable atom values.
facebookexperimental/Recoil#112 (comment)

@dai-shi
Copy link
Member

dai-shi commented Nov 6, 2020

But because callbacks with updated value are used - UI is still valid.

I'm not sure if I understand this.

If atom is changed inherently, there is no notification of that, we don't know that it's happened.

And if useAtom(atom) will be called in some other components we accidentally get wrong value. Without even knowing about it.

But, I get these two.

If I'm correct Recoil has immutable atom values.

Hm, wonder if they deep freeze it.

@dai-shi
Copy link
Member

dai-shi commented Nov 6, 2020

I would like to revisit this: #41 (comment)

const​ ​updatedTags​ ​=​ ​produce(Array.from(taskTags),​ ​(draft)​ ​=>​ ​{​
  ​const​ ​tag​ ​=​ ​draft.find((it)​ ​=>​ ​it.id​ ​===​ ​action.id);​
  ​if​ ​(tag!==undefined)​ ​{​
    ​tag.isSelected​ ​=​ ​action.isSelected;​
  ​}});​
​console.log(​
  ​`taskTagsAtom: updatedTags are ​${JSON.stringify(updatedTags,​ ​null,​ ​2)}​`​
​);

Why on earth immer goes wrong?

@likern
Copy link
Contributor Author

likern commented Nov 6, 2020

I would like to revisit this: #41 (comment)

const​ ​updatedTags​ ​=​ ​produce(Array.from(taskTags),​ ​(draft)​ ​=>​ ​{​
  ​const​ ​tag​ ​=​ ​draft.find((it)​ ​=>​ ​it.id​ ​===​ ​action.id);​
  ​if​ ​(tag!==undefined)​ ​{​
    ​tag.isSelected​ ​=​ ​action.isSelected;​
  ​}});​
​console.log(​
  ​`taskTagsAtom: updatedTags are ​${JSON.stringify(updatedTags,​ ​null,​ ​2)}​`​
​);

Why on earth immer goes wrong?

Frankly speaking I don't know. From my understanding this is totally valid code. But taskTags is also updated.
I tried different variations of code, still that behaviour.

@likern
Copy link
Contributor Author

likern commented Nov 6, 2020

But I use Hermes engine in React Native. It might lack some of API. Also, since in my Hermes version no Proxy support, I use enableES5() from setup instructions. https://immerjs.github.io/immer/docs/installation#immer-on-older-javascript-environments

@dai-shi
Copy link
Member

dai-shi commented Nov 6, 2020

Does removing Array.from help?

- const​ ​updatedTags​ ​=​ ​produce​(​Array​.​from​(​taskTags​)​,​ ​(​draft​)​ ​=>​ ​{​
+ const​ ​updatedTags​ ​=​ ​produce​(​taskTags​,​ ​(​draft​)​ ​=>​ ​{​

I use enableES5() from setup instructions.

I see. that can be something.

@likern
Copy link
Contributor Author

likern commented Nov 6, 2020

Does removing Array.from help?

- const​ ​updatedTags​ ​=​ ​produce​(​Array​.​from​(​taskTags​)​,​ ​(​draft​)​ ​=>​ ​{​
+ const​ ​updatedTags​ ​=​ ​produce​(​taskTags)​,​ ​(​draft​)​ ​=>​ ​{​

No, didn't help. Changing find to findIndex also.

@italodeandra
Copy link
Contributor

Why on earth immer goes wrong?

I was really bothered by this. I wasn't able to understand why immer wasn't doing it's job. So I replicated OP problem on codesandbox but for some reason, the code works normally as intended. The original tagsAtom is not mutated.

The problem might be on the instanced array that populated the tagsAtom. I wanted to test it better (with the most real case possible to be able to really replicate it). Can you send the type that this line returns, please?

const tagRepository = getTagRepository();
return tagRepository.find();

@likern
Copy link
Contributor Author

likern commented Nov 6, 2020

Why on earth immer goes wrong?

I was really bothered by this. I wasn't able to understand why immer wasn't doing it's job. So I replicated OP problem on codesandbox but for some reason, the code works normally as intended. The original tagsAtom is not mutated.

The problem might be on the instanced array that populated the tagsAtom. I wanted to test it better (with the most real case possible to be able to really replicate it). Can you send the type that this line returns, please?

const tagRepository = getTagRepository();
return tagRepository.find();

It returns Promise<Tag[]> with Tag being

@Entity({ name: 'tags' })
export class Tag {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Column('text')
  label!: string;

  @Column('text')
  iconName!: IconName;

  @Column('text')
  iconColor!: string;

  @Column({ type: 'boolean', default: Number(false) })
  isSelected!: boolean;
}

It's populated by TypeORM.

@likern
Copy link
Contributor Author

likern commented Nov 6, 2020

Why on earth immer goes wrong?

I was really bothered by this. I wasn't able to understand why immer wasn't doing it's job. So I replicated OP problem on codesandbox but for some reason, the code works normally as intended. The original tagsAtom is not mutated.

The problem might be on the instanced array that populated the tagsAtom. I wanted to test it better (with the most real case possible to be able to really replicate it). Can you send the type that this line returns, please?

const tagRepository = getTagRepository();
return tagRepository.find();

I get it, thank you so much for pointing out to the right direction!

+ import { immerable } from 'immer';

@Entity({ name: 'tags' })
export class Tag {
+  [immerable] = true;

  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Column('text')
  label!: string;

  @Column('text')
  iconName!: IconName;

  @Column('text')
  iconColor!: string;

  @Column({ type: 'boolean', default: Number(false) })
  isSelected!: boolean;
}

solves the problem 😄 at least for this producer

const updatedTags = produce(Array.from(taskTags), (draft) => {
  const tagIndex = draft.findIndex((it) => it.id === action.id);
  if (tagIndex >= 0) {
    // Found tag with this id
    draft[tagIndex].isSelected = action.isSelected;
  }
});

@italodeandra
Copy link
Contributor

Nice. I was suspecting you were using a class. But I thought it was weird for this to happen. I wasn't even aware of immerable, it is really nice to know.

@dai-shi
Copy link
Member

dai-shi commented Nov 6, 2020

@italodeandra Nice comments!
@likern Glad you solve it and clarify it.

Now, back to the original discussion, I'm not a big fan of adding deep-freeze or any kind in core.
So, I think this should be solved in utils or docs.

Let's see how this works:

const useFrozenAtom = (anAtom) => {
  const [value, setValue] = useAtom(anAtom)
  return useMemo(() => [deepFreeze(value), setValue], [value, setValue])
}
const freezeAtom = (anAtom) => {
  const frozenAtom = atom(
    get => deepFreeze(get(anAtom)),
    (get, set, arg) => set(anAtom, arg)
  )
  return frozenAtom
}

Would anyone be interested in trying?

@likern
Copy link
Contributor Author

likern commented Nov 7, 2020

@dai-shi I was wondering how it is implemented in Recoil.
It looks like they implemented deep freeze https://github.com/facebookexperimental/Recoil/blob/master/src/util/Recoil_deepFreezeValue.js, but it works only in __DEV__ mode.

if (__DEV__) {
  if (options.dangerouslyAllowMutability !== true) {
    deepFreezeValue(newValue);
  }
}

https://github.com/facebookexperimental/Recoil/blob/19bfa7515d375ad68a88462f7cc479fdd2d8fdb7/src/recoil_values/Recoil_atom.js#L428

I think this might be an option.

@dai-shi
Copy link
Member

dai-shi commented Nov 8, 2020

Yeah, that's one option for sure.

Let me propose another one which I think is to keep our code simple.

import deepFreeze from 'deep-freeze'

<Provider freeze={deepFreeze}>

@dai-shi
Copy link
Member

dai-shi commented Nov 8, 2020

Hm, I noticed Recoil_atom.js is atom.ts in our case, not Provider.

So, this should be another option:

import { atom as origAtom } from 'jotai'

export const atom = (read, write) => {
  const anAtom = origAtom(read, write)
  const origRead = anAtom.read
  anAtom.read = get => deepFreeze(origRead(get))
  return anAtom
}

@dai-shi
Copy link
Member

dai-shi commented Nov 8, 2020

Would you like to try #171 and see if it's satisfying?

@likern
Copy link
Contributor Author

likern commented Nov 8, 2020

Would you like to try #171 and see if it's satisfying?

if (freeze) {
  // XXX this is not very efficient
  console.log(`state: ${JSON.stringify(state)}`)
  mKeys(state).forEach(function (a) {
    var aState = mGet(state, a);
    console.log(`aState: ${typeof aState.value}`)
    freeze(aState.value);
  });
}
[Sun Nov 08 2020 12:55:56.795]  LOG      state: [{}]
[Sun Nov 08 2020 12:55:56.807]  LOG      aState: undefined

While without freeze it works as expected.

@dai-shi
Copy link
Member

dai-shi commented Nov 8, 2020

@likern Thanks for trying!

% node
> Object.freeze(undefined)
undefined
> Object.getOwnPropertyNames(undefined)
Uncaught TypeError: Cannot convert undefined or null to object
    at Function.getOwnPropertyNames (<anonymous>)

Oh, no. It was unexpected, sorry.

@dai-shi
Copy link
Member

dai-shi commented Nov 8, 2020

fc64847
This should fix it.

@dai-shi
Copy link
Member

dai-shi commented Nov 9, 2020

Now, let's try another approach.

const freezeAtom = (anAtom) => {
  const frozenAtom = atom(
    get => deepFreeze(get(anAtom)),
    (get, set, arg) => set(anAtom, arg)
  )
  return frozenAtom
}

Does this work?

https://codesandbox.io/s/react-forked-mdmic?file=/src/App.js

Okay, this seems fine.

Now, I prefer to avoid having deepFreeze in core.
The question is how we switch freeze mode and nomal mode without affecting app code.

@dai-shi
Copy link
Member

dai-shi commented Nov 10, 2020

@likern I made a util #185.

@dai-shi dai-shi linked a pull request Nov 15, 2020 that will close this issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants