diff --git a/docs/adr/0001-query-broadcast-client.md b/docs/adr/0001-query-broadcast-client.md index 4ced20f2..ed40273d 100644 --- a/docs/adr/0001-query-broadcast-client.md +++ b/docs/adr/0001-query-broadcast-client.md @@ -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. diff --git a/src/api/api.ts b/src/api/api.ts index d27d3365..51897b61 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -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"); diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx new file mode 100644 index 00000000..eadbf15d --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx @@ -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>; + options: { + companies: VendorFacetCompany[]; + trades: string[]; + locations: VendorFacetLocation[]; + jobBuckets: VendorFacetJobBucket[]; + }; +} + +function CompanyFilterFields({ + draft, + setDraft, + companyOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + companyOptions: VendorFacetCompany[]; +}) { + return ( + }> + + + Select vendor companies + + + + (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 ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + ); +} + +function TradeFilterFields({ + draft, + setDraft, + tradeOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + tradeOptions: string[]; +}) { + return ( + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function LocationFilterFields({ + draft, + setDraft, + locationOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + locationOptions: VendorFacetLocation[]; +}) { + return ( + }> + (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 ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function JobBucketFilterFields({ + draft, + setDraft, + jobBucketOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + jobBucketOptions: VendorFacetJobBucket[]; +}) { + return ( + }> + (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 ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +export function VendorFiltersDrawerFields({ + draft, + setDraft, + options, +}: VendorFiltersDrawerFieldsProps) { + return ( + <> + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx new file mode 100644 index 00000000..9d7c2997 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx @@ -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 ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index ae4b51ba..86cb612a 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -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 ( - - } aria-controls={`${title}-filters-content`}> - - {icon} - - {title} - - - - - {children} - - - ); -} - export function VendorFiltersDrawer({ open, onClose, @@ -125,7 +76,7 @@ export function VendorFiltersDrawer({ Filters - {activeCount > 0 && } + {Boolean(activeCount > 0) && } @@ -133,165 +84,16 @@ export function VendorFiltersDrawer({ - }> - - - Select vendor companies - - - - (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 ( -
  • - - {option.name} -
  • - ); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.name; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> -
    - - }> - - setDraft((current) => ({ ...current, trades: value as string[] })) - } - renderValue={(value, getItemProps) => - value.map((option, index) => { - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (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 ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (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 ; - }) - } - renderInput={(params) => ( - - )} - /> - +
    void }) { + return ( + + Failed to load vendor company. Try again. + + + + + ); +} + +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 ( - - Failed to load vendor company. Try again. - - - - - ); + return 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 ( @@ -143,7 +163,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa {...companySelectionProps} /> - {isEdit && vendorId !== undefined && vendorId !== "" && ( + {showPortalToken && vendorId !== undefined && ( )} @@ -156,7 +176,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa Cancel diff --git a/src/domain/auth/use-cases/use-login.ts b/src/domain/auth/use-cases/use-login.ts index 301ec5a4..6c4617de 100644 --- a/src/domain/auth/use-cases/use-login.ts +++ b/src/domain/auth/use-cases/use-login.ts @@ -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 { return useQuery({ @@ -28,7 +29,9 @@ export function useLogin(): UseMutationResult 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 { 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"); }, diff --git a/src/lib/query/query-client.ts b/src/lib/query/query-client.ts index bef74cd1..1223fc2b 100644 --- a/src/lib/query/query-client.ts +++ b/src/lib/query/query-client.ts @@ -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; diff --git a/src/lib/query/setup-query-broadcast.ts b/src/lib/query/setup-query-broadcast.ts index 3b5f24d6..abc05bfa 100644 --- a/src/lib/query/setup-query-broadcast.ts +++ b/src/lib/query/setup-query-broadcast.ts @@ -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(); +} diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx index 36bd571d..c5687abd 100644 --- a/src/providers/auth-provider.tsx +++ b/src/providers/auth-provider.tsx @@ -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( () => ({ user: sessionQuery.data, diff --git a/src/test/lib/query/setup-query-broadcast.test.ts b/src/test/lib/query/setup-query-broadcast.test.ts index bc203093..19871038 100644 --- a/src/test/lib/query/setup-query-broadcast.test.ts +++ b/src/test/lib/query/setup-query-broadcast.test.ts @@ -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(); }); });