From 6f8145c8fc7f10bd522048c2159e203549913dc3 Mon Sep 17 00:00:00 2001 From: priosshrsth Date: Mon, 24 Aug 2026 09:15:34 +0000 Subject: [PATCH 1/2] =?UTF-8?q?OUT-4093=20|=20Remove=20the=20localStorage?= =?UTF-8?q?=E2=86=92localForage=20assignee=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessing window.localStorage throws SecurityError when Chrome blocks third-party storage for the embedded iframe, and migrateAssignees read it unguarded. The migration shipped in OUT-2348 eleven months ago and only moved a cache; anything unmigrated by now just re-fetches over the network. Fixes TASKS-9X Co-Authored-By: Claude Opus 5 (1M context) --- src/app/_cache/AssigneeCacheGetter.tsx | 3 +-- src/app/_cache/forageStorage.ts | 15 --------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/app/_cache/AssigneeCacheGetter.tsx b/src/app/_cache/AssigneeCacheGetter.tsx index ccb7cda7b..1d665706f 100644 --- a/src/app/_cache/AssigneeCacheGetter.tsx +++ b/src/app/_cache/AssigneeCacheGetter.tsx @@ -3,7 +3,7 @@ import { setAssigneeList } from '@/redux/features/taskBoardSlice' import store from '@/redux/store' import { useEffect } from 'react' -import { getAssignees, migrateAssignees } from '@/app/_cache/forageStorage' +import { getAssignees } from '@/app/_cache/forageStorage' interface ClientAssigneeCacheGetterProps { lookupKey: string @@ -12,7 +12,6 @@ interface ClientAssigneeCacheGetterProps { export const AssigneeCacheGetter = ({ lookupKey }: ClientAssigneeCacheGetterProps) => { useEffect(() => { const run = async () => { - await migrateAssignees(lookupKey) //migrate from localStorage to localForage if required. Remember to remove this after a while. const assignee = await getAssignees(lookupKey) if (assignee.length) { store.dispatch(setAssigneeList(assignee)) diff --git a/src/app/_cache/forageStorage.ts b/src/app/_cache/forageStorage.ts index e8baf6391..977fc8d7f 100644 --- a/src/app/_cache/forageStorage.ts +++ b/src/app/_cache/forageStorage.ts @@ -8,21 +8,6 @@ localforage.config({ storeName: 'assignees', }) -export async function migrateAssignees(lookupKey: string) { - const lKey = `assignees.${lookupKey}` - const existing = localStorage.getItem(lKey) - - if (existing) { - try { - const parsed = JSON.parse(existing) - await localforage.setItem(lKey, parsed) - localStorage.removeItem(lKey) - } catch (err) { - console.error('Migration failed', err) - } - } -} //a utility function to migrate existing assignee data from localStorage to localForage - export async function getAssignees(lookupKey: string): Promise { if (typeof window === 'undefined') return [] From 048a69b1bfcb09b097cedb2e0094d18edcb724a4 Mon Sep 17 00:00:00 2001 From: priosshrsth Date: Mon, 24 Aug 2026 09:21:11 +0000 Subject: [PATCH 2/2] OUT-4093 | Drop the Storage Access API gate on the assignee cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasStorageAccess() reports access to unpartitioned *cookies*; the cache lives in IndexedDB, so it was the wrong signal. MDN documents it returning false in browsers that don't block third-party access by default, which sent us into requestStorageAccess() — automatically denied without a user gesture, and there is none during AssigneeCacheGetter's mount effect. Net effect: the gate disabled the cache for the users it was meant to protect, and threw on Chrome 111, which predates the API. The surrounding try/catch already degrades to a network fetch when storage is genuinely denied, which is what MDN recommends instead of the gate. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/_cache/forageStorage.test.ts | 45 ++++++++++++++++++++++++++++ src/app/_cache/forageStorage.ts | 20 ++----------- 2 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 src/app/_cache/forageStorage.test.ts diff --git a/src/app/_cache/forageStorage.test.ts b/src/app/_cache/forageStorage.test.ts new file mode 100644 index 000000000..dbfe00100 --- /dev/null +++ b/src/app/_cache/forageStorage.test.ts @@ -0,0 +1,45 @@ +const mockGetItem = jest.fn() +const mockSetItem = jest.fn() + +jest.mock('localforage', () => ({ + __esModule: true, + default: { + config: jest.fn(), + getItem: (...args: unknown[]) => mockGetItem(...args), + setItem: (...args: unknown[]) => mockSetItem(...args), + }, +})) + +import { getAssignees, setAssignees } from '@/app/_cache/forageStorage' + +const denied = () => new DOMException('Access is denied for this document.', 'SecurityError') + +describe('assignee cache', () => { + beforeAll(() => { + globalThis.window = {} as Window & typeof globalThis + }) + afterAll(() => { + delete (globalThis as { window?: unknown }).window + }) + beforeEach(() => jest.clearAllMocks()) + + it('returns an empty list when storage access is denied', async () => { + mockGetItem.mockRejectedValue(denied()) + + await expect(getAssignees('lookup-key')).resolves.toEqual([]) + expect(mockGetItem).toHaveBeenCalledWith('assignees.lookup-key') + }) + + it('swallows write failures when storage access is denied', async () => { + mockSetItem.mockRejectedValue(denied()) + + await expect(setAssignees('lookup-key', [])).resolves.toBeUndefined() + expect(mockSetItem).toHaveBeenCalledWith('assignees.lookup-key', []) + }) + + it('returns an empty list when nothing is cached', async () => { + mockGetItem.mockResolvedValue(null) + + await expect(getAssignees('lookup-key')).resolves.toEqual([]) + }) +}) diff --git a/src/app/_cache/forageStorage.ts b/src/app/_cache/forageStorage.ts index 977fc8d7f..e4c23abe0 100644 --- a/src/app/_cache/forageStorage.ts +++ b/src/app/_cache/forageStorage.ts @@ -12,33 +12,19 @@ export async function getAssignees(lookupKey: string): Promise(`assignees.${lookupKey}`)) ?? [] } catch (error: unknown) { - console.error( - "Storage access not granted. Under Chrome's Settings > Privacy and Security, make sure 'Third-party cookies' is allowed.", - ) + console.info('Assignee cache unavailable, falling back to network', error) return [] } } -export async function setAssignees(lookupKey: string, value: any) { +export async function setAssignees(lookupKey: string, value: IAssigneeCombined[]) { if (typeof window === 'undefined') return try { - if (!(await document.hasStorageAccess())) { - console.info('Browswer has no storage access') - await document.requestStorageAccess() - } - return await localforage.setItem(`assignees.${lookupKey}`, value) } catch (error: unknown) { - console.error( - "Storage access not granted. Under Chrome's Settings > Privacy and Security, make sure 'Third-party cookies' is allowed.", - ) + console.info('Assignee cache write skipped, storage unavailable', error) } }