fix(coach): open the deep-linked sheet only once the viewport is known

/coach?settings=data opened the conversations sheet from the first
render. The server and hydration renders do not know the viewport and
take the narrow layout, so on a desktop the sheet was open until the
docked panel replaced it; only the batching of the hydration re-render
kept it off the screen. The deep link now opens the sheet only once
hydrated on a viewport that does not dock the panel; on a docked one the
panel is revealed as before.

The desktop deep-link spec records every mount of the sheet from the
first byte and expects none; a phone deep-link spec covers the sheet
path and serves as the watcher's positive control.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 08:34:28 +02:00
1 parent 4bb03600b3
commit 4db7fc5113
3 files changed
+139 -2

No files matched your search

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { sheetShown } from "../conversations-panel";
/**
* `/coach?settings=data` opens the phone sheet, and only the phone sheet.
* The server and hydration renders take the narrow layout (the viewport is
* not known yet), so a deep link that opened the sheet from the first
* render flashed it on a desktop before the docked panel replaced it.
*/
describe("sheetShown", () => {
const deepLink = { chosen: false, deepLinkPending: true };
it("keeps the sheet shut while the viewport is not known", () => {
expect(sheetShown({ ...deepLink, hydrated: false, docked: false })).toBe(
false,
);
});
it("never opens the sheet for a deep link on a docked viewport", () => {
expect(sheetShown({ ...deepLink, hydrated: true, docked: true })).toBe(
false,
);
});
it("opens the sheet for a deep link on a phone once hydrated", () => {
expect(sheetShown({ ...deepLink, hydrated: true, docked: false })).toBe(
true,
);
});
it("follows the person's own choice otherwise", () => {
const base = { deepLinkPending: false, hydrated: true, docked: false };
expect(sheetShown({ ...base, chosen: true })).toBe(true);
expect(sheetShown({ ...base, chosen: false })).toBe(false);
});
});
@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import {
PanelRightClose,
@@ -48,6 +48,25 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
*/
export const COACH_PANEL_ID = "coach-conversations-panel";
const noSubscription = () => () => {};
/**
* Whether the phone sheet is open. A settings deep link opens it only once
* the viewport is known: the server and hydration renders take the narrow
* (sheet) layout, so opening it from the first render flashed the sheet on
* a desktop before the docked panel replaced it.
*/
export function sheetShown(state: {
chosen: boolean;
deepLinkPending: boolean;
hydrated: boolean;
docked: boolean;
}): boolean {
return (
state.chosen || (state.deepLinkPending && state.hydrated && !state.docked)
);
}
export interface ConversationsPanelProps {
activeId: string | null;
onSelect: (id: string) => void;
@@ -75,7 +94,26 @@ export function ConversationsPanel({
// A settings deep-link shows the panel without rewriting the remembered
// choice; the next toggle settles it.
const [revealed, setRevealed] = useState(openSettingsOnData);
const [sheetOpen, setSheetOpen] = useState(openSettingsOnData);
const hydrated = useSyncExternalStore(
noSubscription,
() => true,
() => false,
);
const [deepLinkPending, setDeepLinkPending] = useState(openSettingsOnData);
const [sheetChosen, setSheetChosen] = useState(false);
const sheetOpen = sheetShown({
chosen: sheetChosen,
deepLinkPending,
hydrated,
docked,
});
// On a docked viewport the deep link is served by `revealed`; it must not
// open the sheet later if the window narrows.
if (deepLinkPending && hydrated && docked) setDeepLinkPending(false);
const setSheetOpen = (next: boolean) => {
setDeepLinkPending(false);
setSheetChosen(next);
};
const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData);
const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData);