angular-extensions / model

Angular Model - Simple state management with minimalist API, one way data flow, multiple model support and immutable data exposed as RxJS Observable.

Home Page:https://tomastrajan.github.io/angular-model-pattern-example

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[Bug] Data in not immutable for Model of type T[]

hassavokado opened this issue · comments

Hi,

if you create a Model of type T[], you will not get immutable data, as JS does only reference arrays.

Minimal reproduction of the bug with instructions:

These tests will fail.

it('should be an immutable array', () => {
    const factory: ModelFactory<TestModel[]> = new ModelFactory<TestModel[]>();

    const initial: TestModel[] = [];
    const store = factory.create(initial);

    const initialState = store.get();
    initialState.push({ value: 'updated' });

    assert.equal(store.get().length, 0); // will be 1
  });

  it('should be an immutable array after set', () => {
    const factory: ModelFactory<TestModel[]> = new ModelFactory<TestModel[]>();

    const initial: TestModel[] = [];
    const store = factory.create(initial);

    const updateArray = [{ value: 'first element' }];
    store.set(updateArray);

    updateArray.push({ value: '2nd element' });
    assert.equal(store.get().length, 1); // will be 2
  });

  it('should be immutable array on sub', () => {
    const factory: ModelFactory<TestModel[]> = new ModelFactory<TestModel[]>();

    const initial: TestModel[] = [];
    const store = factory.create(initial);

    const initialState = store.get();
    initialState.push({ value: 'updated' });

    store.data$.subscribe(v => {
      assert.equal(store.get().length, 0); // will be 1
    });
  });

Expected behavior:

Tests succeed.

Other information:

I've forked your repo and fixed the issues with the same approach you used inside the pipe. Not sure if there are more elegant/performant ways though.

I would be willing to submit a PR to fix this issue:

[X ] Yes (Assistance is provided if you need help submitting a pull request)
[ ] No

Hi @hassavokado !

The thing is immutability is only available when you subscribe to the data as an observable stream using data$ property exposed by the model. The model in the service itself is mutable because it is just a BehaviorSubject behind the scenes which has a value...

Hope that makes sense?

Immutable: this.model.data$.subscribe(immutableData => /* do stuff */);

Mutable: const mutable = this.model.get()

Hey @tomastrajan the issue with arrays is, that it's not immutable even if you use .subscribe(). See the last test.

@hassavokado aha, must have missed that, let's see!

@hassavokado it's just hard to imagine how could that be possible because what is received from the subscription goes through JSON.parse(JSON.stringify(data)) so that should be completely new object right ?

ANyway I do like the proposed changes, just am bit worried that it might break people depending on that behavior but its something i will definitelly release with version 9.0.0 ( when angular is released which should be around October/November 2019) which is not that far in the future.

For now, I might release 9.0.0-beta.1 or something ? So that you can use it already with the fix ?

@tomastrajan Yep, it is weird. And sure, you can release it on 9.0.0, as I'm not actually using the library right now...

I "cloned" and adjusted it for work purposes and stumbled upon this issue. Thought it will only be fair to contribute back.

@hassavokado yes, thank you, its very appreciated!

@hassavokado it's because the data inside of model is mutable so that if the subscription comes after it was mutated it will get that mutated data which is definitively not good...

it('should be immutable array on sub', () => {
    const factory = new ModelFactory<TestModel[]>();

    const model = factory.create([]);

    model.data$.subscribe(v => {
      assert.strictEqual(model.get().length, 0); // will be 0
    });
    
    const collection = model.get();
    collection.push({ value: 'some-value' });
    
    model.data$.subscribe(v => {
      assert.strictEqual(model.get().length, 0); // will be 1
    });

  });

So maybe is enough just to cone data in model.get() to prevent mutations ? so that people MUST use model.set()

@tomastrajan You're right, the mutation occurs before the subscription clones the already mutated object.

Sadly it will not suffice to just clone on model.get(), as that will lead to the 2nd test breaking.

The issue is, you can mutate the object you provide to model.set() afterward and it will lead to unintended mutations inside the store.

@hassavokado Also now I discovered there was a test from the point of view of the data consumer

it('should use immutable data in exposed observable by default', () => {
    const model = modelFactory.create({ value: 'test' });

    model.data$.subscribe(data => {
      data.value = 'changed';                     // <----- consumer can't mutate store
      assert.deepStrictEqual(model.get(), { value: 'test' });
    });
  });

but what was missing is that the store can't mutate consumer...

 it('should use immutable data in model by default', () => {
    const model = modelFactory.create({ value: 'test' });

    const modelData = model.get();
    modelData.value = 'changed';

    model.data$.subscribe(data => {
      assert.deepStrictEqual(data, { value: 'test' });  // <--- this will fail without the fix proposed by you
    });
  });