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 { 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;

View file

@ -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
}
}
`);

View file

@ -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 (

View file

@ -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);

View file

@ -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`

View file

@ -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",
],
],
]
`);
});

View file

@ -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",
},
],
}