mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(web): scope personalization cache by account
This commit is contained in:
parent
e651045ac5
commit
6ceabb15f8
5 changed files with 459 additions and 68 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
291
apps/web/hooks/use-personalization.test.tsx
Normal file
291
apps/web/hooks/use-personalization.test.tsx
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue