fix(query): scope broadcast channel per account and clear on auth

Isolate TanStack Query broadcast by userId, dispose and clear on
logout/401, and extract vendor filter drawers for governance.
This commit is contained in:
arthur.bassi 2026-08-11 14:12:40 -03:00
parent c8a8522499
commit e4370591ef
11 changed files with 505 additions and 275 deletions

View file

@ -25,17 +25,23 @@ Use TanStack's own experimental broadcast client,
wired up in a single dedicated setup module,
`src/lib/query/setup-query-broadcast.ts`. The module:
- Wraps `broadcastQueryClient({ queryClient, broadcastChannel })`, keyed to a
single named channel (`seahaven-admin-query`).
- No-ops outside the browser (SSR/build) and under Vitest (`import.meta.env.MODE
=== "test"`), so it never runs in unit tests or node-based tooling.
- Is invoked once from `src/lib/query/query-client.ts` against the app's
singleton `QueryClient`, so every tab that loads the SPA subscribes to the
same channel automatically — no per-feature wiring required.
- Exposes `startQueryBroadcast(queryClient, userId)` and
`stopQueryBroadcast(queryClient)`.
- Keys `broadcastQueryClient` to an **account-scoped** channel
(`seahaven-admin-query:${userId}`), so tabs belonging to different accounts
on the same origin do not share cache traffic.
- Disposes the prior subscription (the unsubscribe returned by
`broadcastQueryClient`) and clears the `QueryClient` when the authenticated
user changes or the session ends.
- No-ops the BroadcastChannel outside the browser (SSR/build) and under Vitest
(`import.meta.env.MODE === "test"`).
- Is started after authentication (`AuthProvider` session restore + login
success) and stopped on logout and HTTP 401 session clear — not at
`QueryClient` module load.
This piggybacks on the query cache we already have (no parallel store), uses
the library that owns the `QueryClient` we already depend on, and requires
close to zero application code (~20 lines) to adopt.
the library that owns the `QueryClient` we already depend on, and keeps the
integration isolated to auth/session boundaries.
### Alternatives considered
@ -44,6 +50,9 @@ close to zero application code (~20 lines) to adopt.
serialization/versioning of query keys and mutation results, and keeping
every future mutation hook wired to broadcast. More code to own and more
surface area for subtle cache-desync bugs.
- **Constant channel from module load** — simplest wiring, but shares one
channel across all sessions on the origin; after logout/login or multi-account
use, cached work-order/vendor data can leak into the next session. Rejected.
- **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem
unresolved; users would need to manually refresh or wait for
`refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
@ -56,26 +65,27 @@ close to zero application code (~20 lines) to adopt.
## Consequences
- **Positive**: sibling tabs reflect mutations (status changes, dispatch
actions, comments, patches) without a manual refresh; the integration is
isolated to one setup file and does not touch domain/query-key code.
- **Positive**: sibling tabs for the same authenticated account reflect
mutations (status changes, dispatch actions, comments, patches) without a
manual refresh; logout/401 tear down the broadcaster and clear sensitive
cache so the next account cannot inherit prior data.
- **Risk — package lifecycle**: the dependency is explicitly "experimental" in
the TanStack ecosystem; its API may change or be deprecated between minor
versions. `@tanstack/query-broadcast-client-experimental`,
`@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
the **exact same version** in `package.json` (no `^` range). All three
resolve to the identical `@tanstack/query-core` version at that pin, which
keeps the `QueryClient` type used by `setupQueryBroadcast` structurally
keeps the `QueryClient` type used by the broadcast helpers structurally
identical to the one constructed in `query-client.ts` — a caret range lets
npm resolve the broadcast client and React Query against two different
`query-core` versions independently, which breaks `QueryClient` type
identity (TS2322) even though both packages build fine in isolation. Bump
all three together and re-evaluate on every TanStack Query upgrade.
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
origin; it does not sync across different users/sessions or devices. This is
acceptable for the current requirement (single user, multiple tabs).
origin; it does not sync across different users/sessions or devices. Account
scoping further limits sync to tabs of the same `userId`.
- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
dependency from `package.json` and delete the call to `setupQueryBroadcast`
in `src/lib/query/query-client.ts` (and the `setup-query-broadcast.ts` module
itself). No other code depends on it, so rollback is a single, isolated
change with no data migration.
dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove
the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and
`api.ts`. No other code depends on it, so rollback is isolated with no data
migration.

View file

@ -3,13 +3,13 @@ import { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client";
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
function clearSessionAndRedirectToLogin(): void {
clearAuth();
queryClient.setQueryData(queryKeys.auth.session(), null);
stopQueryBroadcast(queryClient);
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.replace("/login");

View file

@ -0,0 +1,248 @@
import type { Dispatch, SetStateAction } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import { Autocomplete, Button, Checkbox, Chip, Stack, TextField, Typography } from "@mui/material";
import type {
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import type { VendorFilters } from "./vendor-filters";
import { FilterSection } from "./vendor-filters-drawer-section";
interface VendorFiltersDrawerFieldsProps {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
options: {
companies: VendorFacetCompany[];
trades: string[];
locations: VendorFacetLocation[];
jobBuckets: VendorFacetJobBucket[];
};
}
function CompanyFilterFields({
draft,
setDraft,
companyOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
companyOptions: VendorFacetCompany[];
}) {
return (
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Select vendor companies
</Typography>
<Button
size="small"
onClick={() =>
setDraft((current) => ({
...current,
companies:
current.companies.length === companyOptions.length
? []
: companyOptions.map((company) => company.name),
}))
}
>
{draft.companies.length === companyOptions.length && companyOptions.length > 0
? "Clear all"
: "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={companyOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
typeof entry === "string" ? entry : entry.name,
);
setDraft((current) => ({ ...current, companies: next }));
}}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<li key={key} {...optionProps}>
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
{option.name}
</li>
);
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.name;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
);
}
function TradeFilterFields({
draft,
setDraft,
tradeOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
tradeOptions: string[];
}) {
return (
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={tradeOptions}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
)}
/>
</FilterSection>
);
}
function LocationFilterFields({
draft,
setDraft,
locationOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
locationOptions: VendorFacetLocation[];
}) {
return (
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={locationOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
typeof entry === "string" ? entry : entry.label,
);
setDraft((current) => ({ ...current, locations: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
);
}
function JobBucketFilterFields({
draft,
setDraft,
jobBucketOptions,
}: {
draft: VendorFilters;
setDraft: Dispatch<SetStateAction<VendorFilters>>;
jobBucketOptions: VendorFacetJobBucket[];
}) {
return (
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={jobBucketOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
typeof entry === "string" ? entry : entry.id,
);
setDraft((current) => ({ ...current, jobBuckets: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
);
}
export function VendorFiltersDrawerFields({
draft,
setDraft,
options,
}: VendorFiltersDrawerFieldsProps) {
return (
<>
<CompanyFilterFields draft={draft} setDraft={setDraft} companyOptions={options.companies} />
<TradeFilterFields draft={draft} setDraft={setDraft} tradeOptions={options.trades} />
<LocationFilterFields draft={draft} setDraft={setDraft} locationOptions={options.locations} />
<JobBucketFilterFields
draft={draft}
setDraft={setDraft}
jobBucketOptions={options.jobBuckets}
/>
</>
);
}

View file

@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Stack,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export function FilterSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
{children}
</AccordionDetails>
</Accordion>
);
}

View file

@ -1,32 +1,9 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import { useEffect, useMemo, useState } from "react";
import CloseIcon from "@mui/icons-material/Close";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Drawer,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import type {
VendorFacets,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
import type { VendorFacets } from "@/domain/vendors/types/vendor";
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields";
interface VendorFiltersDrawerProps {
open: boolean;
@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps {
const DRAWER_WIDTH = 360;
function FilterSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
{children}
</AccordionDetails>
</Accordion>
);
}
export function VendorFiltersDrawer({
open,
onClose,
@ -125,7 +76,7 @@ export function VendorFiltersDrawer({
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
Filters
</Typography>
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />}
{Boolean(activeCount > 0) && <Chip size="small" color="primary" label={activeCount} />}
</Stack>
<IconButton aria-label="Close filters" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
@ -133,165 +84,16 @@ export function VendorFiltersDrawer({
</Stack>
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Select vendor companies
</Typography>
<Button
size="small"
onClick={() =>
setDraft((current) => ({
...current,
companies:
current.companies.length === companyOptions.length
? []
: companyOptions.map((company) => company.name),
}))
}
>
{draft.companies.length === companyOptions.length && companyOptions.length > 0
? "Clear all"
: "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={companyOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
typeof entry === "string" ? entry : entry.name,
);
setDraft((current) => ({ ...current, companies: next }));
}}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<li key={key} {...optionProps}>
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
{option.name}
</li>
);
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.name;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={tradeOptions}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.trades.length === 0 ? "All trades" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={locationOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
typeof entry === "string" ? entry : entry.label,
);
setDraft((current) => ({ ...current, locations: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={jobBucketOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
typeof entry === "string" ? entry : entry.id,
);
setDraft((current) => ({ ...current, jobBuckets: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
<VendorFiltersDrawerFields
draft={draft}
setDraft={setDraft}
options={{
companies: companyOptions,
trades: tradeOptions,
locations: locationOptions,
jobBuckets: jobBucketOptions,
}}
/>
</Box>
<Stack

View file

@ -67,6 +67,37 @@ function PageShell({
);
}
function RosterLoadErrorState({ onBack }: { onBack: () => void }) {
return (
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
<Button variant="outlined" onClick={onBack}>
Back to vendors
</Button>
</Stack>
</PageShell>
);
}
function rosterPageCopy(isEdit: boolean): { title: string; subtitle: string } {
if (isEdit) {
return {
title: "Edit Vendor Company",
subtitle: "Update company details and technicians in one save.",
};
}
return {
title: "Add Vendor Company",
subtitle: "Register a company and add zero, one, or multiple technicians.",
};
}
function submitLabel(isSaving: boolean, isEdit: boolean): string {
if (isSaving) return "Saving...";
return isEdit ? "Save" : "Create";
}
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
const navigate = useNavigate();
const isEdit = vendorId !== undefined || companyId !== undefined;
@ -94,26 +125,14 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
}
if (isEdit && form.isError) {
return (
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
<Button variant="outlined" onClick={() => navigate("/vendors")}>
Back to vendors
</Button>
</Stack>
</PageShell>
);
return <RosterLoadErrorState onBack={() => navigate("/vendors")} />;
}
if (isEdit && !form.roster) {
return null;
}
const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company";
const subtitle = isEdit
? "Update company details and technicians in one save."
: "Register a company and add zero, one, or multiple technicians.";
const { title, subtitle } = rosterPageCopy(isEdit);
const companySelectionProps = isEdit
? {}
: {
@ -122,6 +141,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
onSelectCompany: form.selectCompany,
onClearSelectedCompany: form.clearSelectedCompany,
};
const showPortalToken = Boolean(isEdit && vendorId);
return (
<PageShell title={title} subtitle={subtitle}>
@ -143,7 +163,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
{...companySelectionProps}
/>
{isEdit && vendorId !== undefined && vendorId !== "" && (
{showPortalToken && vendorId !== undefined && (
<VendorPortalTokenPanel vendorId={vendorId} />
)}
@ -156,7 +176,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
Cancel
</Button>
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
{form.isSaving ? "Saving..." : isEdit ? "Save" : "Create"}
{submitLabel(form.isSaving, isEdit)}
</Button>
</Stack>
</Stack>

View file

@ -10,6 +10,7 @@ import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
import { queryKeys } from "@/infra/query-key/query-key";
import { startQueryBroadcast, stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
export function useSession(): UseQueryResult<AuthUser | null, Error> {
return useQuery({
@ -28,7 +29,9 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
meta: { suppressErrorToast: true },
onSuccess: (user) => {
stopQueryBroadcast(queryClient);
queryClient.setQueryData(queryKeys.auth.session(), user);
startQueryBroadcast(queryClient, user.id);
toast.success("Login successful!");
navigate("/");
},
@ -42,13 +45,12 @@ export function useLogout(): UseMutationResult<void, Error, void> {
return useMutation({
mutationFn: authApi.logout,
onSuccess: () => {
queryClient.setQueryData(queryKeys.auth.session(), null);
queryClient.removeQueries({ queryKey: queryKeys.auth.all });
stopQueryBroadcast(queryClient);
toast.info("You have been logged out");
navigate("/login");
},
onError: (error: Error) => {
queryClient.setQueryData(queryKeys.auth.session(), null);
stopQueryBroadcast(queryClient);
toast.error(error.message || "Logout failed");
navigate("/login");
},

View file

@ -1,6 +1,5 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { setupQueryBroadcast } from "@/lib/query/setup-query-broadcast";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
@ -31,6 +30,4 @@ export const queryClient = new QueryClient({
},
});
setupQueryBroadcast(queryClient);
export default queryClient;

View file

@ -1,19 +1,60 @@
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
import type { QueryClient } from "@tanstack/react-query";
/** Same-origin channel name for cross-tab QueryClient sync. */
export const QUERY_BROADCAST_CHANNEL = "seahaven-admin-query";
/** Prefix for same-origin BroadcastChannel names (suffixed with authenticated user id). */
export const QUERY_BROADCAST_CHANNEL_PREFIX = "seahaven-admin-query";
let unsubscribe: (() => void) | null = null;
let activeUserId: string | number | null = null;
/** Builds the account-scoped channel name for cross-tab QueryClient sync. */
export function queryBroadcastChannelForUser(userId: string | number): string {
return `${QUERY_BROADCAST_CHANNEL_PREFIX}:${String(userId)}`;
}
function disposeBroadcast(): void {
unsubscribe?.();
unsubscribe = null;
activeUserId = null;
}
function canUseBroadcast(): boolean {
return typeof window !== "undefined" && import.meta.env.MODE !== "test";
}
/**
* Syncs QueryClient cache (invalidations + data) across browser tabs via BroadcastChannel.
* No-op outside the browser and during Vitest runs.
* Starts (or switches) account-scoped QueryClient broadcast.
* Disposes any prior subscription; clears the cache when the authenticated user changes.
* No-op for the BroadcastChannel outside the browser and during Vitest runs.
*/
export function setupQueryBroadcast(queryClient: QueryClient): void {
if (typeof window === "undefined") return;
if (import.meta.env.MODE === "test") return;
export function startQueryBroadcast(queryClient: QueryClient, userId: string | number): void {
if (activeUserId === userId) {
return;
}
broadcastQueryClient({
const previousUserId = activeUserId;
disposeBroadcast();
if (previousUserId != null && previousUserId !== userId) {
queryClient.clear();
}
activeUserId = userId;
if (!canUseBroadcast()) {
return;
}
unsubscribe = broadcastQueryClient({
queryClient,
broadcastChannel: QUERY_BROADCAST_CHANNEL,
broadcastChannel: queryBroadcastChannelForUser(userId),
});
}
/**
* Tears down the broadcaster and clears the entire QueryClient cache (logout / 401).
*/
export function stopQueryBroadcast(queryClient: QueryClient): void {
disposeBroadcast();
queryClient.clear();
}

View file

@ -1,14 +1,26 @@
import { useMemo, type ReactNode } from "react";
import { useEffect, useMemo, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login";
import { startQueryBroadcast } from "@/lib/query/setup-query-broadcast";
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const sessionQuery = useSession();
const loginMutation = useLogin();
const logoutMutation = useLogout();
const changePasswordMutation = useChangePassword();
const userId = sessionQuery.data?.id;
useEffect(() => {
if (userId == null) {
return;
}
startQueryBroadcast(queryClient, userId);
}, [queryClient, userId]);
const value = useMemo<AuthContextValue>(
() => ({
user: sessionQuery.data,

View file

@ -1,15 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
const disposeA = vi.fn();
const disposeB = vi.fn();
const broadcastQueryClient = vi.fn();
vi.mock("@tanstack/query-broadcast-client-experimental", () => ({
broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args),
}));
describe("setupQueryBroadcast", () => {
describe("query broadcast account isolation", () => {
beforeEach(() => {
broadcastQueryClient.mockClear();
broadcastQueryClient.mockReset();
disposeA.mockReset();
disposeB.mockReset();
vi.unstubAllEnvs();
});
@ -19,29 +23,87 @@ describe("setupQueryBroadcast", () => {
it("does not call broadcastQueryClient when MODE is test", async () => {
vi.stubEnv("MODE", "test");
const { setupQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
vi.resetModules();
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
setupQueryBroadcast(client);
startQueryBroadcast(client, "user-a");
expect(broadcastQueryClient).not.toHaveBeenCalled();
});
it("calls broadcastQueryClient with the app channel when MODE is not test", async () => {
it("scopes the BroadcastChannel to the authenticated user id", async () => {
vi.stubEnv("MODE", "development");
vi.resetModules();
broadcastQueryClient.mockReturnValue(disposeA);
const { setupQueryBroadcast, QUERY_BROADCAST_CHANNEL } =
const { startQueryBroadcast, queryBroadcastChannelForUser, QUERY_BROADCAST_CHANNEL_PREFIX } =
await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
setupQueryBroadcast(client);
startQueryBroadcast(client, 42);
expect(QUERY_BROADCAST_CHANNEL_PREFIX).toBe("seahaven-admin-query");
expect(queryBroadcastChannelForUser(42)).toBe("seahaven-admin-query:42");
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
expect(broadcastQueryClient).toHaveBeenCalledWith({
queryClient: client,
broadcastChannel: QUERY_BROADCAST_CHANNEL,
broadcastChannel: "seahaven-admin-query:42",
});
expect(QUERY_BROADCAST_CHANNEL).toBe("seahaven-admin-query");
});
it("disposes the prior broadcaster, clears cache, and switches channel across accounts", async () => {
vi.stubEnv("MODE", "development");
vi.resetModules();
broadcastQueryClient.mockReturnValueOnce(disposeA).mockReturnValueOnce(disposeB);
const { startQueryBroadcast, stopQueryBroadcast } =
await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
const clearSpy = vi.spyOn(client, "clear");
client.setQueryData(["work-orders", "sensitive"], { from: "user-a" });
startQueryBroadcast(client, "user-a");
expect(broadcastQueryClient).toHaveBeenCalledWith({
queryClient: client,
broadcastChannel: "seahaven-admin-query:user-a",
});
startQueryBroadcast(client, "user-b");
expect(disposeA).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledTimes(1);
expect(client.getQueryData(["work-orders", "sensitive"])).toBeUndefined();
expect(broadcastQueryClient).toHaveBeenCalledTimes(2);
expect(broadcastQueryClient).toHaveBeenLastCalledWith({
queryClient: client,
broadcastChannel: "seahaven-admin-query:user-b",
});
client.setQueryData(["vendors", "roster"], { from: "user-b" });
stopQueryBroadcast(client);
expect(disposeB).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledTimes(2);
expect(client.getQueryData(["vendors", "roster"])).toBeUndefined();
});
it("is a no-op when start is called again for the same user", async () => {
vi.stubEnv("MODE", "development");
vi.resetModules();
broadcastQueryClient.mockReturnValue(disposeA);
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
const clearSpy = vi.spyOn(client, "clear");
startQueryBroadcast(client, "user-a");
startQueryBroadcast(client, "user-a");
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
expect(disposeA).not.toHaveBeenCalled();
expect(clearSpy).not.toHaveBeenCalled();
});
});