perf: code-split and defer the notification bell
This commit is contained in:
parent
7040a90630
commit
7bbe73e903
3 changed files with 83 additions and 5 deletions
|
|
@ -59,7 +59,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { NewsletterOverlay } from '@/components/NewsletterOverlay';
|
import { NewsletterOverlay } from '@/components/NewsletterOverlay';
|
||||||
import { NotificationBell } from '@/components/NotificationBell';
|
import { NotificationBellLazy } from '@/components/NotificationBellLazy';
|
||||||
import { OsNotificationBridge } from '@/components/OsNotificationBridge';
|
import { OsNotificationBridge } from '@/components/OsNotificationBridge';
|
||||||
|
|
||||||
// Cashu balance widget is lazy — see components/wallet/CashuBalance.tsx for why
|
// Cashu balance widget is lazy — see components/wallet/CashuBalance.tsx for why
|
||||||
|
|
@ -781,7 +781,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Notifications (crew invites & membership requests) */}
|
{/* Notifications (crew invites & membership requests) */}
|
||||||
{user && <NotificationBell />}
|
{user && <NotificationBellLazy />}
|
||||||
{/* Offline Indicator */}
|
{/* Offline Indicator */}
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
{/* Language Toggle */}
|
{/* Language Toggle */}
|
||||||
|
|
@ -825,7 +825,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{/* Notifications (crew invites & membership requests) */}
|
{/* Notifications (crew invites & membership requests) */}
|
||||||
{user && <NotificationBell />}
|
{user && <NotificationBellLazy />}
|
||||||
{/* User menu when logged in / Login button when not.
|
{/* User menu when logged in / Login button when not.
|
||||||
IMPORTANT: LoginArea hosts the SignupDialog. The signup flow logs
|
IMPORTANT: LoginArea hosts the SignupDialog. The signup flow logs
|
||||||
the user in at the *first* step (to publish their profile), then
|
the user in at the *first* step (to publish their profile), then
|
||||||
|
|
|
||||||
|
|
@ -209,9 +209,15 @@ function EmptyState({ label }: { label: string }) {
|
||||||
* Unread state is tracked by a per-account "last read" timestamp; opening the
|
* 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.
|
* 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 { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
||||||
useCrewNotifications();
|
useCrewNotifications();
|
||||||
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
||||||
|
|
|
||||||
72
src/components/NotificationBellLazy.tsx
Normal file
72
src/components/NotificationBellLazy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue