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, +};