ember-data-model-fragments icon indicating copy to clipboard operation
ember-data-model-fragments copied to clipboard

Model fragments broken against Ember Data 3.28

Open kevinkucharczyk opened this issue 3 years ago • 27 comments

I was testing Ember Data Model Fragments against the latest Ember Data - version 3.28 - and unfortunately it looks like they're not compatible. Here's what happens when I try to save a model that contains a fragment: Screen Shot 2021-08-26 at 7 30 55 am

index.js:178 Uncaught Error: Assertion Failed: You need to pass a model name to the store's serializerFor method
    at assert (index.js:178)
    at Store.serializerFor (ext.js:230)
    at Store.superWrapper [as serializerFor] (index.js:442)
    at Class.serializeFragment [as serialize] (fragment.js:40)
    at ApplicationSerializer.serializeAttribute (json.js:1105)
    at json.js:1035
    at -private.js:1611
    at Array.forEach (<anonymous>)
    at Snapshot.eachAttribute (-private.js:1610)
    at ApplicationSerializer.serialize (json.js:1034)
assert @ index.js:178
serializerFor @ ext.js:230
superWrapper @ index.js:442
serializeFragment @ fragment.js:40
serializeAttribute @ json.js:1105
(anonymous) @ json.js:1035
(anonymous) @ -private.js:1611
eachAttribute @ -private.js:1610
serialize @ json.js:1034
serialize @ rest.js:584
superWrapper @ index.js:442
serializeIntoHash @ rest.js:612
serializeIntoHash @ -private.js:634
createRecord @ rest.js:694
(anonymous) @ -private.js:1867
invokeCallback @ rsvp.js:493
(anonymous) @ rsvp.js:558
(anonymous) @ rsvp.js:19
invoke @ backburner.js:338
flush @ backburner.js:229
flush @ backburner.js:426
_end @ backburner.js:958
end @ backburner.js:708
_run @ backburner.js:1013
run @ backburner.js:752
run @ index.js:116
callback @ application.js:434

This particular problem seems to happen at https://github.com/adopted-ember-addons/ember-data-model-fragments/blob/b1a661b3a8c07bd9d395769498062db8871d25f9/addon/transforms/fragment.js#L38 The serialize method is expecting a Snapshot, but actually receives the model instance of the fragment (I'm not sure why) - modelName is empty there.

Here's a minimal reproduction on a fresh Ember app: ember-quickstart.zip

kevinkucharczyk avatar Aug 25 '21 21:08 kevinkucharczyk

Which version of this addon did you use?

knownasilya avatar Aug 26 '21 02:08 knownasilya

@knownasilya I used v5.0.0-beta.2

kevinkucharczyk avatar Aug 27 '21 00:08 kevinkucharczyk

Any chance you can make a small reproduction app?

knownasilya avatar Aug 27 '21 00:08 knownasilya

@knownasilya I've already included a zipped reproduction app in my first comment, at the very end is a link: Model fragments broken against Ember Data 3 28 · Issue #406 · adopted-ember-addons:ember-data-model-fragments 2021-08-27 13-27-18

kevinkucharczyk avatar Aug 27 '21 03:08 kevinkucharczyk

🤦‍♂️

knownasilya avatar Aug 27 '21 13:08 knownasilya

I can confirm this. I just bumped my app to 3.28.0 and was bitten by the same error.

lcmen avatar Aug 31 '21 15:08 lcmen

Give https://github.com/adopted-ember-addons/ember-data-model-fragments/pull/407 a try. Update package.json to

"ember-data-model-fragments": "knownasilya/ember-data-model-fragments#e22f3b80285f"

And npm/yarn install.

knownasilya avatar Sep 01 '21 19:09 knownasilya

@knownasilya that fix is not quite enough, unfortunately. If you have a fragment array, that'll fail too under 3.28 (it requires a similar fix in the fragment array serializer).

Either way, I don't think the proposed fix in the PR is the right way to go. What strikes me as weird is the fact that what's passed in is no longer a snapshot. Is that expected behavior? What exactly changed in Ember/Ember Data that caused this? I feel like patching the transforms like that may still cause issues elsewhere down the line if we don't know what's happening under the hood.

kevinkucharczyk avatar Sep 02 '21 02:09 kevinkucharczyk

@knownasilya @kevinkucharczyk I believe found the culprit: CoreStore in 3.27.1 doesn't have REQUEST_SERVICE flag enabled (in 3.28.1 it's already on) so scheduleSave relies on pendingSave queue (which uses internalModel.createSnapshot(options) to create model's snapshots):

let snapshot = internalModel.createSnapshot(options);
this._pendingSave.push({
  snapshot: snapshot,
  resolver: resolver,
});

emberBackburner.scheduleOnce('actions', this, this.flushPendingSave);

In 3.28.1 scheduleSave goes through fetchManager (via if (REQUEST_SERVICE) { ... } conditional) and it uses Snapshot constructor to build model's snapshot:

let snapshot = new Snapshot(options, identifier, this._store);

For some reason, fragments are not correctly converted to snapshots in this case. I don't know if it's bug or desired behavior.

I'm not an Ember expert by any means so I'm not sure what's the most appropriate solution but in 3.28.1 serializer.serialize(snapshot) operates on Fragment instead of Snapshot. I don't know what consequences it might have :shrug: .

lcmen avatar Sep 02 '21 16:09 lcmen

@runspired any advice?

knownasilya avatar Sep 02 '21 17:09 knownasilya

@lowski internalModel.createSnapshot uses the same way to create the Snapshot

https://github.com/emberjs/data/blob/584c14b5181513756750e65b73338cde7244d1a1/packages/store/addon/-private/system/model/internal-model.ts#L964

The underlying issue here is more likely related to the activation of CUSTOM_MODEL_CLASS features, this library still hasn't fully converted to the record-data approach to fragments and likely this leads this code and this code in Snapshot to behave differently. Probably the FragmentRecordData is storying the fragment as the value within the record-data instead of the serialized value.

runspired avatar Sep 15 '21 16:09 runspired

@knownasilya I've looked into this again and it looks this addon overrides internalModel.createSnapshot in addon/ext.js file. By doing so, it can traverse all attributes and call createSnapshot recursively. When you use new Snapshot() in fetchManager.scheduleSave, this no longer works.

@runspired what is the best way to modify scheduleSave in so it uses internalModel.createSnapshot? Could we for example replace: new Snapshot(options, identifier, this._store) with _store._internalModelForResource(identifier).createSnapshot(options)? Or maybe it's a wrong direction and we should try to override get _attributes() in Snapshot class (if so, how can we do it)?

lcmen avatar Sep 19 '21 15:09 lcmen

The right way to do this is to store the serialized form in the fragment RecordData, no private apis would be necessary then.

runspired avatar Sep 20 '21 03:09 runspired

@runspired could you provide more information how to do it? I'd like to try making this add-on compatible with Ember Data 3.28.

From my understanding fragments are basically computed properties on the model and they use _recordData to set / get fragment value. If we stored fragments as serialized values than how we can get appropriate full fragment instance when we call model.myFragmentProperty? How can we ensure that calling myFragmentProperty twice returns exactly same object?

Could you also explain what _recordData is, please? And how it differs from internalModel? It would help me to better understand the context. I tried to look api docs and source code I haven't found much info.

lcmen avatar Sep 23 '21 18:09 lcmen

@knownasilya I've just tested it on my project and #407 doesn't fix this issue - now I'm getting a following error:

Error: Assertion Failed: The `attr` method is not available on Model, a Snapshot was probably expected. Are you passing a Model instead of a Snapshot to your serializer?

@kevinkucharczyk can you check latest master on your project as well?

lcmen avatar Oct 07 '21 18:10 lcmen

Can you create a failing test as a PR?

knownasilya avatar Oct 08 '21 01:10 knownasilya

@knownasilya there are already failing tests on existing test suite related to that error:

https://github.com/adopted-ember-addons/ember-data-model-fragments/runs/3613005831?check_suite_focus=true#step:7:641 https://github.com/adopted-ember-addons/ember-data-model-fragments/runs/3613005831?check_suite_focus=true#step:7:456

lcmen avatar Oct 08 '21 18:10 lcmen

@knownasilya I've just tested it on my project and #407 doesn't fix this issue - now I'm getting a following error:

Error: Assertion Failed: The `attr` method is not available on Model, a Snapshot was probably expected. Are you passing a Model instead of a Snapshot to your serializer?

@kevinkucharczyk can you check latest master on your project as well?

I encounter exactly the same issue than @lowski with the latest commit on master ☝️🤔

adriguy avatar Nov 03 '21 13:11 adriguy

The same issue in our project.

AnDeVerin avatar Nov 08 '21 13:11 AnDeVerin

As @lowski pointed out, the problem may be that before 3.28 the Snapshot for serialization is created by calling InternalModel.createSnapshot which is overridden in ext.js to recursively transform Fragments to Snapshots. In 3.28 the Snapshot for serialization is created by calling new Snapshot() directly instead so Fragments are no longer transformed to Snapshots.

I was able to dirty fix it by calling createSnapshot manually in the FragmentTransform.serialize method :

// transforms/fragment.js

serialize: function serializeFragment(snapshot) {
  if (!snapshot) {
    return null;
  }

  // Dirty fix for Ember Data 3.28
  // snapshot may be a Fragment instead of a Snapshot
  if (typeof snapshot._createSnapshot === 'function') {
    snapshot = snapshot._createSnapshot();
  }

  let store = this.store;
  let serializer = store.serializerFor(snapshot.modelName);

  return serializer.serialize(snapshot);
},
// transforms/fragment-array.js

serialize: function serializeFragmentArray(snapshots) {
  if (!snapshots) {
    return null;
  }

  // Dirty fix for Ember Data 3.28
  // snapshots may be a Fragment instead of an Array<Snapshot>
  if (typeof snapshots._createSnapshot === 'function') {
    snapshots = snapshots._createSnapshot();
  }

  let store = this.store;

  return snapshots.map(snapshot => {
    let serializer = store.serializerFor(snapshot.modelName);
    return serializer.serialize(snapshot);
  });
}

There is probably a better way / place to do this.

ngriselle avatar Nov 09 '21 13:11 ngriselle

Here's a quick workaround,

import FragmentTransform from 'ember-data-model-fragments/transforms/fragment';
import FragmentArrayTransform from 'ember-data-model-fragments/transforms/fragment-array';

FragmentTransform.reopen({
  serialize(snapshot) {
    return this._super(snapshot?._createSnapshot?.() ?? snapshot);
  },
});

FragmentArrayTransform.reopen({
  serialize(snapshots) {
    return this._super(snapshots?._createSnapshot?.() ?? snapshots);
  },
});

dwickern avatar Nov 11 '21 22:11 dwickern

I merged that fix, so master should work as well. Will try to research a bit more and see if there is a better solution.

knownasilya avatar Nov 12 '21 03:11 knownasilya

The above fix is incomplete. Here's what did work in our app. Note I still am advocating for fixing the root cause (as currently we are hacking into internals instead of using record-data as designed), but this will help to unblock ember-data upgrades (there is still a separate issue I will open separately).

Fragment:

import Transform from "ember-data-model-fragments/transforms/fragment";

export default class Fragment extends Transform {
  serialize(snapshot) {
    if (!snapshot) {
      return null;
    }

    const { store } = this;
    const serializer = store.serializerFor(
      snapshot.modelName || snapshot.constructor.modelName
    );

    return serializer.serialize(
      snapshot.modelName ? snapshot : snapshot._internalModel.createSnapshot(),
      { includeId: true }
    );
  }
}

Fragment Array:

import { Snapshot } from "@ember-data/store/-private";
import Transform from "ember-data-model-fragments/transforms/fragment-array";

export default class FragmentArray extends Transform {
  serialize(snapshots) {
    if (!snapshots) {
      return null;
    }
    if (snapshots.length === 0) {
      return [];
    }
    const { store } = this;
    const arr = snapshots.toArray();
    const firstSnapshot = arr[0];
    const isNotYetSnapshot =
      !firstSnapshot.modelName && !(firstSnapshot instanceof Snapshot);
    const modelName = isNotYetSnapshot
      ? firstSnapshot.constructor.modelName
      : firstSnapshot.modelName;
    const serializer = store.serializerFor(modelName);

    return arr.map((s) =>
      serializer.serialize(
        s && isNotYetSnapshot ? s._internalModel.createSnapshot() : s,
        { includeId: true }
      )
    );
  }
}

runspired avatar Dec 08 '21 01:12 runspired

There's also an issue with property change notifications for hasDirtyAttributes on fragment arrays, which accounts for some test failures on the master branch.

I did attempt doing things the "right way" with record-data. I'll open a PR.

dwickern avatar Dec 08 '21 05:12 dwickern

There's also an issue with property change notifications for hasDirtyAttributes on fragment arrays, which accounts for some test failures on the master branch.

I did attempt doing things the "right way" with record-data. I'll open a PR.

That's the other issue. It's due ember-data removing the state machine.

runspired avatar Dec 08 '21 05:12 runspired

The above fix is incomplete. Here's what did work in our app. Note I still am advocating for fixing the root cause (as currently we are hacking into internals instead of using record-data as designed), but this will help to unblock ember-data upgrades (there is still a separate issue I will open separately).

Fragment:

import Transform from "ember-data-model-fragments/transforms/fragment";

export default class Fragment extends Transform {
  serialize(snapshot) {
    if (!snapshot) {
      return null;
    }

    const { store } = this;
    const serializer = store.serializerFor(
      snapshot.modelName || snapshot.constructor.modelName
    );

    return serializer.serialize(
      snapshot.modelName ? snapshot : snapshot._internalModel.createSnapshot(),
      { includeId: true }
    );
  }
}

Fragment Array:

import { Snapshot } from "@ember-data/store/-private";
import Transform from "ember-data-model-fragments/transforms/fragment-array";

export default class FragmentArray extends Transform {
  serialize(snapshots) {
    if (!snapshots) {
      return null;
    }
    if (snapshots.length === 0) {
      return [];
    }
    const { store } = this;
    const arr = snapshots.toArray();
    const firstSnapshot = arr[0];
    const isNotYetSnapshot =
      !firstSnapshot.modelName && !(firstSnapshot instanceof Snapshot);
    const modelName = isNotYetSnapshot
      ? firstSnapshot.constructor.modelName
      : firstSnapshot.modelName;
    const serializer = store.serializerFor(modelName);

    return arr.map((s) =>
      serializer.serialize(
        s && isNotYetSnapshot ? s._internalModel.createSnapshot() : s,
        { includeId: true }
      )
    );
  }
}

Thx @runspired ! The patch worked very well for our 3.28 upgrade. We only had to adapt it a bit to work with polymorphic fragmentArray relationship (essentially instead of using firstSnapshot we run the same logic for each snapshot in arr.map)

enspandi avatar Dec 15 '21 12:12 enspandi

Could you try with the latest release @kevinkucharczyk ?

VincentMolinie avatar Jul 13 '23 13:07 VincentMolinie