style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -14,37 +14,37 @@
|
||||
*
|
||||
* @param dialogRef - ref to the dialog container element
|
||||
*/
|
||||
import type { KeyboardEvent, RefObject } from 'react'
|
||||
import type { KeyboardEvent, RefObject } from 'react';
|
||||
|
||||
export function useFocusTrap(
|
||||
dialogRef: RefObject<HTMLDivElement | null>,
|
||||
): (e: KeyboardEvent<HTMLDivElement>) => void {
|
||||
return (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== 'Tab' || !dialogRef.current) return
|
||||
if (e.key !== 'Tab' || !dialogRef.current) return;
|
||||
|
||||
const focusable = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1')
|
||||
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1');
|
||||
|
||||
if (focusable.length === 0) return
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab: if on first element, wrap to last
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab: if on last element, wrap to first
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
* Run: pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock EventSource
|
||||
@@ -25,60 +25,60 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MockEventSourceInstance = {
|
||||
url: string
|
||||
withCredentials: boolean
|
||||
readyState: number
|
||||
onopen: ((ev: Event) => void) | null
|
||||
onerror: ((ev: Event) => void) | null
|
||||
listeners: Map<string, Array<(ev: MessageEvent) => void>>
|
||||
addEventListener: (type: string, handler: (ev: MessageEvent) => void) => void
|
||||
close: () => void
|
||||
url: string;
|
||||
withCredentials: boolean;
|
||||
readyState: number;
|
||||
onopen: ((ev: Event) => void) | null;
|
||||
onerror: ((ev: Event) => void) | null;
|
||||
listeners: Map<string, Array<(ev: MessageEvent) => void>>;
|
||||
addEventListener: (type: string, handler: (ev: MessageEvent) => void) => void;
|
||||
close: () => void;
|
||||
// Test helpers — trigger events
|
||||
_triggerOpen: () => void
|
||||
_triggerError: () => void
|
||||
_triggerMessage: (type: string, data: unknown) => void
|
||||
}
|
||||
_triggerOpen: () => void;
|
||||
_triggerError: () => void;
|
||||
_triggerMessage: (type: string, data: unknown) => void;
|
||||
};
|
||||
|
||||
let mockInstances: MockEventSourceInstance[] = []
|
||||
let mockInstances: MockEventSourceInstance[] = [];
|
||||
|
||||
class MockEventSource {
|
||||
url: string
|
||||
withCredentials: boolean
|
||||
readyState: number = 0
|
||||
onopen: ((ev: Event) => void) | null = null
|
||||
onerror: ((ev: Event) => void) | null = null
|
||||
listeners: Map<string, Array<(ev: MessageEvent) => void>> = new Map()
|
||||
closeCalled = false
|
||||
url: string;
|
||||
withCredentials: boolean;
|
||||
readyState: number = 0;
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
listeners: Map<string, Array<(ev: MessageEvent) => void>> = new Map();
|
||||
closeCalled = false;
|
||||
|
||||
constructor(url: string, init?: { withCredentials?: boolean }) {
|
||||
this.url = url
|
||||
this.withCredentials = init?.withCredentials ?? false
|
||||
mockInstances.push(this)
|
||||
this.url = url;
|
||||
this.withCredentials = init?.withCredentials ?? false;
|
||||
mockInstances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, handler: (ev: MessageEvent) => void) {
|
||||
const existing = this.listeners.get(type) ?? []
|
||||
this.listeners.set(type, [...existing, handler])
|
||||
const existing = this.listeners.get(type) ?? [];
|
||||
this.listeners.set(type, [...existing, handler]);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closeCalled = true
|
||||
this.readyState = 2 // CLOSED
|
||||
this.closeCalled = true;
|
||||
this.readyState = 2; // CLOSED
|
||||
}
|
||||
|
||||
_triggerOpen() {
|
||||
this.readyState = 1 // OPEN
|
||||
this.onopen?.({} as Event)
|
||||
this.readyState = 1; // OPEN
|
||||
this.onopen?.({} as Event);
|
||||
}
|
||||
|
||||
_triggerError() {
|
||||
this.onerror?.({} as Event)
|
||||
this.onerror?.({} as Event);
|
||||
}
|
||||
|
||||
_triggerMessage(type: string, data: unknown) {
|
||||
const handlers = this.listeners.get(type) ?? []
|
||||
const event = new MessageEvent(type, { data: JSON.stringify(data) })
|
||||
handlers.forEach((h) => h(event))
|
||||
const handlers = this.listeners.get(type) ?? [];
|
||||
const event = new MessageEvent(type, { data: JSON.stringify(data) });
|
||||
handlers.forEach((h) => h(event));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,22 +88,22 @@ class MockEventSource {
|
||||
|
||||
function makeWrapper(queryClient: QueryClient) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockInstances = []
|
||||
vi.useFakeTimers()
|
||||
mockInstances = [];
|
||||
vi.useFakeTimers();
|
||||
// Replace global EventSource with mock
|
||||
vi.stubGlobal('EventSource', MockEventSource)
|
||||
})
|
||||
vi.stubGlobal('EventSource', MockEventSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
mockInstances = []
|
||||
})
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
mockInstances = [];
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
@@ -111,222 +111,214 @@ afterEach(() => {
|
||||
|
||||
describe('useListSSE — D-11 bounded backoff', () => {
|
||||
it('reports connected and resets attempt counter when EventSource fires open', async () => {
|
||||
const { useListSSE } = await import('./useListSSE.js')
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const stateChanges: string[] = []
|
||||
const { useListSSE } = await import('./useListSSE.js');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const stateChanges: string[] = [];
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
|
||||
{ wrapper: makeWrapper(queryClient) },
|
||||
)
|
||||
);
|
||||
|
||||
// Trigger open on the created EventSource
|
||||
const es = mockInstances[0] as unknown as { _triggerOpen: () => void }
|
||||
const es = mockInstances[0] as unknown as { _triggerOpen: () => void };
|
||||
act(() => {
|
||||
es._triggerOpen()
|
||||
})
|
||||
es._triggerOpen();
|
||||
});
|
||||
|
||||
expect(stateChanges).toContain('connected')
|
||||
unmount()
|
||||
})
|
||||
expect(stateChanges).toContain('connected');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('invalidates [list, listId] query on successful open (D-10 full refetch on reconnect)', async () => {
|
||||
const { useListSSE } = await import('./useListSSE.js')
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const { useListSSE } = await import('./useListSSE.js');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() => useListSSE({ listId: 42, onStateChange: vi.fn() }),
|
||||
{ wrapper: makeWrapper(queryClient) },
|
||||
)
|
||||
const { unmount } = renderHook(() => useListSSE({ listId: 42, onStateChange: vi.fn() }), {
|
||||
wrapper: makeWrapper(queryClient),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const es = mockInstances[0] as unknown as { _triggerOpen: () => void }
|
||||
es._triggerOpen()
|
||||
})
|
||||
const es = mockInstances[0] as unknown as { _triggerOpen: () => void };
|
||||
es._triggerOpen();
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: ['list', 42] }),
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['list', 42] }));
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('invalidates [list, listId] when a list-change SSE event is received', async () => {
|
||||
const { useListSSE } = await import('./useListSSE.js')
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const { useListSSE } = await import('./useListSSE.js');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() => useListSSE({ listId: 7, onStateChange: vi.fn() }),
|
||||
{ wrapper: makeWrapper(queryClient) },
|
||||
)
|
||||
const { unmount } = renderHook(() => useListSSE({ listId: 7, onStateChange: vi.fn() }), {
|
||||
wrapper: makeWrapper(queryClient),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const es = mockInstances[0] as unknown as {
|
||||
_triggerOpen: () => void
|
||||
_triggerMessage: (type: string, data: unknown) => void
|
||||
}
|
||||
es._triggerOpen()
|
||||
es._triggerMessage('item:added', { type: 'item:added', listId: 7 })
|
||||
})
|
||||
_triggerOpen: () => void;
|
||||
_triggerMessage: (type: string, data: unknown) => void;
|
||||
};
|
||||
es._triggerOpen();
|
||||
es._triggerMessage('item:added', { type: 'item:added', listId: 7 });
|
||||
});
|
||||
|
||||
const calls = invalidateSpy.mock.calls
|
||||
const calls = invalidateSpy.mock.calls;
|
||||
const hasListQuery = calls.some((args) => {
|
||||
const opts = args[0] as { queryKey?: unknown[] }
|
||||
return JSON.stringify(opts?.queryKey) === JSON.stringify(['list', 7])
|
||||
})
|
||||
expect(hasListQuery).toBe(true)
|
||||
unmount()
|
||||
})
|
||||
const opts = args[0] as { queryKey?: unknown[] };
|
||||
return JSON.stringify(opts?.queryKey) === JSON.stringify(['list', 7]);
|
||||
});
|
||||
expect(hasListQuery).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('transitions to reconnecting on first error and schedules retry after 250ms (D-11 step 0)', async () => {
|
||||
const { useListSSE } = await import('./useListSSE.js')
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const stateChanges: string[] = []
|
||||
const { useListSSE } = await import('./useListSSE.js');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const stateChanges: string[] = [];
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
|
||||
{ wrapper: makeWrapper(queryClient) },
|
||||
)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
mockInstances[0]._triggerError()
|
||||
})
|
||||
mockInstances[0]._triggerError();
|
||||
});
|
||||
|
||||
// Should be reconnecting (not disconnected — still have attempts left)
|
||||
expect(stateChanges).toContain('reconnecting')
|
||||
expect(stateChanges).toContain('reconnecting');
|
||||
|
||||
// Advance 250ms — a new EventSource should be created
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250)
|
||||
})
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(mockInstances.length).toBeGreaterThanOrEqual(2) // reconnected
|
||||
unmount()
|
||||
})
|
||||
expect(mockInstances.length).toBeGreaterThanOrEqual(2); // reconnected
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('stops retrying after MAX_ATTEMPTS and transitions to disconnected (D-11 backoff exhausted)', async () => {
|
||||
const { useListSSE } = await import('./useListSSE.js')
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const stateChanges: string[] = []
|
||||
const { useListSSE } = await import('./useListSSE.js');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const stateChanges: string[] = [];
|
||||
|
||||
renderHook(
|
||||
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
|
||||
{ wrapper: makeWrapper(queryClient) },
|
||||
)
|
||||
renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), {
|
||||
wrapper: makeWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Exhaust all 6 backoff steps: 250→500→1000→2000→4000→8000ms
|
||||
const backoffSteps = [250, 500, 1000, 2000, 4000, 8000]
|
||||
const backoffSteps = [250, 500, 1000, 2000, 4000, 8000];
|
||||
|
||||
for (const delay of backoffSteps) {
|
||||
// Trigger error on the latest EventSource instance
|
||||
act(() => {
|
||||
const es = mockInstances[mockInstances.length - 1]
|
||||
es._triggerError()
|
||||
})
|
||||
const es = mockInstances[mockInstances.length - 1];
|
||||
es._triggerError();
|
||||
});
|
||||
// Advance to the next backoff delay to allow next connection attempt
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(delay)
|
||||
})
|
||||
vi.advanceTimersByTime(delay);
|
||||
});
|
||||
}
|
||||
|
||||
// After exhausting all attempts, trigger error on last instance
|
||||
act(() => {
|
||||
const es = mockInstances[mockInstances.length - 1]
|
||||
es._triggerError()
|
||||
})
|
||||
const es = mockInstances[mockInstances.length - 1];
|
||||
es._triggerError();
|
||||
});
|
||||
|
||||
// State must be 'disconnected' — no more retries
|
||||
const lastState = stateChanges[stateChanges.length - 1]
|
||||
expect(lastState).toBe('disconnected')
|
||||
const lastState = stateChanges[stateChanges.length - 1];
|
||||
expect(lastState).toBe('disconnected');
|
||||
|
||||
// No additional timer scheduled — advancing time should not create a new instance
|
||||
const instanceCount = mockInstances.length
|
||||
const instanceCount = mockInstances.length;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000)
|
||||
})
|
||||
expect(mockInstances.length).toBe(instanceCount) // no new connection attempt
|
||||
})
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
expect(mockInstances.length).toBe(instanceCount); // no new connection attempt
|
||||
});
|
||||
|
||||
it('resets backoff counter on successful reconnect (D-11 — counter reset)', async () => {
|
||||
const { useListSSE } = await import('./useListSSE.js')
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const stateChanges: string[] = []
|
||||
const { useListSSE } = await import('./useListSSE.js');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const stateChanges: string[] = [];
|
||||
|
||||
renderHook(
|
||||
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
|
||||
{ wrapper: makeWrapper(queryClient) },
|
||||
)
|
||||
renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), {
|
||||
wrapper: makeWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Fail once
|
||||
act(() => {
|
||||
mockInstances[0]._triggerError()
|
||||
})
|
||||
mockInstances[0]._triggerError();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250) // first backoff
|
||||
})
|
||||
vi.advanceTimersByTime(250); // first backoff
|
||||
});
|
||||
|
||||
// Reconnect successfully
|
||||
act(() => {
|
||||
const latest = mockInstances[mockInstances.length - 1]
|
||||
latest._triggerOpen()
|
||||
})
|
||||
const latest = mockInstances[mockInstances.length - 1];
|
||||
latest._triggerOpen();
|
||||
});
|
||||
|
||||
expect(stateChanges).toContain('connected')
|
||||
expect(stateChanges).toContain('connected');
|
||||
|
||||
// The attempt counter should be reset. Fail again — should reconnect, not give up.
|
||||
const statesBefore = stateChanges.length
|
||||
const statesBefore = stateChanges.length;
|
||||
act(() => {
|
||||
const latest = mockInstances[mockInstances.length - 1]
|
||||
latest._triggerError()
|
||||
})
|
||||
const latest = mockInstances[mockInstances.length - 1];
|
||||
latest._triggerError();
|
||||
});
|
||||
|
||||
// Should be reconnecting again (counter reset → still has attempts)
|
||||
const newStates = stateChanges.slice(statesBefore)
|
||||
expect(newStates).toContain('reconnecting')
|
||||
})
|
||||
const newStates = stateChanges.slice(statesBefore);
|
||||
expect(newStates).toContain('reconnecting');
|
||||
});
|
||||
|
||||
it('closes EventSource and clears timers on unmount (no reconnect storm — Pitfall 3)', async () => {
|
||||
const { useListSSE } = await import('./useListSSE.js')
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const { useListSSE } = await import('./useListSSE.js');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() => useListSSE({ listId: 1, onStateChange: vi.fn() }),
|
||||
{ wrapper: makeWrapper(queryClient) },
|
||||
)
|
||||
const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), {
|
||||
wrapper: makeWrapper(queryClient),
|
||||
});
|
||||
|
||||
const firstEs = mockInstances[0]
|
||||
const firstEs = mockInstances[0];
|
||||
|
||||
// Trigger error to schedule a reconnect timer
|
||||
act(() => {
|
||||
firstEs._triggerError()
|
||||
})
|
||||
firstEs._triggerError();
|
||||
});
|
||||
|
||||
// Unmount — should cancel the pending timer and close the EventSource
|
||||
unmount()
|
||||
unmount();
|
||||
|
||||
const instanceCountAfterUnmount = mockInstances.length
|
||||
const instanceCountAfterUnmount = mockInstances.length;
|
||||
|
||||
// Advance well past any backoff timer — no new instances should be created
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000)
|
||||
})
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
|
||||
expect(mockInstances.length).toBe(instanceCountAfterUnmount) // no new connection post-unmount
|
||||
expect((firstEs as unknown as { closeCalled?: boolean }).closeCalled).toBeTruthy()
|
||||
})
|
||||
expect(mockInstances.length).toBe(instanceCountAfterUnmount); // no new connection post-unmount
|
||||
expect((firstEs as unknown as { closeCalled?: boolean }).closeCalled).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses withCredentials: true on EventSource (Pitfall 7 — session cookie)', async () => {
|
||||
const { useListSSE } = await import('./useListSSE.js')
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const { useListSSE } = await import('./useListSSE.js');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() => useListSSE({ listId: 1, onStateChange: vi.fn() }),
|
||||
{ wrapper: makeWrapper(queryClient) },
|
||||
)
|
||||
const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), {
|
||||
wrapper: makeWrapper(queryClient),
|
||||
});
|
||||
|
||||
const es = mockInstances[0]
|
||||
expect(es.withCredentials).toBe(true)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
const es = mockInstances[0];
|
||||
expect(es.withCredentials).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,21 +18,21 @@
|
||||
* Source: RESEARCH.md Finding 4 verbatim pattern.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export type SyncState = 'connected' | 'reconnecting' | 'disconnected'
|
||||
export type SyncState = 'connected' | 'reconnecting' | 'disconnected';
|
||||
|
||||
/**
|
||||
* Backoff schedule per D-11: 250ms → 500ms → 1000ms → 2000ms → 4000ms → cap 8000ms.
|
||||
* 6 steps → MAX_ATTEMPTS = 6; after exhaustion, state → 'disconnected'.
|
||||
*/
|
||||
const BACKOFF_STEPS_MS = [250, 500, 1000, 2000, 4000, 8000]
|
||||
const MAX_ATTEMPTS = BACKOFF_STEPS_MS.length
|
||||
const BACKOFF_STEPS_MS = [250, 500, 1000, 2000, 4000, 8000];
|
||||
const MAX_ATTEMPTS = BACKOFF_STEPS_MS.length;
|
||||
|
||||
export interface UseListSSEOptions {
|
||||
listId: number
|
||||
onStateChange: (state: SyncState) => void
|
||||
listId: number;
|
||||
onStateChange: (state: SyncState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,68 +48,68 @@ export interface UseListSSEOptions {
|
||||
* Unmount → es.close() + clearTimeout(timerRef)
|
||||
*/
|
||||
export function useListSSE({ listId, onStateChange }: UseListSSEOptions): void {
|
||||
const queryClient = useQueryClient()
|
||||
const esRef = useRef<EventSource | null>(null)
|
||||
const attemptsRef = useRef(0)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const queryClient = useQueryClient();
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const attemptsRef = useRef(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// handleListChange is stable — invalidate the list on any list-change event
|
||||
const handleListChange = useCallback(() => {
|
||||
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', listId] })
|
||||
}, [queryClient, listId])
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', listId] });
|
||||
}, [queryClient, listId]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// Close any existing connection (prevents stacking)
|
||||
esRef.current?.close()
|
||||
esRef.current?.close();
|
||||
|
||||
const es = new EventSource('/api/sse/lists', { withCredentials: true })
|
||||
esRef.current = es
|
||||
const es = new EventSource('/api/sse/lists', { withCredentials: true });
|
||||
esRef.current = es;
|
||||
|
||||
// Subscribe to all list-change event types (D-10 — trigger refetch on any change)
|
||||
es.addEventListener('item:added', handleListChange)
|
||||
es.addEventListener('item:updated', handleListChange)
|
||||
es.addEventListener('item:deleted', handleListChange)
|
||||
es.addEventListener('list:updated', handleListChange)
|
||||
es.addEventListener('list:deleted', handleListChange)
|
||||
es.addEventListener('item:added', handleListChange);
|
||||
es.addEventListener('item:updated', handleListChange);
|
||||
es.addEventListener('item:deleted', handleListChange);
|
||||
es.addEventListener('list:updated', handleListChange);
|
||||
es.addEventListener('list:deleted', handleListChange);
|
||||
|
||||
es.onopen = () => {
|
||||
// Reset attempt counter on successful open (D-11)
|
||||
attemptsRef.current = 0
|
||||
onStateChange('connected')
|
||||
attemptsRef.current = 0;
|
||||
onStateChange('connected');
|
||||
// Full refetch on (re)connect — D-10: no Last-Event-ID replay, just refetch; fire-and-forget
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', listId] })
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['list', listId] });
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
// Close BEFORE scheduling retry — prevents browser auto-reconnect stacking (Pitfall 3)
|
||||
es.close()
|
||||
es.close();
|
||||
|
||||
const attempt = attemptsRef.current
|
||||
const attempt = attemptsRef.current;
|
||||
|
||||
if (attempt >= MAX_ATTEMPTS) {
|
||||
// Backoff exhausted — surface "Updates paused" indicator and stop (D-11)
|
||||
onStateChange('disconnected')
|
||||
onStateChange('disconnected');
|
||||
// D-12 polling fallback (refetchInterval:30000 in ListDetail) keeps data fresh
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
onStateChange('reconnecting')
|
||||
const delay = BACKOFF_STEPS_MS[attempt]
|
||||
attemptsRef.current = attempt + 1
|
||||
timerRef.current = setTimeout(connect, delay)
|
||||
}
|
||||
}, [listId, queryClient, onStateChange, handleListChange])
|
||||
onStateChange('reconnecting');
|
||||
const delay = BACKOFF_STEPS_MS[attempt];
|
||||
attemptsRef.current = attempt + 1;
|
||||
timerRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
}, [listId, queryClient, onStateChange, handleListChange]);
|
||||
|
||||
useEffect(() => {
|
||||
connect()
|
||||
connect();
|
||||
return () => {
|
||||
// Cleanup: close EventSource + cancel any pending reconnect timer (Pitfall 3)
|
||||
esRef.current?.close()
|
||||
esRef.current?.close();
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [connect])
|
||||
};
|
||||
}, [connect]);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
* setEnabled(false) — unsubscribe + set '0'.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -42,15 +42,15 @@ import { useState, useEffect } from 'react'
|
||||
* applicationServerKey (Web Push spec — key must be a BufferSource).
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
const buffer = new ArrayBuffer(rawData.length)
|
||||
const outputArray = new Uint8Array(buffer)
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
const buffer = new ArrayBuffer(rawData.length);
|
||||
const outputArray = new Uint8Array(buffer);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,18 +59,18 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
* are instant — the tap handler can proceed without an extra round-trip.
|
||||
*/
|
||||
async function fetchVapidKey(): Promise<string> {
|
||||
const cached = sessionStorage.getItem('vapidPublicKey')
|
||||
if (cached) return cached
|
||||
const cached = sessionStorage.getItem('vapidPublicKey');
|
||||
if (cached) return cached;
|
||||
|
||||
const res = await fetch('/api/push/vapid-public-key', {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch VAPID key: ${res.status}`)
|
||||
const data = (await res.json()) as { publicKey: string }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch VAPID key: ${res.status}`);
|
||||
const data = (await res.json()) as { publicKey: string };
|
||||
if (data.publicKey) {
|
||||
sessionStorage.setItem('vapidPublicKey', data.publicKey)
|
||||
sessionStorage.setItem('vapidPublicKey', data.publicKey);
|
||||
}
|
||||
return data.publicKey
|
||||
return data.publicKey;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,9 +79,9 @@ async function fetchVapidKey(): Promise<string> {
|
||||
|
||||
function readNotificationsEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('notificationsEnabled') === '1'
|
||||
return localStorage.getItem('notificationsEnabled') === '1';
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,18 +91,18 @@ function readNotificationsEnabled(): boolean {
|
||||
*/
|
||||
function readNotificationsDisabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('notificationsEnabled') === '0'
|
||||
return localStorage.getItem('notificationsEnabled') === '0';
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function persistNotificationsEnabled(value: boolean): void {
|
||||
try {
|
||||
if (value) {
|
||||
localStorage.setItem('notificationsEnabled', '1')
|
||||
localStorage.setItem('notificationsEnabled', '1');
|
||||
} else {
|
||||
localStorage.setItem('notificationsEnabled', '0')
|
||||
localStorage.setItem('notificationsEnabled', '0');
|
||||
}
|
||||
} catch {
|
||||
// Private mode / storage disabled — ignore
|
||||
@@ -121,12 +121,12 @@ export interface UsePushSubscriptionReturn {
|
||||
* in a useEffect) and passed in here. This eliminates the network round-trip inside
|
||||
* the tap handler, preserving the iOS user-gesture requirement for pushManager.subscribe().
|
||||
*/
|
||||
subscribe: (registration: ServiceWorkerRegistration, vapidKey: string) => Promise<void>
|
||||
unsubscribe: () => Promise<void>
|
||||
subscribe: (registration: ServiceWorkerRegistration, vapidKey: string) => Promise<void>;
|
||||
unsubscribe: () => Promise<void>;
|
||||
/** Current Notification.permission value ('default' | 'granted' | 'denied') */
|
||||
permission: NotificationPermission
|
||||
permission: NotificationPermission;
|
||||
/** True when an active push subscription exists in pushManager. */
|
||||
isSubscribed: boolean
|
||||
isSubscribed: boolean;
|
||||
/**
|
||||
* Master on/off toggle (D-09).
|
||||
*
|
||||
@@ -138,27 +138,27 @@ export interface UsePushSubscriptionReturn {
|
||||
* setEnabled(false):
|
||||
* - Calls unsubscribe() (DELETE server + browser) + persists '0'.
|
||||
*/
|
||||
setEnabled: (on: boolean) => Promise<void>
|
||||
setEnabled: (on: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
const [permission, setPermission] = useState<NotificationPermission>(
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'default',
|
||||
)
|
||||
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||
);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
|
||||
// Health-check on mount (D-10): if OS permission is granted but no active
|
||||
// subscription exists (expired/cleared) AND user hasn't explicitly disabled,
|
||||
// silently re-subscribe in background.
|
||||
useEffect(() => {
|
||||
if (typeof Notification === 'undefined') return
|
||||
if (Notification.permission !== 'granted') return
|
||||
if (!navigator.serviceWorker) return
|
||||
if (typeof Notification === 'undefined') return;
|
||||
if (Notification.permission !== 'granted') return;
|
||||
if (!navigator.serviceWorker) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const existingSub = await registration.pushManager.getSubscription()
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existingSub = await registration.pushManager.getSubscription();
|
||||
|
||||
if (existingSub) {
|
||||
// Re-confirm server-side record (handles 410-prune recovery — D-10).
|
||||
@@ -168,33 +168,33 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(existingSub.toJSON()),
|
||||
}).catch(() => {}) // non-blocking — ignore network failures
|
||||
setIsSubscribed(true)
|
||||
return
|
||||
}).catch(() => {}); // non-blocking — ignore network failures
|
||||
setIsSubscribed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// No active subscription — only silently re-subscribe if the user
|
||||
// hasn't explicitly turned notifications off (D-10).
|
||||
if (readNotificationsDisabled()) return
|
||||
if (readNotificationsDisabled()) return;
|
||||
|
||||
const vapidKey = await fetchVapidKey()
|
||||
const vapidKey = await fetchVapidKey();
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||
})
|
||||
});
|
||||
await fetch('/api/push/subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
persistNotificationsEnabled(true)
|
||||
setIsSubscribed(true)
|
||||
});
|
||||
persistNotificationsEnabled(true);
|
||||
setIsSubscribed(true);
|
||||
} catch {
|
||||
// Ignore health-check errors — non-blocking background task
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* subscribe — MUST be called inside an onClick handler.
|
||||
@@ -219,7 +219,7 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||
})
|
||||
});
|
||||
|
||||
// Persist the subscription to the server
|
||||
const res = await fetch('/api/push/subscription', {
|
||||
@@ -227,39 +227,39 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to persist subscription: ${res.status}`)
|
||||
throw new Error(`Failed to persist subscription: ${res.status}`);
|
||||
}
|
||||
|
||||
persistNotificationsEnabled(true)
|
||||
setPermission(Notification.permission)
|
||||
setIsSubscribed(true)
|
||||
}
|
||||
persistNotificationsEnabled(true);
|
||||
setPermission(Notification.permission);
|
||||
setIsSubscribed(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* unsubscribe — retrieve the active subscription and cancel it.
|
||||
* Also removes the server-side row via DELETE /api/push/subscription.
|
||||
*/
|
||||
const unsubscribe = async (): Promise<void> => {
|
||||
if (!navigator.serviceWorker) return
|
||||
if (!navigator.serviceWorker) return;
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const sub = await registration.pushManager.getSubscription()
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const sub = await registration.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await sub.unsubscribe()
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
|
||||
await fetch('/api/push/subscription', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
});
|
||||
|
||||
persistNotificationsEnabled(false)
|
||||
setPermission(Notification.permission)
|
||||
setIsSubscribed(false)
|
||||
}
|
||||
persistNotificationsEnabled(false);
|
||||
setPermission(Notification.permission);
|
||||
setIsSubscribed(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* setEnabled — master on/off for the settings toggle (D-09).
|
||||
@@ -271,52 +271,52 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
*/
|
||||
const setEnabled = async (on: boolean): Promise<void> => {
|
||||
if (!on) {
|
||||
await unsubscribe()
|
||||
return
|
||||
await unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
// on=true path
|
||||
const currentPermission =
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'default'
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'default';
|
||||
if (currentPermission !== 'granted') {
|
||||
// 'default' → caller must use tap-gated subscribe(); 'denied' → no-op
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Permission is granted — silently subscribe without a tap gesture
|
||||
// (already have OS permission, no dialog required).
|
||||
if (!navigator.serviceWorker) return
|
||||
if (!navigator.serviceWorker) return;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const existingSub = await registration.pushManager.getSubscription()
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existingSub = await registration.pushManager.getSubscription();
|
||||
if (existingSub) {
|
||||
// Already subscribed — just flip the stored flag back on.
|
||||
persistNotificationsEnabled(true)
|
||||
setIsSubscribed(true)
|
||||
return
|
||||
persistNotificationsEnabled(true);
|
||||
setIsSubscribed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const vapidKey = await fetchVapidKey()
|
||||
const vapidKey = await fetchVapidKey();
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||
})
|
||||
});
|
||||
const res = await fetch('/api/push/subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
persistNotificationsEnabled(true)
|
||||
setIsSubscribed(true)
|
||||
persistNotificationsEnabled(true);
|
||||
setIsSubscribed(true);
|
||||
}
|
||||
} catch {
|
||||
// Ignore — non-fatal; user can retry via toggle
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { subscribe, unsubscribe, permission, isSubscribed, setEnabled }
|
||||
return { subscribe, unsubscribe, permission, isSubscribed, setEnabled };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,7 +326,7 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
*/
|
||||
export async function prefetchVapidKey(): Promise<void> {
|
||||
try {
|
||||
await fetchVapidKey()
|
||||
await fetchVapidKey();
|
||||
} catch {
|
||||
// Non-fatal — the subscribe() call will retry if sessionStorage miss
|
||||
}
|
||||
@@ -337,4 +337,4 @@ export async function prefetchVapidKey(): Promise<void> {
|
||||
* (localStorage.notificationsEnabled === '1').
|
||||
* Exported for use in PermissionDeniedBanner and SettingsSheet initial state.
|
||||
*/
|
||||
export { readNotificationsEnabled }
|
||||
export { readNotificationsEnabled };
|
||||
|
||||
Reference in New Issue
Block a user