perf: code-split and defer the notification bell

This commit is contained in:
SkateSpots 2026-10-28 12:58:14 +07:00
parent 7040a90630
commit 7bbe73e903
3 changed files with 83 additions and 5 deletions

View file

@ -59,7 +59,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { NewsletterOverlay } from '@/components/NewsletterOverlay';
import { NotificationBell } from '@/components/NotificationBell';
import { NotificationBellLazy } from '@/components/NotificationBellLazy';
import { OsNotificationBridge } from '@/components/OsNotificationBridge';
// Cashu balance widget is lazy — see components/wallet/CashuBalance.tsx for why
@ -781,7 +781,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
</Button>
)}
{/* Notifications (crew invites & membership requests) */}
{user && <NotificationBell />}
{user && <NotificationBellLazy />}
{/* Offline Indicator */}
<OfflineIndicator />
{/* Language Toggle */}
@ -825,7 +825,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
<div className="flex items-center gap-1 flex-shrink-0">
{/* Notifications (crew invites & membership requests) */}
{user && <NotificationBell />}
{user && <NotificationBellLazy />}
{/* User menu when logged in / Login button when not.
IMPORTANT: LoginArea hosts the SignupDialog. The signup flow logs
the user in at the *first* step (to publish their profile), then

View file

@ -209,9 +209,15 @@ function EmptyState({ label }: { label: string }) {
* Unread state is tracked by a per-account "last read" timestamp; opening the
* bell (or hitting "mark all read") clears the badge. Only renders when logged in.
*/
export function NotificationBell({ className }: { className?: string }) {
export function NotificationBell({
className,
defaultOpen = false,
}: {
className?: string;
defaultOpen?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
useCrewNotifications();
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();

View file

@ -0,0 +1,72 @@
import { Suspense, useEffect, useState } from 'react';
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/contexts/language';
import { lazyWithRetry } from '@/lib/lazyWithRetry';
import { cn } from '@/lib/utils';
type BellProps = { className?: string; defaultOpen?: boolean };
// Keep the whole notification center (and its five data sources) out of the
// entry bundle. It loads after first paint on idle, or immediately if the user
// taps the bell. The placeholder is the same footprint as the real button.
const LazyBell = lazyWithRetry(() =>
import('@/components/NotificationBell').then((m) => ({
default: m.NotificationBell as unknown as React.ComponentType<object>,
})),
) as React.LazyExoticComponent<React.ComponentType<BellProps>>;
function scheduleIdle(callback: () => void): () => void {
const win = window as Window & {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
if (win.requestIdleCallback) {
const id = win.requestIdleCallback(callback, { timeout: 1800 });
return () => win.cancelIdleCallback?.(id);
}
const id = window.setTimeout(callback, 1200);
return () => window.clearTimeout(id);
}
/**
* Deferred notification bell. The bell icon is present immediately; the data
* hooks and lazy chunk arrive once the map/app has had its first paint.
*/
export function NotificationBellLazy({ className }: { className?: string }) {
const { t } = useTranslation();
const [armed, setArmed] = useState(false);
const [openOnMount, setOpenOnMount] = useState(false);
useEffect(() => {
if (armed) return;
return scheduleIdle(() => setArmed(true));
}, [armed]);
const placeholder = (
<Button
variant="ghost"
size="icon"
className={cn(
'group relative text-gray-300 transition-colors hover:text-[#ccff00] hover:bg-[#ccff00]/10',
className,
)}
title={t('socialUi.bell.notifications')}
aria-label={t('socialUi.bell.notifications')}
onClick={() => {
setOpenOnMount(true);
setArmed(true);
}}
>
<Bell className="h-5 w-5" />
</Button>
);
if (!armed) return placeholder;
return (
<Suspense fallback={placeholder}>
<LazyBell className={className} defaultOpen={openOnMount} />
</Suspense>
);
}