Skip to content
105 changes: 105 additions & 0 deletions apps/web/src/app/[locale]/(social)/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DashboardNotificationSummary } from '@/lib/dashboard-notifications';

const mocks = vi.hoisted(() => ({
auth: vi.fn(),
getSummary: vi.fn(),
redirect: vi.fn(),
}));

vi.mock('@/auth', () => ({ auth: mocks.auth }));
vi.mock('@/i18n/navigation', () => ({ redirect: mocks.redirect }));
vi.mock('@/lib/dashboard-notifications', () => ({
getDashboardNotificationSummary: mocks.getSummary,
}));
vi.mock('@/components/settings/theme-sync-initializer', () => ({ ThemeSyncInitializer: () => null }));
vi.mock('@/components/settings/accessibility-sync-initializer', () => ({
AccessibilitySyncInitializer: () => null,
}));
vi.mock('@/components/settings/editor-preferences-sync-initializer', () => ({
EditorPreferencesSyncInitializer: () => null,
}));
vi.mock('@/components/feed/social-app-shell', () => ({
SocialAppShell: ({
children,
notificationsMenu,
}: {
children: React.ReactNode;
notificationsMenu?: React.ReactNode;
}) => (
<div data-testid="social-app-shell">
{notificationsMenu}
<main>{children}</main>
</div>
),
}));
vi.mock('@/components/app/app-shell-header-menu', () => ({
AppShellHeaderMenu: ({ notifications }: { notifications?: DashboardNotificationSummary }) => (
<div
data-testid="header-menu"
data-unread={notifications ? String(notifications.unreadCount) : 'undefined'}
/>
),
}));

import Layout from './layout';

describe('social layout notifications streaming', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.auth.mockResolvedValue({
user: { id: 'user-1', name: 'Ada', email: 'ada@example.com', image: null },
});
});
afterEach(cleanup);

it('renders the shell and children while a slow (300ms) summary is still pending', async () => {
mocks.getSummary.mockImplementation(
async () =>
await new Promise<DashboardNotificationSummary>((resolve) =>
setTimeout(() => resolve({ items: [], unreadCount: 1 }), 300),
),
);

const ui = await Layout({
children: <div data-testid="feed-page">feed</div>,
params: Promise.resolve({ locale: 'en' }),
} as never);
render(ui);

expect(mocks.getSummary).toHaveBeenCalledWith('user-1');
expect(screen.getByTestId('social-app-shell')).toBeInTheDocument();
expect(screen.getByTestId('feed-page')).toBeInTheDocument();
expect(screen.getAllByTestId('header-menu').length).toBeGreaterThan(0);
});

it('renders the shell and children when the summary rejects, with no layout throw', async () => {
mocks.getSummary.mockRejectedValue(new Error('notifications api down'));

const ui = await Layout({
children: <div data-testid="feed-page">feed</div>,
params: Promise.resolve({ locale: 'en' }),
} as never);
render(ui);

expect(screen.getByTestId('social-app-shell')).toBeInTheDocument();
expect(screen.getByTestId('feed-page')).toBeInTheDocument();
expect(screen.getAllByTestId('header-menu').length).toBeGreaterThan(0);
});

it('still redirects unauthenticated sessions before any notifications work', async () => {
mocks.auth.mockResolvedValue(null);

await expect(
Layout({
children: <div />,
params: Promise.resolve({ locale: 'en' }),
} as never),
).rejects.toThrow('Unauthenticated social access');

expect(mocks.redirect).toHaveBeenCalledWith({ href: { pathname: '/sign-in' }, locale: 'en' });
expect(mocks.getSummary).not.toHaveBeenCalled();
});
});
16 changes: 12 additions & 4 deletions apps/web/src/app/[locale]/(social)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { auth } from '@/auth';
import { SocialAppShell } from '@/components/feed/social-app-shell';
import { AppShellHeaderMenu } from '@/components/app/app-shell-header-menu';
import { AccessibilitySyncInitializer } from '@/components/settings/accessibility-sync-initializer';
import { EditorPreferencesSyncInitializer } from '@/components/settings/editor-preferences-sync-initializer';
import { ThemeSyncInitializer } from '@/components/settings/theme-sync-initializer';
import { redirect } from '@/i18n/navigation';
import { getDashboardNotificationSummary } from '@/lib/dashboard-notifications';
import React from 'react';
import React, { Suspense } from 'react';
import { NotificationsSlot } from './notifications-slot';

export default async function Layout({ children, params }: LayoutProps<'/[locale]'>): Promise<React.JSX.Element> {
const { locale } = await params;
Expand All @@ -17,16 +18,23 @@ export default async function Layout({ children, params }: LayoutProps<'/[locale
throw new Error('Unauthenticated social access');
}

const notifications = await getDashboardNotificationSummary(session.user.id);
const socialUser = {
id: session.user.id,
name: session.user.name?.trim() || session.user.email?.split('@')[0] || 'GameGuild member',
email: session.user.email ?? 'No email available',
image: session.user.image ?? null,
};

// Streams the bell behind Suspense; the chrome-only fallback keeps the shell
// paint off the notifications critical path and contains summary failures.
const notificationsMenu = (
<Suspense fallback={<AppShellHeaderMenu user={socialUser} />}>
<NotificationsSlot userId={session.user.id} user={socialUser} />
</Suspense>
);

return (
<SocialAppShell user={socialUser} notifications={notifications}>
<SocialAppShell user={socialUser} notificationsMenu={notificationsMenu}>
<ThemeSyncInitializer />
<AccessibilitySyncInitializer />
<EditorPreferencesSyncInitializer />
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/app/[locale]/(social)/notifications-slot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DashboardNotificationSummary } from '@/lib/dashboard-notifications';

const mocks = vi.hoisted(() => ({ getSummary: vi.fn() }));

vi.mock('@/lib/dashboard-notifications', () => ({
getDashboardNotificationSummary: mocks.getSummary,
}));
vi.mock('@/components/app/app-shell-header-menu', () => ({
AppShellHeaderMenu: ({
notifications,
user,
}: {
notifications?: DashboardNotificationSummary;
user?: { name?: string };
}) => (
<div
data-testid="header-menu"
data-unread={notifications ? String(notifications.unreadCount) : 'undefined'}
>
{user?.name}
</div>
),
}));

import { NotificationsSlot } from './notifications-slot';

const user = { id: 'user-1', name: 'Ada', email: 'ada@example.com', image: null };

describe('NotificationsSlot', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(cleanup);

it('passes the fetched summary to the header menu', async () => {
mocks.getSummary.mockResolvedValue({ items: [], unreadCount: 2 } satisfies DashboardNotificationSummary);

const ui = await NotificationsSlot({ userId: 'user-1', user });
render(ui);

expect(screen.getByTestId('header-menu')).toHaveAttribute('data-unread', '2');
});

it('degrades to an empty summary instead of throwing when the fetch rejects', async () => {
mocks.getSummary.mockRejectedValue(new Error('notifications api down'));

const ui = await NotificationsSlot({ userId: 'user-1', user });
render(ui);

expect(screen.getByTestId('header-menu')).toHaveAttribute('data-unread', '0');
expect(screen.getByText('Ada')).toBeInTheDocument();
});
});
28 changes: 28 additions & 0 deletions apps/web/src/app/[locale]/(social)/notifications-slot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { AppShellHeaderMenu } from '@/components/app/app-shell-header-menu';
import type { WorkspaceUser } from '@/components/console/workspace-user-menu';
import {
getDashboardNotificationSummary,
type DashboardNotificationSummary,
} from '@/lib/dashboard-notifications';

const EMPTY_SUMMARY: DashboardNotificationSummary = { items: [], unreadCount: 0 };

interface NotificationsSlotProps {
userId: string;
user: WorkspaceUser;
}

/**
* Suspense slot for the social layout: fetches the notifications summary off
* the critical path, degrading to an empty summary on failure.
*/
export async function NotificationsSlot({ userId, user }: NotificationsSlotProps): Promise<React.JSX.Element> {
let notifications: DashboardNotificationSummary;
try {
notifications = await getDashboardNotificationSummary(userId);
} catch {
notifications = EMPTY_SUMMARY;
}

return <AppShellHeaderMenu notifications={notifications} user={user} />;
}
29 changes: 29 additions & 0 deletions apps/web/src/components/feed/feed-skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { FeedSkeleton } from "./feed-skeleton";

describe("FeedSkeleton", () => {
it("mounts the loading skeleton with 3 post placeholders", () => {
render(<FeedSkeleton />);

const root = screen.getByTestId("feed-loading-skeleton");
expect(root).toBeInTheDocument();
expect(root.getAttribute("role")).toBe("status");
expect(screen.getAllByTestId("feed-skeleton-post")).toHaveLength(3);
});

it("mirrors the feed column structure: tab bar, stories, composer, posts", () => {
const { container } = render(<FeedSkeleton />);

// Sticky tab bar placeholder (h-14)
const tabBar = container.querySelector(".sticky.h-14");
expect(tabBar).not.toBeNull();
// Stories strip: 6 circle placeholders
const circles = container.querySelectorAll(".h-24 .rounded-full");
expect(circles).toHaveLength(6);
// Composer card placeholder (h-20)
expect(container.querySelector(".h-20")).not.toBeNull();
});
});
81 changes: 81 additions & 0 deletions apps/web/src/components/feed/feed-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from "react";

/**
* Static skeleton mirroring the /feed left column (social-shell.tsx):
* sticky tab bar (h-14) → stories strip (h-24 row of circles) → composer
* (rounded card h-20) → post cards. Used as the nested Suspense fallback so
* cold-load streaming shows structure without flashing on soft tab switches
* (searchParams-only navigations keep the page shell mounted).
* CSS animation only (animate-pulse), no runtime hooks.
*/
export function FeedSkeleton(): React.JSX.Element {
return (
<div
data-testid="feed-loading-skeleton"
role="status"
aria-label="Loading feed"
className="mx-auto w-full max-w-[1260px] xl:grid xl:grid-cols-[minmax(0,820px)_360px] xl:gap-6"
>
<div className="min-w-0">
{/* Tab bar placeholder — matches SocialFeedTabs sticky h-14 */}
<div className="sticky top-0 z-20 flex h-14 items-end gap-8 bg-background/95 px-4 backdrop-blur-xl sm:px-6">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-4 w-14 animate-pulse rounded bg-muted"
/>
))}
</div>

{/* Stories strip placeholder — matches BuildStories px-4 py-4 */}
<section
aria-hidden="true"
className="overflow-hidden px-4 py-4 sm:px-6"
>
<div className="flex h-24 gap-4 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="flex w-[4.5rem] shrink-0 flex-col items-center gap-2"
>
<div className="size-[3.25rem] animate-pulse rounded-full bg-muted" />
<div className="h-2.5 w-12 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</section>

{/* Composer placeholder — matches SocialComposer px-4 py-3 card */}
<section aria-hidden="true" className="px-4 py-3 sm:px-6">
<div className="flex h-20 items-center gap-3 rounded-xl bg-card p-2.5">
<div className="size-9 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="h-8 min-w-0 flex-1 animate-pulse rounded-lg bg-muted" />
</div>
</section>

{/* Post card placeholders — mirror PostCard article rhythm */}
{Array.from({ length: 3 }).map((_, i) => (
<article
key={i}
aria-hidden="true"
data-testid="feed-skeleton-post"
className="border-b border-border/35 bg-card py-2"
>
<div className="flex items-center gap-3 px-4 py-4 pb-2 sm:px-6">
<div className="size-10 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-3.5 w-32 animate-pulse rounded bg-muted" />
<div className="h-2.5 w-24 animate-pulse rounded bg-muted" />
</div>
</div>
<div className="space-y-2 px-4 pb-4 pt-4 sm:px-6">
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-4/5 animate-pulse rounded bg-muted" />
</div>
</article>
))}
</div>
{/* Right rail exists only on xl; omitted — scoped to left column per plan */}
</div>
);
}
41 changes: 41 additions & 0 deletions apps/web/src/components/feed/infinite-post-feed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,47 @@ describe("InfinitePostFeed", () => {
expect(screen.queryByText("Only for you")).not.toBeInTheDocument();
});

it("resets the visible items and the dedupe set when the feed identity changes", async () => {
// PostCard also creates IntersectionObservers, and stale observers (old scope
// closures) must not fire after disconnect — trigger only connected instances.
const instances: { cb: IntersectionObserverCallback; connected: boolean }[] = [];
vi.stubGlobal("IntersectionObserver", class {
record: { cb: IntersectionObserverCallback; connected: boolean };
constructor(cb: IntersectionObserverCallback) {
this.record = { cb, connected: true };
instances.push(this.record);
}
observe = vi.fn();
disconnect = () => { this.record.connected = false; };
});
const trigger = () => act(() => {
for (const { cb, connected } of [...instances]) if (connected) cb([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});

// B's pagination returns an ID A already showed: proves the dedupe (seenRef) reset, not just the item list.
mocks.loadSocialFeedPage
.mockResolvedValueOnce({ items: [post("a-3", "Shared across scopes")], nextCursor: null })
.mockResolvedValueOnce({ items: [post("a-3", "Shared across scopes")], nextCursor: null });

const view = render(
<InfinitePostFeed scope="following" initialItems={[post("a-1", "A only"), post("a-2", "A second")]} initialNextCursor="cursor-a" />,
);
expect(screen.getAllByTestId("post-card")).toHaveLength(2);
trigger();
await waitFor(() => expect(screen.getAllByTestId("post-card")).toHaveLength(3));

view.rerender(<InfinitePostFeed scope="community" initialItems={[post("b-1", "B only")]} initialNextCursor="cursor-b" />);

await waitFor(() => expect(screen.queryByText("A only")).not.toBeInTheDocument());
expect(screen.queryByText("A second")).not.toBeInTheDocument();
expect(screen.getByText("B only")).toBeInTheDocument();
expect(screen.getAllByTestId("post-card")).toHaveLength(1);

trigger();
await waitFor(() => expect(mocks.loadSocialFeedPage).toHaveBeenCalledWith(expect.objectContaining({ scope: "community", cursor: "cursor-b" })));
await waitFor(() => expect(screen.getAllByTestId("post-card")).toHaveLength(2));
});

it("keeps the SSR tag on every subsequent page request", async () => {
const io = intersectionCallback();
mocks.loadSocialFeedPage.mockResolvedValue({ items: [post("p-2")], nextCursor: null });
Expand Down
Loading
Loading