fix: avoid SSR auth errors and blank flash in settings panels

- Replace useSuspenseQuery with useQuery in NotificationsPanel and AccountPanel
  to avoid SSR auth context issues (protected queries can't run server-side)
- Each panel now handles its own loading state internally with skeletons
- Keep SettingsErrorBoundary wrappers for consistent error recovery UX
- Remove Suspense wrappers from settings-content.tsx (no longer needed)
- Add rewrite rule for /settings -> /settings/subscription to eliminate
  blank flash during redirect
- Remove redundant settings page.tsx and loading.tsx files
This commit is contained in:
2026-01-18 12:52:46 -05:00
parent 2d752d4a4e
commit f9c616d23e
10 changed files with 100 additions and 95 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ export default function StaticLayout({
"prose-headings:font-semibold prose-strong:font-semibold",
"prose-a:decoration-muted-foreground/90 prose-a:hover:text-foreground/70",
// External link icons
"prose-a:[&_svg]:ml-1 prose-a:[&_svg]:inline-block prose-a:[&_svg]:size-3.5 prose-a:[&_svg]:-translate-y-0.5 prose-a:[&_svg]:text-foreground/70",
"prose-a:[&_svg]:mr-0.5 prose-a:[&_svg]:ml-1 prose-a:[&_svg]:inline-block prose-a:[&_svg]:size-3.5 prose-a:[&_svg]:-translate-y-0.5 prose-a:[&_svg]:text-foreground/70",
"[&>header]:mb-6 [&>header]:border-border/50 [&>header]:border-b [&>header]:pb-6",
"[&>section]:scroll-mt-24",
)}
+19 -6
View File
@@ -1,16 +1,29 @@
import { BookmarksSimpleIcon } from "@phosphor-icons/react/ssr";
import { BookmarkletContent } from "@/components/bookmarklet/bookmarklet-content";
import { Modal, ModalContent } from "@/components/ui/modal";
import {
Modal,
ModalContent,
ModalDescription,
ModalHeader,
ModalTitle,
} from "@/components/ui/modal";
export default function InterceptedBookmarkletPage() {
return (
<Modal>
<ModalContent className="!max-w-lg p-5">
<div className="mb-2 flex items-center gap-2 font-medium text-lg">
<BookmarksSimpleIcon className="h-4.5 w-4.5" />
Bookmarklet
<ModalContent className="!max-w-lg">
<ModalHeader>
<ModalTitle className="flex items-center gap-2">
<BookmarksSimpleIcon className="size-5" />
Bookmarklet
</ModalTitle>
<ModalDescription>
Use these shortcuts to investigate domains from anywhere.
</ModalDescription>
</ModalHeader>
<div className="min-w-0 p-4 [contain:inline-size]">
<BookmarkletContent />
</div>
<BookmarkletContent />
</ModalContent>
</Modal>
);
+5 -1
View File
@@ -1,3 +1,4 @@
import { GearIcon } from "@phosphor-icons/react/dist/ssr";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { Suspense } from "react";
@@ -26,7 +27,10 @@ export default function SettingsModalLayout() {
<Modal>
<ModalContent>
<ModalHeader>
<ModalTitle>Settings</ModalTitle>
<ModalTitle className="flex items-center gap-2">
<GearIcon className="size-5" />
Settings
</ModalTitle>
<ModalDescription>
Manage your subscription, notifications, and account preferences.
</ModalDescription>
-24
View File
@@ -1,24 +0,0 @@
import {
SettingsSkeletonPanels,
SettingsSkeletonTabsList,
} from "@/components/settings/settings-skeleton";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function SettingsLoading() {
return (
<div className="space-y-5">
{/* Page header skeleton */}
<div>
<Skeleton className="h-8 w-28" />
<Skeleton className="mt-2 h-5 w-80" />
</div>
{/* Settings content skeleton */}
<Card className="overflow-hidden p-3">
<SettingsSkeletonTabsList />
<SettingsSkeletonPanels className="mt-2 p-2" />
</Card>
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function SettingsPage() {
redirect("/settings/subscription");
}
@@ -27,7 +27,7 @@ import { Separator } from "@/components/ui/separator";
import { VideoPlayer, VideoPlayerContent } from "@/components/ui/video-player";
import { APPLE_SHORTCUT_ID, BASE_URL } from "@/lib/constants/app";
export function BookmarkletContent({ className }: { className?: string }) {
export function BookmarkletContent() {
// a little hack to "unsafely" use raw javascript as a link
const hrefScript = (element: HTMLAnchorElement | null) => {
if (!element) return;
@@ -36,8 +36,8 @@ export function BookmarkletContent({ className }: { className?: string }) {
};
return (
<div className={className}>
<div className="mb-4 space-y-3.5">
<>
<div className="mb-4 space-y-4">
<p className="text-muted-foreground text-sm">
Drag the button below to your bookmarks bar. Then, press it on any
site and the report for that domain will open in a new tab, like
@@ -45,7 +45,6 @@ export function BookmarkletContent({ className }: { className?: string }) {
</p>
<div className="flex justify-center">
<Button
variant="outline"
size="lg"
nativeButton={false}
// biome-ignore lint/a11y/useValidAnchor: set by hrefScript above
@@ -64,7 +63,7 @@ export function BookmarkletContent({ className }: { className?: string }) {
</div>
</div>
<Separator className="my-4 bg-border/80 dark:bg-border/50" />
<Separator className="my-5 bg-border/80 dark:bg-border/50" />
<Accordion
multiple
@@ -199,6 +198,6 @@ export function BookmarkletContent({ className }: { className?: string }) {
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</>
);
}
+23 -15
View File
@@ -1,13 +1,10 @@
import { FingerprintIcon } from "@phosphor-icons/react/ssr";
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { DangerZoneCollapsible } from "@/components/settings/account/danger-zone-collapsible";
import { LinkedAccountRow } from "@/components/settings/account/linked-account-row";
import { LinkedAccountsSkeleton } from "@/components/settings/settings-skeleton";
import {
AlertDialog,
AlertDialogAction,
@@ -43,18 +40,14 @@ export function AccountPanel() {
// Handle auth callback errors from URL params (account linking)
useAuthCallback();
// Query for linked accounts - uses Suspense for loading/error states
const { data: linkedAccounts } = useSuspenseQuery(
trpc.user.getLinkedAccounts.queryOptions(),
);
// Query for linked accounts
const {
data: linkedAccounts,
isLoading,
isError,
} = useQuery(trpc.user.getLinkedAccounts.queryOptions());
const linkedAccountsQueryKey = trpc.user.getLinkedAccounts.queryKey();
// Set of linked provider IDs for quick lookup
const linkedProviderIds = new Set(linkedAccounts.map((a) => a.providerId));
// Check if user can unlink (must have at least 2 linked accounts)
const canUnlink = linkedProviderIds.size > 1;
// Handle linking a provider
const handleLink = async (provider: OAuthProvider) => {
setLinkingProvider(provider.id);
@@ -146,6 +139,21 @@ export function AccountPanel() {
unlinkMutation.mutate(providerId);
};
// Loading and error states (after all hooks)
if (isLoading) {
return <LinkedAccountsSkeleton />;
}
if (isError || !linkedAccounts) {
throw new Error("Failed to load linked accounts");
}
// Set of linked provider IDs for quick lookup
const linkedProviderIds = new Set(linkedAccounts.map((a) => a.providerId));
// Check if user can unlink (must have at least 2 linked accounts)
const canUnlink = linkedProviderIds.size > 1;
// Get the provider config being unlinked for the dialog
const providerToUnlink = unlinkingProvider
? enabledProviders.find((p) => p.id === unlinkingProvider)
@@ -4,11 +4,7 @@ import {
InfoIcon,
SlidersHorizontalIcon,
} from "@phosphor-icons/react/ssr";
import {
useMutation,
useQueryClient,
useSuspenseQueries,
} from "@tanstack/react-query";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { Suspense } from "react";
import { toast } from "sonner";
import {
@@ -18,6 +14,7 @@ import {
import { DomainNotificationRow } from "@/components/settings/notifications/domain-notification-row";
import { GlobalNotificationRow } from "@/components/settings/notifications/global-notification-row";
import { SettingsErrorBoundary } from "@/components/settings/settings-error-boundary";
import { NotificationsSkeleton } from "@/components/settings/settings-skeleton";
import {
CardContent,
CardDescription,
@@ -47,16 +44,15 @@ export function NotificationsPanel() {
const domainsQueryKey = trpc.tracking.listDomains.queryKey();
const globalPrefsQueryKey = trpc.user.getNotificationPreferences.queryKey();
// Queries - both run in parallel with Suspense for loading state
const [domainsResult, globalPrefsResult] = useSuspenseQueries({
// Queries - both run in parallel
const [domainsResult, globalPrefsResult] = useQueries({
queries: [
trpc.tracking.listDomains.queryOptions(),
trpc.user.getNotificationPreferences.queryOptions(),
],
});
const globalPrefsData = globalPrefsResult.data;
// Mutations with optimistic updates
// Mutations with optimistic updates (must be called before early returns)
const updateGlobalMutation = useMutation({
...trpc.user.updateGlobalNotificationPreferences.mutationOptions(),
onMutate: async (newPrefs) => {
@@ -181,6 +177,36 @@ export function NotificationsPanel() {
},
});
// Loading and error states (after all hooks)
const isLoading = domainsResult.isLoading || globalPrefsResult.isLoading;
const isError = domainsResult.isError || globalPrefsResult.isError;
if (isLoading) {
return <NotificationsSkeleton />;
}
if (isError || !domainsResult.data || !globalPrefsResult.data) {
throw new Error("Failed to load notification settings");
}
// Data is now guaranteed to be defined
const domains = domainsResult.data;
const globalPrefsData = globalPrefsResult.data;
const defaultGlobalPrefs: UserNotificationPreferences = {
domainExpiry: { inApp: true, email: true },
certificateExpiry: { inApp: true, email: true },
registrationChanges: { inApp: true, email: true },
providerChanges: { inApp: true, email: true },
certificateChanges: { inApp: true, email: true },
};
// Merge defaults with saved preferences to ensure new fields are always present
const globalPrefs: UserNotificationPreferences = {
...defaultGlobalPrefs,
...globalPrefsData,
};
const handleGlobalToggle = (
category: NotificationCategory,
type: "email" | "inApp",
@@ -242,26 +268,10 @@ export function NotificationsPanel() {
resetDomainMutation.mutate({ trackedDomainId });
};
// With useSuspenseQueries, data is guaranteed to be defined
const domains = domainsResult.data;
const verifiedDomains = domains
.filter((d) => d.verified)
.sort((a, b) => a.domainName.localeCompare(b.domainName));
const defaultGlobalPrefs: UserNotificationPreferences = {
domainExpiry: { inApp: true, email: true },
certificateExpiry: { inApp: true, email: true },
registrationChanges: { inApp: true, email: true },
providerChanges: { inApp: true, email: true },
certificateChanges: { inApp: true, email: true },
};
// Merge defaults with saved preferences to ensure new fields are always present
const globalPrefs: UserNotificationPreferences = {
...defaultGlobalPrefs,
...globalPrefsData,
};
const isPending =
updateGlobalMutation.isPending ||
updateDomainMutation.isPending ||
+6 -12
View File
@@ -7,7 +7,6 @@ import {
} from "@phosphor-icons/react/ssr";
import { useSelectedLayoutSegment } from "next/navigation";
import {
Suspense,
useCallback,
useEffect,
useLayoutEffect,
@@ -19,10 +18,7 @@ import { createPortal } from "react-dom";
import { AccountPanel } from "@/components/settings/account/account-panel";
import { NotificationsPanel } from "@/components/settings/notifications/notifications-panel";
import { SettingsErrorBoundary } from "@/components/settings/settings-error-boundary";
import {
LinkedAccountsSkeleton,
NotificationsSkeleton,
} from "@/components/settings/settings-skeleton";
import { SubscriptionPanel } from "@/components/settings/subscription/subscription-panel";
import {
ResponsiveTooltip,
@@ -94,22 +90,20 @@ function SettingsPanels({ className }: { className?: string }) {
return (
<>
<TabsContent value="subscription" className={className}>
<SubscriptionPanel />
<SettingsErrorBoundary sectionName="Subscription">
<SubscriptionPanel />
</SettingsErrorBoundary>
</TabsContent>
<TabsContent value="notifications" className={className}>
<SettingsErrorBoundary sectionName="Notifications">
<Suspense fallback={<NotificationsSkeleton />}>
<NotificationsPanel />
</Suspense>
<NotificationsPanel />
</SettingsErrorBoundary>
</TabsContent>
<TabsContent value="account" className={className}>
<SettingsErrorBoundary sectionName="Account">
<Suspense fallback={<LinkedAccountsSkeleton />}>
<AccountPanel />
</Suspense>
<AccountPanel />
</SettingsErrorBoundary>
</TabsContent>
</>
+6
View File
@@ -27,6 +27,12 @@ let nextConfig: NextConfig = {
},
},
rewrites: async () => [
// Rewrite /settings to default tab without a client-side navigation flash.
// IMPORTANT: If the first tab ever changes, we also need to change this rewrite.
{
source: "/settings",
destination: "/settings/subscription",
},
{
source: "/dashboard/feed.ics",
has: [