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

Add failing test of updating nested inline array fields #3419

Closed
wants to merge 1 commit into from
Closed

Add failing test of updating nested inline array fields #3419

wants to merge 1 commit into from

Conversation

dferber90
Copy link
Contributor

@dferber90 dferber90 commented May 7, 2018

This is an issue report in the form of a failing test case. Hope that is okay. If preferred, I can open a regular issue instead.

Our app crashes when Apollo has to update the store's data. Specifically, when it has to update an inlined, nested selection of a changing type in an array.

query {
  shelter {
    __typename
    id
    animals {
      __typename
      name
      species {
        __typename
        name
      }
    }
  }
}

In this case shelter.animals is an array containing a field species with a varying type, namely either Cat or Dog.

The app crashes only when the update replaces an existing entry at an index where the previous entry had a different type than the new entry. The app crashes because Apollo writes the wrong data to the cache and subsequent reads seem to fail because of that.

So, when [{ species: Cat}, { species:Dog }] gets replaced by [{ species:Dog }]. Then the type of species at index 0 changes. This leads to Apollo writing the wrong data to the cache.

In the other cases ([{ species: Cat}, { species: Dog }] gets replaced by [{ species: Cat }]) the data seems to get corrupted, but without causing a crash.

Intended outcome:

I would expect the data to get normalised correctly. I am not sure which data Apollo should write exactly, but I know that the currently written data is wrong.

Maybe something like:

{
    '$Shelter__some-id.animals.0.species': {
        name: 'dog',
    },
    'Shelter__some-id': {
        animals: [{
            generated: true,
            id: 'Shelter__some-id.animals.0',
            type: 'id',
            typename: 'Animal',
        }, ],
        id: 'some-id',
    },
    'Shelter__some-id.animals.0': {
        name: 'Dieter',
        species: {
            generated: true,
            id: '$Shelter__some-id.animals.0.species',
            type: 'id',
            typename: 'Dog',
        },
    },
    'Shelter__some-id.animals.1': {
        name: 'Dieter',
        species: {
            generated: true,
            id: '$Shelter__some-id.animals.1.species',
            type: 'id',
            typename: 'Dog',
        },
    },
    ROOT_QUERY: {
        shelter: {
            generated: false,
            id: 'Shelter__some-id',
            type: 'id',
            typename: 'Shelter',
        },
    },
}

Actual outcome:

🛑 Apollo writes to the cache, but it loses the data on the type nested in the array: "$Shelter__some-id.animals.0.species" should not be undefined. It should be { "name": "dog" }.

"$Shelter__some-id.animals.1.species" should probably not exist. Not sure about this.

{
    "$Shelter__some-id.animals.0.species": undefined,
    "$Shelter__some-id.animals.1.species": {
        "name": "dog"
    },
    "ROOT_QUERY": {
        "shelter": {
            "generated": false,
            "id": "Shelter__some-id",
            "type": "id",
            "typename": "Shelter"
        }
    },
    "Shelter__some-id": {
        "animals": [{
            "generated": true,
            "id": "Shelter__some-id.animals.0",
            "type": "id",
            "typename": "Animal"
        }],
        "id": "some-id"
    },
    "Shelter__some-id.animals.0": {
        "name": "Dieter",
        "species": {
            "generated": true,
            "id": "$Shelter__some-id.animals.0.species",
            "type": "id",
            "typename": "Dog"
        }
    },
    "Shelter__some-id.animals.1": {
        "name": "Dieter",
        "species": {
            "generated": true,
            "id": "$Shelter__some-id.animals.1.species",
            "type": "id",
            "typename": "Dog"
        }
    }
}

How to reproduce the issue:

The contained failing test shows how to reproduce the issue in code.

For an actual user flow, either of these might lead to this situation:

  1. Fetch some data which contains a nested selection of an array.
  2. The array contains data of type A and type B.
  3. A mutation changes the data and refetches it
  4. The updated array contains data of type A only
  5. The app crashes as the wrong data is written to apollo-cache-inmemory

A simpler reproduction:

  1. Fetch some data which contains a nested selection of an array.
  2. The array contains data of type A and type B.
  3. A writeQuery changes the data
  4. The updated array contains data of type A only
  5. The app crashes as the wrong data is written to apollo-cache-inmemory

Version

@ghost ghost added the has-reproduction label May 7, 2018
@apollo-cla
Copy link

Fails
🚫

No CHANGELOG added.

Messages
📖

Please add your name and email to the AUTHORS file (optional)

Generated by 🚫 dangerJS

return undefined;
};

writeQueryToStore({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

On the first write, all goes according to plan.

},
});

writeQueryToStore({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

On the second write, we remove the first animal. However things go downward when trying to write this data to the store, as the store does not get updated correctly.

},
},
});
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here is where the test fails.

The received value is

 {
    "$Shelter__some-id.animals.0.species": undefined,
    "$Shelter__some-id.animals.1.species": {
        "name": "dog"
    },
    "ROOT_QUERY": {
        "shelter": {
            "generated": false,
            "id": "Shelter__some-id",
            "type": "id",
            "typename": "Shelter"
        }
    },
    "Shelter__some-id": {
        "animals": [{
            "generated": true,
            "id": "Shelter__some-id.animals.0",
            "type": "id",
            "typename": "Animal"
        }],
        "id": "some-id"
    },
    "Shelter__some-id.animals.0": {
        "name": "Dieter",
        "species": {
            "generated": true,
            "id": "$Shelter__some-id.animals.0.species",
            "type": "id",
            "typename": "Dog"
        }
    },
    "Shelter__some-id.animals.1": {
        "name": "Dieter",
        "species": {
            "generated": true,
            "id": "$Shelter__some-id.animals.1.species",
            "type": "id",
            "typename": "Dog"
        }
    }
}

The actual difference being:

  {
-    "$Shelter__some-id.animals.0.species": undefined,
-    "$Shelter__some-id.animals.1.species": {
+    "$Shelter__some-id.animals.0.species": {
         "name": "dog"
     },
     "ROOT_QUERY": {

@dferber90
Copy link
Contributor Author

dferber90 commented May 7, 2018

I was able to dumb it down some more. The following fails as well:

Show failing test case
it("should updated inlined array fields", () => {
    const store = defaultNormalizedCacheFactory();

    const query = gql`
      query {
        animals {
          species {
            name
          }
        }
      }
    `;

    const dataIdFromObject = (object: any) => {
      if (object.__typename && object.id) {
        return object.__typename + "__" + object.id;
      }
      return undefined;
    };

    writeQueryToStore({
      query,
      dataIdFromObject,
      result: {
        animals: [
          {
            __typename: "Animal",
            name: "Carol",
            species: {
              __typename: "Cat",
              name: "cat"
            }
          },
          {
            __typename: "Animal",
            name: "Dieter",
            species: {
              __typename: "Dog",
              name: "dog"
            }
          }
        ]
      },
      store
    });

    expect(store.toObject()).toEqual({
      "$ROOT_QUERY.animals.0.species": { name: "cat" },
      "$ROOT_QUERY.animals.1.species": { name: "dog" },
      ROOT_QUERY: {
        animals: [
          {
            generated: true,
            id: "ROOT_QUERY.animals.0",
            type: "id",
            typename: "Animal"
          },
          {
            generated: true,
            id: "ROOT_QUERY.animals.1",
            type: "id",
            typename: "Animal"
          }
        ]
      },
      "ROOT_QUERY.animals.0": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.0.species",
          type: "id",
          typename: "Cat"
        }
      },
      "ROOT_QUERY.animals.1": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.1.species",
          type: "id",
          typename: "Dog"
        }
      }
    });

    writeQueryToStore({
      query,
      dataIdFromObject,
      result: {
        animals: [
          // First element deleted -> nested "type" field has GraphQL Type change
          {
            __typename: "Animal",
            name: "Dieter",
            species: {
              __typename: "Dog",
              name: "dog"
            }
          }
        ]
      },
      store
    });

    // This shows the actual output. Notice the "not".
    // The correct output should be something else.
    //
    // Especially the species of the first result is lost, as it is `undefined`
    expect(store.toObject()).not.toEqual({
      "$ROOT_QUERY.animals.0.species": undefined,
      "$ROOT_QUERY.animals.1.species": { name: "dog" },
      ROOT_QUERY: {
        animals: [
          {
            generated: true,
            id: "ROOT_QUERY.animals.0",
            type: "id",
            typename: "Animal"
          }
        ]
      },
      "ROOT_QUERY.animals.0": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.0.species",
          type: "id",
          typename: "Dog"
        }
      },
      "ROOT_QUERY.animals.1": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.1.species",
          type: "id",
          typename: "Dog"
        }
      }
    });
  });

@dferber90
Copy link
Contributor Author

I digged some more and the culprit seems to be here:

Using the second failing test example from this comment, generatedKey is "$ROOT_QUERY.animals.0.species".

The store looks like this before the deletion

{
    "$ROOT_QUERY.animals.0.species": {
        "name": "dog"
    },
    "$ROOT_QUERY.animals.1.species": {
        "name": "dog"
    },
    "ROOT_QUERY": {
        animals: [
            { generated: true, id: "ROOT_QUERY.animals.0", type: "id", typename: "Animal" },
            { generated: true, id: "ROOT_QUERY.animals.1", type: "id", typename: "Animal" }
        ]
    },
    "ROOT_QUERY.animals.0": {
        species: { generated: true, type: "id", id: "$ROOT_QUERY.animals.0.species", typename: "Cat"  },
    },
     "ROOT_QUERY.animals.1": {
        species: { generated: true, type: "id", id: "$ROOT_QUERY.animals.1.species", typename: "Dog"  },
    }
}

So "$ROOT_QUERY.animals.0.species" gets set correctly but is then later deleted.

When commenting out store.delete(generatedKey); the value stays set, however another test (should allow a union of objects of a different type, when overwriting a generated id with a real id) breaks as expected.

The issue seems to be that we're deleting after writing (or deleting the wrong thing).

@dferber90
Copy link
Contributor Author

dferber90 commented May 7, 2018

Based on these findings, I've come up with an even simpler test case

Simpler case
  it("should updated nested fields of mixed types within arrays", () => {
    const store = defaultNormalizedCacheFactory();

    const query = gql`
      query {
        animals {
          species {
            name
          }
        }
      }
    `;

    writeQueryToStore({
      query,
      result: {
        animals: [
          {
            __typename: "Animal",
            species: {
              __typename: "Cat",
              name: "cat"
            }
          }
        ]
      },
      store
    });

    expect(store.toObject()).toEqual({
      "$ROOT_QUERY.animals.0.species": { name: "cat" },
      ROOT_QUERY: {
        animals: [
          {
            generated: true,
            id: "ROOT_QUERY.animals.0",
            type: "id",
            typename: "Animal"
          }
        ]
      },
      "ROOT_QUERY.animals.0": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.0.species",
          type: "id",
          typename: "Cat"
        }
      }
    });

    writeQueryToStore({
      query,
      result: {
        animals: [
          {
            __typename: "Animal",
            species: {
              __typename: "Dog",
              name: "dog"
            }
          }
        ]
      },
      store
    });

    expect(store.toObject()).not.toEqual({
      //  ⚠️ This should not be undefined
      "$ROOT_QUERY.animals.0.species": undefined,
      ROOT_QUERY: {
        animals: [
          {
            generated: true,
            id: "ROOT_QUERY.animals.0",
            type: "id",
            typename: "Animal"
          }
        ]
      },
      "ROOT_QUERY.animals.0": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.0.species",
          type: "id",
          typename: "Dog"
        }
      }
    });
  });

@dferber90
Copy link
Contributor Author

dferber90 commented May 7, 2018

I think I actually figured out how to solve this one.

 if (escapedId.generated) {
     generatedKey = escapedId.id;
     // we should only merge if it's an object of the same type
     // otherwise, we should delete the generated object
     if (typenameChanged) {
-        store.delete(generatedKey);
+        // remove the old generated value in case the old value was
+        // inlined and the new value is not, which is indicated by
+        // the old id being generated and the new id not having been generated
+        if (!generated) {
+            store.delete(generatedKey);
+        }
     } else {
         shouldMerge = true;
     }
 }

It seems that the cache attempted to do cleanup of a generated object in case the reference to it gets replaced with one where we're actually provided a non-generated id. However, this also ran when a generated object's type changed, thus removing the new generated object by accident.

We also need to adapt the should allow a union of objects of a different type, when overwriting a generated id with a real id spec:

     const expStoreWithAuthor = defaultNormalizedCacheFactory({
+      "$ROOT_QUERY.author": undefined,
       Author__129: {

And that's all.


I've found another case where the cache stays polluted with unreferenced objects which happens when removing an item form an array which contained an inlined reference. This happens even after this fix. It seems unrelated to this PR though, maybe it's even intentional.

Reproduction for case where unreferenced elements stay in cache
  it("should remove unreferenced items from the cache", () => {
    const store = defaultNormalizedCacheFactory();

    const query = gql`
      query {
        animals {
          species {
            name
          }
        }
      }
    `;

    const dataIdFromObject = (object: any) => {
      if (object.__typename && object.id) {
        return object.__typename + "__" + object.id;
      }
      return undefined;
    };

    writeQueryToStore({
      query,
      dataIdFromObject,
      result: {
        animals: [
          {
            __typename: "Animal",
            name: "Carol",
            species: {
              __typename: "Cat",
              name: "cat"
            }
          },
          {
            __typename: "Animal",
            name: "Dieter",
            species: {
              __typename: "Dog",
              name: "dog"
            }
          }
        ]
      },
      store
    });

    expect(store.toObject()).toEqual({
      "$ROOT_QUERY.animals.0.species": { name: "cat" },
      "$ROOT_QUERY.animals.1.species": { name: "dog" },
      ROOT_QUERY: {
        animals: [
          {
            generated: true,
            id: "ROOT_QUERY.animals.0",
            type: "id",
            typename: "Animal"
          },
          {
            generated: true,
            id: "ROOT_QUERY.animals.1",
            type: "id",
            typename: "Animal"
          }
        ]
      },
      "ROOT_QUERY.animals.0": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.0.species",
          type: "id",
          typename: "Cat"
        }
      },
      "ROOT_QUERY.animals.1": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.1.species",
          type: "id",
          typename: "Dog"
        }
      }
    });

    writeQueryToStore({
      query,
      dataIdFromObject,
      result: {
        animals: [
          // First element deleted -> nested "type" field has GraphQL Type change
          {
            __typename: "Animal",
            name: "Dieter",
            species: {
              __typename: "Dog",
              name: "dog"
            }
          }
        ]
      },
      store
    });

    expect(store.toObject()).not.toEqual({
      "$ROOT_QUERY.animals.0.species": { name: "dog" },
      "$ROOT_QUERY.animals.1.species": { name: "dog" },
      ROOT_QUERY: {
        animals: [
          {
            generated: true,
            id: "ROOT_QUERY.animals.0",
            type: "id",
            typename: "Animal"
          }
        ]
      },
      "ROOT_QUERY.animals.0": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.0.species",
          type: "id",
          typename: "Dog"
        }
      },
      "ROOT_QUERY.animals.1": {
        species: {
          generated: true,
          id: "$ROOT_QUERY.animals.1.species",
          type: "id",
          typename: "Dog"
        }
      }
    });
  });

@dferber90
Copy link
Contributor Author

I opened another PR with a clean slate and without all the noise: #3422

@dferber90 dferber90 closed this May 7, 2018
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants