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!
This commit is contained in:
Emi Matchu 2020-08-31 18:25:42 -07:00
parent 4a91d0cab8
commit 856d8586e4
7 changed files with 402 additions and 16 deletions

View file

@ -1,30 +1,96 @@
import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client"; import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import { createPersistedQueryLink } from "apollo-link-persisted-queries"; import { createPersistedQueryLink } from "apollo-link-persisted-queries";
import gql from "graphql-tag";
const cachedZones = require("./cached-data/zones.json"); const cachedZones = require("./cached-data/zones.json");
const cachedZonesById = new Map(cachedZones.map((z) => [z.id, z])); 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 = { const typePolicies = {
Query: { Query: {
fields: { 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 }) => { items: (_, { args, toReference }) => {
return args.ids.map((id) => return args.ids.map((id) =>
toReference({ __typename: "Item", id }, true) toReference({ __typename: "Item", id }, true)
); );
}, },
// Similar for a single item lookup!
item: (_, { args, toReference }) => { item: (_, { args, toReference }) => {
return toReference({ __typename: "Item", id: args.id }, true); return toReference({ __typename: "Item", id: args.id }, true);
}, },
petAppearanceById: (_, { args, toReference }) => { petAppearanceById: (_, { args, toReference }) => {
return toReference({ __typename: "PetAppearance", id: args.id }, true); 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 * apolloClient is the global Apollo Client instance we use for GraphQL
* queries. This is how we communicate with the server! * queries. This is how we communicate with the server!
*/ */
export default new ApolloClient({ const client = new ApolloClient({
link: persistedQueryLink.concat(httpLink), link: persistedQueryLink.concat(httpLink),
cache: new InMemoryCache({ typePolicies }), cache: new InMemoryCache({ typePolicies }),
connectToDevTools: true, connectToDevTools: true,
}); });
export default client;

View file

@ -32,12 +32,13 @@ function SpeciesColorPicker({
allSpecies { allSpecies {
id id
name name
standardBodyId # Used for keeping items on during standard color changes
} }
allColors { allColors {
id id
name name
isStandard # Not used here, but helpful for caching! isStandard # Used for keeping items on during standard color changes
} }
} }
`); `);

View file

@ -182,6 +182,11 @@ const typeDefs = gql`
type Species @cacheControl(maxAge: 604800) { type Species @cacheControl(maxAge: 604800) {
id: ID! id: ID!
name: String! 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 { type SpeciesColorPair {
@ -231,6 +236,9 @@ const typeDefs = gql`
petAppearances(speciesId: ID!, colorId: ID!): [PetAppearance!]! petAppearances(speciesId: ID!, colorId: ID!): [PetAppearance!]!
outfit(id: ID!): Outfit outfit(id: ID!): Outfit
color(id: ID!): Color
species(id: ID!): Species
petOnNeopetsDotCom(petName: String!): Outfit petOnNeopetsDotCom(petName: String!): Outfit
} }
@ -523,6 +531,13 @@ const resolvers = {
const speciesTranslation = await speciesTranslationLoader.load(id); const speciesTranslation = await speciesTranslationLoader.load(id);
return capitalize(speciesTranslation.name); return capitalize(speciesTranslation.name);
}, },
standardBodyId: async ({ id }, _, { petTypeBySpeciesAndColorLoader }) => {
const petType = await petTypeBySpeciesAndColorLoader.load({
speciesId: id,
colorId: "8", // Blue
});
return petType.bodyId;
},
}, },
Outfit: { Outfit: {
name: async ({ id }, _, { outfitLoader }) => { name: async ({ id }, _, { outfitLoader }) => {
@ -551,8 +566,8 @@ const resolvers = {
const allColors = await colorLoader.loadAll(); const allColors = await colorLoader.loadAll();
return allColors; return allColors;
}, },
allSpecies: async (_, { ids }, { loadAllSpecies }) => { allSpecies: async (_, { ids }, { speciesLoader }) => {
const allSpecies = await loadAllSpecies(); const allSpecies = await speciesLoader.loadAll();
return allSpecies; return allSpecies;
}, },
allValidSpeciesColorPairs: async (_, __, { loadAllPetTypes }) => { allValidSpeciesColorPairs: async (_, __, { loadAllPetTypes }) => {
@ -645,6 +660,20 @@ const resolvers = {
}; };
return outfit; 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: { Mutation: {
setManualSpecialColor: async ( setManualSpecialColor: async (

View file

@ -52,12 +52,38 @@ const buildColorTranslationLoader = (db) =>
); );
}); });
const loadAllSpecies = (db) => async () => { 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 [rows, _] = await db.execute(`SELECT * FROM species`);
const entities = rows.map(normalizeRow); const entities = rows.map(normalizeRow);
for (const species of entities) {
speciesLoader.prime(species.id, species);
}
return entities; return entities;
}; };
return speciesLoader;
};
const buildSpeciesTranslationLoader = (db) => const buildSpeciesTranslationLoader = (db) =>
new DataLoader(async (speciesIds) => { new DataLoader(async (speciesIds) => {
const qs = speciesIds.map((_) => "?").join(","); const qs = speciesIds.map((_) => "?").join(",");
@ -409,7 +435,6 @@ const buildZoneTranslationLoader = (db) =>
function buildLoaders(db) { function buildLoaders(db) {
const loaders = {}; const loaders = {};
loaders.loadAllSpecies = loadAllSpecies(db);
loaders.loadAllPetTypes = loadAllPetTypes(db); loaders.loadAllPetTypes = loadAllPetTypes(db);
loaders.colorLoader = buildColorLoader(db); loaders.colorLoader = buildColorLoader(db);
@ -435,6 +460,7 @@ function buildLoaders(db) {
db, db,
loaders loaders
); );
loaders.speciesLoader = buildSpeciesLoader(db);
loaders.speciesTranslationLoader = buildSpeciesTranslationLoader(db); loaders.speciesTranslationLoader = buildSpeciesTranslationLoader(db);
loaders.zoneLoader = buildZoneLoader(db); loaders.zoneLoader = buildZoneLoader(db);
loaders.zoneTranslationLoader = buildZoneTranslationLoader(db); loaders.zoneTranslationLoader = buildZoneTranslationLoader(db);

View file

@ -2,6 +2,48 @@ const gql = require("graphql-tag");
const { query, getDbCalls } = require("./setup.js"); const { query, getDbCalls } = require("./setup.js");
describe("Color", () => { 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 () => { it("loads all colors", async () => {
const res = await query({ const res = await query({
query: gql` query: gql`

View file

@ -2,6 +2,55 @@ const gql = require("graphql-tag");
const { query, getDbCalls } = require("./setup.js"); const { query, getDbCalls } = require("./setup.js");
describe("Species", () => { 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 () => { it("loads all species", async () => {
const res = await query({ const res = await query({
query: gql` query: gql`
@ -9,6 +58,7 @@ describe("Species", () => {
allSpecies { allSpecies {
id id
name name
standardBodyId
} }
} }
`, `,
@ -82,6 +132,121 @@ describe("Species", () => {
"55", "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",
],
],
] ]
`); `);
}); });

View file

@ -6,222 +6,277 @@ Object {
Object { Object {
"id": "1", "id": "1",
"name": "Acara", "name": "Acara",
"standardBodyId": "93",
}, },
Object { Object {
"id": "2", "id": "2",
"name": "Aisha", "name": "Aisha",
"standardBodyId": "106",
}, },
Object { Object {
"id": "3", "id": "3",
"name": "Blumaroo", "name": "Blumaroo",
"standardBodyId": "47",
}, },
Object { Object {
"id": "4", "id": "4",
"name": "Bori", "name": "Bori",
"standardBodyId": "84",
}, },
Object { Object {
"id": "5", "id": "5",
"name": "Bruce", "name": "Bruce",
"standardBodyId": "146",
}, },
Object { Object {
"id": "6", "id": "6",
"name": "Buzz", "name": "Buzz",
"standardBodyId": "250",
}, },
Object { Object {
"id": "7", "id": "7",
"name": "Chia", "name": "Chia",
"standardBodyId": "212",
}, },
Object { Object {
"id": "8", "id": "8",
"name": "Chomby", "name": "Chomby",
"standardBodyId": "74",
}, },
Object { Object {
"id": "9", "id": "9",
"name": "Cybunny", "name": "Cybunny",
"standardBodyId": "94",
}, },
Object { Object {
"id": "10", "id": "10",
"name": "Draik", "name": "Draik",
"standardBodyId": "132",
}, },
Object { Object {
"id": "11", "id": "11",
"name": "Elephante", "name": "Elephante",
"standardBodyId": "56",
}, },
Object { Object {
"id": "12", "id": "12",
"name": "Eyrie", "name": "Eyrie",
"standardBodyId": "90",
}, },
Object { Object {
"id": "13", "id": "13",
"name": "Flotsam", "name": "Flotsam",
"standardBodyId": "136",
}, },
Object { Object {
"id": "14", "id": "14",
"name": "Gelert", "name": "Gelert",
"standardBodyId": "138",
}, },
Object { Object {
"id": "15", "id": "15",
"name": "Gnorbu", "name": "Gnorbu",
"standardBodyId": "166",
}, },
Object { Object {
"id": "16", "id": "16",
"name": "Grarrl", "name": "Grarrl",
"standardBodyId": "119",
}, },
Object { Object {
"id": "17", "id": "17",
"name": "Grundo", "name": "Grundo",
"standardBodyId": "126",
}, },
Object { Object {
"id": "18", "id": "18",
"name": "Hissi", "name": "Hissi",
"standardBodyId": "67",
}, },
Object { Object {
"id": "19", "id": "19",
"name": "Ixi", "name": "Ixi",
"standardBodyId": "163",
}, },
Object { Object {
"id": "20", "id": "20",
"name": "Jetsam", "name": "Jetsam",
"standardBodyId": "147",
}, },
Object { Object {
"id": "21", "id": "21",
"name": "Jubjub", "name": "Jubjub",
"standardBodyId": "80",
}, },
Object { Object {
"id": "22", "id": "22",
"name": "Kacheek", "name": "Kacheek",
"standardBodyId": "117",
}, },
Object { Object {
"id": "23", "id": "23",
"name": "Kau", "name": "Kau",
"standardBodyId": "201",
}, },
Object { Object {
"id": "24", "id": "24",
"name": "Kiko", "name": "Kiko",
"standardBodyId": "51",
}, },
Object { Object {
"id": "25", "id": "25",
"name": "Koi", "name": "Koi",
"standardBodyId": "208",
}, },
Object { Object {
"id": "26", "id": "26",
"name": "Korbat", "name": "Korbat",
"standardBodyId": "196",
}, },
Object { Object {
"id": "27", "id": "27",
"name": "Kougra", "name": "Kougra",
"standardBodyId": "143",
}, },
Object { Object {
"id": "28", "id": "28",
"name": "Krawk", "name": "Krawk",
"standardBodyId": "150",
}, },
Object { Object {
"id": "29", "id": "29",
"name": "Kyrii", "name": "Kyrii",
"standardBodyId": "175",
}, },
Object { Object {
"id": "30", "id": "30",
"name": "Lenny", "name": "Lenny",
"standardBodyId": "173",
}, },
Object { Object {
"id": "31", "id": "31",
"name": "Lupe", "name": "Lupe",
"standardBodyId": "199",
}, },
Object { Object {
"id": "32", "id": "32",
"name": "Lutari", "name": "Lutari",
"standardBodyId": "52",
}, },
Object { Object {
"id": "33", "id": "33",
"name": "Meerca", "name": "Meerca",
"standardBodyId": "109",
}, },
Object { Object {
"id": "34", "id": "34",
"name": "Moehog", "name": "Moehog",
"standardBodyId": "134",
}, },
Object { Object {
"id": "35", "id": "35",
"name": "Mynci", "name": "Mynci",
"standardBodyId": "95",
}, },
Object { Object {
"id": "36", "id": "36",
"name": "Nimmo", "name": "Nimmo",
"standardBodyId": "96",
}, },
Object { Object {
"id": "37", "id": "37",
"name": "Ogrin", "name": "Ogrin",
"standardBodyId": "154",
}, },
Object { Object {
"id": "38", "id": "38",
"name": "Peophin", "name": "Peophin",
"standardBodyId": "55",
}, },
Object { Object {
"id": "39", "id": "39",
"name": "Poogle", "name": "Poogle",
"standardBodyId": "76",
}, },
Object { Object {
"id": "40", "id": "40",
"name": "Pteri", "name": "Pteri",
"standardBodyId": "156",
}, },
Object { Object {
"id": "41", "id": "41",
"name": "Quiggle", "name": "Quiggle",
"standardBodyId": "78",
}, },
Object { Object {
"id": "42", "id": "42",
"name": "Ruki", "name": "Ruki",
"standardBodyId": "191",
}, },
Object { Object {
"id": "43", "id": "43",
"name": "Scorchio", "name": "Scorchio",
"standardBodyId": "187",
}, },
Object { Object {
"id": "44", "id": "44",
"name": "Shoyru", "name": "Shoyru",
"standardBodyId": "46",
}, },
Object { Object {
"id": "45", "id": "45",
"name": "Skeith", "name": "Skeith",
"standardBodyId": "178",
}, },
Object { Object {
"id": "46", "id": "46",
"name": "Techo", "name": "Techo",
"standardBodyId": "100",
}, },
Object { Object {
"id": "47", "id": "47",
"name": "Tonu", "name": "Tonu",
"standardBodyId": "130",
}, },
Object { Object {
"id": "48", "id": "48",
"name": "Tuskaninny", "name": "Tuskaninny",
"standardBodyId": "188",
}, },
Object { Object {
"id": "49", "id": "49",
"name": "Uni", "name": "Uni",
"standardBodyId": "257",
}, },
Object { Object {
"id": "50", "id": "50",
"name": "Usul", "name": "Usul",
"standardBodyId": "206",
}, },
Object { Object {
"id": "51", "id": "51",
"name": "Wocky", "name": "Wocky",
"standardBodyId": "101",
}, },
Object { Object {
"id": "52", "id": "52",
"name": "Xweetok", "name": "Xweetok",
"standardBodyId": "68",
}, },
Object { Object {
"id": "53", "id": "53",
"name": "Yurble", "name": "Yurble",
"standardBodyId": "182",
}, },
Object { Object {
"id": "54", "id": "54",
"name": "Zafara", "name": "Zafara",
"standardBodyId": "180",
}, },
Object { Object {
"id": "55", "id": "55",
"name": "Vandagyre", "name": "Vandagyre",
"standardBodyId": "306",
}, },
], ],
} }