[WIP] Migrate outfit page, with known bug

Okay so there's a bug here where navigating directly to /outfits/new?species=X&color=Y will reset to a Blue Acara, because Next.js statically renders the Blue Acara on build, and then rehydrates a Blue Acara on load, and then updates the real page query in—and our state management for outfits doesn't *listen* to URL changes, it only *emits* them.

It'd be good to consider like… changing that? It's tricky because our state model is… not simple, when you consider that we have both local state and URL state and saved-outfit state in play. But it could be done! But there might be another option too. I'll take a look at this after moving the home page, which will give me the chance to see what the experience navigating in from there is like!
This commit is contained in:
Emi Matchu 2022-09-15 00:27:49 -07:00
parent 5d28c36e8a
commit eb602556bf
10 changed files with 167 additions and 86 deletions

View file

@ -2,34 +2,38 @@
// extra SSR for outfit sharing meta tags! // extra SSR for outfit sharing meta tags!
import Head from "next/head"; import Head from "next/head";
import { NextPageWithLayout } from "../_app";
import WardrobePage from "../../src/app/WardrobePage";
// @ts-ignore: doesn't understand module.exports
import connectToDb from "../../src/server/db"; import connectToDb from "../../src/server/db";
// @ts-ignore: doesn't understand module.exports
import { normalizeRow } from "../../src/server/util"; import { normalizeRow } from "../../src/server/util";
import { GetServerSideProps } from "next";
// import NextIndexWrapper from '../../src' type Outfit = {
id: string;
name: string;
updatedAt: string;
};
type PageProps = {
outfit: Outfit;
};
// next/dynamic is used to prevent breaking incompatibilities const WardrobePageWrapper: NextPageWithLayout<PageProps> = ({ outfit }) => {
// with SSR from window.SOME_VAR usage, if this is not used
// next/dynamic can be removed to take advantage of SSR/prerendering
import dynamic from "next/dynamic";
// try changing "ssr" to true below to test for incompatibilities, if
// no errors occur the above static import can be used instead and the
// below removed
const App = dynamic(() => import("../../src/app/App"), { ssr: false });
export default function Page({ outfit, ...props }) {
return ( return (
<> <>
<Head> <Head>
<title>{outfit.name || "Untitled outfit"} | Dress to Impress</title> <title>{outfit.name || "Untitled outfit"} | Dress to Impress</title>
<OutfitMetaTags outfit={outfit} /> <OutfitMetaTags outfit={outfit} />
</Head> </Head>
<App {...props} /> <WardrobePage />
</> </>
); );
} };
function OutfitMetaTags({ outfit }) { WardrobePageWrapper.renderWithLayout = (children) => children;
function OutfitMetaTags({ outfit }: { outfit: Outfit }) {
const updatedAtTimestamp = Math.floor( const updatedAtTimestamp = Math.floor(
new Date(outfit.updatedAt).getTime() / 1000 new Date(outfit.updatedAt).getTime() / 1000
); );
@ -57,8 +61,13 @@ function OutfitMetaTags({ outfit }) {
); );
} }
export async function getServerSideProps({ params }) { export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const outfit = await loadOutfitData(params.id); const outfitId = params?.outfitId;
if (typeof outfitId !== "string") {
throw new Error(`assertion failed: outfitId route param is missing`);
}
const outfit = await loadOutfitData(outfitId);
if (outfit == null) { if (outfit == null) {
return { notFound: true }; return { notFound: true };
} }
@ -72,9 +81,9 @@ export async function getServerSideProps({ params }) {
}, },
}, },
}; };
} };
async function loadOutfitData(id) { async function loadOutfitData(id: string) {
const db = await connectToDb(); const db = await connectToDb();
const [rows] = await db.query(`SELECT * FROM outfits WHERE id = ?;`, [id]); const [rows] = await db.query(`SELECT * FROM outfits WHERE id = ?;`, [id]);
if (rows.length === 0) { if (rows.length === 0) {
@ -83,3 +92,5 @@ async function loadOutfitData(id) {
return normalizeRow(rows[0]); return normalizeRow(rows[0]);
} }
export default WardrobePageWrapper;

View file

@ -1,19 +0,0 @@
// This is just a copy of our higher-level catch-all page.
// That way, /outfits/new renders as normal, but /outfits/:slug
// does the SSR thing!
// import NextIndexWrapper from '../../src'
// next/dynamic is used to prevent breaking incompatibilities
// with SSR from window.SOME_VAR usage, if this is not used
// next/dynamic can be removed to take advantage of SSR/prerendering
import dynamic from "next/dynamic";
// try changing "ssr" to true below to test for incompatibilities, if
// no errors occur the above static import can be used instead and the
// below removed
const App = dynamic(() => import("../../src/app/App"), { ssr: false });
export default function Page(props) {
return <App {...props} />;
}

10
pages/outfits/new.tsx Normal file
View file

@ -0,0 +1,10 @@
import WardrobePage from "../../src/app/WardrobePage";
import type { NextPageWithLayout } from "../_app";
const WardrobePageWrapper: NextPageWithLayout = () => {
return <WardrobePage />;
};
WardrobePageWrapper.renderWithLayout = (children) => children;
export default WardrobePageWrapper;

View file

@ -7,13 +7,9 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import PageLayout from "./PageLayout"; import PageLayout from "./PageLayout";
import WardrobePageLayout from "./WardrobePage/WardrobePageLayout";
import { loadable } from "./util"; import { loadable } from "./util";
const HomePage = loadable(() => import("./HomePage")); const HomePage = loadable(() => import("./HomePage"));
const WardrobePage = loadable(() => import("./WardrobePage"), {
fallback: <WardrobePageLayout />,
});
/** /**
* App is the entry point of our application. There's not a ton of exciting * App is the entry point of our application. There's not a ton of exciting
@ -27,12 +23,6 @@ function App() {
<ScrollToTop /> <ScrollToTop />
<Switch> <Switch>
<Route path="/outfits/new">
<WardrobePage />
</Route>
<Route path="/outfits/:id">
<WardrobePage />
</Route>
<Route path="/"> <Route path="/">
<PageLayout hideHomeLink> <PageLayout hideHomeLink>
<HomePage /> <HomePage />

View file

@ -10,7 +10,7 @@ import {
useTheme, useTheme,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons"; import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
import { Link } from "react-router-dom"; import Link from "next/link";
import { loadable } from "../util"; import { loadable } from "../util";
import { import {
@ -249,13 +249,13 @@ function ItemActionButton({ icon, label, to, onClick }) {
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Tooltip label={label} placement="top"> <Tooltip label={label} placement="top">
<IconButton <LinkOrButton
as={to ? Link : "button"} component={IconButton}
href={to}
icon={icon} icon={icon}
aria-label={label} aria-label={label}
variant="ghost" variant="ghost"
color="gray.400" color="gray.400"
to={to}
onClick={onClick} onClick={onClick}
className={css` className={css`
opacity: 0; opacity: 0;
@ -286,6 +286,19 @@ function ItemActionButton({ icon, label, to, onClick }) {
); );
} }
function LinkOrButton({ href, component = Button, ...props }) {
const ButtonComponent = component;
if (href != null) {
return (
<Link href={href} passHref>
<ButtonComponent as="a" {...props} />
</Link>
);
} else {
return <ButtonComponent {...props} />;
}
}
/** /**
* ItemListContainer is a container for Item components! Wrap your Item * ItemListContainer is a container for Item components! Wrap your Item
* components in this to ensure a consistent list layout. * components in this to ensure a consistent list layout.

View file

@ -35,7 +35,6 @@ import {
WarningTwoIcon, WarningTwoIcon,
} from "@chakra-ui/icons"; } from "@chakra-ui/icons";
import { CSSTransition, TransitionGroup } from "react-transition-group"; import { CSSTransition, TransitionGroup } from "react-transition-group";
import { useHistory } from "react-router-dom";
import { import {
Delay, Delay,
@ -50,6 +49,7 @@ import { IoCloudUploadOutline } from "react-icons/io5";
import { MdMoreVert } from "react-icons/md"; import { MdMoreVert } from "react-icons/md";
import { buildOutfitUrl } from "./useOutfitState"; import { buildOutfitUrl } from "./useOutfitState";
import { gql, useMutation } from "@apollo/client"; import { gql, useMutation } from "@apollo/client";
import { useRouter } from "next/router";
/** /**
* ItemsPanel shows the items in the current outfit, and lets the user toggle * ItemsPanel shows the items in the current outfit, and lets the user toggle
@ -455,7 +455,7 @@ function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
function DeleteOutfitMenuItem({ outfitState }) { function DeleteOutfitMenuItem({ outfitState }) {
const { id, name } = outfitState; const { id, name } = outfitState;
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const history = useHistory(); const { push: pushHistory } = useRouter();
const [sendDeleteOutfitMutation, { loading, error }] = useMutation( const [sendDeleteOutfitMutation, { loading, error }] = useMutation(
gql` gql`
@ -506,7 +506,7 @@ function DeleteOutfitMenuItem({ outfitState }) {
onClick={() => onClick={() =>
sendDeleteOutfitMutation({ variables: { id } }) sendDeleteOutfitMutation({ variables: { id } })
.then(() => { .then(() => {
history.push(`/your-outfits`); pushHistory(`/your-outfits`);
}) })
.catch((e) => { .catch((e) => {
/* handled in error UI */ /* handled in error UI */

View file

@ -33,7 +33,7 @@ import {
SettingsIcon, SettingsIcon,
} from "@chakra-ui/icons"; } from "@chakra-ui/icons";
import { MdPause, MdPlayArrow } from "react-icons/md"; import { MdPause, MdPlayArrow } from "react-icons/md";
import { Link } from "react-router-dom"; import Link from "next/link";
import { getBestImageUrlForLayer } from "../components/OutfitPreview"; import { getBestImageUrlForLayer } from "../components/OutfitPreview";
import HTML5Badge, { layerUsesHTML5 } from "../components/HTML5Badge"; import HTML5Badge, { layerUsesHTML5 } from "../components/HTML5Badge";
@ -318,14 +318,15 @@ function BackButton({ outfitState }) {
outfitState.creator && outfitState.creator.id === currentUser.id; outfitState.creator && outfitState.creator.id === currentUser.id;
return ( return (
<ControlButton <Link href={outfitBelongsToCurrentUser ? "/your-outfits" : "/"} passHref>
as={Link} <ControlButton
to={outfitBelongsToCurrentUser ? "/your-outfits" : "/"} as="a"
icon={<ArrowBackIcon />} icon={<ArrowBackIcon />}
aria-label="Leave this outfit" aria-label="Leave this outfit"
d="inline-flex" // Not sure why <a> requires this to style right! ^^` d="inline-flex" // Not sure why <a> requires this to style right! ^^`
data-test-id="wardrobe-nav-back-button" data-test-id="wardrobe-nav-back-button"
/> />
</Link>
); );
} }

View file

@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import { Prompt } from "react-router-dom";
import { useToast } from "@chakra-ui/react"; import { useToast } from "@chakra-ui/react";
import { loadable } from "../util"; import { loadable } from "../util";
@ -11,6 +10,7 @@ import useOutfitState, { OutfitStateContext } from "./useOutfitState";
import { usePageTitle } from "../util"; import { usePageTitle } from "../util";
import WardrobePageLayout from "./WardrobePageLayout"; import WardrobePageLayout from "./WardrobePageLayout";
import WardrobePreviewAndControls from "./WardrobePreviewAndControls"; import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
import { useRouter } from "next/router";
const WardrobeDevHacks = loadable(() => import("./WardrobeDevHacks")); const WardrobeDevHacks = loadable(() => import("./WardrobeDevHacks"));
@ -119,4 +119,36 @@ function WardrobePage() {
); );
} }
/**
* Prompt blocks client-side navigation via Next.js when the `when` prop is
* true. This is our attempt at a drop-in replacement for the Prompt component
* offered by react-router!
*
* Adapted from https://github.com/vercel/next.js/issues/2694#issuecomment-778225625
*/
function Prompt({ when, message }) {
const router = useRouter();
React.useEffect(() => {
const handleWindowClose = (e) => {
if (!when) return;
e.preventDefault();
return (e.returnValue = message);
};
const handleBrowseAway = () => {
if (!when) return;
if (window.confirm(message)) return;
router.events.emit("routeChangeError");
throw "routeChange aborted by <Prompt>.";
};
window.addEventListener("beforeunload", handleWindowClose);
router.events.on("routeChangeStart", handleBrowseAway);
return () => {
window.removeEventListener("beforeunload", handleWindowClose);
router.events.off("routeChangeStart", handleBrowseAway);
};
}, [when, message, router]);
return null;
}
export default WardrobePage; export default WardrobePage;

View file

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { useToast } from "@chakra-ui/react"; import { useToast } from "@chakra-ui/react";
import { useHistory } from "react-router-dom"; import { useRouter } from "next/router";
import { useDebounce } from "../util"; import { useDebounce } from "../util";
import useCurrentUser from "../components/useCurrentUser"; import useCurrentUser from "../components/useCurrentUser";
import gql from "graphql-tag"; import gql from "graphql-tag";
@ -9,7 +9,7 @@ import { outfitStatesAreEqual } from "./useOutfitState";
function useOutfitSaving(outfitState, dispatchToOutfit) { function useOutfitSaving(outfitState, dispatchToOutfit) {
const { isLoggedIn, id: currentUserId } = useCurrentUser(); const { isLoggedIn, id: currentUserId } = useCurrentUser();
const history = useHistory(); const { pathname, push: pushHistory } = useRouter();
const toast = useToast(); const toast = useToast();
// There's not a way to reset an Apollo mutation state to clear out the error // There's not a way to reset an Apollo mutation state to clear out the error
@ -158,8 +158,8 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
// Navigate to the new saved outfit URL. Our Apollo cache should pick // Navigate to the new saved outfit URL. Our Apollo cache should pick
// up the data from this mutation response, and combine it with the // up the data from this mutation response, and combine it with the
// existing cached data, to make this smooth without any loading UI. // existing cached data, to make this smooth without any loading UI.
if (history.location.pathname !== `/outfits/${outfit.id}`) { if (pathname !== `/outfits/[outfitId]`) {
history.push(`/outfits/${outfit.id}`); pushHistory(`/outfits/${outfit.id}`);
} }
}) })
.catch((e) => { .catch((e) => {
@ -175,7 +175,7 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
// It's important that this callback _doesn't_ change when the outfit // It's important that this callback _doesn't_ change when the outfit
// changes, so that the auto-save effect is only responding to the // changes, so that the auto-save effect is only responding to the
// debounced state! // debounced state!
[sendSaveOutfitMutation, history, toast] [sendSaveOutfitMutation, pathname, pushHistory, toast]
); );
const saveOutfit = React.useCallback( const saveOutfit = React.useCallback(

View file

@ -2,9 +2,9 @@ import React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import produce, { enableMapSet } from "immer"; import produce, { enableMapSet } from "immer";
import { useQuery, useApolloClient } from "@apollo/client"; import { useQuery, useApolloClient } from "@apollo/client";
import { useLocation, useParams } from "react-router-dom";
import { itemAppearanceFragment } from "../components/useOutfitAppearance"; import { itemAppearanceFragment } from "../components/useOutfitAppearance";
import { useRouter } from "next/router";
enableMapSet(); enableMapSet();
@ -249,8 +249,13 @@ function useOutfitState() {
}; };
// Keep the URL up-to-date. (We don't listen to it, though 😅) // Keep the URL up-to-date. (We don't listen to it, though 😅)
// TODO: Seems like we should hook this in with the actual router... I'm
// avoiding it rn, but I'm worried Next.js won't necessarily play nice
// with this hack, even though react-router did. Hard to predict!
React.useEffect(() => { React.useEffect(() => {
window.history.replaceState(null, "", url); if (typeof history !== "undefined") {
history.replaceState(null, "", url);
}
}, [url]); }, [url]);
return { return {
@ -369,39 +374,74 @@ const EMPTY_CUSTOMIZATION_STATE = {
}; };
function useParseOutfitUrl() { function useParseOutfitUrl() {
const { id } = useParams(); const { query } = useRouter();
const { search } = useLocation(); const { outfitId } = query;
// We memoize this to make `outfitStateWithoutExtras` an even more reliable // We memoize this to make `outfitStateWithoutExtras` an even more reliable
// stable object! // stable object!
const memoizedOutfitState = React.useMemo(() => { const memoizedOutfitState = React.useMemo(() => {
// For the /outfits/:id page, ignore the query string, and just wait for the // For the /outfits/:id page, ignore the query string, and just wait for the
// outfit data to load in! // outfit data to load in!
if (id != null) { if (outfitId != null) {
return { return {
...EMPTY_CUSTOMIZATION_STATE, ...EMPTY_CUSTOMIZATION_STATE,
id, id: outfitId,
}; };
} }
// Otherwise, parse the query string, and fill in default values for anything // Otherwise, parse the query string, and fill in default values for anything
// not specified. // not specified.
const urlParams = new URLSearchParams(search);
return { return {
id: null, id: null,
name: urlParams.get("name"), name: getValueFromQuery(query.name),
speciesId: urlParams.get("species") || "1", speciesId: getValueFromQuery(query.species) || "1",
colorId: urlParams.get("color") || "8", colorId: getValueFromQuery(query.color) || "8",
pose: urlParams.get("pose") || "HAPPY_FEM", pose: getValueFromQuery(query.pose) || "HAPPY_FEM",
appearanceId: urlParams.get("state") || null, appearanceId: getValueFromQuery(query.state) || null,
wornItemIds: new Set(urlParams.getAll("objects[]")), wornItemIds: new Set(getListFromQuery(query["objects[]"])),
closetedItemIds: new Set(urlParams.getAll("closet[]")), closetedItemIds: new Set(getListFromQuery(query["closet[]"])),
}; };
}, [id, search]); }, [outfitId, query]);
return memoizedOutfitState; return memoizedOutfitState;
} }
/**
* getValueFromQuery reads the given value from Next's `router.query` as a
* single value. For example:
*
* ?foo=bar -> "bar" -> "bar"
* ?foo=bar&foo=baz -> ["bar", "baz"] -> "bar"
* ?lol=huh -> undefined -> null
*/
function getValueFromQuery(value) {
if (Array.isArray(value)) {
return value[0];
} else if (value != null) {
return value;
} else {
return null;
}
}
/**
* getListFromQuery reads the given value from Next's `router.query` as a list
* of values. For example:
*
* ?foo=bar -> "bar" -> ["bar"]
* ?foo=bar&foo=baz -> ["bar", "baz"] -> ["bar", "baz"]
* ?lol=huh -> undefined -> []
*/
function getListFromQuery(value) {
if (Array.isArray(value)) {
return value;
} else if (value != null) {
return [value];
} else {
return [];
}
}
function getOutfitStateFromOutfitData(outfit) { function getOutfitStateFromOutfitData(outfit) {
if (!outfit) { if (!outfit) {
return EMPTY_CUSTOMIZATION_STATE; return EMPTY_CUSTOMIZATION_STATE;
@ -602,7 +642,10 @@ function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
export function buildOutfitUrl(outfitState, { withoutOutfitId = false } = {}) { export function buildOutfitUrl(outfitState, { withoutOutfitId = false } = {}) {
const { id } = outfitState; const { id } = outfitState;
const { origin } = window.location; const origin =
typeof window !== "undefined"
? window.location.origin
: "https://impress-2020.openneo.net";
if (id && !withoutOutfitId) { if (id && !withoutOutfitId) {
return origin + `/outfits/${id}`; return origin + `/outfits/${id}`;