[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!
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";
// @ts-ignore: doesn't understand module.exports
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
// 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 }) {
const WardrobePageWrapper: NextPageWithLayout<PageProps> = ({ outfit }) => {
return (
<>
<Head>
<title>{outfit.name || "Untitled outfit"} | Dress to Impress</title>
<OutfitMetaTags outfit={outfit} />
</Head>
<App {...props} />
<WardrobePage />
</>
);
}
};
function OutfitMetaTags({ outfit }) {
WardrobePageWrapper.renderWithLayout = (children) => children;
function OutfitMetaTags({ outfit }: { outfit: Outfit }) {
const updatedAtTimestamp = Math.floor(
new Date(outfit.updatedAt).getTime() / 1000
);
@ -57,8 +61,13 @@ function OutfitMetaTags({ outfit }) {
);
}
export async function getServerSideProps({ params }) {
const outfit = await loadOutfitData(params.id);
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
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) {
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 [rows] = await db.query(`SELECT * FROM outfits WHERE id = ?;`, [id]);
if (rows.length === 0) {
@ -83,3 +92,5 @@ async function loadOutfitData(id) {
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";
import PageLayout from "./PageLayout";
import WardrobePageLayout from "./WardrobePage/WardrobePageLayout";
import { loadable } from "./util";
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
@ -27,12 +23,6 @@ function App() {
<ScrollToTop />
<Switch>
<Route path="/outfits/new">
<WardrobePage />
</Route>
<Route path="/outfits/:id">
<WardrobePage />
</Route>
<Route path="/">
<PageLayout hideHomeLink>
<HomePage />

View file

@ -10,7 +10,7 @@ import {
useTheme,
} from "@chakra-ui/react";
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
import { Link } from "react-router-dom";
import Link from "next/link";
import { loadable } from "../util";
import {
@ -249,13 +249,13 @@ function ItemActionButton({ icon, label, to, onClick }) {
<ClassNames>
{({ css }) => (
<Tooltip label={label} placement="top">
<IconButton
as={to ? Link : "button"}
<LinkOrButton
component={IconButton}
href={to}
icon={icon}
aria-label={label}
variant="ghost"
color="gray.400"
to={to}
onClick={onClick}
className={css`
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
* components in this to ensure a consistent list layout.

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import React from "react";
import { Prompt } from "react-router-dom";
import { useToast } from "@chakra-ui/react";
import { loadable } from "../util";
@ -11,6 +10,7 @@ import useOutfitState, { OutfitStateContext } from "./useOutfitState";
import { usePageTitle } from "../util";
import WardrobePageLayout from "./WardrobePageLayout";
import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
import { useRouter } from "next/router";
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;

View file

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

View file

@ -2,9 +2,9 @@ import React from "react";
import gql from "graphql-tag";
import produce, { enableMapSet } from "immer";
import { useQuery, useApolloClient } from "@apollo/client";
import { useLocation, useParams } from "react-router-dom";
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
import { useRouter } from "next/router";
enableMapSet();
@ -249,8 +249,13 @@ function useOutfitState() {
};
// 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(() => {
window.history.replaceState(null, "", url);
if (typeof history !== "undefined") {
history.replaceState(null, "", url);
}
}, [url]);
return {
@ -369,39 +374,74 @@ const EMPTY_CUSTOMIZATION_STATE = {
};
function useParseOutfitUrl() {
const { id } = useParams();
const { search } = useLocation();
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 (id != null) {
if (outfitId != null) {
return {
...EMPTY_CUSTOMIZATION_STATE,
id,
id: outfitId,
};
}
// Otherwise, parse the query string, and fill in default values for anything
// not specified.
const urlParams = new URLSearchParams(search);
return {
id: null,
name: urlParams.get("name"),
speciesId: urlParams.get("species") || "1",
colorId: urlParams.get("color") || "8",
pose: urlParams.get("pose") || "HAPPY_FEM",
appearanceId: urlParams.get("state") || null,
wornItemIds: new Set(urlParams.getAll("objects[]")),
closetedItemIds: new Set(urlParams.getAll("closet[]")),
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[]"])),
};
}, [id, search]);
}, [outfitId, query]);
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) {
if (!outfit) {
return EMPTY_CUSTOMIZATION_STATE;
@ -602,7 +642,10 @@ function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
export function buildOutfitUrl(outfitState, { withoutOutfitId = false } = {}) {
const { id } = outfitState;
const { origin } = window.location;
const origin =
typeof window !== "undefined"
? window.location.origin
: "https://impress-2020.openneo.net";
if (id && !withoutOutfitId) {
return origin + `/outfits/${id}`;