Show Top Progress Bar on navigation initiated using useRouter hook (#4097)

## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates Next.js navigation usage in the dashboard app to `useDashboardRouter` from `@/lib/DashboardRouter`.

### Detailed summary
- Replaces `useRouter` with `useDashboardRouter` in multiple files
- Adds `DashboardRouterTopProgressBar` component import in layout files
- Introduces `useDashboardRouter` import in various components
- Defines `createStore` function in `@/lib/reactive.ts`

> The following files were skipped due to too many changes: `apps/dashboard/src/@/lib/DashboardRouter.tsx`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
This commit is contained in:
MananTank
2024-08-15 05:48:22 +00:00
parent 1edd6c7e4c
commit 09e4d80b89
15 changed files with 228 additions and 23 deletions
+6
View File
@@ -80,6 +80,12 @@ module.exports = {
message:
"Import from `@chakra-ui/react` instead of `@chakra-ui/menu`.",
},
{
name: "next/navigation",
importNames: ["useRouter"],
message:
'Use `import { useDashboardRouter } from "@/lib/DashboardRouter";` instead',
},
],
},
],
@@ -0,0 +1,139 @@
"use client";
// eslint-disable-next-line no-restricted-imports
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
Suspense,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { createStore } from "./reactive";
// Using useDashboardRouter instead of useRouter gives us a nice progress bar on top of the page when navigating using router.push or router.replace
// using a store instead of context to avoid triggering re-renders on root component
export const LoadingRouteHref = createStore<string | undefined>(undefined);
export function useDashboardRouter() {
const router = useRouter();
return useMemo(() => {
return {
...router,
replace(href: string, options?: { scroll?: boolean }) {
LoadingRouteHref.setValue(href);
router.replace(href, options);
},
push(href: string, options?: { scroll?: boolean }) {
LoadingRouteHref.setValue(href);
router.push(href, options);
},
};
}, [router]);
}
function useRouterLoadingStatus() {
const loadingHref = useSyncExternalStore(
LoadingRouteHref.subscribe,
LoadingRouteHref.getValue,
LoadingRouteHref.getValue,
);
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParamsStr = searchParams?.toString();
const routerHref = pathname + (searchParamsStr ? `?${searchParamsStr}` : "");
const isLoading = loadingHref && loadingHref !== routerHref;
// reset loading on route load
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!isLoading) {
LoadingRouteHref.setValue(undefined);
}
}, [isLoading]);
return isLoading;
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Render this on root
function DashboardRouterTopProgressBarInner() {
const isLoading = useRouterLoadingStatus();
const [progress, setProgress] = useState(0);
const progressStartedRef = useRef(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!isLoading) {
setProgress(0);
progressStartedRef.current = false;
return;
}
if (progressStartedRef.current) {
return;
}
let isMounted = true;
progressStartedRef.current = true;
async function updateProgressBar(progress: number, delay: number) {
if (!isMounted) {
if (!isLoading) {
setProgress(100);
}
return;
}
setProgress(progress);
await wait(delay);
// increase progress by 10% of the remaining progress
// increase the delay by 5% to slow down number of updates for slower progress
updateProgressBar(progress + (100 - progress) / 10, delay * 1.05);
}
async function startEffect() {
// if the loading state remains for atleast 500ms start the progress bar
await wait(500);
if (isMounted) {
updateProgressBar(0, 100);
}
}
startEffect();
return () => {
isMounted = false;
};
}, [isLoading]);
const width = isLoading ? progress : 100;
return (
<span
className="block fixed top-0 h-[2px] bg-blue-500"
style={{
opacity: isLoading ? "100" : "0",
width: `${width}%`,
transition: width === 0 ? "none" : "width 0.2s ease, opacity 0.3s ease",
zIndex: "100000000",
pointerEvents: "none",
}}
/>
);
}
// need to wrap with suspense because of useSearchParams usage
export function DashboardRouterTopProgressBar() {
return (
<Suspense fallback={null}>
<DashboardRouterTopProgressBarInner />
</Suspense>
);
}
+47
View File
@@ -0,0 +1,47 @@
export type Store<T> = {
getValue(): T;
setValue(newValue: T): void;
subscribe(listener: () => void): () => void;
};
/**
* Create a reactive value store
* @param initialValue - The initial value to store
* @example
* ```ts
* const store = createStore(0);
* ```
* @returns A store object
* @internal
*/
export function createStore<T>(initialValue: T): Store<T> {
type Listener = () => void;
const listeners = new Set<Listener>();
let value = initialValue;
const notify = () => {
for (const listener of listeners) {
listener();
}
};
return {
getValue() {
return value;
},
setValue(newValue: T) {
if (newValue === value) {
return;
}
value = newValue;
notify();
},
subscribe(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
@@ -1,7 +1,8 @@
import { isLoginRequired } from "@/constants/auth";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useQuery } from "@tanstack/react-query";
import type { EnsureLoginResponse } from "app/api/auth/ensure-login/route";
import { usePathname, useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useRef } from "react";
import {
useActiveAccount,
@@ -20,7 +21,7 @@ export function useLoggedInUser(): {
isLoggedIn: boolean;
user: { address: string; jwt?: string } | null;
} {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const connectedAddress = useActiveAccount()?.address;
const connectionStatus = useActiveWalletConnectionStatus();
@@ -10,8 +10,9 @@ import {
} from "@/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { ChevronDownIcon, Filter, XIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react";
import { products } from "../../../components/server/products";
@@ -73,7 +74,7 @@ type FilterResetButtonProps = {
const FilterResetButton: React.FC<
PropsWithChildren<FilterResetButtonProps>
> = ({ filters, children }) => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const mutableSearchParams = useMemo(() => {
@@ -124,7 +125,7 @@ type ChainTypeFilterProps = { sectionOnly?: boolean };
export const ChainTypeFilter: React.FC<ChainTypeFilterProps> = ({
sectionOnly,
}) => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -228,7 +229,7 @@ type ChainOptionsFilterProps = { sectionOnly?: boolean };
export const ChainOptionsFilter: React.FC<ChainOptionsFilterProps> = ({
sectionOnly,
}) => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const mutableSearchParams = useMemo(() => {
@@ -332,7 +333,7 @@ type ChainServiceFilterProps = { sectionOnly?: boolean };
export const ChainServiceFilter: React.FC<ChainServiceFilterProps> = ({
sectionOnly,
}) => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const mutableSearchParams = useMemo(() => {
@@ -1,7 +1,8 @@
"use client";
import { PaginationButtons } from "@/components/pagination-buttons";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
type ChainlistPaginationProps = {
@@ -15,7 +16,7 @@ export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const router = useDashboardRouter();
const createPageURL = useCallback(
(pageNumber: number) => {
@@ -2,8 +2,9 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { SearchIcon, XCircleIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { useRef } from "react";
import { useDebouncedCallback } from "use-debounce";
@@ -15,7 +16,7 @@ function cleanUrl(url: string) {
}
export const SearchInput: React.FC = () => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -1,8 +1,9 @@
"use client";
import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { Grid2X2Icon, ListIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
type hainListViewProps = {
@@ -12,7 +13,7 @@ type hainListViewProps = {
export const ChainListView: React.FC<hainListViewProps> = ({ activeView }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const router = useDashboardRouter();
const createPageURL = useCallback(
(view: "grid" | "table") => {
@@ -15,11 +15,11 @@ import { ImageUpload } from "@/components/ui/image-upload";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -49,7 +49,7 @@ export function CreateEcosystemForm() {
>();
const { data: billingAccountInfo } = useAccount();
const router = useRouter();
const router = useDashboardRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -6,10 +6,11 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { usePathname, useSearchParams } from "next/navigation";
export function TablePagination() {
const router = useRouter();
const router = useDashboardRouter();
const path = usePathname();
const searchParams = useSearchParams();
const page = Number(searchParams?.get("page") || 1);
@@ -1,7 +1,8 @@
"use client";
import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { ChevronDown } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import type { SortBy, TimeRange } from "../../../../lib/search";
export type SortingHeaderProps = {
@@ -13,7 +14,7 @@ export type SortingHeaderProps = {
};
export function SortingHeader(props: SortingHeaderProps) {
const router = useRouter();
const router = useDashboardRouter();
const path = usePathname();
const enableSorting = !!props.searchParams;
const { timeRange, page, sortBy } = props.searchParams || {};
@@ -1,10 +1,11 @@
"use client";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { TimeRange } from "lib/search";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
export function TimeRangeSwitcher() {
const router = useRouter();
const router = useDashboardRouter();
const path = usePathname();
const searchParams = useSearchParams();
const currentSort = searchParams?.get("sortBy") || "transactionCount";
+2
View File
@@ -1,5 +1,6 @@
import "@/styles/globals.css";
import { Toaster } from "@/components/ui/sonner";
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { Metadata } from "next";
import PlausibleProvider from "next-plausible";
@@ -70,6 +71,7 @@ export default function RootLayout({
<PostHogPageView />
<AppRouterProviders>{children}</AppRouterProviders>
<Toaster richColors />
<DashboardRouterTopProgressBar />
<NextTopLoader
color="hsl(var(--primary))"
height={2}
@@ -18,11 +18,11 @@ import { useContract } from "@thirdweb-dev/react";
import type { Abi } from "@thirdweb-dev/sdk";
import { SourcesPanel } from "components/contract-components/shared/sources-panel";
import { useContractSources } from "contract-ui/hooks/useContractSources";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { FiCheckCircle, FiXCircle } from "react-icons/fi";
import { toast } from "sonner";
import { Badge, Button, Card, Heading } from "tw-components";
import { useDashboardRouter } from "../../../@/lib/DashboardRouter";
interface ContractSourcesPageProps {
contractAddress?: string;
@@ -257,7 +257,7 @@ function RefreshContractMetadataButton(props: {
chainId: number;
contractAddress: string;
}) {
const router = useRouter();
const router = useDashboardRouter();
const queryClient = useQueryClient();
const contractCacheMutation = useMutation({
mutationFn: async () => {
+3
View File
@@ -23,6 +23,7 @@ import { generateBreakpointTypographyCssVars } from "tw-components/utils/typogra
import type { ThirdwebNextPage } from "utils/types";
import chakraTheme from "../theme";
import "@/styles/globals.css";
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
const inter = interConstructor({
subsets: ["latin"],
@@ -269,6 +270,8 @@ const ConsoleApp = memo(function ConsoleApp({
transitionTimingFunction="ease"
/>
<DashboardRouterTopProgressBar />
<TailwindTheme>
<ChakraProvider theme={chakraThemeWithFonts}>
<AnnouncementBanner />