fix(web): scope personalization cache by account

This commit is contained in:
shamAnimates 2026-08-16 17:59:41 +05:30
parent e651045ac5
commit 6ceabb15f8
5 changed files with 459 additions and 68 deletions

View file

@ -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

View file

@ -0,0 +1,291 @@
import { afterEach, beforeAll, describe, expect, it, mock } from "bun:test"
import { Window } from "happy-dom"
import { act, cleanup, renderHook, waitFor } from "@testing-library/react"
type AuthState = {
isSessionPending: boolean
isRestoring: boolean
session: { userId: string; activeOrganizationId: string } | null
user: { id: string } | null
org: { id: string } | null
}
type SearchResponse = {
data: {
results: Array<{ title: string; summary: string; chunks: never[] }>
}
}
const search = mock(
(): Promise<SearchResponse> => Promise.resolve({ data: { results: [] } }),
)
let auth: AuthState
mock.module("@lib/api", () => ({ $fetch: search }))
mock.module("@lib/auth-context", () => ({ useAuth: () => auth }))
let usePersonalization: typeof import("./use-personalization").usePersonalization
let clearPersonalizationCache: typeof import("./use-personalization").clearPersonalizationCache
function settledAuth(userId: string, orgId: string): AuthState {
return {
isSessionPending: false,
isRestoring: false,
session: { userId, activeOrganizationId: orgId },
user: { id: userId },
org: { id: orgId },
}
}
function cacheKey(userId: string, orgId: string) {
return `sm_profession_v2:u:${encodeURIComponent(userId)}:o:${encodeURIComponent(orgId)}`
}
function cacheProfession(userId: string, orgId: string, profession: string) {
localStorage.setItem(
cacheKey(userId, orgId),
JSON.stringify({ profession, ts: Date.now() }),
)
}
function searchResponse(keyword: string): SearchResponse {
return {
data: {
results: [{ title: keyword, summary: "", chunks: [] }],
},
}
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
beforeAll(async () => {
const window = new Window({ url: "https://app.supermemory.ai" })
Object.defineProperties(globalThis, {
window: { configurable: true, value: window },
document: { configurable: true, value: window.document },
localStorage: { configurable: true, value: window.localStorage },
navigator: { configurable: true, value: window.navigator },
HTMLElement: { configurable: true, value: window.HTMLElement },
Node: { configurable: true, value: window.Node },
})
;(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true
;({ usePersonalization, clearPersonalizationCache } = await import(
"./use-personalization"
))
})
afterEach(() => {
cleanup()
localStorage.clear()
search.mockReset()
})
describe("usePersonalization auth scoping", () => {
it("does not reuse account A's cache for account B", async () => {
auth = settledAuth("user-a", "shared-org")
cacheProfession("user-a", "shared-org", "developer")
search.mockResolvedValue(searchResponse("finance portfolio"))
const { result, rerender } = renderHook(() => usePersonalization())
await waitFor(() => expect(result.current.profession).toBe("developer"))
expect(search).not.toHaveBeenCalled()
auth = {
...settledAuth("user-b", "shared-org"),
isSessionPending: true,
}
rerender()
expect(result.current.profession).toBe("default")
expect(search).not.toHaveBeenCalled()
auth = settledAuth("user-b", "shared-org")
rerender()
expect(result.current.profession).toBe("default")
await waitFor(() => expect(result.current.profession).toBe("finance"))
expect(search).toHaveBeenCalledTimes(1)
})
it("resets to defaults without searching after logout", async () => {
auth = settledAuth("logout-user", "logout-org")
cacheProfession("logout-user", "logout-org", "developer")
const { result, rerender } = renderHook(() => usePersonalization())
await waitFor(() => expect(result.current.profession).toBe("developer"))
auth = {
isSessionPending: false,
isRestoring: false,
session: null,
user: null,
org: null,
}
rerender()
expect(result.current.profession).toBe("default")
expect(search).not.toHaveBeenCalled()
})
it("ignores account A's late result after switching to account B", async () => {
const accountA = deferred<ReturnType<typeof searchResponse>>()
const accountB = deferred<ReturnType<typeof searchResponse>>()
search
.mockImplementationOnce(() => accountA.promise)
.mockImplementationOnce(() => accountB.promise)
auth = settledAuth("deferred-a", "shared-org")
const { result, rerender } = renderHook(() => usePersonalization())
await waitFor(() => expect(search).toHaveBeenCalledTimes(1))
auth = settledAuth("deferred-b", "shared-org")
rerender()
expect(result.current.profession).toBe("default")
await waitFor(() => expect(search).toHaveBeenCalledTimes(2))
await act(async () => accountB.resolve(searchResponse("medical clinical")))
await waitFor(() => expect(result.current.profession).toBe("medical"))
await act(async () =>
accountA.resolve(searchResponse("software developer")),
)
expect(result.current.profession).toBe("medical")
expect(
JSON.parse(
localStorage.getItem(cacheKey("deferred-b", "shared-org")) ?? "{}",
).profession,
).toBe("medical")
expect(
localStorage.getItem(cacheKey("deferred-a", "shared-org")),
).toBeNull()
})
it("keeps a manual choice when an older detection resolves", async () => {
const pendingDetection = deferred<ReturnType<typeof searchResponse>>()
search.mockImplementationOnce(() => pendingDetection.promise)
auth = settledAuth("manual-race-user", "manual-race-org")
const { result } = renderHook(() => usePersonalization())
await waitFor(() => expect(search).toHaveBeenCalledTimes(1))
act(() => result.current.setProfession("marketing"))
expect(result.current.profession).toBe("marketing")
expect(
JSON.parse(
localStorage.getItem(cacheKey("manual-race-user", "manual-race-org")) ??
"{}",
).profession,
).toBe("marketing")
await act(async () =>
pendingDetection.resolve(searchResponse("software developer")),
)
expect(result.current.profession).toBe("marketing")
expect(
JSON.parse(
localStorage.getItem(cacheKey("manual-race-user", "manual-race-org")) ??
"{}",
).profession,
).toBe("marketing")
})
it("uses a fresh cache for the same account and org without searching", async () => {
auth = settledAuth("cached-user", "cached-org")
cacheProfession("cached-user", "cached-org", "research")
const { result } = renderHook(() => usePersonalization())
await waitFor(() => expect(result.current.profession).toBe("research"))
expect(search).not.toHaveBeenCalled()
})
it("writes manual profession changes only to the active scope", async () => {
auth = settledAuth("manual-user", "org-one")
cacheProfession("manual-user", "org-one", "developer")
cacheProfession("manual-user", "org-two", "finance")
const { result, rerender } = renderHook(() => usePersonalization())
await waitFor(() => expect(result.current.profession).toBe("developer"))
act(() => result.current.setProfession("marketing"))
expect(
JSON.parse(
localStorage.getItem(cacheKey("manual-user", "org-one")) ?? "{}",
).profession,
).toBe("marketing")
auth = settledAuth("manual-user", "org-two")
rerender()
await waitFor(() => expect(result.current.profession).toBe("finance"))
act(() => result.current.setProfession("medical"))
expect(
JSON.parse(
localStorage.getItem(cacheKey("manual-user", "org-one")) ?? "{}",
).profession,
).toBe("marketing")
expect(
JSON.parse(
localStorage.getItem(cacheKey("manual-user", "org-two")) ?? "{}",
).profession,
).toBe("medical")
expect(search).not.toHaveBeenCalled()
})
it("waits for an org switch to settle before reading or searching", async () => {
auth = settledAuth("org-user", "org-one")
cacheProfession("org-user", "org-one", "legal")
search.mockResolvedValue(searchResponse("figma product design"))
const { result, rerender } = renderHook(() => usePersonalization())
await waitFor(() => expect(result.current.profession).toBe("legal"))
auth = { ...auth, org: { id: "org-two" } }
rerender()
expect(result.current.profession).toBe("default")
expect(search).not.toHaveBeenCalled()
auth = settledAuth("org-user", "org-two")
rerender()
await waitFor(() => expect(result.current.profession).toBe("design"))
expect(search).toHaveBeenCalledTimes(1)
})
it("does not read the unscoped v1 cache", async () => {
auth = settledAuth("legacy-user", "legacy-org")
localStorage.setItem(
"sm_profession_v1",
JSON.stringify({ profession: "developer", ts: Date.now() }),
)
search.mockResolvedValue(searchResponse("finance investment"))
const { result } = renderHook(() => usePersonalization())
await waitFor(() => expect(result.current.profession).toBe("finance"))
expect(search).toHaveBeenCalledTimes(1)
expect(localStorage.getItem("sm_profession_v1")).toBeNull()
})
it("ignores inherited-property profession values in a scoped cache", async () => {
auth = settledAuth("malformed-user", "malformed-org")
cacheProfession("malformed-user", "malformed-org", "toString")
search.mockResolvedValue(searchResponse("finance investment"))
const { result } = renderHook(() => usePersonalization())
await waitFor(() => expect(result.current.profession).toBe("finance"))
expect(search).toHaveBeenCalledTimes(1)
})
it("clears every personalization cache without touching unrelated storage", () => {
localStorage.setItem("sm_profession_v1", "legacy")
localStorage.setItem(cacheKey("clear-a", "org-a"), "a")
localStorage.setItem(cacheKey("clear-b", "org-b"), "b")
localStorage.setItem("unrelated", "keep")
clearPersonalizationCache()
expect(localStorage.getItem("sm_profession_v1")).toBeNull()
expect(localStorage.getItem(cacheKey("clear-a", "org-a"))).toBeNull()
expect(localStorage.getItem(cacheKey("clear-b", "org-b"))).toBeNull()
expect(localStorage.getItem("unrelated")).toBe("keep")
})
})

View file

@ -2,9 +2,11 @@
import { useState, useEffect, useCallback } from "react"
import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
import type { SearchResult } from "@repo/validation/api"
const CACHE_KEY = "sm_profession_v1"
const CACHE_KEY_PREFIX = "sm_profession_v2"
const LEGACY_CACHE_KEY = "sm_profession_v1"
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
export type Profession =
@ -244,6 +246,60 @@ function defaultCopy(p: Profession): PersonalizedCopy {
}
}
const DEFAULT_COPY = defaultCopy("default")
type PersonalizationState = {
scopeKey: string | null
copy: PersonalizedCopy
profession: Profession
}
function defaultState(scopeKey: string | null): PersonalizationState {
return { scopeKey, copy: DEFAULT_COPY, profession: "default" }
}
function getScopeKey(userId: string, orgId: string): string {
return `u:${encodeURIComponent(userId)}:o:${encodeURIComponent(orgId)}`
}
function getCacheKey(scopeKey: string): string {
return `${CACHE_KEY_PREFIX}:${scopeKey}`
}
function readCachedProfession(scopeKey: string): Profession | null {
try {
const raw = localStorage.getItem(getCacheKey(scopeKey))
if (!raw) return null
const cached = JSON.parse(raw) as {
profession?: unknown
ts?: unknown
}
const age = Date.now() - Number(cached.ts)
if (
typeof cached.profession !== "string" ||
!Object.hasOwn(COPY_POOLS, cached.profession) ||
typeof cached.ts !== "number" ||
!Number.isFinite(cached.ts) ||
age < 0 ||
age >= CACHE_TTL_MS
) {
return null
}
return cached.profession as Profession
} catch {
return null
}
}
function writeCachedProfession(scopeKey: string, profession: Profession) {
try {
localStorage.setItem(
getCacheKey(scopeKey),
JSON.stringify({ profession, ts: Date.now() }),
)
} catch {}
}
const sessionCopyCache: Partial<Record<Profession, PersonalizedCopy>> = {}
function getSessionCopy(p: Profession): PersonalizedCopy {
@ -399,96 +455,132 @@ function classifyProfession(results: SearchResult[]): Profession {
return best && best[1] > 0 ? best[0] : "default"
}
let inflightPromise: Promise<void> | null = null
const inflightPromises = new Map<string, Promise<Profession | null>>()
const manualSelectionVersions = new Map<string, number>()
function detectProfession(scopeKey: string): Promise<Profession | null> {
const inflight = inflightPromises.get(scopeKey)
if (inflight) return inflight
const request = $fetch("@post/search", {
body: {
q: "career profession field industry background work role",
limit: 8,
},
})
.then((res) => {
const results = res.data?.results
if (!results?.length) return null
return classifyProfession(results)
})
.catch(() => null)
inflightPromises.set(scopeKey, request)
void request.finally(() => {
if (inflightPromises.get(scopeKey) === request) {
inflightPromises.delete(scopeKey)
}
})
return request
}
export function usePersonalization(): {
copy: PersonalizedCopy
profession: Profession
setProfession: (p: Profession) => void
} {
const [copy, setCopy] = useState<PersonalizedCopy>(() =>
defaultCopy("default"),
const { isSessionPending, isRestoring, session, user, org } = useAuth()
const scopeKey =
!isSessionPending &&
!isRestoring &&
session &&
user &&
org &&
session.userId === user.id &&
session.activeOrganizationId === org.id
? getScopeKey(user.id, org.id)
: null
const [state, setState] = useState<PersonalizationState>(() =>
defaultState(null),
)
const [profession, setProfessionState] = useState<Profession>("default")
const visibleState =
state.scopeKey === scopeKey ? state : defaultState(scopeKey)
const setProfession = useCallback((p: Profession) => {
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({ profession: p, ts: Date.now() }),
const setProfession = useCallback(
(p: Profession) => {
if (!scopeKey) return
manualSelectionVersions.set(
scopeKey,
(manualSelectionVersions.get(scopeKey) ?? 0) + 1,
)
} catch {}
// Re-pick on explicit change so the user sees fresh copy for the new identity
const freshCopy = pickCopy(p)
sessionCopyCache[p] = freshCopy
setCopy(freshCopy)
setProfessionState(p)
}, [])
writeCachedProfession(scopeKey, p)
// Re-pick on explicit change so the user sees fresh copy for the new identity
const freshCopy = pickCopy(p)
sessionCopyCache[p] = freshCopy
setState({ scopeKey, copy: freshCopy, profession: p })
},
[scopeKey],
)
useEffect(() => {
try {
const raw = localStorage.getItem(CACHE_KEY)
if (raw) {
const { profession: cached, ts } = JSON.parse(raw) as {
profession: Profession
ts: number
}
if (Date.now() - ts < CACHE_TTL_MS && COPY_POOLS[cached]) {
setCopy(getSessionCopy(cached))
setProfessionState(cached)
return
}
}
localStorage.removeItem(LEGACY_CACHE_KEY)
} catch {}
setState(defaultState(scopeKey))
if (!scopeKey) return
if (inflightPromise) {
inflightPromise.then(() => {
try {
const raw = localStorage.getItem(CACHE_KEY)
if (raw) {
const { profession: cached } = JSON.parse(raw) as {
profession: Profession
}
if (COPY_POOLS[cached]) {
setCopy(getSessionCopy(cached))
setProfessionState(cached)
}
}
} catch {}
const cached = readCachedProfession(scopeKey)
if (cached) {
setState({
scopeKey,
copy: getSessionCopy(cached),
profession: cached,
})
return
}
inflightPromise = $fetch("@post/search", {
body: {
q: "career profession field industry background work role",
limit: 8,
},
let cancelled = false
const manualSelectionVersion = manualSelectionVersions.get(scopeKey) ?? 0
void detectProfession(scopeKey).then((detected) => {
if (
cancelled ||
!detected ||
(manualSelectionVersions.get(scopeKey) ?? 0) !== manualSelectionVersion
) {
return
}
const cachedAfterRequest = readCachedProfession(scopeKey)
const resolvedProfession = cachedAfterRequest ?? detected
if (!cachedAfterRequest) {
writeCachedProfession(scopeKey, detected)
}
setState({
scopeKey,
copy: getSessionCopy(resolvedProfession),
profession: resolvedProfession,
})
})
.then((res) => {
const results = res.data?.results
if (!results?.length) return
const detected = classifyProfession(results)
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({ profession: detected, ts: Date.now() }),
)
} catch {}
setCopy(getSessionCopy(detected))
setProfessionState(detected)
})
.catch(() => {})
.finally(() => {
inflightPromise = null
})
}, [])
return () => {
cancelled = true
}
}, [scopeKey])
return { copy, profession, setProfession }
return {
copy: visibleState.copy,
profession: visibleState.profession,
setProfession,
}
}
export function clearPersonalizationCache() {
try {
localStorage.removeItem(CACHE_KEY)
manualSelectionVersions.clear()
localStorage.removeItem(LEGACY_CACHE_KEY)
for (let index = localStorage.length - 1; index >= 0; index--) {
const key = localStorage.key(index)
if (key?.startsWith(`${CACHE_KEY_PREFIX}:`)) {
localStorage.removeItem(key)
}
}
} catch {}
}

View file

@ -120,12 +120,14 @@
"@biomejs/biome": "^2.2.2",
"@sentry/cli": "^2.52.0",
"@tailwindcss/postcss": "^4.1.11",
"@testing-library/react": "^16.3.2",
"@total-typescript/tsconfig": "^1.0.4",
"@types/canvas-confetti": "^1.9.0",
"@types/is-hotkey": "^0.1.10",
"@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"

View file

@ -245,12 +245,14 @@
"@biomejs/biome": "^2.2.2",
"@sentry/cli": "^2.52.0",
"@tailwindcss/postcss": "^4.1.11",
"@testing-library/react": "^16.3.2",
"@total-typescript/tsconfig": "^1.0.4",
"@types/canvas-confetti": "^1.9.0",
"@types/is-hotkey": "^0.1.10",
"@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",