From 8f5be8de601771dadc72df1b3f9f8d8810b976ae Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 27 Nov 2023 13:16:19 -0700 Subject: [PATCH 1/2] Don't revalidate root if the route is /songs songs may set the URL a lot with a submission. There is no reason to continuously fetch the user / honeypot --- app/root.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/root.tsx b/app/root.tsx index 62fd9b2..dfc952c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -8,6 +8,7 @@ import { Outlet, Scripts, ScrollRestoration, + ShouldRevalidateFunction, useLoaderData, } from "@remix-run/react"; import { HoneypotProvider } from "remix-utils/honeypot/react"; @@ -29,6 +30,16 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }); }; +export const shouldRevalidate: ShouldRevalidateFunction = ({ + nextUrl, + defaultShouldRevalidate, +}) => { + if (nextUrl?.pathname === "/songs") { + // lot of changing of URL here and we don't need extra requests for the user/honeypot + return false; + } else return defaultShouldRevalidate; +}; + export default function App() { const { honeypotInputProps } = useLoaderData(); return ( From bac668a8fed0575ae2297f138b4982a03b4e09b2 Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 27 Nov 2023 13:20:51 -0700 Subject: [PATCH 2/2] Fix client side maintaining of song list - utilize the URLSearchParams without triggering loader - debounce the textual input so we don't have a history entry for each keypress - use navigate(-1) over links to preserve history --- app/components/filter-menu.tsx | 28 ++++---- app/components/mobile-song-list.tsx | 11 ++-- app/routes/songs.$songId.tsx | 14 ++-- app/routes/songs._index.tsx | 99 +++++++++++++++++++++++------ 4 files changed, 104 insertions(+), 48 deletions(-) diff --git a/app/components/filter-menu.tsx b/app/components/filter-menu.tsx index 596c068..6cfbbaa 100644 --- a/app/components/filter-menu.tsx +++ b/app/components/filter-menu.tsx @@ -1,26 +1,22 @@ import * as Ariakit from "@ariakit/react"; -import { useSearchParams } from "@remix-run/react"; -import { useRef, useState } from "react"; +import { useRef } from "react"; import { FiFilter } from "react-icons/fi/index.js"; -export default function FilterMenu() { - const [searchParams, setSearchParams] = useSearchParams(); +export default function FilterMenu({ + incomplete, + setIncomplete, + handleSubmit, +}: { + incomplete: boolean; + setIncomplete: React.Dispatch>; + handleSubmit: ({ incompleteArg }: { incompleteArg?: boolean }) => void; +}) { const filterRef = useRef(null); - const [incomplete, setIncomplete] = useState( - searchParams.get("filter") === "incomplete", - ); const handleClick = (e: React.MouseEvent) => { e.preventDefault(); - setSearchParams((prev) => { - if (!incomplete) { - prev.set("filter", "incomplete"); - } else { - prev.delete("filter"); - } - return prev; - }); setIncomplete((prev) => !prev); + handleSubmit({ incompleteArg: !incomplete }); }; return ( @@ -28,7 +24,7 @@ export default function FilterMenu() { {incomplete ? ( -
+
1
) : null} diff --git a/app/components/mobile-song-list.tsx b/app/components/mobile-song-list.tsx index ef367d6..0fe9444 100644 --- a/app/components/mobile-song-list.tsx +++ b/app/components/mobile-song-list.tsx @@ -1,21 +1,20 @@ import type { Song } from "@prisma/client"; -import { Link } from "@remix-run/react"; +import { useNavigate } from "@remix-run/react"; export default function MobileSongList({ songListItems, - q, }: { songListItems: Pick< Song, "id" | "title" | "artist" | "danceChoreographer" | "danceName" >[]; - q?: string; }) { + const navigate = useNavigate(); return (
{songListItems.map((song, index) => ( - navigate(-1)} key={song.id} className={`flex flex-col border-gray-200 ${ index !== songListItems.length - 1 @@ -27,7 +26,7 @@ export default function MobileSongList({
{song.title}
{song.artist}
- + ))}
); diff --git a/app/routes/songs.$songId.tsx b/app/routes/songs.$songId.tsx index 7d8a59c..e306313 100644 --- a/app/routes/songs.$songId.tsx +++ b/app/routes/songs.$songId.tsx @@ -6,8 +6,8 @@ import { isRouteErrorResponse, useActionData, useLoaderData, + useNavigate, useRouteError, - useSearchParams, } from "@remix-run/react"; import { useEffect } from "react"; import { BsArrowLeft, BsPencil, BsYoutube } from "react-icons/bs/index.js"; @@ -55,7 +55,7 @@ export const action = async ({ params, request }: ActionFunctionArgs) => { export default function SongDetailsPage() { const { song } = useLoaderData(); const user = useOptionalUser(); - const [searchParams] = useSearchParams(); + const navigate = useNavigate(); const result = useActionData(); useEffect(() => { @@ -72,16 +72,14 @@ export default function SongDetailsPage() {
- navigate(-1)} > - Songs - + Back to Songs + {user?.isAdmin ? ( { - const url = new URL(request.url); - const q = url.searchParams.get("q") ?? ""; - +export const loader = async () => { const songListItems = await getSongListItems(); - return json({ songListItems, q }); + return json({ songListItems }); }; +// don't revalidate because we don't want to refetch the song list +// all filtering/searching/sorting will be client side +export const shouldRevalidate = () => false; + export default function SongsPage() { - const { q, songListItems } = useLoaderData(); + const { songListItems } = useLoaderData(); const user = useOptionalUser(); const inputRef = useRef(null); const theadRef = useRef(null); - const [search, setSearch] = useState(q); const [searchParams] = useSearchParams(); + const [search, setSearch] = useState(searchParams.get("q") ?? ""); + const [incomplete, setIncomplete] = useState( + searchParams.get("filter") === "incomplete", + ); + const submit = useSubmit(); + const timeoutRef = useRef(null); useEffect(() => { const thead = theadRef.current as HTMLTableSectionElement; @@ -43,13 +54,63 @@ export default function SongsPage() { }; }, [theadRef]); + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [timeoutRef]); + + const q = searchParams.get("q"); + const incompleteFilter = searchParams.get("filter"); + + // update the UI to match the URL SearchParams (e.g., seen on back button) + // this does fire superflously when user types in the search box + // but it's a necessary tradeoff afaict to keep state in sync with URL + useEffect(() => { + setSearch(q || ""); + }, [q]); + + useEffect(() => { + setIncomplete(incompleteFilter === "incomplete"); + }, [incompleteFilter]); + + const handleSubmit = ({ + incompleteArg, + searchArg, + debounce = false, + }: { + incompleteArg?: boolean; + searchArg?: string; + debounce?: boolean; + } = {}) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + const q = typeof searchArg === "string" ? searchArg : searchParams.get("q"); + const filter = + typeof incompleteArg === "boolean" + ? incompleteArg + : searchParams.get("filter"); + + const formData = new FormData(); + if (q) { + formData.set("q", q); + } + if (filter) { + formData.set("filter", "incomplete"); + } + if (debounce) { + timeoutRef.current = setTimeout(() => submit(formData), 300); + } else { + submit(formData); + } + }; + const handleClear = () => { setSearch(""); inputRef.current?.focus(); + handleSubmit({ searchArg: "" }); }; - const incompleteFilter = searchParams.get("filter"); - const filteredSongItems = useMemo(() => { let list = songListItems; if (incompleteFilter) { @@ -71,7 +132,10 @@ export default function SongsPage() { setSearch(e.target.value)} + onChange={(e) => { + setSearch(e.target.value); + handleSubmit({ searchArg: e.target.value, debounce: true }); + }} aria-label="Search songs" type="search" placeholder="Search" @@ -99,10 +163,11 @@ export default function SongsPage() { ) : null}
+
-
- +
+
{filteredSongItems.length === 0 ? ( @@ -116,7 +181,7 @@ export default function SongsPage() {
) : ( <> - + 30 ? song.title : "" }`} > - - {song.title} - + {song.title}