From 78a9f237f83a3bb67a8b5c5e2a2e8a88026f11e6 Mon Sep 17 00:00:00 2001 From: Juan Andrade <78118656+Dosbodoke@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:35:52 -0800 Subject: [PATCH] Bump to React 19 and Next 15, implement NUQS as also to manage query params (#49) * Enable static rendering for festival and home page * Bump to Next 15 and React 19 * Update React to stable version --- app/[locale]/Providers.tsx | 46 +- .../[id]/_components/highline-tabs.tsx | 50 +- app/[locale]/[id]/page.tsx | 15 +- app/[locale]/_components/HighlineList.tsx | 13 +- app/[locale]/_components/search.tsx | 16 +- app/[locale]/auth/callback/route.ts | 4 +- .../festival/_components/festival-tabs.tsx | 5 +- app/[locale]/festival/page.tsx | 21 +- app/[locale]/layout.tsx | 36 +- app/[locale]/page.tsx | 47 +- .../[username]/_components/LastWalks.tsx | 6 +- .../_components/WalkActivityCalendar.tsx | 21 +- app/[locale]/profile/[username]/page.tsx | 24 +- app/actions/getHighline.ts | 2 +- components/CreateHighline.tsx | 8 +- components/Map/Controls.tsx | 23 +- components/Map/LocationPicker.tsx | 8 +- components/Map/Map.tsx | 2 +- components/Map/MapToggle.tsx | 7 +- components/Map/Markers.tsx | 10 +- components/Map/Selected.tsx | 6 +- components/Ranking/CategoryDropdown.tsx | 6 +- components/Ranking/index.tsx | 15 +- components/layout/navbar/index.tsx | 6 +- components/ui/NumberPicker.tsx | 2 +- hooks/useQueryString.ts | 49 - package.json | 35 +- public/sw.js | 4 +- utils/supabase/server.ts | 3 +- utils/useLoginModal.ts | 23 +- yarn.lock | 2233 ++++++++++++----- 31 files changed, 1785 insertions(+), 961 deletions(-) delete mode 100644 hooks/useQueryString.ts diff --git a/app/[locale]/Providers.tsx b/app/[locale]/Providers.tsx index 33dba08..aab74d8 100644 --- a/app/[locale]/Providers.tsx +++ b/app/[locale]/Providers.tsx @@ -3,10 +3,10 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { SpeedInsights } from "@vercel/speed-insights/next"; -import { useSearchParams } from "next/navigation"; import { type AbstractIntlMessages, NextIntlClientProvider } from "next-intl"; import { ThemeProvider } from "next-themes"; -import type { ReactNode } from "react"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { Suspense, type ReactNode } from "react"; import { Toaster } from "@/components/ui/sonner"; import { getQueryClient } from "@/lib/query"; @@ -18,33 +18,29 @@ interface Props { } function Providers({ locale, messages, children }: Props) { - const searchParams = useSearchParams(); const queryClient = getQueryClient(); const now = new Date(); - const forcedThemeFromSearchParams = - searchParams.get("view") === "map" ? "light" : undefined; - return ( - - - - - - {children} - - - - + + + + + + + + {children} + + + + + + ); } diff --git a/app/[locale]/[id]/_components/highline-tabs.tsx b/app/[locale]/[id]/_components/highline-tabs.tsx index c8cbe96..6a4ea0c 100644 --- a/app/[locale]/[id]/_components/highline-tabs.tsx +++ b/app/[locale]/[id]/_components/highline-tabs.tsx @@ -1,14 +1,12 @@ "use client"; -import { motion } from "framer-motion"; -import { useSearchParams } from "next/navigation"; +import { motion } from "motion/react"; import { useTranslations } from "next-intl"; -import { useMemo } from "react"; +import { useQueryState } from "nuqs"; import type { Highline } from "@/app/actions/getHighline"; import { Ranking } from "@/components/Ranking"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useQueryString } from "@/hooks/useQueryString"; import Comments from "./Comments"; import Info from "./Info"; @@ -19,38 +17,34 @@ interface Props { export const HighlineTabs = ({ highline }: Props) => { const t = useTranslations("highline.tabs"); - const searchParams = useSearchParams(); - const { replaceQueryParam } = useQueryString(); + const [tab, setTab] = useQueryState("tab"); - const selectedTab = searchParams.get("tab") || "info"; + const selectedTab = tab || "info"; - const tabs = useMemo( - () => [ - { - id: "info", - label: t("informations.label"), - content: , - }, - { - id: "comments", - label: t("comments"), - content: , - }, - { - id: "ranking", - label: "Ranking", - content: , - }, - ], - [t, highline] - ); + const tabs = [ + { + id: "info", + label: t("informations.label"), + content: , + }, + { + id: "comments", + label: t("comments"), + content: , + }, + { + id: "ranking", + label: "Ranking", + content: , + }, + ]; return ( <> { - replaceQueryParam("tab", value); + setTab(value); }} > diff --git a/app/[locale]/[id]/page.tsx b/app/[locale]/[id]/page.tsx index e2dbc6e..c05cd42 100644 --- a/app/[locale]/[id]/page.tsx +++ b/app/[locale]/[id]/page.tsx @@ -8,8 +8,8 @@ import HighlineCard from "./_components/HighlineCard"; export const dynamic = "force-dynamic"; type Props = { - params: { id: string }; - searchParams: { [key: string]: string | undefined }; + params: Promise<{ id: string }>; + searchParams: Promise<{ [key: string]: string | undefined }>; }; const getHigh = cache(async ({ id }: { id: string }) => { @@ -19,10 +19,13 @@ const getHigh = cache(async ({ id }: { id: string }) => { return result.data; }); -export default async function Highline({ - params: { id }, - searchParams, -}: Props) { +export default async function Highline(props: Props) { + const params = await props.params; + + const { + id + } = params; + const highlines = await getHigh({ id }); if (!highlines || highlines.length === 0) return notFound(); diff --git a/app/[locale]/_components/HighlineList.tsx b/app/[locale]/_components/HighlineList.tsx index 960eede..71bbdcc 100644 --- a/app/[locale]/_components/HighlineList.tsx +++ b/app/[locale]/_components/HighlineList.tsx @@ -1,8 +1,8 @@ "use client"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { motion } from "framer-motion"; -import { useSearchParams } from "next/navigation"; +import { motion } from "motion/react"; +import { useQueryState } from "nuqs"; import { getHighline } from "@/app/actions/getHighline"; @@ -12,13 +12,16 @@ import { HighlineListSkeleton } from "./HighlineListSkeleton"; const PAGE_SIZE = 6; export function HighlineList() { - const searchParams = useSearchParams(); - const searchValue = searchParams.get("q") || ""; + const [searchValue = ""] = useQueryState("q"); const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery({ queryKey: ["highlines", { searchValue }], queryFn: ({ pageParam }) => - getHighline({ pageParam, searchValue, pageSize: PAGE_SIZE }), + getHighline({ + pageParam, + searchValue: searchValue ?? undefined, + pageSize: PAGE_SIZE, + }), initialPageParam: 1, getNextPageParam: (lastPage, pages) => { const nextPage = pages.length + 1; diff --git a/app/[locale]/_components/search.tsx b/app/[locale]/_components/search.tsx index 7a0dffa..eca724e 100644 --- a/app/[locale]/_components/search.tsx +++ b/app/[locale]/_components/search.tsx @@ -2,22 +2,22 @@ import { SearchIcon } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useQueryState } from "nuqs"; import { Input } from "@/components/ui/input"; -import { useQueryString } from "@/hooks/useQueryString"; export default function Search() { const t = useTranslations("home"); - const { searchParams, pushQueryParam, deleteQueryParam } = useQueryString(); + const [search, setSearch] = useQueryState("q"); function onSubmit(e: React.FormEvent) { e.preventDefault(); const val = e.target as HTMLFormElement; - const search = val.search as HTMLInputElement; - if (search.value) { - pushQueryParam("q", search.value); + const searchInput = val.search as HTMLInputElement; + if (searchInput.value) { + setSearch(searchInput.value); } else { - deleteQueryParam("q"); + setSearch(null); } } @@ -27,12 +27,12 @@ export default function Search() { diff --git a/app/[locale]/auth/callback/route.ts b/app/[locale]/auth/callback/route.ts index 16fd5b2..ad6203a 100644 --- a/app/[locale]/auth/callback/route.ts +++ b/app/[locale]/auth/callback/route.ts @@ -1,7 +1,6 @@ // Refer to the following documentation for more context // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange -import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import { useSupabaseServer } from "@/utils/supabase/server"; @@ -16,9 +15,8 @@ export async function GET(request: Request) { const redirectTo = requestUrl.searchParams.get("redirect_to"); if (code) { - const cookieStore = cookies(); // eslint-disable-next-line react-hooks/rules-of-hooks - const supabase = useSupabaseServer(cookieStore); + const supabase = await useSupabaseServer(); await supabase.auth.exchangeCodeForSession(code); } diff --git a/app/[locale]/festival/_components/festival-tabs.tsx b/app/[locale]/festival/_components/festival-tabs.tsx index 4504de1..4dc7e99 100644 --- a/app/[locale]/festival/_components/festival-tabs.tsx +++ b/app/[locale]/festival/_components/festival-tabs.tsx @@ -1,4 +1,3 @@ -import { cookies } from "next/headers"; import React from "react"; import { getHighline } from "@/app/actions/getHighline"; @@ -9,8 +8,8 @@ import { useSupabaseServer } from "@/utils/supabase/server"; import { Highline } from "../../_components/Highline"; export const FestivalTabs = async () => { - const cookieStore = cookies(); - const supabase = useSupabaseServer(cookieStore); + // eslint-disable-next-line react-hooks/rules-of-hooks + const supabase = await useSupabaseServer(); const { data: sectors } = await supabase .from("sector") diff --git a/app/[locale]/festival/page.tsx b/app/[locale]/festival/page.tsx index f230622..bf295c7 100644 --- a/app/[locale]/festival/page.tsx +++ b/app/[locale]/festival/page.tsx @@ -1,21 +1,26 @@ +import { Loader2 } from "lucide-react"; import Image from "next/image"; import { useTranslations } from "next-intl"; -import { Suspense } from "react"; +import { unstable_setRequestLocale } from "next-intl/server"; +import { Suspense, use } from "react"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { FestivalTabs } from "./_components/festival-tabs"; -import { Loader2 } from "lucide-react"; type Props = { - params: { locale: string; username: string }; - searchParams: { [key: string]: string | undefined }; + params: Promise<{ locale: string; username: string }>; + searchParams: Promise<{ [key: string]: string | undefined }>; }; -export default function Festival({ - params: { username }, - searchParams, -}: Props) { +export default function Festival(props: Props) { + const params = use(props.params); + + const { + locale + } = params; + + unstable_setRequestLocale(locale); const t = useTranslations("festival"); return ( diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 54931f3..e28f95d 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -5,8 +5,10 @@ import { GeistSans } from "geist/font/sans"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { useMessages } from "next-intl"; +import { unstable_setRequestLocale } from "next-intl/server"; +import { use } from "react"; -import Footer from "@/components/Footer"; +// import Footer from "@/components/Footer"; import NavBar from "@/components/layout/navbar"; import { locales } from "@/navigation"; @@ -59,13 +61,27 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ - children, - params: { locale }, -}: { - children: React.ReactNode; - params: { locale: "en" | "pt" }; -}) { +export function generateStaticParams() { + return locales.map((locale) => ({ locale })); +} + +export default function RootLayout( + props: { + children: React.ReactNode; + params: Promise<{ locale: "en" | "pt" }>; + } +) { + const params = use(props.params); + + const { + locale + } = params; + + const { + children + } = props; + + unstable_setRequestLocale(locale); // Validate that the incoming `locale` parameter is valid if (!locales.includes(locale)) notFound(); const messages = useMessages(); @@ -73,7 +89,7 @@ export default function RootLayout({ return ( // suppressHydrationWarning because of `next-themes` // refer to https://github.com/pacocoursey/next-themes#with-app - + (
@@ -88,6 +104,6 @@ export default function RootLayout({ - + ) ); } diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 7948af2..a9c4dc0 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,11 +1,9 @@ -import { - dehydrate, - HydrationBoundary, - QueryClient, -} from "@tanstack/react-query"; +"use client"; + import dynamic from "next/dynamic"; +import { useLocale } from "next-intl"; +import { useQueryState } from "nuqs"; -import { getHighline } from "@/app/actions/getHighline"; import CreateHighline from "@/components/CreateHighline"; import MapToggle from "@/components/Map/MapToggle"; @@ -13,8 +11,6 @@ import { HeroPromoCard } from "./_components/hero-promo-card"; import { HighlineList } from "./_components/HighlineList"; import Search from "./_components/search"; -const PAGE_SIZE = 6; - const Map = dynamic(() => import("@/components/Map/Map"), { ssr: false, loading: () => ( @@ -39,30 +35,15 @@ const Map = dynamic(() => import("@/components/Map/Map"), { ), }); -export default async function Home({ - params: { locale }, - searchParams, -}: { - params: { locale: "en" | "pt" }; - searchParams: { [key: string]: string | undefined }; -}) { - const mapOpen = searchParams["view"] === "map"; - const location = searchParams["location"] || null; - const focusedMarker = searchParams["focusedMarker"]; +export default function Home() { + const locale = useLocale(); + + const [view] = useQueryState("view"); + const [location] = useQueryState("location"); + const [focusedMarker] = useQueryState("focusedMarker"); + + const mapOpen = view === "map"; const isPickingLocation = location === "picking"; - const searchValue = searchParams["q"] || ""; - const queryClient = new QueryClient(); - await queryClient.prefetchInfiniteQuery({ - queryKey: ["highlines", { searchValue }], - queryFn: ({ pageParam }) => - getHighline({ pageParam, searchValue, pageSize: PAGE_SIZE }), - initialPageParam: 1, - getNextPageParam: (lastPage, pages) => { - const nextPage = pages.length + 1; - return lastPage.data?.length === PAGE_SIZE ? nextPage : undefined; - }, - pages: 2, - }); return (
@@ -76,9 +57,7 @@ export default async function Home({ <> - - - + )} { - const searchParams = useSearchParams(); - const router = useRouter(); - const pathname = usePathname(); - - // Get a new searchParams string by merging the current - // searchParams with a provided key/value pair - const createQueryString = React.useCallback( - (name: string, value: string) => { - const params = new URLSearchParams(searchParams.toString()); - params.set(name, value); - - return params.toString(); - }, - [searchParams] - ); + const [year, setYear] = useQueryState("year"); function handleYearChange(year: string) { - router.push(pathname + "?" + createQueryString("year", year)); + setYear(year); } return ( diff --git a/app/[locale]/profile/[username]/page.tsx b/app/[locale]/profile/[username]/page.tsx index 49cfde9..055c4e0 100644 --- a/app/[locale]/profile/[username]/page.tsx +++ b/app/[locale]/profile/[username]/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import { cookies } from "next/headers"; import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; @@ -14,14 +13,12 @@ import UserNotFound from "./_components/UserNotFound"; export const dynamic = "force-dynamic"; type Props = { - params: { locale: string; username: string }; - searchParams: { [key: string]: string | undefined }; + params: Promise<{ locale: string; username: string }>; + searchParams: Promise<{ [key: string]: string | undefined }>; }; -export async function generateMetadata({ - params, - searchParams, -}: Props): Promise { +export async function generateMetadata(props: Props): Promise { + const params = await props.params; const t = await getTranslations("profileMetadata"); return { title: t("title", { username: `@${params.username}` }), @@ -29,12 +26,13 @@ export async function generateMetadata({ }; } -export default async function Profile({ - params: { username }, - searchParams, -}: Props) { - const cookieStore = cookies(); - const supabase = useSupabaseServer(cookieStore); +export default async function Profile(props: Props) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const supabase = await useSupabaseServer(); + + const searchParams = await props.searchParams; + const params = await props.params; + const { username } = params; const result = await Promise.all([ supabase.auth.getUser(), diff --git a/app/actions/getHighline.ts b/app/actions/getHighline.ts index 9d8bbdb..80a772a 100644 --- a/app/actions/getHighline.ts +++ b/app/actions/getHighline.ts @@ -20,7 +20,7 @@ export const getHighline = async ({ pageSize, id, }: Props) => { - const cookieStore = cookies(); + const cookieStore = await cookies(); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, diff --git a/components/CreateHighline.tsx b/components/CreateHighline.tsx index 5ac98e2..a4d6b0d 100644 --- a/components/CreateHighline.tsx +++ b/components/CreateHighline.tsx @@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query"; import type { LatLng } from "leaflet"; import { PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useQueryState } from "nuqs"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { v4 as uuidv4 } from "uuid"; @@ -22,7 +23,6 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; -import { useQueryString } from "@/hooks/useQueryString"; import { Link } from "@/navigation"; import { decodeLocation, @@ -85,20 +85,20 @@ const CreateHighline = ({ location: string | null; hidden?: boolean; }) => { - const { pushQueryParam, deleteQueryParam } = useQueryString(); + const [locationParam, setLocationParam] = useQueryState("location"); const [open, setOpen] = useState(false); function handleToggleDrawer(open: boolean) { // If user is on map, set location query parameter to picking so he can set the anchors if (mapIsOpen && !location) { - pushQueryParam("location", "picking"); + setLocationParam("picking"); return; } // If user is picking location don't open the Drawer if (location === "picking") return; // If there is a location setted and he is closing the drawer, reset the location if (open === false && location) { - deleteQueryParam("location"); + setLocationParam(null); } setOpen(open); } diff --git a/components/Map/Controls.tsx b/components/Map/Controls.tsx index f074873..e66d6de 100644 --- a/components/Map/Controls.tsx +++ b/components/Map/Controls.tsx @@ -8,6 +8,7 @@ import { SatelliteIcon, } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useQueryState } from "nuqs"; import { useState } from "react"; import { TileLayer, useMapEvents } from "react-leaflet"; @@ -16,18 +17,16 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { useQueryString } from "@/hooks/useQueryString"; -export const MapControls = ({ locale }: { locale: string }) => { +export const MapControls = () => { const t = useTranslations("map.type"); - const { pushQueryParam, searchParams } = useQueryString(); + const [mapType, setMapType] = useQueryState("mapType"); const acceptedTypes = ["map", "satelite"] as const; - const mapType: (typeof acceptedTypes)[number] = acceptedTypes.includes( - // @ts-expect-error - searchParams.get("mapType") || "" + const currentMapType: (typeof acceptedTypes)[number] = acceptedTypes.includes( + mapType as (typeof acceptedTypes)[number] ) - ? (searchParams.get("mapType") as (typeof acceptedTypes)[number]) + ? (mapType as (typeof acceptedTypes)[number]) : "map"; const [isLocated, setIsLocated] = useState(false); @@ -43,7 +42,7 @@ export const MapControls = ({ locale }: { locale: string }) => { return ( <> - {mapType === "satelite" ? ( + {currentMapType === "satelite" ? ( Mapbox © OpenStreetMap Improve this map`} url={`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/{z}/{x}/{y}?access_token=${process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}`} @@ -82,8 +81,8 @@ export const MapControls = ({ locale }: { locale: string }) => {