Skip to content

Commit 4c73639

Browse files
authored
fix homepage hydration and chat date rendering (#63)
* fix homepage hydration and route prefetch noise * refine homepage demo links and labels * remove homepage prefetch overrides * remove applink wrapper * address pr review feedback * refine chat date rendering * close remaining chat hydration gaps * address final copilot feedback
1 parent 4bf9b29 commit 4c73639

18 files changed

Lines changed: 509 additions & 131 deletions

File tree

e2e/home.spec.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("homepage hydrates without chat date mismatches", async ({
4+
browser,
5+
page,
6+
}) => {
7+
const { baseURL, extraHTTPHeaders, locale } = test.info().project.use;
8+
9+
if (typeof baseURL !== "string") {
10+
throw new Error("Expected Playwright baseURL to be configured");
11+
}
12+
13+
const pageErrors: string[] = [];
14+
const consoleErrors: string[] = [];
15+
16+
page.on("pageerror", (error) => {
17+
pageErrors.push(error.message);
18+
});
19+
20+
page.on("console", (message) => {
21+
if (message.type() === "error") {
22+
consoleErrors.push(message.text());
23+
}
24+
});
25+
26+
await page.goto("/", { waitUntil: "domcontentloaded" });
27+
await expect(
28+
page.getByRole("heading", {
29+
name: "Find a home for your food scraps, wherever you are",
30+
})
31+
).toBeVisible();
32+
33+
const serverContext = await browser.newContext({
34+
baseURL,
35+
extraHTTPHeaders,
36+
javaScriptEnabled: false,
37+
locale,
38+
});
39+
const serverPage = await serverContext.newPage();
40+
await serverPage.goto("/");
41+
const serverDayLabels = await serverPage
42+
.getByTestId("chat-day-label")
43+
.allTextContents();
44+
const serverTimestamps = await serverPage
45+
.getByTestId("chat-message-timestamp")
46+
.allTextContents();
47+
await serverContext.close();
48+
49+
await page.waitForLoadState("networkidle");
50+
await expect
51+
.poll(async () => page.getByTestId("chat-day-label").allTextContents())
52+
.toEqual(["Yesterday", "Today"]);
53+
54+
const hydratedDayLabels = await page
55+
.getByTestId("chat-day-label")
56+
.allTextContents();
57+
const hydratedTimestamps = await page
58+
.getByTestId("chat-message-timestamp")
59+
.allTextContents();
60+
61+
expect(serverDayLabels).toEqual(["Thu, May 1", "Fri, May 2"]);
62+
expect(hydratedDayLabels).toEqual(["Yesterday", "Today"]);
63+
expect(hydratedTimestamps).toEqual(serverTimestamps);
64+
expect(
65+
pageErrors.some((message) => message.includes("Minified React error #418"))
66+
).toBeFalsy();
67+
expect(
68+
consoleErrors.some((message) =>
69+
message.includes("Minified React error #418")
70+
)
71+
).toBeFalsy();
72+
});

e2e/listings.spec.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ test("listing edit saves and restores seeded business fields", async ({
7878
email: HOST_EMAIL,
7979
redirectTo: BUSINESS_LISTING_EDIT_PATH,
8080
});
81-
await expect(page.getByTestId("listing-write-form")).toBeVisible();
82-
const descriptionInput = page.locator("#description");
83-
const visibilityInput = page.locator("#visibility");
81+
const listingWriteForm = page.getByTestId("listing-write-form");
82+
await expect(listingWriteForm).toBeVisible();
83+
const descriptionInput = listingWriteForm.locator("#description").first();
84+
const visibilityInput = listingWriteForm.locator("#visibility");
8485
const originalDescription = await descriptionInput.inputValue();
8586
const originalVisibility = await visibilityInput.inputValue();
8687
const updatedDescription =
@@ -105,20 +106,33 @@ test("listing edit saves and restores seeded business fields", async ({
105106
await updateNavigation;
106107

107108
await page.goto(BUSINESS_LISTING_EDIT_PATH);
108-
await expect(page.locator("#description")).toHaveValue(updatedDescription);
109-
await expect(page.locator("#visibility")).toHaveValue(updatedVisibility);
109+
await expect(listingWriteForm.locator("#description").first()).toHaveValue(
110+
updatedDescription
111+
);
112+
await expect(listingWriteForm.locator("#visibility")).toHaveValue(
113+
updatedVisibility
114+
);
110115

111-
await page.locator("#description").fill(originalDescription);
112-
await page.locator("#visibility").selectOption(originalVisibility);
116+
await listingWriteForm
117+
.locator("#description")
118+
.first()
119+
.fill(originalDescription);
120+
await listingWriteForm
121+
.locator("#visibility")
122+
.selectOption(originalVisibility);
113123

114124
await Promise.all([
115125
page.waitForURL(/\/listings\/demo-inner-west-cafe\?status=updated$/),
116126
page.getByTestId("listing-write-submit").click(),
117127
]);
118128

119129
await page.goto(BUSINESS_LISTING_EDIT_PATH);
120-
await expect(page.locator("#description")).toHaveValue(originalDescription);
121-
await expect(page.locator("#visibility")).toHaveValue(originalVisibility);
130+
await expect(listingWriteForm.locator("#description").first()).toHaveValue(
131+
originalDescription
132+
);
133+
await expect(listingWriteForm.locator("#visibility")).toHaveValue(
134+
originalVisibility
135+
);
122136
});
123137

124138
test("residential listing edit leaves avatar management on the profile page", async ({

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
"supabase:diff": "supabase db diff",
1515
"i18n:check": "node scripts/check-i18n-messages.mjs",
1616
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
17-
"check": "npm run i18n:check && npm run format:check",
17+
"check": "npm run i18n:check && npm run format:check && npm run test:unit",
18+
"test:unit": "node --test --experimental-strip-types src/utils/dateUtils.test.ts",
1819
"test:e2e": "playwright test",
19-
"pretest:e2e:prod": "if [ -d .next ]; then echo '.next exists, skipping build'; else npm run build; fi",
20+
"pretest:e2e:prod": "npm run build",
2021
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
2122
"test:e2e:debug": "playwright test --headed --workers=1",
2223
"format": "prettier --write .",

src/app/(core)/(interact)/(centered)/listings/[slug]/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export default async function ListingPage({
6161
}) {
6262
const { slug } = await params;
6363
const { user, listing } = await getListingData(slug);
64+
const referenceNow = new Date().toISOString();
6465

6566
if (!listing) {
6667
notFound();
@@ -70,7 +71,7 @@ export default async function ListingPage({
7071

7172
return (
7273
<StyledMain>
73-
<ListingRead user={user} listing={listing} />
74+
<ListingRead user={user} listing={listing} referenceNow={referenceNow} />
7475
</StyledMain>
7576
);
7677
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export default async function ChatsPage({ params }: ChatsPageProps) {
8585
const { threadId: threadIdSegments } = await params;
8686
const threadId = threadIdSegments?.[0] ?? null;
8787
const redirectPath = threadId ? `/chats/${threadId}` : "/chats";
88+
const referenceNow = new Date().toISOString();
8889

8990
const supabase = await createClient();
9091
const {
@@ -198,6 +199,7 @@ export default async function ChatsPage({ params }: ChatsPageProps) {
198199
initialThreads={typedThreads}
199200
initialThreadId={threadId}
200201
selectedThread={selectedThread}
202+
referenceNow={referenceNow}
201203
/>
202204
);
203205
}

src/app/(core)/(interact)/(stretched)/map/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,14 @@ export async function generateMetadata({
5858
export default async function Page({ searchParams }: MapPageProps) {
5959
const listingSlug = (await searchParams)?.listing;
6060
const { user, listing } = await getInitialData(listingSlug);
61+
const referenceNow = new Date().toISOString();
6162

6263
return (
6364
<MapPageClient
6465
user={user}
6566
initialListingSlug={listingSlug ?? null}
6667
initialListing={listing}
68+
referenceNow={referenceNow}
6769
/>
6870
);
6971
}

src/components/ChatMessage/ChatMessage.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,23 @@ const Timestamp = styled.p`
5151
function ChatMessage({
5252
direction,
5353
message,
54+
locale,
55+
timeZone,
5456
}: {
5557
direction: ChatDirection;
5658
message: { content: string; created_at: string };
59+
locale: string;
60+
timeZone: string;
5761
}) {
5862
return (
5963
<ChatMessageContainer $direction={direction}>
6064
<ChatBubble $direction={direction}>{message.content}</ChatBubble>
61-
<Timestamp>{formatTimestamp(message.created_at)}</Timestamp>
65+
<Timestamp data-testid="chat-message-timestamp">
66+
{formatTimestamp(message.created_at, {
67+
locale,
68+
timeZone,
69+
})}
70+
</Timestamp>
6271
</ChatMessageContainer>
6372
);
6473
}

src/components/ChatPageClient/ChatPageClient.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,13 @@ export default function ChatPageClient({
6666
initialThreads,
6767
initialThreadId,
6868
selectedThread,
69+
referenceNow,
6970
}: {
7071
user: User;
7172
initialThreads: ChatThreadListItem[];
7273
initialThreadId?: string | null;
7374
selectedThread?: ChatThreadView | null;
75+
referenceNow: string;
7476
}) {
7577
const t = useTranslations("Chat");
7678
const { setTabBarProps } = useTabBar();
@@ -105,6 +107,7 @@ export default function ChatPageClient({
105107
user={user}
106108
listing={selectedThread.listing}
107109
existingThread={selectedThread}
110+
referenceNow={referenceNow}
108111
/>
109112
) : (
110113
<ChatWindowEmptyState>

src/components/ChatWindow/ChatWindow.tsx

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ import {
1414
markChatThreadRead,
1515
sendChatMessage,
1616
} from "@/components/ChatWindow/chatWindowController";
17-
import { formatWeekday } from "@/utils/dateUtils";
17+
import { DEMO_CHAT_REFERENCE_TIME } from "@/data/demo/threads";
18+
import {
19+
CHAT_RENDER_TIME_ZONE,
20+
formatWeekday,
21+
getChatDateKey,
22+
} from "@/utils/dateUtils";
1823

1924
import { styled } from "next-yak";
2025
import { useUnreadMessages } from "@/contexts/UnreadMessagesContext";
2126
import { useInlineMutation } from "@/hooks/useInlineMutation";
22-
import { useTranslations } from "next-intl";
27+
import { useLocale, useTranslations } from "next-intl";
2328
import type {
2429
ChatListing,
2530
ChatMessageRecord,
@@ -30,15 +35,45 @@ import type {
3035
import type { DemoListing } from "@/types/listing";
3136
import type { FormSubmitEvent } from "@/types/events";
3237

33-
type ChatWindowProps = {
38+
type SharedChatWindowProps = {
3439
isDrawer?: boolean;
3540
user: User | null;
3641
listing: ChatListing | DemoListing;
3742
existingThread?: ChatThreadRecord | ChatThreadView | null;
38-
isDemo?: boolean;
3943
};
4044

45+
type DemoChatWindowProps = SharedChatWindowProps & {
46+
isDemo: true;
47+
referenceNow?: never;
48+
};
49+
50+
type NonDemoChatWindowProps = SharedChatWindowProps & {
51+
isDemo?: false;
52+
referenceNow: string;
53+
};
54+
55+
type ChatWindowProps = DemoChatWindowProps | NonDemoChatWindowProps;
56+
4157
const DIRECTIONS_FOR_DEMO = ["sent", "received"] as const;
58+
type ChatRenderOptions = {
59+
locale: string;
60+
now?: string;
61+
timeZone: string;
62+
useRelativeDayLabels: boolean;
63+
};
64+
65+
const defaultChatRenderOptions: ChatRenderOptions = {
66+
locale: "en",
67+
now: undefined,
68+
timeZone: CHAT_RENDER_TIME_ZONE,
69+
useRelativeDayLabels: false,
70+
};
71+
72+
function getClientTimeZone() {
73+
return (
74+
Intl.DateTimeFormat().resolvedOptions().timeZone ?? CHAT_RENDER_TIME_ZONE
75+
);
76+
}
4277

4378
const StyledChatWindow = styled.div`
4479
height: 100%;
@@ -115,8 +150,10 @@ const ChatWindow = memo(function ChatWindow({
115150
listing,
116151
existingThread = null,
117152
isDemo = false,
153+
referenceNow,
118154
}: ChatWindowProps) {
119155
const t = useTranslations();
156+
const locale = useLocale();
120157
const supabase = useMemo(() => (isDemo ? null : createClient()), [isDemo]);
121158
const { setUnreadCount, markThreadAsRead } = useUnreadMessages();
122159
const realListing = isDemo ? null : (listing as ChatListing);
@@ -130,6 +167,34 @@ const ChatWindow = memo(function ChatWindow({
130167
const [messages, setMessages] = useState<ChatMessageRecord[]>(
131168
getThreadMessages(existingThread)
132169
);
170+
const [clientTimeZone, setClientTimeZone] = useState<string | null>(null);
171+
const chatRenderOptions = useMemo<ChatRenderOptions>(
172+
() =>
173+
isDemo
174+
? {
175+
locale,
176+
now: DEMO_CHAT_REFERENCE_TIME,
177+
timeZone: CHAT_RENDER_TIME_ZONE,
178+
useRelativeDayLabels: clientTimeZone !== null,
179+
}
180+
: {
181+
...defaultChatRenderOptions,
182+
locale,
183+
now: referenceNow,
184+
timeZone: clientTimeZone ?? CHAT_RENDER_TIME_ZONE,
185+
useRelativeDayLabels: clientTimeZone !== null,
186+
},
187+
[clientTimeZone, isDemo, locale, referenceNow]
188+
);
189+
const messageDateKeys = useMemo(
190+
() =>
191+
messages.map((chatMessage) =>
192+
getChatDateKey(chatMessage.created_at, {
193+
timeZone: chatRenderOptions.timeZone,
194+
})
195+
),
196+
[messages, chatRenderOptions.timeZone]
197+
);
133198

134199
function resolveChatErrorMessage(errorMessage: string | null) {
135200
if (!errorMessage) {
@@ -150,6 +215,10 @@ const ChatWindow = memo(function ChatWindow({
150215
return errorMessage;
151216
}
152217

218+
useEffect(() => {
219+
setClientTimeZone(getClientTimeZone());
220+
}, []);
221+
153222
useEffect(() => {
154223
setThreadId(existingThread?.id ?? null);
155224
setMessages(getThreadMessages(existingThread));
@@ -333,18 +402,26 @@ const ChatWindow = memo(function ChatWindow({
333402
)}
334403

335404
{messages.map((chatMessage, index) => {
405+
const currentDateKey = messageDateKeys[index];
406+
const previousDateKey = messageDateKeys[index - 1];
336407
const showDateHeader =
337-
index === 0 ||
338-
new Date(chatMessage.created_at).toDateString() !==
339-
new Date(messages[index - 1].created_at).toDateString();
408+
index === 0 || currentDateKey !== previousDateKey;
340409
const showInitiationHeader = index === 0;
341410

342411
return (
343412
<Day key={isDemo ? index : chatMessage.id}>
344413
{showDateHeader || showInitiationHeader ? (
345414
<DayHeader>
346415
{showDateHeader && (
347-
<h3>{formatWeekday(chatMessage.created_at)}</h3>
416+
<h3 data-testid="chat-day-label">
417+
{formatWeekday(chatMessage.created_at, {
418+
locale: chatRenderOptions.locale,
419+
now: chatRenderOptions.now,
420+
timeZone: chatRenderOptions.timeZone,
421+
useRelativeDayLabels:
422+
chatRenderOptions.useRelativeDayLabels,
423+
})}
424+
</h3>
348425
)}
349426
{showInitiationHeader && (
350427
<p>
@@ -373,6 +450,8 @@ const ChatWindow = memo(function ChatWindow({
373450
: "received"
374451
}
375452
message={chatMessage}
453+
locale={chatRenderOptions.locale}
454+
timeZone={chatRenderOptions.timeZone}
376455
/>
377456
</Day>
378457
);

0 commit comments

Comments
 (0)