Skip to content

Commit 41b832c

Browse files
committed
improve first paint performance
1 parent d6f03d7 commit 41b832c

29 files changed

Lines changed: 834 additions & 328 deletions

File tree

e2e/home.spec.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { expect, test } from "@playwright/test";
2-
import { HOST_EMAIL, signIn } from "./helpers";
2+
import { DONOR_EMAIL, HOST_EMAIL, signIn } from "./helpers";
33

4-
test("homepage hydrates without chat date mismatches", async ({
4+
test("homepage renders static content before deferred chat demo hydrates", async ({
55
browser,
66
page,
77
}) => {
@@ -42,9 +42,6 @@ test("homepage hydrates without chat date mismatches", async ({
4242
const serverDayLabels = await serverPage
4343
.getByTestId("chat-day-label")
4444
.allTextContents();
45-
const serverTimestamps = await serverPage
46-
.getByTestId("chat-message-timestamp")
47-
.allTextContents();
4845
await serverContext.close();
4946

5047
await page.waitForLoadState("networkidle");
@@ -59,9 +56,9 @@ test("homepage hydrates without chat date mismatches", async ({
5956
.getByTestId("chat-message-timestamp")
6057
.allTextContents();
6158

62-
expect(serverDayLabels).toEqual(["Thu, May 1", "Fri, May 2"]);
59+
expect(serverDayLabels).toEqual([]);
6360
expect(hydratedDayLabels).toEqual(["Yesterday", "Today"]);
64-
expect(hydratedTimestamps).toEqual(serverTimestamps);
61+
expect(hydratedTimestamps).toHaveLength(2);
6562
expect(
6663
pageErrors.some((message) => message.includes("Minified React error #418"))
6764
).toBeFalsy();
@@ -152,6 +149,7 @@ test("homepage account button stays hidden while signed-in profile state loads",
152149

153150
await expect(profileAccountButton).toHaveAttribute("href", "/profile");
154151
await expect(profileAccountButton).toHaveCSS("opacity", "1");
152+
await expect(page.getByTestId("locale-picker-select")).toHaveCount(0);
155153
});
156154

157155
test("homepage account button links guests to sign in", async ({ page }) => {
@@ -162,4 +160,40 @@ test("homepage account button links guests to sign in", async ({ page }) => {
162160
"/sign-in"
163161
);
164162
await expect(page.getByTestId("account-button-profile")).toHaveCount(0);
163+
await expect(page.getByTestId("locale-picker-select")).toBeVisible();
164+
});
165+
166+
test("homepage unread chat dot appears after scoped unread check", async ({
167+
page,
168+
}) => {
169+
await page.route(/\/rest\/v1\/chat_threads(?:\?|$)/, async (route) => {
170+
if (route.request().method() !== "GET") {
171+
await route.continue();
172+
return;
173+
}
174+
175+
await route.fulfill({
176+
status: 200,
177+
contentType: "application/json",
178+
body: JSON.stringify([{ id: "33333333-3333-4333-8333-333333333333" }]),
179+
});
180+
});
181+
await page.route(
182+
/\/rest\/v1\/rpc\/unread_chat_thread_ids(?:\?|$)/,
183+
async (route) => {
184+
await route.fulfill({
185+
status: 200,
186+
contentType: "application/json",
187+
body: JSON.stringify([
188+
{ thread_id: "33333333-3333-4333-8333-333333333333" },
189+
]),
190+
});
191+
}
192+
);
193+
194+
await signIn(page, { email: DONOR_EMAIL, redirectTo: "/" });
195+
196+
await expect(page.getByTestId("tab-unread-dot").first()).toBeVisible({
197+
timeout: 15_000,
198+
});
165199
});

src/app/(core)/(interact)/(centered)/layout.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import TabBar from "@/components/TabBar";
22
import { TabBarProvider } from "@/contexts/TabBarContext";
3+
import { UnreadMessagesProvider } from "@/contexts/UnreadMessagesContext";
34
import { styled } from "next-yak";
45
import { theme } from "@/styles/theme.yak";
56
import type { ReactNode } from "react";
@@ -22,11 +23,13 @@ const CenteredPage = styled.div`
2223
export default async function Layout({ children }: { children: ReactNode }) {
2324
return (
2425
<TabBarProvider>
25-
<CenteredPage>
26-
<TabBar breakpoint="md" position="dynamic" />
27-
{children}
28-
<TabBar breakpoint="sm" />
29-
</CenteredPage>
26+
<UnreadMessagesProvider>
27+
<CenteredPage>
28+
<TabBar breakpoint="md" position="dynamic" />
29+
{children}
30+
<TabBar breakpoint="sm" />
31+
</CenteredPage>
32+
</UnreadMessagesProvider>
3033
</TabBarProvider>
3134
);
3235
}

src/app/(core)/(interact)/(stretched)/chats/[threadId]/page.tsx

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { createClient } from "@/utils/supabase/server";
21
import { ChatConversationClient } from "@/components/ChatPageClient";
3-
import { getSelectedChatThread, isUuid } from "@/features/chat/chatData";
2+
import { isUuid } from "@/features/chat/chatData";
3+
import { getCurrentSelectedChatThread } from "@/features/chat/chatPageData";
44
import { redirect } from "next/navigation";
5-
import { cache } from "react";
65
import type { Metadata } from "next";
76
import { siteConfig } from "@/config/site";
87
import { createPeelsMetadata, noindexFollowMetadata } from "@/utils/seo";
@@ -13,33 +12,10 @@ type ChatThreadPageProps = {
1312
}>;
1413
};
1514

16-
const getChatThreadPageData = cache(async (threadId: string) => {
17-
const supabase = await createClient();
18-
const {
19-
data: { user },
20-
} = await supabase.auth.getUser();
21-
22-
if (!user) {
23-
return {
24-
user: null,
25-
selectedThread: null,
26-
};
27-
}
28-
29-
const selectedThread = await getSelectedChatThread(
30-
supabase,
31-
user.id,
32-
threadId
33-
);
34-
35-
return {
36-
user,
37-
selectedThread,
38-
};
39-
});
40-
4115
function getChatThreadTitle(
42-
selectedThread: Awaited<ReturnType<typeof getSelectedChatThread>>,
16+
selectedThread: Awaited<
17+
ReturnType<typeof getCurrentSelectedChatThread>
18+
>["selectedThread"],
4319
userId: string
4420
) {
4521
if (!selectedThread) return "Chats";
@@ -65,7 +41,7 @@ export async function generateMetadata({
6541
});
6642
}
6743

68-
const { user, selectedThread } = await getChatThreadPageData(threadId);
44+
const { user, selectedThread } = await getCurrentSelectedChatThread(threadId);
6945

7046
if (!user) {
7147
return createPeelsMetadata({
@@ -98,7 +74,7 @@ export default async function ChatThreadPage({ params }: ChatThreadPageProps) {
9874
redirect("/chats");
9975
}
10076

101-
const { user, selectedThread } = await getChatThreadPageData(threadId);
77+
const { user, selectedThread } = await getCurrentSelectedChatThread(threadId);
10278

10379
if (!user) {
10480
redirect(`/sign-in?redirect_to=/chats/${threadId}`);

src/app/(core)/(interact)/(stretched)/chats/layout.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { createClient } from "@/utils/supabase/server";
21
import ChatPageClient from "@/components/ChatPageClient";
3-
import { getChatThreads } from "@/features/chat/chatData";
2+
import { getCurrentChatThreads } from "@/features/chat/chatPageData";
43
import { currentPathHeaderName } from "@/utils/supabase/authState";
54
import { normaliseNextPath } from "@/utils/authRedirects";
65
import { headers } from "next/headers";
@@ -16,19 +15,14 @@ export default async function ChatsLayout({
1615
}: {
1716
children: ReactNode;
1817
}) {
19-
const supabase = await createClient();
20-
const {
21-
data: { user },
22-
} = await supabase.auth.getUser();
18+
const { user, threads } = await getCurrentChatThreads();
2319

2420
if (!user) {
2521
const currentPath = (await headers()).get(currentPathHeaderName);
2622
const redirectTo = normaliseNextPath(currentPath, "/chats");
2723
redirect(`/sign-in?redirect_to=${encodeURIComponent(redirectTo)}`);
2824
}
2925

30-
const threads = await getChatThreads(supabase, user.id);
31-
3226
return (
3327
<ChatPageClient user={user} initialThreads={threads}>
3428
{children}

src/app/(core)/(interact)/(stretched)/layout.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import TabBar from "@/components/TabBar";
22
import { TabBarProvider } from "@/contexts/TabBarContext";
3+
import { UnreadMessagesProvider } from "@/contexts/UnreadMessagesContext";
34
import { styled } from "next-yak";
45
import { theme } from "@/styles/theme.yak";
56
import type { ReactNode } from "react";
@@ -20,11 +21,13 @@ const StretchedPage = styled.div`
2021
export default async function Layout({ children }: { children: ReactNode }) {
2122
return (
2223
<TabBarProvider>
23-
<StretchedPage>
24-
<TabBar breakpoint="md" />
25-
{children}
26-
<TabBar breakpoint="sm" />
27-
</StretchedPage>
24+
<UnreadMessagesProvider>
25+
<StretchedPage>
26+
<TabBar breakpoint="md" />
27+
{children}
28+
<TabBar breakpoint="sm" />
29+
</StretchedPage>
30+
</UnreadMessagesProvider>
2831
</TabBarProvider>
2932
);
3033
}

src/app/(core)/(static)/layout.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import TabBar from "@/components/TabBar";
22
import { TabBarProvider } from "@/contexts/TabBarContext";
3+
import { UnreadMessagesProvider } from "@/contexts/UnreadMessagesContext";
34
import SiteFooter from "@/components/SiteFooter";
45
import { styled } from "next-yak";
56
import AccountButton from "@/components/AccountButton";
@@ -25,13 +26,15 @@ const StaticPage = styled.div`
2526
export default async function Layout({ children }: { children: ReactNode }) {
2627
return (
2728
<TabBarProvider>
28-
<StaticPage>
29-
<StyledAccountButton />
30-
<TabBar breakpoint="md" position="fixed" />
31-
{children}
32-
<SiteFooter />
33-
<TabBar breakpoint="sm" />
34-
</StaticPage>
29+
<UnreadMessagesProvider>
30+
<StaticPage>
31+
<StyledAccountButton />
32+
<TabBar breakpoint="md" position="fixed" />
33+
{children}
34+
<SiteFooter />
35+
<TabBar breakpoint="sm" />
36+
</StaticPage>
37+
</UnreadMessagesProvider>
3538
</TabBarProvider>
3639
);
3740
}

src/app/layout.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { SpeedInsights } from "@vercel/speed-insights/next";
1111
import "./globals.css";
1212
import "./theme.css";
1313

14-
import { UnreadMessagesProvider } from "@/contexts/UnreadMessagesContext";
1514
import AttributionCapture from "@/components/AttributionCapture";
1615
import AuthHashCompletion from "@/components/AuthHashCompletion";
1716
import JsonLd from "@/components/JsonLd";
@@ -104,9 +103,7 @@ export default async function RootLayout({
104103
{inlineFontFaces ? <style>{inlineFontFaces}</style> : null}
105104
<AuthHashCompletion />
106105
<AttributionCapture />
107-
<NextIntlClientProvider>
108-
<UnreadMessagesProvider>{children}</UnreadMessagesProvider>
109-
</NextIntlClientProvider>
106+
<NextIntlClientProvider>{children}</NextIntlClientProvider>
110107
<Analytics />
111108
<SpeedInsights />
112109
</body>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"use client";
2+
3+
import dynamic from "next/dynamic";
4+
import { useEffect, useState } from "react";
5+
6+
type IdleWindow = Window & {
7+
requestIdleCallback?: (
8+
callback: () => void,
9+
options?: { timeout?: number }
10+
) => number;
11+
cancelIdleCallback?: (handle: number) => void;
12+
};
13+
14+
const IntroHeaderRotator = dynamic(
15+
() => import("@/components/IntroHeader/IntroHeaderRotator"),
16+
{ ssr: false }
17+
);
18+
19+
function scheduleIdleTask(callback: () => void) {
20+
const idleWindow = window as IdleWindow;
21+
22+
if (typeof idleWindow.requestIdleCallback === "function") {
23+
const idleCallbackId = idleWindow.requestIdleCallback(callback, {
24+
timeout: 1_500,
25+
});
26+
27+
return () => idleWindow.cancelIdleCallback?.(idleCallbackId);
28+
}
29+
30+
const timeoutId = globalThis.setTimeout(callback, 250);
31+
return () => globalThis.clearTimeout(timeoutId);
32+
}
33+
34+
export default function DeferredIntroHeaderRotator() {
35+
const [isReady, setIsReady] = useState(false);
36+
37+
useEffect(() => scheduleIdleTask(() => setIsReady(true)), []);
38+
39+
return isReady ? <IntroHeaderRotator /> : null;
40+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from "./DeferredIntroHeaderRotator";
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"use client";
2+
3+
import dynamic from "next/dynamic";
4+
5+
import HomepageDemoPlaceholder from "@/components/HomepageDemoPlaceholder";
6+
import { useDeferredHomepageDemo } from "@/hooks/useDeferredHomepageDemo";
7+
8+
const PeelsChatDemo = dynamic(() => import("@/components/PeelsChatDemo"), {
9+
ssr: false,
10+
loading: () => <HomepageDemoPlaceholder variant="chat" />,
11+
});
12+
13+
export default function DeferredPeelsChatDemo() {
14+
const { isReady, rootRef } = useDeferredHomepageDemo();
15+
16+
return (
17+
<div ref={rootRef} data-testid="homepage-chat-demo-shell">
18+
{isReady ? <PeelsChatDemo /> : <HomepageDemoPlaceholder variant="chat" />}
19+
</div>
20+
);
21+
}

0 commit comments

Comments
 (0)