diff --git a/DESIGN.md b/DESIGN.md index ed45af41a..ad6c61eb5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -117,6 +117,19 @@ Central manager only ever sends the boolean, never the other organizations' ids; an expired session — and let still-mounted pages rebuild their queries after the sign-out's cache clear and fetch again on the dead session. The guard redirects every signed-out dashboard load, so the spinner only covers the moment before it does (`endedSessionRouteFailures.test.tsx`, HarperFast/studio#1546). +- **Signing out in one tab signs out every tab.** `signOutAllLocally` rewrites `Studio:AppSignOut`, and each other tab + holding the app sign-in runs `clearAuthStateAfterSignOutElsewhere` on that `storage` event (`installCrossTabSignOut`). + Without it, a tab learns the session ended only from its next central-manager request, whose 403 toasts as a + permission refusal. `signOutAllLocally` runs once the shared session has ended: after `/Logout/` succeeds, on a 401, + after a password change. The sign-out sweep does not announce, because it runs before `/Logout/`: a failed logout + would sign the other tabs out of a live session, and one signed back in meanwhile would be ended by that `/Logout/`. + The value names the user whose session ended (the sweep remembers them, since it clears the user before `/Logout/`), + and a tab follows only when that is its own app user: a late 401 or event from user U's session must not sign out a + tab that has since signed in as V. A tab whose check is still out remembers the user, and if the check comes back as + them, asks again: central manager may have confirmed them just before the session ended. + Tabs follow that key, not the marker record, because a tab also drops the app sign-in from the record when its own + check merely fails (a timeout, a 5xx). A tab that follows writes no `localStorage`: its copy of the markers is a + snapshot from its own load, and writing it back could undo the other tab's next sign-in (`crossTabSignOut.test.tsx`). - **Data fetching**: TanStack React Query 5. Query keys must be instance-scoped when the request targets a specific instance. - **Polling**: every `refetchInterval` on a query that can reject against an instance or a cluster goes through [`pollUnlessForbidden`](src/react-query/pollUnlessForbidden.ts), which stops the timer on a 403 and backs off a sustained 400 or 5xx; a raw number polls a failing endpoint at full rate for as long as the page is open. When the interval depends on the data, compute it and hand it over per tick: `(query) => pollUnlessForbidden(base(query))(query)` (the back-off state is per query, not per wrapper). - **Forms**: react-hook-form with a zod `zodResolver`. A form whose submit button waits on `formState.isValid` must not use the default `onSubmit` mode, which keeps `isValid` current but shows errors only after a submit the disabled button never allows, so the user is blocked with no reason given (HarperFast/studio#1550). Use `mode: 'onTouched'`: a field's error appears once it is first left and then follows each change, while `isValid` still tracks every keystroke (`onBlur` recomputes `isValid` only on blur, so the button lags the text). Enter neither submits past a disabled button nor leaves the field, so give the `