From 28f0f5bd058201fa3538e11e8637678919d7277f Mon Sep 17 00:00:00 2001 From: Alexander Taylor Date: Sun, 3 May 2026 03:47:08 +0000 Subject: [PATCH 1/3] fix(events): scope cache to chapter The cache of activist names for attendance autofill mixed data from different chapters and shared one last-sync timestamp meaning many names would not get synced when switching chapters. This change makes caches per-chapter to fix this issue and keep chapter databases isolated from each other. --- .../app/(authed)/events/activist-storage.ts | 23 +++++++++++++++---- .../src/app/(authed)/events/event-form.tsx | 2 +- .../src/app/(authed)/events/events-page.tsx | 4 +++- .../(authed)/events/useActivistRegistry.ts | 12 ++++++---- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/frontend-v2/src/app/(authed)/events/activist-storage.ts b/frontend-v2/src/app/(authed)/events/activist-storage.ts index 2b4fb43a..38451f77 100644 --- a/frontend-v2/src/app/(authed)/events/activist-storage.ts +++ b/frontend-v2/src/app/(authed)/events/activist-storage.ts @@ -21,13 +21,17 @@ interface SyncMetadata { lastSyncTime: string // ISO 8601 timestamp } -const DB_NAME = 'activist-registry' const DB_VERSION = 2 const STORE_NAME = 'activists' const METADATA_STORE = 'metadata' export class ActivistStorage { private dbPromise: Promise | null = null + private readonly dbName: string + + constructor(chapterId: number) { + this.dbName = `activist-registry-${chapterId}` + } /** * Initialize the IndexedDB database with required object stores. @@ -35,7 +39,7 @@ export class ActivistStorage { private openDB(): Promise { if (!this.dbPromise) { this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION) + const request = indexedDB.open(this.dbName, DB_VERSION) request.onerror = () => reject(request.error) request.onsuccess = () => { @@ -219,6 +223,15 @@ function isIndexedDBAvailable(): boolean { } } -// Singleton instance - only create if IndexedDB is available -export const activistStorage: ActivistStorage | undefined = - isIndexedDBAvailable() ? new ActivistStorage() : undefined +const storageByChapter = new Map() + +export function getActivistStorage( + chapterId: number, +): ActivistStorage | undefined { + if (!isIndexedDBAvailable()) return undefined + const existing = storageByChapter.get(chapterId) + if (existing) return existing + const storage = new ActivistStorage(chapterId) + storageByChapter.set(chapterId, storage) + return storage +} diff --git a/frontend-v2/src/app/(authed)/events/event-form.tsx b/frontend-v2/src/app/(authed)/events/event-form.tsx index c3d0c4e6..df2fe3f7 100644 --- a/frontend-v2/src/app/(authed)/events/event-form.tsx +++ b/frontend-v2/src/app/(authed)/events/event-form.tsx @@ -97,7 +97,7 @@ export const EventForm = ({ mode }: EventFormProps) => { // The registry loads cached data from IndexedDB first, then syncs any // new/updated activists from the server in the background. const { registry: activistRegistry, isLoading: isLoadingActivists } = - useActivistRegistry() + useActivistRegistry(user.ChapterID) // Fetch existing event/connection, if editing. const { diff --git a/frontend-v2/src/app/(authed)/events/events-page.tsx b/frontend-v2/src/app/(authed)/events/events-page.tsx index eea3b413..60efdc20 100644 --- a/frontend-v2/src/app/(authed)/events/events-page.tsx +++ b/frontend-v2/src/app/(authed)/events/events-page.tsx @@ -36,6 +36,7 @@ import { } from '@/components/ui/select' import { DatePicker } from '@/components/ui/date-picker' import { useActivistRegistry } from './useActivistRegistry' +import { useAuthedPageContext } from '@/hooks/useAuthedPageContext' import { SuggestionInput } from './suggestion-input' import { EventListTable } from './event-list-table' import { cn } from '@/lib/utils' @@ -119,7 +120,8 @@ export default function EventsPage({ mode = 'events' }: Props) { const defaultParams = useMemo(() => buildDefaultParams(mode), [mode]) const isConnections = mode === 'connections' const queryClient = useQueryClient() - const { registry } = useActivistRegistry() + const { user } = useAuthedPageContext() + const { registry } = useActivistRegistry(user.ChapterID) // URL params drive the query and are the source of truth for committed filters const [urlParams, setUrlParams] = useQueryStates({ diff --git a/frontend-v2/src/app/(authed)/events/useActivistRegistry.ts b/frontend-v2/src/app/(authed)/events/useActivistRegistry.ts index 2c4e9e83..0efc4d1a 100644 --- a/frontend-v2/src/app/(authed)/events/useActivistRegistry.ts +++ b/frontend-v2/src/app/(authed)/events/useActivistRegistry.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react' import { useQuery } from '@tanstack/react-query' import { apiClient, API_PATH } from '@/lib/api' import { ActivistRegistry, type ActivistRecord } from './activist-registry' -import { activistStorage } from './activist-storage' +import { getActivistStorage } from './activist-storage' import toast from 'react-hot-toast' /** @@ -13,7 +13,7 @@ import toast from 'react-hot-toast' * * @returns Object containing the registry instance and query state */ -export function useActivistRegistry() { +export function useActivistRegistry(chapterId: number) { const registryRef = useRef(new ActivistRegistry()) const [isStorageLoaded, setIsStorageLoaded] = useState(false) const [isServerLoaded, setIsServerLoaded] = useState(false) @@ -22,8 +22,10 @@ export function useActivistRegistry() { useEffect(() => { let mounted = true + const storage = getActivistStorage(chapterId) + // If IndexedDB is not available (e.g., iOS lockdown mode), skip loading from storage - if (!activistStorage) { + if (!storage) { console.info( '[Registry] IndexedDB not available - running without local caching', ) @@ -32,7 +34,7 @@ export function useActivistRegistry() { } registryRef.current - .loadFromStorage(activistStorage) + .loadFromStorage(storage) .then(() => { if (mounted) setIsStorageLoaded(true) }) @@ -56,7 +58,7 @@ export function useActivistRegistry() { return () => { mounted = false } - }, []) + }, [chapterId]) const query = useQuery({ queryKey: [API_PATH.ACTIVIST_LIST_BASIC], From 71bf8015f6de5329d6723f45e01ad43210da80e3 Mon Sep 17 00:00:00 2001 From: Alexander Taylor Date: Sun, 3 May 2026 03:50:58 +0000 Subject: [PATCH 2/3] fix(nav): fix chapter switching race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixed by removing invalidateQueries before chapter switch navigation. Calling queryClient.invalidateQueries() immediately before window.location.href triggered React Query to fire refetch requests carrying the old session cookie. Each refetch response included a Set-Cookie header (written by renewAuthSession on the server) resetting the chapter back to the previous value. If those responses arrived after the chapter-switch redirect chain had already set the new chapter cookie, they would silently overwrite it — a non-deterministic race. Since window.location.href causes a full page reload, React Query's in-memory cache is discarded automatically; the invalidateQueries call was both redundant and the source of the race. --- frontend-v2/src/components/nav.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend-v2/src/components/nav.tsx b/frontend-v2/src/components/nav.tsx index 8dfbf190..14047abb 100644 --- a/frontend-v2/src/components/nav.tsx +++ b/frontend-v2/src/components/nav.tsx @@ -16,7 +16,7 @@ import { usePathname, useSearchParams } from 'next/navigation' import { useAuthedPageContext } from '@/hooks/useAuthedPageContext' import buefyStyles from './nav.module.css' import clsx from 'clsx' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { useQueryStates } from 'nuqs' import { API_PATH, apiClient } from '@/lib/api' import { SF_BAY_CHAPTER_ID } from '@/lib/constants' @@ -201,7 +201,6 @@ const DropdownItem = ({ const ChapterSwitcher = () => { const { user } = useAuthedPageContext() - const queryClient = useQueryClient() const { data, isLoading, isError } = useQuery({ queryKey: [API_PATH.CHAPTER_LIST], @@ -229,7 +228,6 @@ const ChapterSwitcher = () => { } const switchChapter = (e: React.ChangeEvent) => { - queryClient.invalidateQueries() // invalidate existing cache for previous chapter window.location.href = `/auth/switch_chapter?chapter_id=${e.target.value}` } From a37edbc2fb9664f0eb21079c56aad85045d49f0b Mon Sep 17 00:00:00 2001 From: Alexander Taylor Date: Sun, 3 May 2026 04:09:15 +0000 Subject: [PATCH 3/3] rename IndexedDB db This avoids scoping the database to just activists in case we want to use it for other purposes, avoiding a combinatoric blowup of databases from crossing chapter IDs with entity types. --- frontend-v2/src/app/(authed)/events/activist-storage.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend-v2/src/app/(authed)/events/activist-storage.ts b/frontend-v2/src/app/(authed)/events/activist-storage.ts index 38451f77..3969df7a 100644 --- a/frontend-v2/src/app/(authed)/events/activist-storage.ts +++ b/frontend-v2/src/app/(authed)/events/activist-storage.ts @@ -30,7 +30,7 @@ export class ActivistStorage { private readonly dbName: string constructor(chapterId: number) { - this.dbName = `activist-registry-${chapterId}` + this.dbName = `adb-chapter-${chapterId}` } /** @@ -102,7 +102,7 @@ export class ActivistStorage { return new Promise((resolve, reject) => { const transaction = db.transaction([METADATA_STORE], 'readonly') const store = transaction.objectStore(METADATA_STORE) - const request = store.get('lastSync') + const request = store.get('lastActivistSync') request.onsuccess = () => { const metadata = request.result as SyncMetadata | undefined @@ -124,10 +124,10 @@ export class ActivistStorage { const request = timestamp === null - ? store.delete('lastSync') + ? store.delete('lastActivistSync') : store.put( { lastSyncTime: timestamp } satisfies SyncMetadata, - 'lastSync', + 'lastActivistSync', ) request.onsuccess = () => resolve()