mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(02-12): hold peer tabs during record switches
- Clear scoped reads before the server session changes - Reconcile every tab with a fresh access response
This commit is contained in:
1 parent
c66b29d22a
commit
38214e4726
13 files changed
+711
-45
No files matched your search
@@ -0,0 +1,116 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { SCOPE_DELEGATE_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
test.describe.serial("cross-tab shared-record session switches", () => {
|
||||
test("holds a peer tab before both switch-in and switch-out change the shared session", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const switchingPage = await context.newPage();
|
||||
const peerPage = await context.newPage();
|
||||
const pausedSwitches: ReturnType<typeof deferred>[] = [];
|
||||
const peerRecordReads: string[] = [];
|
||||
|
||||
await switchingPage.route("**/api/account/switch", async (route) => {
|
||||
const pause = pausedSwitches.shift();
|
||||
if (pause) await pause.promise;
|
||||
await route.continue();
|
||||
});
|
||||
peerPage.on("request", (request) => {
|
||||
if (request.method() !== "GET") return;
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
[
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/insights/targets",
|
||||
"/api/medications",
|
||||
"/api/medications/compliance",
|
||||
"/api/labs",
|
||||
"/api/labs/ocr/capability",
|
||||
"/api/insights/coach/nudge-status",
|
||||
].includes(path)
|
||||
) {
|
||||
peerRecordReads.push(path);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([switchingPage.goto("/"), peerPage.goto("/")]);
|
||||
await expect(
|
||||
peerPage.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
|
||||
await openSwitcher(switchingPage);
|
||||
const entry = switchingPage.locator(
|
||||
'[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]',
|
||||
);
|
||||
await expect(entry).toBeVisible();
|
||||
const switchIn = deferred();
|
||||
pausedSwitches.push(switchIn);
|
||||
peerRecordReads.length = 0;
|
||||
const switchInClick = entry.click({ noWaitAfter: true });
|
||||
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.getByRole("button", { name: "User menu" }),
|
||||
).toHaveCount(0);
|
||||
await peerPage.waitForTimeout(200);
|
||||
expect(peerRecordReads).toEqual([]);
|
||||
|
||||
switchIn.release();
|
||||
await switchInClick;
|
||||
await expect(
|
||||
switchingPage.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
const switchOut = deferred();
|
||||
pausedSwitches.push(switchOut);
|
||||
peerRecordReads.length = 0;
|
||||
const switchOutClick = switchingPage
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click({ noWaitAfter: true });
|
||||
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
await peerPage.waitForTimeout(200);
|
||||
expect(peerRecordReads).toEqual([]);
|
||||
|
||||
switchOut.release();
|
||||
await switchOutClick;
|
||||
await expect(
|
||||
switchingPage.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
peerPage.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,9 @@ const mockAuthLoadingRef = { value: false };
|
||||
const mockAccountAccessStatusRef = {
|
||||
value: "valid" as "absent" | "valid" | "invalid",
|
||||
};
|
||||
const mockRecordSessionPhaseRef = {
|
||||
value: "ready" as "ready" | "blocking" | "resolving",
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
@@ -76,6 +79,14 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
clearCachesForSessionEnd: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-session-transition", () => ({
|
||||
useRecordSessionTransition: () => ({
|
||||
id: "cross-tab-switch",
|
||||
phase: mockRecordSessionPhaseRef.value,
|
||||
expectedScope: "record-a",
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockPathRef = { value: "/" };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -192,6 +203,21 @@ describe("<AuthShell> — the owner-only doors", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("holds protected children and owner-only doors while a peer tab changes the session", () => {
|
||||
mockRecordSessionPhaseRef.value = "blocking";
|
||||
try {
|
||||
const html = render(OWN_RECORD, "/labs");
|
||||
|
||||
expect(html).toContain('data-slot="record-scope-hydration-gate"');
|
||||
expect(html).not.toContain("sentinel-page");
|
||||
for (const slot of DOORS) {
|
||||
expect(html).not.toContain(slot);
|
||||
}
|
||||
} finally {
|
||||
mockRecordSessionPhaseRef.value = "ready";
|
||||
}
|
||||
});
|
||||
|
||||
it("does not delay public routes for record capability resolution", () => {
|
||||
mockAuthLoadingRef.value = true;
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { isDestinationInSharedRecord } from "./nav-model";
|
||||
@@ -58,6 +59,7 @@ export function AuthShell({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const recordSessionTransition = useRecordSessionTransition();
|
||||
|
||||
const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
||||
// v1.4.26 — `/privacy` is a long-form legal page that brings its own
|
||||
@@ -79,8 +81,13 @@ export function AuthShell({
|
||||
// decides what to mount and the surfaces beneath it decide what to offer,
|
||||
// and the two disagreeing is the whole class of defect this hook exists to
|
||||
// close.
|
||||
const { inSharedRecord, recordKind, sections, accessRefused } =
|
||||
useRecordCapabilities();
|
||||
const {
|
||||
inSharedRecord,
|
||||
recordKind,
|
||||
sections,
|
||||
accessRefused,
|
||||
recordSessionPending,
|
||||
} = useRecordCapabilities();
|
||||
// A managed profile has a deliberately small Settings surface of its own.
|
||||
// Let that route family reach its record-aware gate; adult shared records
|
||||
// stay in the generic refusal branch even at MANAGE.
|
||||
@@ -231,7 +238,11 @@ export function AuthShell({
|
||||
// Admin. The payload resolves the active record as well as identity, so a
|
||||
// child mounted before it could issue an actor-scoped read before the shell
|
||||
// knows that a switched record must be refused or scoped differently.
|
||||
if (isLoading) {
|
||||
if (
|
||||
isLoading ||
|
||||
recordSessionTransition.phase !== "ready" ||
|
||||
recordSessionPending
|
||||
) {
|
||||
return <RecordScopeHydrationGate label={t("nav.loadingScreen")} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,16 @@ import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
|
||||
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import {
|
||||
discardDashboardSnapshotPreload,
|
||||
prefetchDashboardSnapshot,
|
||||
} from "@/lib/queries/use-dashboard-snapshot";
|
||||
import {
|
||||
getRecordSessionTransition,
|
||||
subscribeToRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications";
|
||||
import {
|
||||
getRecordScope,
|
||||
@@ -208,16 +216,19 @@ function DashboardSnapshotPreloader() {
|
||||
const queryClient = useQueryClient();
|
||||
const pathname = usePathname();
|
||||
const { user, isLoading } = useAuth();
|
||||
const transition = useRecordSessionTransition();
|
||||
const recordScope = useSyncExternalStore(
|
||||
subscribeToClientRecordScope,
|
||||
getClientRecordScope,
|
||||
getServerRecordScope,
|
||||
);
|
||||
const ready = isRecordPreloadReady({
|
||||
isLoading,
|
||||
accountAccessStatus: user?.accountAccessStatus,
|
||||
activeAccountId: user?.accountAccess?.active?.accountId ?? null,
|
||||
});
|
||||
const ready =
|
||||
transition.phase === "ready" &&
|
||||
isRecordPreloadReady({
|
||||
isLoading,
|
||||
accountAccessStatus: user?.accountAccessStatus,
|
||||
activeAccountId: user?.accountAccess?.active?.accountId ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
@@ -237,7 +248,7 @@ function DashboardSnapshotPreloader() {
|
||||
// A transition to an unresolved, refused, or different record scope must
|
||||
// abort a preloader before its response can be adopted by the next route.
|
||||
return () => controller.abort();
|
||||
}, [pathname, queryClient, ready, recordScope]);
|
||||
}, [pathname, queryClient, ready, recordScope, transition.phase]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -245,9 +256,13 @@ function DashboardSnapshotPreloader() {
|
||||
|
||||
function QueryVisibilityRefreshBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const transition = useRecordSessionTransition();
|
||||
useEffect(
|
||||
() => subscribeToMeaningfulVisibilityRefresh(queryClient),
|
||||
[queryClient],
|
||||
() =>
|
||||
transition.phase === "ready"
|
||||
? subscribeToMeaningfulVisibilityRefresh(queryClient)
|
||||
: undefined,
|
||||
[queryClient, transition.phase],
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -261,10 +276,16 @@ function QueryVisibilityRefreshBridge() {
|
||||
// empty skeletons. Build-version + age gated; cleared on logout.
|
||||
function QueryPersistenceBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const transition = useRecordSessionTransition();
|
||||
useEffect(() => {
|
||||
if (transition.phase !== "ready") return;
|
||||
let stop: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void restorePersistedQueryCache(queryClient, SHELL_VERSION).finally(() => {
|
||||
void restorePersistedQueryCache(
|
||||
queryClient,
|
||||
SHELL_VERSION,
|
||||
() => !cancelled,
|
||||
).finally(() => {
|
||||
if (!cancelled) {
|
||||
stop = startPersistingQueryCache(queryClient, SHELL_VERSION);
|
||||
}
|
||||
@@ -273,7 +294,42 @@ function QueryPersistenceBridge() {
|
||||
cancelled = true;
|
||||
stop?.();
|
||||
};
|
||||
}, [queryClient, transition.phase]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tab-local reads immediately whenever the browser-wide transition
|
||||
* store enters its hold. The AuthShell consumes the same store synchronously,
|
||||
* so protected children unmount while this bridge cancels their observers.
|
||||
*/
|
||||
function RecordSessionTransitionBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const { refetch } = useAuth();
|
||||
const transition = useRecordSessionTransition();
|
||||
|
||||
useEffect(() => {
|
||||
const clearForTransition = () => {
|
||||
if (getRecordSessionTransition().phase === "ready") return;
|
||||
discardDashboardSnapshotPreload();
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
};
|
||||
// A tab opened while another tab is already switching needs the same
|
||||
// cache purge even though it has no future broadcast to receive.
|
||||
clearForTransition();
|
||||
return subscribeToRecordSessionTransition(clearForTransition);
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
transition.phase === "resolving" ||
|
||||
(transition.phase === "ready" && transition.id !== null)
|
||||
) {
|
||||
void refetch();
|
||||
}
|
||||
}, [refetch, transition.id, transition.phase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -329,6 +385,7 @@ export function Providers({
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale={initialLocale}>
|
||||
<QueryPersistenceBridge />
|
||||
<RecordSessionTransitionBridge />
|
||||
<QueryVisibilityRefreshBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
<OfflineMutationToaster />
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
/**
|
||||
* v1.36.0 — entering and leaving somebody else's record, from the browser.
|
||||
*
|
||||
* The switch is three acts in a fixed order, and the order is the whole
|
||||
* safety argument:
|
||||
* The switch is four acts in a fixed browser-coordination order:
|
||||
*
|
||||
* 1. **Ask the server.** `POST /api/account/switch` validates the grant and
|
||||
* stamps the session. Nothing local changes until it answers, so a switch
|
||||
* the server refuses leaves the browser exactly where it was.
|
||||
* 2. **Empty every cache that survives a reload.** The IndexedDB query
|
||||
* 1. **Hold every tab.** Publish a cross-tab `blocking` transition and clear
|
||||
* local reads before the POST can change the shared server session.
|
||||
* 2. **Ask the server.** `POST /api/account/switch` validates the grant and
|
||||
* stamps the session. Any client-visible failure stays held until a
|
||||
* fresh `/api/auth/me` resolves the record the server actually kept; it
|
||||
* never restores stale caches.
|
||||
* 3. **Empty every cache that survives a reload.** The IndexedDB query
|
||||
* snapshot and the service worker's `healthlog-data-*` cache both answer
|
||||
* reads from disk. Left in place, the first paint on the other side can
|
||||
* be the PREVIOUS record's numbers under a banner naming the new one —
|
||||
* and nothing in the page's own code would be wrong, because the answer
|
||||
* would not have come from the page. Awaited, not fired: a wipe racing
|
||||
* the navigation is a wipe that sometimes does not happen.
|
||||
* 3. **Reload the document.** Not `router.refresh()`, not a query
|
||||
* 4. **Reload the document.** Not `router.refresh()`, not a query
|
||||
* invalidation. A hard navigation is what discards the in-memory cache,
|
||||
* every React state tree holding a rendered value, and every in-flight
|
||||
* request that was issued against the old record. The reload IS the cache
|
||||
@@ -38,7 +40,11 @@ import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { clearOfflineCachesForRecordSwitch } from "@/lib/pwa/query-persister";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { setRecordScope } from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
beginRecordSessionTransition,
|
||||
commitRecordSessionTransition,
|
||||
resolveUnknownRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
|
||||
/**
|
||||
* The two stable sharing error codes, as the envelope publishes them
|
||||
@@ -96,17 +102,13 @@ async function postSwitch(accountId: string | null): Promise<SwitchResponse> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the switch: stamp the cache scope, empty the disk layers, reload.
|
||||
* Complete a server-confirmed switch: empty the disk layers, reload.
|
||||
*
|
||||
* Extracted from the mutation so the grant-loss reset can reuse the exact same
|
||||
* sequence. Two copies of "wipe, then reload" is one copy too many for a step
|
||||
* whose order is the safety property.
|
||||
*/
|
||||
async function landOnRecord(
|
||||
accountId: string | null,
|
||||
destination = "/",
|
||||
): Promise<never> {
|
||||
setRecordScope(accountId);
|
||||
async function landOnRecord(destination = "/"): Promise<never> {
|
||||
await clearOfflineCachesForRecordSwitch();
|
||||
window.location.assign(destination);
|
||||
// The document is being replaced. Nothing after this runs, and the promise
|
||||
@@ -136,12 +138,26 @@ export function useAccountSwitch() {
|
||||
accountId: string | null;
|
||||
destination: string;
|
||||
}) => {
|
||||
await postSwitch(accountId);
|
||||
// Publish the browser-wide hold before the server sees the session
|
||||
// change. Every recipient blocks its shell and clears local reads before
|
||||
// it reconciles the committed scope through a fresh `/me`.
|
||||
const transitionId = beginRecordSessionTransition(accountId);
|
||||
// The in-memory cache holds the record we are leaving. Clearing it here
|
||||
// as well as reloading costs nothing and closes the window in which a
|
||||
// failed navigation would leave the old entries reachable.
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
return landOnRecord(accountId, destination);
|
||||
try {
|
||||
await postSwitch(accountId);
|
||||
} catch (error) {
|
||||
// A client error cannot prove that the server left the session alone:
|
||||
// its response may have been lost after the server committed. Keep all
|
||||
// tabs held until a fresh `/me` resolves the record the server kept.
|
||||
resolveUnknownRecordSessionTransition(transitionId);
|
||||
throw error;
|
||||
}
|
||||
commitRecordSessionTransition(transitionId, accountId);
|
||||
return landOnRecord(destination);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
@@ -168,14 +184,15 @@ export function useAccountSwitch() {
|
||||
*
|
||||
* The switcher-reset the contract asks for: on `sharing.access.denied` the
|
||||
* session is still stamped with an account it may no longer act on, and every
|
||||
* delegable read from here on is a 403. Clearing the stamp puts the browser
|
||||
* back in its own account rather than leaving somebody staring at a wall of
|
||||
* refusals under a banner that says they are somewhere they are not.
|
||||
* delegable read from here on is a 403. When the server confirms the reset,
|
||||
* its own-record scope replaces the stamp rather than leaving somebody
|
||||
* staring at refusals under a banner that says they are somewhere they are
|
||||
* not.
|
||||
*
|
||||
* `POST /api/account/switch` with `null` is an actor surface and always
|
||||
* succeeds — it is the way out, by design. If even that fails (offline, server
|
||||
* down) the reload still happens: the stamp survives, but so does the honest
|
||||
* refusal, and the alternative is a page that neither recovers nor says why.
|
||||
* down), the browser keeps the transition unresolved and reloads. A fresh
|
||||
* `/api/auth/me`, not a local scope guess, decides what may render next.
|
||||
*/
|
||||
/**
|
||||
* Watch the cache for the moment the server stops honouring the switch.
|
||||
@@ -204,12 +221,20 @@ export function subscribeToGrantLoss(
|
||||
}
|
||||
|
||||
export function useLeaveSharedRecordOnGrantLoss() {
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback(async () => {
|
||||
const transitionId = beginRecordSessionTransition(null);
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
try {
|
||||
await postSwitch(null);
|
||||
commitRecordSessionTransition(transitionId, null);
|
||||
} catch {
|
||||
/* the reload below is the part that matters */
|
||||
// The grant-loss path deliberately still reloads, but it cannot claim
|
||||
// an own-record scope when the POST outcome is unknown. The next `/me`
|
||||
// is the only authority that may release this resolving hold.
|
||||
resolveUnknownRecordSessionTransition(transitionId);
|
||||
}
|
||||
await landOnRecord(null);
|
||||
}, []);
|
||||
await landOnRecord();
|
||||
}, [queryClient]);
|
||||
}
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
setRecordScope,
|
||||
setRefusedRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
settleRecordSessionTransition,
|
||||
settleRefusedRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
import { parseAccountAccess } from "@/lib/sharing/account-access-schema";
|
||||
|
||||
@@ -320,6 +324,15 @@ export async function fetchMe(): Promise<AuthUser> {
|
||||
} else {
|
||||
setRecordScope(accountAccess.active?.accountId ?? null);
|
||||
}
|
||||
// A cross-tab switch holds the shell until this server-resolved payload
|
||||
// confirms the record the transition committed. The malformed branch has
|
||||
// already installed its refusal sentinel, so it may release only into the
|
||||
// refusal door rather than being mistaken for the caller's own record.
|
||||
if (accountAccessStatus === "invalid") {
|
||||
settleRefusedRecordSessionTransition();
|
||||
} else {
|
||||
settleRecordSessionTransition(accountAccess.active?.accountId ?? null);
|
||||
}
|
||||
return {
|
||||
...(data as AuthUser),
|
||||
disableCoach: data.disableCoach ?? false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
@@ -71,6 +72,8 @@ import {
|
||||
* search a button in gets a sentence.
|
||||
*/
|
||||
export interface RecordCapabilities {
|
||||
/** A tab is awaiting a fresh `/me` after another tab changed the session. */
|
||||
recordSessionPending?: boolean;
|
||||
/** A present account-access block was malformed and must not mount a record. */
|
||||
accessRefused?: boolean;
|
||||
/** Is this browser acting on somebody else's record right now. */
|
||||
@@ -114,10 +117,12 @@ export interface RecordCapabilities {
|
||||
export function resolveRecordCapabilities(
|
||||
active: AccountAccessEntry | null | undefined,
|
||||
accessRefused = false,
|
||||
recordSessionPending = false,
|
||||
): RecordCapabilities {
|
||||
if (accessRefused) {
|
||||
if (accessRefused || recordSessionPending) {
|
||||
return {
|
||||
accessRefused: true,
|
||||
accessRefused: accessRefused || undefined,
|
||||
recordSessionPending: recordSessionPending || undefined,
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
@@ -171,9 +176,11 @@ export function resolveRecordCapabilities(
|
||||
*/
|
||||
export function useRecordCapabilities(): RecordCapabilities {
|
||||
const { user } = useAuth();
|
||||
const transition = useRecordSessionTransition();
|
||||
return resolveRecordCapabilities(
|
||||
user?.accountAccess?.active,
|
||||
user?.accountAccessStatus === "invalid",
|
||||
transition.phase !== "ready",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
getRecordSessionTransition,
|
||||
subscribeToRecordSessionTransition,
|
||||
type RecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
|
||||
const SERVER_TRANSITION: RecordSessionTransition = {
|
||||
id: null,
|
||||
phase: "ready",
|
||||
expectedScope: undefined,
|
||||
};
|
||||
|
||||
function getServerRecordSessionTransition(): RecordSessionTransition {
|
||||
return SERVER_TRANSITION;
|
||||
}
|
||||
|
||||
/** The tab-local view of a browser-wide record-session transition. */
|
||||
export function useRecordSessionTransition(): RecordSessionTransition {
|
||||
return useSyncExternalStore(
|
||||
subscribeToRecordSessionTransition,
|
||||
getRecordSessionTransition,
|
||||
getServerRecordSessionTransition,
|
||||
);
|
||||
}
|
||||
@@ -172,6 +172,7 @@ function currentUserId(queryClient: QueryClient): string | null {
|
||||
export async function restorePersistedQueryCache(
|
||||
queryClient: QueryClient,
|
||||
buildVersion: string,
|
||||
canHydrate: () => boolean = () => true,
|
||||
): Promise<void> {
|
||||
if (typeof indexedDB === "undefined") return;
|
||||
// v1.36.0 — a browser inside somebody else's record restores nothing. The
|
||||
@@ -219,7 +220,7 @@ export async function restorePersistedQueryCache(
|
||||
(q) => cache.get(q.queryHash) === undefined,
|
||||
),
|
||||
};
|
||||
if (filtered.queries.length === 0) return;
|
||||
if (filtered.queries.length === 0 || !canHydrate()) return;
|
||||
|
||||
hydrate(queryClient, filtered);
|
||||
// Restored queries carry their original `dataUpdatedAt`, so with the
|
||||
|
||||
@@ -78,6 +78,11 @@ export function _resetDashboardSnapshotPreloadForTests(): void {
|
||||
preloadedSnapshot = null;
|
||||
}
|
||||
|
||||
/** Drop a handoff when a tab begins or receives a record-session switch. */
|
||||
export function discardDashboardSnapshotPreload(): void {
|
||||
preloadedSnapshot = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick the snapshot fetch off ahead of the dashboard page chunk.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
__resetRecordSessionTransitionForTests,
|
||||
beginRecordSessionTransition,
|
||||
commitRecordSessionTransition,
|
||||
getRecordSessionTransition,
|
||||
resolveUnknownRecordSessionTransition,
|
||||
settleRefusedRecordSessionTransition,
|
||||
settleRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
import {
|
||||
__resetRecordScopeForTests,
|
||||
getRecordScope,
|
||||
setRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
|
||||
beforeEach(() => {
|
||||
__resetRecordScopeForTests();
|
||||
__resetRecordSessionTransitionForTests();
|
||||
});
|
||||
|
||||
describe("record session transition", () => {
|
||||
it("blocks every tab before the server switch starts", () => {
|
||||
setRecordScope(null);
|
||||
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
|
||||
expect(getRecordSessionTransition()).toMatchObject({
|
||||
id: transitionId,
|
||||
phase: "blocking",
|
||||
expectedScope: "shared-record",
|
||||
});
|
||||
expect(getRecordScope()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the hold through the committed scope until /me confirms it", () => {
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
|
||||
commitRecordSessionTransition(transitionId, "shared-record");
|
||||
|
||||
expect(getRecordScope()).toBe("shared-record");
|
||||
expect(getRecordSessionTransition()).toMatchObject({
|
||||
id: transitionId,
|
||||
phase: "resolving",
|
||||
expectedScope: "shared-record",
|
||||
});
|
||||
|
||||
settleRecordSessionTransition("shared-record");
|
||||
|
||||
expect(getRecordSessionTransition().phase).toBe("ready");
|
||||
});
|
||||
|
||||
it("keeps a failed switch held until /me reconciles the prior scope", () => {
|
||||
setRecordScope(null);
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
|
||||
resolveUnknownRecordSessionTransition(transitionId);
|
||||
|
||||
expect(getRecordSessionTransition()).toMatchObject({
|
||||
id: transitionId,
|
||||
phase: "resolving",
|
||||
expectedScope: undefined,
|
||||
});
|
||||
expect(getRecordScope()).toBeNull();
|
||||
|
||||
settleRecordSessionTransition(null);
|
||||
|
||||
expect(getRecordSessionTransition().phase).toBe("ready");
|
||||
});
|
||||
|
||||
it("does not release a committed transition for a divergent /me response", () => {
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
commitRecordSessionTransition(transitionId, "shared-record");
|
||||
|
||||
settleRecordSessionTransition("different-record");
|
||||
|
||||
expect(getRecordSessionTransition()).toMatchObject({
|
||||
phase: "resolving",
|
||||
expectedScope: "shared-record",
|
||||
});
|
||||
});
|
||||
|
||||
it("releases an explicitly refused /me response without trusting a scope", () => {
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
commitRecordSessionTransition(transitionId, "shared-record");
|
||||
|
||||
settleRefusedRecordSessionTransition();
|
||||
|
||||
expect(getRecordSessionTransition()).toEqual({
|
||||
id: transitionId,
|
||||
phase: "ready",
|
||||
expectedScope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@
|
||||
*/
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
const STORAGE_KEY = "healthlog-record-scope";
|
||||
export const RECORD_SCOPE_STORAGE_KEY = "healthlog-record-scope";
|
||||
const REFUSED_RECORD_SCOPE = "__healthlog_refused_record__";
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ function notifyScopeListeners(): void {
|
||||
|
||||
function readMirror(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(RECORD_SCOPE_STORAGE_KEY);
|
||||
return raw === null || raw.length === 0 ? null : raw;
|
||||
} catch {
|
||||
// Private mode, disabled storage, or SSR. "Own record" is the safe
|
||||
@@ -110,9 +110,9 @@ export function setRecordScope(accountId: string | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (accountId === null) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(RECORD_SCOPE_STORAGE_KEY);
|
||||
} else {
|
||||
localStorage.setItem(STORAGE_KEY, accountId);
|
||||
localStorage.setItem(RECORD_SCOPE_STORAGE_KEY, accountId);
|
||||
}
|
||||
} catch {
|
||||
/* storage unavailable — the in-process copy still partitions this tab */
|
||||
@@ -132,7 +132,7 @@ export function subscribeToRecordScope(listener: () => void): () => void {
|
||||
return () => scopeListeners.delete(listener);
|
||||
}
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== STORAGE_KEY) return;
|
||||
if (event.key !== RECORD_SCOPE_STORAGE_KEY) return;
|
||||
scope =
|
||||
event.newValue === null || event.newValue.length === 0
|
||||
? null
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RECORD_SCOPE_STORAGE_KEY,
|
||||
setRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
|
||||
/**
|
||||
* A browser-wide hold around a server-side record-session switch.
|
||||
*
|
||||
* The session stamp is shared by every tab, but React Query and `/api/auth/me`
|
||||
* are tab-local. A peer with a five-minute fresh `/me` cache can otherwise
|
||||
* keep owner chrome mounted after another tab changes the server session. The
|
||||
* switcher therefore publishes `blocking` before its POST. Recipients clear
|
||||
* their local reads and render only the hydration gate. A committed scope then
|
||||
* moves every tab to `resolving`; only a fresh `/me` whose active record
|
||||
* exactly matches the committed scope releases the hold.
|
||||
*
|
||||
* This is a browser-side coordination point, not a distributed-security
|
||||
* fence: BroadcastChannel and storage delivery cannot pre-empt a request that
|
||||
* a suspended peer already sent. The server remains authoritative for every
|
||||
* such request. After a tab processes the hold, this protocol removes local
|
||||
* results and prevents its protected shell from issuing new reads until `/me`
|
||||
* resolves the scope.
|
||||
*/
|
||||
export type RecordSessionPhase = "ready" | "blocking" | "resolving";
|
||||
|
||||
export interface RecordSessionTransition {
|
||||
id: string | null;
|
||||
phase: RecordSessionPhase;
|
||||
/** Undefined only for a recovery reload whose server outcome is unknown. */
|
||||
expectedScope: string | null | undefined;
|
||||
}
|
||||
|
||||
interface WireTransition {
|
||||
id: string;
|
||||
phase: Exclude<RecordSessionPhase, "ready"> | "ready";
|
||||
expectedScope?: string | null;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "healthlog-record-session-transition";
|
||||
const CHANNEL_NAME = "healthlog-record-session-transition";
|
||||
const TRANSITION_MAX_AGE_MS = 30_000;
|
||||
const READY: RecordSessionTransition = {
|
||||
id: null,
|
||||
phase: "ready",
|
||||
expectedScope: undefined,
|
||||
};
|
||||
|
||||
let transition = READY;
|
||||
let seeded = false;
|
||||
const listeners = new Set<() => void>();
|
||||
let channel: BroadcastChannel | null = null;
|
||||
let storageListenerAttached = false;
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function makeId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function parseWire(value: string | null): WireTransition | null {
|
||||
if (value === null) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<WireTransition>;
|
||||
if (
|
||||
typeof parsed.id !== "string" ||
|
||||
(parsed.phase !== "blocking" &&
|
||||
parsed.phase !== "resolving" &&
|
||||
parsed.phase !== "ready") ||
|
||||
typeof parsed.at !== "number" ||
|
||||
Date.now() - parsed.at > TRANSITION_MAX_AGE_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
parsed.expectedScope !== undefined &&
|
||||
parsed.expectedScope !== null &&
|
||||
typeof parsed.expectedScope !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as WireTransition;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toSnapshot(next: WireTransition): RecordSessionTransition {
|
||||
return {
|
||||
id: next.id,
|
||||
// A new tab cannot know whether an inherited blocking POST is still in
|
||||
// flight. It resolves `/me` rather than leaving the browser wedged if the
|
||||
// initiating tab crashed between its POST and terminal message.
|
||||
phase: next.phase === "blocking" && !seeded ? "resolving" : next.phase,
|
||||
expectedScope: next.expectedScope,
|
||||
};
|
||||
}
|
||||
|
||||
function applyWire(next: WireTransition): void {
|
||||
if (
|
||||
transition.id === next.id &&
|
||||
transition.phase === next.phase &&
|
||||
transition.expectedScope === next.expectedScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
transition = toSnapshot(next);
|
||||
seeded = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
function writeWire(next: WireTransition): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* storage unavailable — BroadcastChannel can still notify peers */
|
||||
}
|
||||
}
|
||||
|
||||
function postWire(next: WireTransition): void {
|
||||
try {
|
||||
channel?.postMessage(next);
|
||||
} catch {
|
||||
/* a closed channel is only an optimisation loss; storage remains */
|
||||
}
|
||||
}
|
||||
|
||||
function publish(next: WireTransition): void {
|
||||
applyWire(next);
|
||||
writeWire(next);
|
||||
postWire(next);
|
||||
}
|
||||
|
||||
function ensureTransport(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!storageListenerAttached) {
|
||||
window.addEventListener("storage", (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
const next = parseWire(event.newValue);
|
||||
if (next) applyWire(next);
|
||||
return;
|
||||
}
|
||||
// A scope write without a transition marker is an external/legacy
|
||||
// switch. Fail closed and force `/me` to reconcile it before any stale
|
||||
// shell can issue a new record read.
|
||||
if (event.key === RECORD_SCOPE_STORAGE_KEY) {
|
||||
const expectedScope =
|
||||
event.newValue === null || event.newValue.length === 0
|
||||
? null
|
||||
: event.newValue;
|
||||
// A current protocol transition already names this scope. The scope
|
||||
// mirror is its second storage write, not a separate legacy switch.
|
||||
if (
|
||||
transition.phase !== "ready" &&
|
||||
transition.expectedScope === expectedScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
publish({
|
||||
id: makeId(),
|
||||
phase: "resolving",
|
||||
expectedScope,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
storageListenerAttached = true;
|
||||
}
|
||||
if (channel === null && typeof BroadcastChannel !== "undefined") {
|
||||
channel = new BroadcastChannel(CHANNEL_NAME);
|
||||
channel.onmessage = (event: MessageEvent<unknown>) => {
|
||||
const value = event.data;
|
||||
if (value === null || typeof value !== "object") return;
|
||||
const next = parseWire(JSON.stringify(value));
|
||||
if (next) applyWire(next);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function seed(): void {
|
||||
if (seeded) return;
|
||||
if (typeof window === "undefined") {
|
||||
seeded = true;
|
||||
return;
|
||||
}
|
||||
const next = parseWire(localStorage.getItem(STORAGE_KEY));
|
||||
if (next) {
|
||||
transition = toSnapshot(next);
|
||||
}
|
||||
seeded = true;
|
||||
}
|
||||
|
||||
export function getRecordSessionTransition(): RecordSessionTransition {
|
||||
seed();
|
||||
return transition;
|
||||
}
|
||||
|
||||
export function subscribeToRecordSessionTransition(
|
||||
listener: () => void,
|
||||
): () => void {
|
||||
seed();
|
||||
ensureTransport();
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Publish the safe hold before issuing `POST /api/account/switch`. */
|
||||
export function beginRecordSessionTransition(
|
||||
expectedScope: string | null,
|
||||
): string {
|
||||
seed();
|
||||
ensureTransport();
|
||||
const id = makeId();
|
||||
publish({ id, phase: "blocking", expectedScope, at: Date.now() });
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Record the server-confirmed scope, while every tab still waits for `/me`. */
|
||||
export function commitRecordSessionTransition(
|
||||
id: string,
|
||||
expectedScope: string | null,
|
||||
): void {
|
||||
if (transition.id !== id) return;
|
||||
publish({ id, phase: "resolving", expectedScope, at: Date.now() });
|
||||
// The terminal transition marker is deliberately published before the
|
||||
// scope mirror. A peer that receives the two storage events separately is
|
||||
// already held when it learns the new record hash.
|
||||
setRecordScope(expectedScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch response was lost after the request was made. Keep all tabs held
|
||||
* and let `/me` decide which record the server actually kept.
|
||||
*/
|
||||
export function resolveUnknownRecordSessionTransition(id: string): void {
|
||||
if (transition.id !== id) return;
|
||||
publish({ id, phase: "resolving", at: Date.now() });
|
||||
}
|
||||
|
||||
/** Release only the `/me` response that confirms the committed record. */
|
||||
export function settleRecordSessionTransition(
|
||||
resolvedScope: string | null,
|
||||
): void {
|
||||
if (transition.phase !== "resolving") return;
|
||||
if (
|
||||
transition.expectedScope !== undefined &&
|
||||
transition.expectedScope !== resolvedScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = transition.id ?? makeId();
|
||||
publish({ id, phase: "ready", at: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the browser hold after a server response has explicitly refused its
|
||||
* access block. The caller has already installed the refused scope sentinel,
|
||||
* so returning to `ready` can only render the access-refusal door; it never
|
||||
* restores a prior record scope.
|
||||
*/
|
||||
export function settleRefusedRecordSessionTransition(): void {
|
||||
if (transition.phase !== "resolving") return;
|
||||
const id = transition.id ?? makeId();
|
||||
publish({ id, phase: "ready", at: Date.now() });
|
||||
}
|
||||
|
||||
/** @internal — deterministic transition state for unit tests. */
|
||||
export function __resetRecordSessionTransitionForTests(): void {
|
||||
transition = READY;
|
||||
seeded = false;
|
||||
listeners.clear();
|
||||
channel?.close();
|
||||
channel = null;
|
||||
}
|
||||
Reference in new issue
Block a user