From 856d8586e44464ce3eb69af1f0942e78d9fd2b35 Mon Sep 17 00:00:00 2001 From: Matchu Date: Mon, 31 Aug 2020 18:25:42 -0700 Subject: [PATCH] cache item data when switching standard colors Previously, when changing a pet's color, we would refresh the items panel and send a new network request for the item appearances, even though they're all the same. This is because item appearance data is queried by species/color, for ease of specification. But! Item appearances are //cached// by body ID. So, if this is a standard color, it's not hard to look in the cache for the standard color's body ID! Now, most color changes are faster and don't flicker the item panel anymore. We do still refresh the panel and send the requests for color changes that _do_ matter though, like standard <-> mutant! --- src/app/apolloClient.js | 84 ++++++++- src/app/components/SpeciesColorPicker.js | 3 +- src/server/index.js | 33 +++- src/server/loaders.js | 36 +++- src/server/query-tests/Color.test.js | 42 +++++ src/server/query-tests/Species.test.js | 165 ++++++++++++++++++ .../__snapshots__/Species.test.js.snap | 55 ++++++ 7 files changed, 402 insertions(+), 16 deletions(-) diff --git a/src/app/apolloClient.js b/src/app/apolloClient.js index 29ae766..299f62f 100644 --- a/src/app/apolloClient.js +++ b/src/app/apolloClient.js @@ -1,30 +1,96 @@ import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client"; import { createPersistedQueryLink } from "apollo-link-persisted-queries"; +import gql from "graphql-tag"; const cachedZones = require("./cached-data/zones.json"); const cachedZonesById = new Map(cachedZones.map((z) => [z.id, z])); +// Teach Apollo to load certain fields from the cache, to avoid extra network +// requests. This happens a lot - e.g. reusing data from item search on the +// outfit immediately! const typePolicies = { Query: { fields: { - // Teach Apollo how to serve `items` queries from the cache. That way, - // when you remove an item from your outfit, or add an item from search, - // Apollo knows it already has the data it needs and doesn't need to ask - // the server again! items: (_, { args, toReference }) => { return args.ids.map((id) => toReference({ __typename: "Item", id }, true) ); }, - - // Similar for a single item lookup! item: (_, { args, toReference }) => { return toReference({ __typename: "Item", id: args.id }, true); }, - petAppearanceById: (_, { args, toReference }) => { return toReference({ __typename: "PetAppearance", id: args.id }, true); }, + species: (_, { args, toReference }) => { + return toReference({ __typename: "Species", id: args.id }, true); + }, + color: (_, { args, toReference }) => { + return toReference({ __typename: "Color", id: args.id }, true); + }, + }, + }, + + Item: { + fields: { + appearanceOn: (appearance, { args, readField, toReference }) => { + // If we already have this exact appearance in the cache, serve it! + if (appearance) { + return appearance; + } + + // Otherwise, we're going to see if this is a standard color, in which + // case we can reuse the standard color appearance if we already have + // it! This helps for fast loading when switching between standard + // colors. + + const { speciesId, colorId } = args; + + // HACK: I can't find a way to do bigger-picture queries like this from + // Apollo's cache field reader API. Am I missing something? I + // don't love escape-hatching to the client like this, but... + let cachedData; + try { + cachedData = client.readQuery({ + query: gql` + query CacheLookupForItemAppearanceReader( + $speciesId: ID! + $colorId: ID! + ) { + species(id: $speciesId) { + standardBodyId + } + color(id: $colorId) { + isStandard + } + } + `, + variables: { speciesId, colorId }, + }); + } catch (e) { + // Some errors are expected while setting up the cache... not sure + // how to distinguish from Real errors. Just gonna ignore them all + // for now! + return undefined; + } + + if (!cachedData) { + // This is an expected case while the page is loading. + return undefined; + } + + const { species, color } = cachedData; + if (color.isStandard) { + const itemId = readField("id"); + const bodyId = species.standardBodyId; + return toReference({ + __typename: "ItemAppearance", + id: `item-${itemId}-body-${bodyId}`, + }); + } else { + return undefined; + } + }, }, }, @@ -54,8 +120,10 @@ const httpLink = createHttpLink({ uri: "/api/graphql" }); * apolloClient is the global Apollo Client instance we use for GraphQL * queries. This is how we communicate with the server! */ -export default new ApolloClient({ +const client = new ApolloClient({ link: persistedQueryLink.concat(httpLink), cache: new InMemoryCache({ typePolicies }), connectToDevTools: true, }); + +export default client; diff --git a/src/app/components/SpeciesColorPicker.js b/src/app/components/SpeciesColorPicker.js index c50706c..dedc8a9 100644 --- a/src/app/components/SpeciesColorPicker.js +++ b/src/app/components/SpeciesColorPicker.js @@ -32,12 +32,13 @@ function SpeciesColorPicker({ allSpecies { id name + standardBodyId # Used for keeping items on during standard color changes } allColors { id name - isStandard # Not used here, but helpful for caching! + isStandard # Used for keeping items on during standard color changes } } `); diff --git a/src/server/index.js b/src/server/index.js index 7d75785..c0ce3c0 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -182,6 +182,11 @@ const typeDefs = gql` type Species @cacheControl(maxAge: 604800) { id: ID! name: String! + + # The bodyId for PetAppearances that use this species and a standard color. + # We use this to preload the standard body IDs, so that items stay when + # switching between standard colors. + standardBodyId: ID! } type SpeciesColorPair { @@ -231,6 +236,9 @@ const typeDefs = gql` petAppearances(speciesId: ID!, colorId: ID!): [PetAppearance!]! outfit(id: ID!): Outfit + color(id: ID!): Color + species(id: ID!): Species + petOnNeopetsDotCom(petName: String!): Outfit } @@ -523,6 +531,13 @@ const resolvers = { const speciesTranslation = await speciesTranslationLoader.load(id); return capitalize(speciesTranslation.name); }, + standardBodyId: async ({ id }, _, { petTypeBySpeciesAndColorLoader }) => { + const petType = await petTypeBySpeciesAndColorLoader.load({ + speciesId: id, + colorId: "8", // Blue + }); + return petType.bodyId; + }, }, Outfit: { name: async ({ id }, _, { outfitLoader }) => { @@ -551,8 +566,8 @@ const resolvers = { const allColors = await colorLoader.loadAll(); return allColors; }, - allSpecies: async (_, { ids }, { loadAllSpecies }) => { - const allSpecies = await loadAllSpecies(); + allSpecies: async (_, { ids }, { speciesLoader }) => { + const allSpecies = await speciesLoader.loadAll(); return allSpecies; }, allValidSpeciesColorPairs: async (_, __, { loadAllPetTypes }) => { @@ -645,6 +660,20 @@ const resolvers = { }; return outfit; }, + color: async (_, { id }, { colorLoader }) => { + const color = await colorLoader.load(id); + if (!color) { + return null; + } + return { id }; + }, + species: async (_, { id }, { speciesLoader }) => { + const species = await speciesLoader.load(id); + if (!species) { + return null; + } + return { id }; + }, }, Mutation: { setManualSpecialColor: async ( diff --git a/src/server/loaders.js b/src/server/loaders.js index 8323a2d..0b08f0c 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -52,10 +52,36 @@ const buildColorTranslationLoader = (db) => ); }); -const loadAllSpecies = (db) => async () => { - const [rows, _] = await db.execute(`SELECT * FROM species`); - const entities = rows.map(normalizeRow); - return entities; +const buildSpeciesLoader = (db) => { + const speciesLoader = new DataLoader(async (speciesIds) => { + const qs = speciesIds.map((_) => "?").join(","); + const [rows, _] = await db.execute( + `SELECT * FROM species WHERE id IN (${qs})`, + speciesIds + ); + + const entities = rows.map(normalizeRow); + const entitiesBySpeciesId = new Map(entities.map((e) => [e.id, e])); + + return speciesIds.map( + (speciesId) => + entitiesBySpeciesId.get(String(speciesId)) || + new Error(`could not find color ${speciesId}`) + ); + }); + + speciesLoader.loadAll = async () => { + const [rows, _] = await db.execute(`SELECT * FROM species`); + const entities = rows.map(normalizeRow); + + for (const species of entities) { + speciesLoader.prime(species.id, species); + } + + return entities; + }; + + return speciesLoader; }; const buildSpeciesTranslationLoader = (db) => @@ -409,7 +435,6 @@ const buildZoneTranslationLoader = (db) => function buildLoaders(db) { const loaders = {}; - loaders.loadAllSpecies = loadAllSpecies(db); loaders.loadAllPetTypes = loadAllPetTypes(db); loaders.colorLoader = buildColorLoader(db); @@ -435,6 +460,7 @@ function buildLoaders(db) { db, loaders ); + loaders.speciesLoader = buildSpeciesLoader(db); loaders.speciesTranslationLoader = buildSpeciesTranslationLoader(db); loaders.zoneLoader = buildZoneLoader(db); loaders.zoneTranslationLoader = buildZoneTranslationLoader(db); diff --git a/src/server/query-tests/Color.test.js b/src/server/query-tests/Color.test.js index 4d13cdf..3f64d19 100644 --- a/src/server/query-tests/Color.test.js +++ b/src/server/query-tests/Color.test.js @@ -2,6 +2,48 @@ const gql = require("graphql-tag"); const { query, getDbCalls } = require("./setup.js"); describe("Color", () => { + it("loads a single color", async () => { + const res = await query({ + query: gql` + query { + color(id: "8") { + id + name + isStandard + } + } + `, + }); + + expect(res).toHaveNoErrors(); + expect(res.data).toMatchInlineSnapshot(` + Object { + "color": Object { + "id": "8", + "isStandard": true, + "name": "Blue", + }, + } + `); + expect(getDbCalls()).toMatchInlineSnapshot(` + Array [ + Array [ + "SELECT * FROM colors WHERE id IN (?) AND prank = 0", + Array [ + "8", + ], + ], + Array [ + "SELECT * FROM color_translations + WHERE color_id IN (?) AND locale = \\"en\\"", + Array [ + "8", + ], + ], + ] + `); + }); + it("loads all colors", async () => { const res = await query({ query: gql` diff --git a/src/server/query-tests/Species.test.js b/src/server/query-tests/Species.test.js index b16fb74..80083b8 100644 --- a/src/server/query-tests/Species.test.js +++ b/src/server/query-tests/Species.test.js @@ -2,6 +2,55 @@ const gql = require("graphql-tag"); const { query, getDbCalls } = require("./setup.js"); describe("Species", () => { + it("loads a single species", async () => { + const res = await query({ + query: gql` + query { + species(id: "1") { + id + name + standardBodyId + } + } + `, + }); + + expect(res).toHaveNoErrors(); + expect(res.data).toMatchInlineSnapshot(` + Object { + "species": Object { + "id": "1", + "name": "Acara", + "standardBodyId": "93", + }, + } + `); + expect(getDbCalls()).toMatchInlineSnapshot(` + Array [ + Array [ + "SELECT * FROM species WHERE id IN (?)", + Array [ + "1", + ], + ], + Array [ + "SELECT * FROM species_translations + WHERE species_id IN (?) AND locale = \\"en\\"", + Array [ + "1", + ], + ], + Array [ + "SELECT * FROM pet_types WHERE (species_id = ? AND color_id = ?)", + Array [ + "1", + "8", + ], + ], + ] + `); + }); + it("loads all species", async () => { const res = await query({ query: gql` @@ -9,6 +58,7 @@ describe("Species", () => { allSpecies { id name + standardBodyId } } `, @@ -82,6 +132,121 @@ describe("Species", () => { "55", ], ], + Array [ + "SELECT * FROM pet_types WHERE (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?) OR (species_id = ? AND color_id = ?)", + Array [ + "1", + "8", + "2", + "8", + "3", + "8", + "4", + "8", + "5", + "8", + "6", + "8", + "7", + "8", + "8", + "8", + "9", + "8", + "10", + "8", + "11", + "8", + "12", + "8", + "13", + "8", + "14", + "8", + "15", + "8", + "16", + "8", + "17", + "8", + "18", + "8", + "19", + "8", + "20", + "8", + "21", + "8", + "22", + "8", + "23", + "8", + "24", + "8", + "25", + "8", + "26", + "8", + "27", + "8", + "28", + "8", + "29", + "8", + "30", + "8", + "31", + "8", + "32", + "8", + "33", + "8", + "34", + "8", + "35", + "8", + "36", + "8", + "37", + "8", + "38", + "8", + "39", + "8", + "40", + "8", + "41", + "8", + "42", + "8", + "43", + "8", + "44", + "8", + "45", + "8", + "46", + "8", + "47", + "8", + "48", + "8", + "49", + "8", + "50", + "8", + "51", + "8", + "52", + "8", + "53", + "8", + "54", + "8", + "55", + "8", + ], + ], ] `); }); diff --git a/src/server/query-tests/__snapshots__/Species.test.js.snap b/src/server/query-tests/__snapshots__/Species.test.js.snap index 9cf5f3c..dd21851 100644 --- a/src/server/query-tests/__snapshots__/Species.test.js.snap +++ b/src/server/query-tests/__snapshots__/Species.test.js.snap @@ -6,222 +6,277 @@ Object { Object { "id": "1", "name": "Acara", + "standardBodyId": "93", }, Object { "id": "2", "name": "Aisha", + "standardBodyId": "106", }, Object { "id": "3", "name": "Blumaroo", + "standardBodyId": "47", }, Object { "id": "4", "name": "Bori", + "standardBodyId": "84", }, Object { "id": "5", "name": "Bruce", + "standardBodyId": "146", }, Object { "id": "6", "name": "Buzz", + "standardBodyId": "250", }, Object { "id": "7", "name": "Chia", + "standardBodyId": "212", }, Object { "id": "8", "name": "Chomby", + "standardBodyId": "74", }, Object { "id": "9", "name": "Cybunny", + "standardBodyId": "94", }, Object { "id": "10", "name": "Draik", + "standardBodyId": "132", }, Object { "id": "11", "name": "Elephante", + "standardBodyId": "56", }, Object { "id": "12", "name": "Eyrie", + "standardBodyId": "90", }, Object { "id": "13", "name": "Flotsam", + "standardBodyId": "136", }, Object { "id": "14", "name": "Gelert", + "standardBodyId": "138", }, Object { "id": "15", "name": "Gnorbu", + "standardBodyId": "166", }, Object { "id": "16", "name": "Grarrl", + "standardBodyId": "119", }, Object { "id": "17", "name": "Grundo", + "standardBodyId": "126", }, Object { "id": "18", "name": "Hissi", + "standardBodyId": "67", }, Object { "id": "19", "name": "Ixi", + "standardBodyId": "163", }, Object { "id": "20", "name": "Jetsam", + "standardBodyId": "147", }, Object { "id": "21", "name": "Jubjub", + "standardBodyId": "80", }, Object { "id": "22", "name": "Kacheek", + "standardBodyId": "117", }, Object { "id": "23", "name": "Kau", + "standardBodyId": "201", }, Object { "id": "24", "name": "Kiko", + "standardBodyId": "51", }, Object { "id": "25", "name": "Koi", + "standardBodyId": "208", }, Object { "id": "26", "name": "Korbat", + "standardBodyId": "196", }, Object { "id": "27", "name": "Kougra", + "standardBodyId": "143", }, Object { "id": "28", "name": "Krawk", + "standardBodyId": "150", }, Object { "id": "29", "name": "Kyrii", + "standardBodyId": "175", }, Object { "id": "30", "name": "Lenny", + "standardBodyId": "173", }, Object { "id": "31", "name": "Lupe", + "standardBodyId": "199", }, Object { "id": "32", "name": "Lutari", + "standardBodyId": "52", }, Object { "id": "33", "name": "Meerca", + "standardBodyId": "109", }, Object { "id": "34", "name": "Moehog", + "standardBodyId": "134", }, Object { "id": "35", "name": "Mynci", + "standardBodyId": "95", }, Object { "id": "36", "name": "Nimmo", + "standardBodyId": "96", }, Object { "id": "37", "name": "Ogrin", + "standardBodyId": "154", }, Object { "id": "38", "name": "Peophin", + "standardBodyId": "55", }, Object { "id": "39", "name": "Poogle", + "standardBodyId": "76", }, Object { "id": "40", "name": "Pteri", + "standardBodyId": "156", }, Object { "id": "41", "name": "Quiggle", + "standardBodyId": "78", }, Object { "id": "42", "name": "Ruki", + "standardBodyId": "191", }, Object { "id": "43", "name": "Scorchio", + "standardBodyId": "187", }, Object { "id": "44", "name": "Shoyru", + "standardBodyId": "46", }, Object { "id": "45", "name": "Skeith", + "standardBodyId": "178", }, Object { "id": "46", "name": "Techo", + "standardBodyId": "100", }, Object { "id": "47", "name": "Tonu", + "standardBodyId": "130", }, Object { "id": "48", "name": "Tuskaninny", + "standardBodyId": "188", }, Object { "id": "49", "name": "Uni", + "standardBodyId": "257", }, Object { "id": "50", "name": "Usul", + "standardBodyId": "206", }, Object { "id": "51", "name": "Wocky", + "standardBodyId": "101", }, Object { "id": "52", "name": "Xweetok", + "standardBodyId": "68", }, Object { "id": "53", "name": "Yurble", + "standardBodyId": "182", }, Object { "id": "54", "name": "Zafara", + "standardBodyId": "180", }, Object { "id": "55", "name": "Vandagyre", + "standardBodyId": "306", }, ], }