diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..c1398b7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,9 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Run web unit tests + working-directory: apps/web + run: bun test + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 2ba044fd..888c2edd 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -88,28 +88,28 @@ export default function RootLayout({ disableTransitionOnChange forcedTheme="dark" > - - - - - - + + + + + + {children} - - - - - + + + + + diff --git a/apps/web/components/query-client-config.test.ts b/apps/web/components/query-client-config.test.ts new file mode 100644 index 00000000..0f4b4c36 --- /dev/null +++ b/apps/web/components/query-client-config.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "bun:test" +import { + createQueryCacheScope, + createQueryClient, + createRouteAwareQueryCacheScope, + shouldScopeQueriesByOrganization, + type QueryCacheScope, +} from "./query-client-config" + +const baseScope: QueryCacheScope = { + isSessionPending: false, + isRestoring: false, + sessionId: "session-a", + userId: "user-a", + activeOrganizationId: "organization-a", + organizationId: "organization-a", +} + +describe("createQueryCacheScope", () => { + it("is stable while the authentication scope is unchanged", () => { + expect(createQueryCacheScope(baseScope)).toBe( + createQueryCacheScope({ ...baseScope }), + ) + }) + + it.each([ + ["session", { sessionId: "session-b" }], + ["user", { userId: "user-b" }], + ["server organization", { activeOrganizationId: "organization-b" }], + ["resolved organization", { organizationId: "organization-b" }], + ["pending session", { isSessionPending: true }], + ["restoring organization", { isRestoring: true }], + [ + "logout with a stale organization", + { sessionId: null, userId: null, activeOrganizationId: null }, + ], + ] satisfies Array< + [string, Partial] + >)("changes when the %s boundary changes", (_name, change) => { + expect(createQueryCacheScope({ ...baseScope, ...change })).not.toBe( + createQueryCacheScope(baseScope), + ) + }) + + it("does not collide when identifiers contain delimiters", () => { + const first = createQueryCacheScope({ + ...baseScope, + sessionId: "session|user", + userId: "organization", + }) + const second = createQueryCacheScope({ + ...baseScope, + sessionId: "session", + userId: "user|organization", + }) + + expect(first).not.toBe(second) + }) + + it("keeps OAuth consent mounted while the selected organization changes", () => { + expect(createRouteAwareQueryCacheScope(baseScope, "/oauth/consent")).toBe( + createRouteAwareQueryCacheScope( + { + ...baseScope, + activeOrganizationId: "organization-b", + organizationId: "organization-b", + }, + "/oauth/consent", + ), + ) + expect( + createRouteAwareQueryCacheScope( + { ...baseScope, sessionId: "session-b", userId: "user-b" }, + "/oauth/consent", + ), + ).not.toBe(createRouteAwareQueryCacheScope(baseScope, "/oauth/consent")) + }) + + it("scopes organization caches everywhere except OAuth consent", () => { + expect(shouldScopeQueriesByOrganization("/oauth/consent")).toBe(false) + expect(shouldScopeQueriesByOrganization("/oauth/consent/")).toBe(false) + expect(shouldScopeQueriesByOrganization("/brain")).toBe(true) + expect(shouldScopeQueriesByOrganization("/settings")).toBe(true) + }) +}) + +describe("createQueryClient", () => { + it("does not share tenant data between clients", async () => { + const queryKey = ["documents", "sm_project_default"] as const + const accountAClient = createQueryClient() + const accountBClient = createQueryClient() + let accountBFetches = 0 + + accountAClient.setQueryData(queryKey, [{ id: "account-a-secret" }]) + + expect(accountBClient.getQueryData(queryKey)).toBeUndefined() + await expect( + accountBClient.fetchQuery({ + queryKey, + queryFn: () => { + accountBFetches += 1 + return [{ id: "account-b-document" }] + }, + }), + ).resolves.toEqual([{ id: "account-b-document" }]) + expect(accountBFetches).toBe(1) + + accountAClient.clear() + accountBClient.clear() + }) + + it("preserves the existing query defaults", () => { + const queryClient = createQueryClient() + + expect(queryClient.getDefaultOptions().queries).toMatchObject({ + refetchIntervalInBackground: false, + refetchOnWindowFocus: false, + staleTime: 60 * 1000, + }) + queryClient.clear() + }) +}) diff --git a/apps/web/components/query-client-config.ts b/apps/web/components/query-client-config.ts new file mode 100644 index 00000000..d90bdf48 --- /dev/null +++ b/apps/web/components/query-client-config.ts @@ -0,0 +1,50 @@ +import { QueryClient } from "@tanstack/react-query" + +export interface QueryCacheScope { + isSessionPending: boolean + isRestoring: boolean + sessionId: string | null + userId: string | null + activeOrganizationId: string | null + organizationId: string | null +} + +export function shouldScopeQueriesByOrganization(pathname: string): boolean { + return pathname !== "/oauth/consent" && pathname !== "/oauth/consent/" +} + +export function createQueryCacheScope( + scope: QueryCacheScope, + options: { includeOrganization?: boolean } = {}, +): string { + const includeOrganization = options.includeOrganization ?? true + return JSON.stringify([ + scope.isSessionPending, + scope.isRestoring, + scope.sessionId, + scope.userId, + includeOrganization ? scope.activeOrganizationId : null, + includeOrganization ? scope.organizationId : null, + ]) +} + +export function createRouteAwareQueryCacheScope( + scope: QueryCacheScope, + pathname: string, +): string { + return createQueryCacheScope(scope, { + includeOrganization: shouldScopeQueriesByOrganization(pathname), + }) +} + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + refetchIntervalInBackground: false, + refetchOnWindowFocus: false, + staleTime: 60 * 1000, + }, + }, + }) +} diff --git a/apps/web/components/query-client.test.tsx b/apps/web/components/query-client.test.tsx new file mode 100644 index 00000000..4686f561 --- /dev/null +++ b/apps/web/components/query-client.test.tsx @@ -0,0 +1,275 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" +import { Window } from "happy-dom" + +let currentPathname = "/brain" +let currentAuth = { + session: { id: "session-a", activeOrganizationId: "organization-a" }, + user: { id: "user-a" }, + org: { id: "organization-a" }, + isSessionPending: false, + isRestoring: false, +} + +mock.module("@lib/auth-context", () => ({ + useAuth: () => currentAuth, +})) +mock.module("next/navigation", () => ({ + usePathname: () => currentPathname, +})) + +const browserWindow = new Window({ url: "https://app.supermemory.ai" }) +const installedGlobals = [ + "window", + "document", + "navigator", + "Node", + "HTMLElement", + "Event", + "MutationObserver", +] as const +const originalDescriptors = new Map( + installedGlobals.map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), +) + +for (const name of installedGlobals) { + Object.defineProperty(globalThis, name, { + configurable: true, + value: browserWindow[name], + }) +} +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + configurable: true, + value: true, +}) + +const [{ act, useEffect }, { createRoot }, { useQueryClient }] = + await Promise.all([ + import("react"), + import("react-dom/client"), + import("@tanstack/react-query"), + ]) +const { QueryProvider, ScopedQueryProvider } = await import("./query-client") + +beforeEach(() => { + currentPathname = "/brain" + currentAuth = { + session: { id: "session-a", activeOrganizationId: "organization-a" }, + user: { id: "user-a" }, + org: { id: "organization-a" }, + isSessionPending: false, + isRestoring: false, + } +}) + +afterAll(() => { + for (const name of installedGlobals) { + const descriptor = originalDescriptors.get(name) + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else Reflect.deleteProperty(globalThis, name) + } + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT") + void browserWindow.close() +}) + +describe("ScopedQueryProvider", () => { + it("discards the mounted cache when the auth scope changes", async () => { + const clients: ReturnType[] = [] + let mounts = 0 + let unmounts = 0 + + function Probe() { + const queryClient = useQueryClient() + clients.push(queryClient) + useEffect(() => { + mounts += 1 + return () => { + unmounts += 1 + } + }, []) + return null + } + + const container = document.createElement("div") + document.body.append(container) + const root = createRoot(container) + + await act(async () => { + root.render( + + + , + ) + }) + const accountAClient = clients.at(-1) + expect(accountAClient).toBeDefined() + accountAClient?.setQueryData( + ["documents", "sm_project_default"], + [{ id: "account-a-secret" }], + ) + + await act(async () => { + root.render( + + + , + ) + }) + expect(clients.at(-1)).toBe(accountAClient) + expect(mounts).toBe(1) + expect(unmounts).toBe(0) + + await act(async () => { + root.render( + + + , + ) + }) + const accountBClient = clients.at(-1) + expect(accountBClient).not.toBe(accountAClient) + expect(accountAClient?.getQueryCache().getAll()).toHaveLength(0) + expect( + accountBClient?.getQueryData(["documents", "sm_project_default"]), + ).toBeUndefined() + accountBClient?.setQueryData( + ["documents", "sm_project_default"], + [{ id: "account-b-secret" }], + ) + expect(mounts).toBe(2) + expect(unmounts).toBe(1) + + await act(async () => { + root.render( + + + , + ) + }) + const accountAReturnClient = clients.at(-1) + expect(accountAReturnClient).not.toBe(accountAClient) + expect(accountAReturnClient).not.toBe(accountBClient) + expect(accountBClient?.getQueryCache().getAll()).toHaveLength(0) + expect( + accountAReturnClient?.getQueryData(["documents", "sm_project_default"]), + ).toBeUndefined() + expect(mounts).toBe(3) + expect(unmounts).toBe(2) + + await act(async () => root.unmount()) + container.remove() + }) +}) + +describe("QueryProvider", () => { + it("preserves consent state across org selection but not account changes", async () => { + const clients: ReturnType[] = [] + let mounts = 0 + + function Probe() { + clients.push(useQueryClient()) + useEffect(() => { + mounts += 1 + }, []) + return null + } + + const container = document.createElement("div") + document.body.append(container) + const root = createRoot(container) + currentPathname = "/oauth/consent" + + await act(async () => { + root.render( + + + , + ) + }) + const accountAClient = clients.at(-1) + accountAClient?.setQueryData(["consent-state"], "account-a") + + currentAuth = { + ...currentAuth, + session: { + ...currentAuth.session, + activeOrganizationId: "organization-b", + }, + org: { id: "organization-b" }, + } + await act(async () => { + root.render( + + + , + ) + }) + expect(clients.at(-1)).toBe(accountAClient) + expect(clients.at(-1)?.getQueryData(["consent-state"])).toBe( + "account-a", + ) + expect(mounts).toBe(1) + + currentAuth = { + ...currentAuth, + session: { id: "session-b", activeOrganizationId: "organization-b" }, + user: { id: "user-b" }, + } + await act(async () => { + root.render( + + + , + ) + }) + expect(clients.at(-1)).not.toBe(accountAClient) + expect(clients.at(-1)?.getQueryData(["consent-state"])).toBeUndefined() + expect(mounts).toBe(2) + + await act(async () => root.unmount()) + container.remove() + }) + + it("still rotates organization-scoped caches outside consent", async () => { + const clients: ReturnType[] = [] + + function Probe() { + clients.push(useQueryClient()) + return null + } + + const container = document.createElement("div") + document.body.append(container) + const root = createRoot(container) + await act(async () => { + root.render( + + + , + ) + }) + const organizationAClient = clients.at(-1) + + currentAuth = { + ...currentAuth, + session: { + ...currentAuth.session, + activeOrganizationId: "organization-b", + }, + org: { id: "organization-b" }, + } + await act(async () => { + root.render( + + + , + ) + }) + expect(clients.at(-1)).not.toBe(organizationAClient) + + await act(async () => root.unmount()) + container.remove() + }) +}) diff --git a/apps/web/components/query-client.tsx b/apps/web/components/query-client.tsx index 11f03a00..04f6d9bb 100644 --- a/apps/web/components/query-client.tsx +++ b/apps/web/components/query-client.tsx @@ -1,23 +1,47 @@ "use client" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { useState } from "react" +import { useAuth } from "@lib/auth-context" +import { QueryClientProvider } from "@tanstack/react-query" +import { usePathname } from "next/navigation" +import { type ReactNode, useEffect, useState } from "react" +import { + createQueryClient, + createRouteAwareQueryCacheScope, +} from "./query-client-config" -export const QueryProvider = ({ children }: { children: React.ReactNode }) => { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - refetchIntervalInBackground: false, - refetchOnWindowFocus: false, - staleTime: 60 * 1000, - }, - }, - }), - ) +function QueryClientOwner({ children }: { children: ReactNode }) { + const [queryClient] = useState(createQueryClient) + useEffect(() => () => queryClient.clear(), [queryClient]) return ( {children} ) } + +export function ScopedQueryProvider({ + children, + scope, +}: { + children: ReactNode + scope: string +}) { + return {children} +} + +export function QueryProvider({ children }: { children: ReactNode }) { + const { session, user, org, isSessionPending, isRestoring } = useAuth() + const pathname = usePathname() + const scope = createRouteAwareQueryCacheScope( + { + isSessionPending, + isRestoring, + sessionId: session?.id ?? null, + userId: user?.id ?? null, + activeOrganizationId: session?.activeOrganizationId ?? null, + organizationId: org?.id ?? null, + }, + pathname, + ) + + return {children} +} diff --git a/apps/web/lib/auth-context.test.tsx b/apps/web/lib/auth-context.test.tsx new file mode 100644 index 00000000..88e51abd --- /dev/null +++ b/apps/web/lib/auth-context.test.tsx @@ -0,0 +1,232 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + mock, +} from "bun:test" +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" + +const ISOLATED_RUN_ENV = "AUTH_CONTEXT_MOUNTED_TEST" +const testFilePath = fileURLToPath(import.meta.url) + +if (process.env[ISOLATED_RUN_ENV] === "1") { + await registerMountedAuthProviderTests() +} else { + describe("AuthProvider mounted regressions", () => { + it("passes in an isolated Bun process", () => { + const result = spawnSync(process.execPath, ["test", testFilePath], { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env, [ISOLATED_RUN_ENV]: "1" }, + }) + + if (result.status !== 0) { + throw new Error( + `Isolated AuthProvider tests failed:\n${result.stdout}\n${result.stderr}`, + ) + } + expect(result.status).toBe(0) + }) + }) +} + +async function registerMountedAuthProviderTests() { + const { Window } = await import("happy-dom") + + const currentOrganization = { id: "org-current", slug: "current" } + const savedOrganization = { id: "org-saved", slug: "saved" } + const organizations = [currentOrganization, savedOrganization] + const sessionResult = { + data: { + session: { + id: "session-a", + activeOrganizationId: currentOrganization.id, + }, + user: { id: "user-a" }, + }, + isPending: false, + } + const refetchOrganizations = mock(async () => ({ data: organizations })) + const setActive = mock( + async (input: { + organizationSlug?: string + organizationId?: string | null + }) => ({ + data: + input.organizationSlug === savedOrganization.slug + ? savedOrganization + : currentOrganization, + }), + ) + const getFullOrganization = mock(async () => ({ + data: currentOrganization, + })) + const authModuleFactory = () => ({ + authClient: { + $Infer: {}, + useListOrganizations: () => ({ + data: organizations, + isPending: false, + refetch: refetchOrganizations, + }), + organization: { + getFullOrganization, + setActive, + }, + }, + useSession: () => sessionResult, + }) + const authModulePath = fileURLToPath( + new URL("../../../packages/lib/auth.ts", import.meta.url), + ) + mock.module(authModulePath, authModuleFactory) + mock.module("@lib/auth", authModuleFactory) + + const browserWindow = new Window({ url: "https://app.supermemory.ai/brain" }) + const installedGlobals = [ + "window", + "document", + "navigator", + "Node", + "HTMLElement", + "Event", + "MutationObserver", + "localStorage", + ] as const + const originalDescriptors = new Map( + installedGlobals.map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), + ) + + for (const name of installedGlobals) { + Object.defineProperty(globalThis, name, { + configurable: true, + value: browserWindow[name], + }) + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + configurable: true, + writable: true, + value: true, + }) + + const { act, useSyncExternalStore } = await import("react") + let currentPathname = window.location.pathname + const pathnameListeners = new Set<() => void>() + const getPathnameSnapshot = () => currentPathname + const subscribeToPathname = (listener: () => void) => { + pathnameListeners.add(listener) + return () => pathnameListeners.delete(listener) + } + mock.module("next/navigation", () => ({ + usePathname: () => + useSyncExternalStore( + subscribeToPathname, + getPathnameSnapshot, + getPathnameSnapshot, + ), + })) + + const [{ createRoot }, { AuthProvider }] = await Promise.all([ + import("react-dom/client"), + import("@lib/auth-context"), + ]) + const savedOrganizationKey = "supermemory-consumer-last-org-slug" + let container: HTMLDivElement + let root: ReturnType + const setPathname = (pathname: string) => { + currentPathname = pathname + window.history.replaceState(null, "", pathname) + for (const listener of pathnameListeners) listener() + } + + beforeEach(() => { + setActive.mockClear() + getFullOrganization.mockClear() + refetchOrganizations.mockClear() + window.localStorage.clear() + window.localStorage.setItem(savedOrganizationKey, savedOrganization.slug) + setPathname("/brain") + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + }) + + afterAll(() => { + for (const name of installedGlobals) { + const descriptor = originalDescriptors.get(name) + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else Reflect.deleteProperty(globalThis, name) + } + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT") + void browserWindow.close() + }) + + describe("saved organization restoration", () => { + it("restores the saved organization away from consent", async () => { + setPathname("/brain") + await act(async () => { + root.render( + +
+ , + ) + }) + + expect(setActive).toHaveBeenCalledTimes(1) + expect(setActive).toHaveBeenCalledWith({ + organizationSlug: savedOrganization.slug, + }) + expect(getFullOrganization).not.toHaveBeenCalled() + }) + + for (const pathname of ["/oauth/consent", "/oauth/consent/"]) { + it(`leaves ${pathname} organization selection to consent`, async () => { + setPathname(pathname) + await act(async () => { + root.render( + +
+ , + ) + }) + + expect(getFullOrganization).toHaveBeenCalledTimes(1) + expect(setActive).not.toHaveBeenCalled() + }) + } + + it("restores the saved organization after consent navigates to brain", async () => { + setPathname("/oauth/consent/") + await act(async () => { + root.render( + +
+ , + ) + }) + expect(getFullOrganization).toHaveBeenCalledTimes(1) + expect(setActive).not.toHaveBeenCalled() + + await act(async () => { + setPathname("/brain") + }) + + expect(setActive).toHaveBeenCalledTimes(1) + expect(setActive).toHaveBeenCalledWith({ + organizationSlug: savedOrganization.slug, + }) + }) + }) +} diff --git a/apps/web/package.json b/apps/web/package.json index 63c4acee..97152e9d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -126,6 +126,7 @@ "@types/node": "^24.0.4", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", + "happy-dom": "^20.9.0", "tailwindcss": "^4.1.11", "typescript": "^5.8.3", "wrangler": "^4.26.0" diff --git a/bun.lock b/bun.lock index d55c8237..0645ddfb 100644 --- a/bun.lock +++ b/bun.lock @@ -251,6 +251,7 @@ "@types/node": "^24.0.4", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", + "happy-dom": "^20.9.0", "tailwindcss": "^4.1.11", "typescript": "^5.8.3", "wrangler": "^4.26.0", diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index acd15e88..f2ac1aa4 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -1,5 +1,6 @@ "use client" +import { usePathname } from "next/navigation" import { createContext, type ReactNode, @@ -18,6 +19,10 @@ type OrganizationListItem = NonNullable< const STORAGE_KEY = "supermemory-consumer-last-org-slug" +function isOAuthConsentPath(pathname: string): boolean { + return pathname === "/oauth/consent" || pathname === "/oauth/consent/" +} + // Reads ?org= from the URL once and removes it, so a deep link that // selects an org doesn't re-fire on refresh or back-navigation. function consumeRequestedOrgSlug(): string | null { @@ -53,6 +58,7 @@ const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: ReactNode }) { const { data: session, isPending: isSessionPending } = useSession() + const currentPathname = usePathname() const [org, setOrg] = useState(null) const [isRestoring, setIsRestoring] = useState(true) const { @@ -129,9 +135,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const run = async () => { try { // OAuth consent owns org selection for the authorization transaction. - const shouldRestoreSavedOrg = - typeof window === "undefined" || - window.location.pathname !== "/oauth/consent" + const shouldRestoreSavedOrg = !isOAuthConsentPath(currentPathname) if (orgs.length === 0) { if (!cancelled) setOrg(null) @@ -207,7 +211,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => { cancelled = true } - }, [isSessionPending, session, orgsData, orgsPending, setActiveOrg]) + }, [ + currentPathname, + isSessionPending, + session, + orgsData, + orgsPending, + setActiveOrg, + ]) useEffect(() => { if (typeof window === "undefined") return