From 2887d952de70911eb1e49a26f97efc2051906f31 Mon Sep 17 00:00:00 2001 From: Matchu Date: Thu, 15 Sep 2022 02:46:14 -0700 Subject: [PATCH] Fix /outfits/new init + add more SSR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whew, setting up a cute GraphQL SSR system! I feel like it strikes a good balance of not having actually too many moving parts, though it's still a bit extensive for the problem we're solving 😅 Anyway, by doing SSR at _all_, we solve the problem where Next's "Automatic Static Optimization" was causing problems by setting the outfit state to the default at the start of the page load. So I figured, why not try to SSR things _good_? Now, when you navigate to the /outfits/new page, Next.js will go get the necessary GraphQL data to show the image before even putting the page into view. This makes the image show up all snappy-like! (when images.neopets.com is behaving :p) We could do this with the stuff in the items panel too, but it's a tiny bit more annoying in the code right now, so I'm just gonna not worry about it and see how this performs in practice! This change _doesn't_ include making the images actually show up before JS loads in, I assume because our JS code tries to validate that the images have loaded before fading them in on the page. Idk if we want to do something smarter there for the SSR case, to try to get them loading in faster! --- pages/_app.tsx | 22 ++++-- pages/outfits/new.tsx | 55 +++++++++++++ src/app/WardrobePage/useOutfitState.js | 52 +++++++------ src/app/apolloClient.js | 29 +++++-- src/server/ssr-graphql.js | 102 +++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 34 deletions(-) create mode 100644 src/server/ssr-graphql.js diff --git a/pages/_app.tsx b/pages/_app.tsx index 3899d15..800e180 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -6,7 +6,7 @@ import * as Sentry from "@sentry/react"; import { Integrations } from "@sentry/tracing"; import { Auth0Provider } from "@auth0/auth0-react"; import { CSSReset, ChakraProvider, extendTheme } from "@chakra-ui/react"; -import { ApolloProvider } from "@apollo/client"; +import { ApolloProvider, NormalizedCacheObject } from "@apollo/client"; import { useAuth0 } from "@auth0/auth0-react"; import { mode } from "@chakra-ui/theme-tools"; @@ -61,7 +61,9 @@ export default function DTIApp({ Component, pageProps }: AppPropsWithLayout) { audience="https://impress-2020.openneo.net/api" scope="" > - + {renderWithLayout()} @@ -76,7 +78,13 @@ function renderWithDefaultLayout(children: JSX.Element) { return {children}; } -function ApolloProviderWithAuth0({ children }: { children: React.ReactNode }) { +function ApolloProviderWithAuth0({ + children, + initialCacheState = {}, +}: { + children: React.ReactNode; + initialCacheState: NormalizedCacheObject; +}) { const auth0 = useAuth0(); const auth0Ref = React.useRef(auth0); @@ -85,8 +93,12 @@ function ApolloProviderWithAuth0({ children }: { children: React.ReactNode }) { }, [auth0]); const client = React.useMemo( - () => buildApolloClient(() => auth0Ref.current), - [] + () => + buildApolloClient({ + getAuth0: () => auth0Ref.current, + initialCacheState, + }), + [initialCacheState] ); return {children}; } diff --git a/pages/outfits/new.tsx b/pages/outfits/new.tsx index 3d3d2b9..fc9d019 100644 --- a/pages/outfits/new.tsx +++ b/pages/outfits/new.tsx @@ -1,5 +1,12 @@ +import { GetServerSideProps } from "next"; import WardrobePage from "../../src/app/WardrobePage"; +import { readOutfitStateFromQuery } from "../../src/app/WardrobePage/useOutfitState"; import type { NextPageWithLayout } from "../_app"; +import { loadGraphqlQuery, gql } from "../../src/server/ssr-graphql"; +import { + itemAppearanceFragment, + petAppearanceFragment, +} from "../../src/app/components/useOutfitAppearance"; const WardrobePageWrapper: NextPageWithLayout = () => { return ; @@ -7,4 +14,52 @@ const WardrobePageWrapper: NextPageWithLayout = () => { WardrobePageWrapper.renderWithLayout = (children) => children; +export const getServerSideProps: GetServerSideProps = async ({ query }) => { + // Read the outfit info necessary to start rendering the image ASAP, and SSR + // with it! We add it as a special `pageProps` key named `graphqlState`, + // which the `App` component intercepts and gives to the Apollo client. + const outfitState = readOutfitStateFromQuery(query); + const res = await loadGraphqlQuery({ + query: gql` + query OutfitsNew_GetServerSideProps( + $speciesId: ID! + $colorId: ID! + $pose: Pose! + $wornItemIds: [ID!]! + ) { + petAppearance(speciesId: $speciesId, colorId: $colorId, pose: $pose) { + id + ...PetAppearanceForOutfitPreview + } + items(ids: $wornItemIds) { + id + name + appearanceOn(speciesId: $speciesId, colorId: $colorId) { + ...ItemAppearanceForOutfitPreview + } + } + } + ${petAppearanceFragment} + ${itemAppearanceFragment} + `, + variables: { + speciesId: outfitState.speciesId, + colorId: outfitState.colorId, + pose: outfitState.pose, + wornItemIds: outfitState.wornItemIds, + }, + }); + if (res.errors) { + console.warn( + `[SSR: /outfits/new] Skipping GraphQL preloading, got errors:` + ); + for (const error of res.errors) { + console.warn(`[SSR: /outfits/new]`, error); + } + return { props: { graphqlState: {} } }; + } + + return { props: { graphqlState: res.state } }; +}; + export default WardrobePageWrapper; diff --git a/src/app/WardrobePage/useOutfitState.js b/src/app/WardrobePage/useOutfitState.js index 04c9b2b..f08b957 100644 --- a/src/app/WardrobePage/useOutfitState.js +++ b/src/app/WardrobePage/useOutfitState.js @@ -375,37 +375,41 @@ const EMPTY_CUSTOMIZATION_STATE = { function useParseOutfitUrl() { const { query } = useRouter(); - const { outfitId } = query; // We memoize this to make `outfitStateWithoutExtras` an even more reliable // stable object! - const memoizedOutfitState = React.useMemo(() => { - // For the /outfits/:id page, ignore the query string, and just wait for the - // outfit data to load in! - if (outfitId != null) { - return { - ...EMPTY_CUSTOMIZATION_STATE, - id: outfitId, - }; - } - - // Otherwise, parse the query string, and fill in default values for anything - // not specified. - return { - id: null, - name: getValueFromQuery(query.name), - speciesId: getValueFromQuery(query.species) || "1", - colorId: getValueFromQuery(query.color) || "8", - pose: getValueFromQuery(query.pose) || "HAPPY_FEM", - appearanceId: getValueFromQuery(query.state) || null, - wornItemIds: new Set(getListFromQuery(query["objects[]"])), - closetedItemIds: new Set(getListFromQuery(query["closet[]"])), - }; - }, [outfitId, query]); + const memoizedOutfitState = React.useMemo( + () => readOutfitStateFromQuery(query), + [query] + ); return memoizedOutfitState; } +export function readOutfitStateFromQuery(query) { + // For the /outfits/:id page, ignore the query string, and just wait for the + // outfit data to load in! + if (query.outfitId != null) { + return { + ...EMPTY_CUSTOMIZATION_STATE, + id: query.outfitId, + }; + } + + // Otherwise, parse the query string, and fill in default values for anything + // not specified. + return { + id: null, + name: getValueFromQuery(query.name), + speciesId: getValueFromQuery(query.species) || "1", + colorId: getValueFromQuery(query.color) || "8", + pose: getValueFromQuery(query.pose) || "HAPPY_FEM", + appearanceId: getValueFromQuery(query.state) || null, + wornItemIds: new Set(getListFromQuery(query["objects[]"])), + closetedItemIds: new Set(getListFromQuery(query["closet[]"])), + }; +} + /** * getValueFromQuery reads the given value from Next's `router.query` as a * single value. For example: diff --git a/src/app/apolloClient.js b/src/app/apolloClient.js index 8949327..a1eac60 100644 --- a/src/app/apolloClient.js +++ b/src/app/apolloClient.js @@ -209,9 +209,9 @@ async function getAccessToken(getAuth0) { } } -const initialCache = {}; +const prebuiltCacheState = {}; for (const zone of cachedZones) { - initialCache[`Zone:${zone.id}`] = { __typename: "Zone", ...zone }; + prebuiltCacheState[`Zone:${zone.id}`] = { __typename: "Zone", ...zone }; } const buildLink = (getAuth0) => @@ -228,11 +228,30 @@ const buildLink = (getAuth0) => * apolloClient is the global Apollo Client instance we use for GraphQL * queries. This is how we communicate with the server! */ -const buildClient = (getAuth0) => - new ApolloClient({ +const buildClient = ({ getAuth0, initialCacheState }) => { + // We have both a pre-built cache of data that we just hardcode at build + // time, and the `initialCacheState` parameter, which some SSR'd pages give + // to us as Next.js props, so that we don't have to request the data again. + const mergedCacheState = mergeCacheStates([ + prebuiltCacheState, + initialCacheState, + ]); + + return new ApolloClient({ link: buildLink(getAuth0), - cache: new InMemoryCache({ typePolicies }).restore(initialCache), + cache: new InMemoryCache({ typePolicies }).restore(mergedCacheState), connectToDevTools: true, }); +}; + +function mergeCacheStates(cacheStates) { + const mergedCacheState = {}; + for (const cacheState of cacheStates) { + for (const key of Object.keys(cacheState)) { + mergedCacheState[key] = { ...mergedCacheState[key], ...cacheState[key] }; + } + } + return mergedCacheState; +} export default buildClient; diff --git a/src/server/ssr-graphql.js b/src/server/ssr-graphql.js new file mode 100644 index 0000000..d0391c1 --- /dev/null +++ b/src/server/ssr-graphql.js @@ -0,0 +1,102 @@ +const { InMemoryCache } = require("@apollo/client"); +const { ApolloServer, gql } = require("apollo-server"); +const { config } = require("./index"); + +const server = new ApolloServer(config); + +async function loadGraphqlQuery({ query, variables = {} }) { + // Edit the query to serve our needs, then send a local in-memory request to + // a simple `ApolloServer` instance just for SSR. + const convertedQuery = addTypenameToSelections(removeClientOnlyFields(query)); + const { data, errors } = await server.executeOperation({ + query: convertedQuery, + variables, + }); + + // To get the cache data, we build a new temporary cache object, write this + // query result to it, and dump it out. (Building a normalized cache state is + // really tricky, this simplifies it a lot without bringing in the weight of + // a whole client!) + const cache = new InMemoryCache(); + cache.writeQuery({ query, variables, data }); + const state = cache.extract(); + + // We return the data, errors, and cache state: we figure callers will almost + // always want the errors and state, and may also want the data! + return { data, errors, state }; +} + +/** + * addTypenameToSelections recursively adds __typename to every selection set + * in the query, and returns a copy. This enables us to use the query data to + * populate a cache! + */ +function addTypenameToSelections(node) { + if (node.kind === "SelectionSet") { + return { + ...node, + selections: [ + { + kind: "Field", + name: { + kind: "Name", + value: "__typename", + arguments: [], + directives: [], + }, + }, + ...node.selections.map((s) => addTypenameToSelections(s)), + ], + }; + } else if (node.selectionSet != null) { + return { + ...node, + selectionSet: addTypenameToSelections(node.selectionSet), + }; + } else if (node.kind === "Document") { + return { + ...node, + definitions: node.definitions.map((d) => addTypenameToSelections(d)), + }; + } else { + return node; + } +} + +/** + * removeClientOnlyFields recursively removes any fields marked with `@client` + * in the given GraphQL document node, and returns a new copy. This enables us + * to borrow queries and fragments from the client, and ignore the fields they + * won't need preloaded for SSR. (This isn't just an optimization: the server + * can't handle the `@client` directive and the query will fail if present!) + */ +function removeClientOnlyFields(node) { + if (node.kind === "SelectionSet") { + return { + ...node, + selections: node.selections + .filter( + (selection) => + !( + selection.kind === "Field" && + selection.directives.some((d) => d.name.value === "client") + ) + ) + .map((selection) => removeClientOnlyFields(selection)), + }; + } else if (node.selectionSet != null) { + return { ...node, selectionSet: removeClientOnlyFields(node.selectionSet) }; + } else if (node.kind === "Document") { + return { + ...node, + definitions: node.definitions.map((d) => removeClientOnlyFields(d)), + }; + } else { + return node; + } +} + +module.exports = { + loadGraphqlQuery, + gql, +};