mirror of
https://github.com/jakejarvis/domainstack.io.git
synced 2026-09-11 05:25:33 -04:00
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:
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user