mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? - Noticing our code we have many patterns of calling localstorage and handling those errors - We should add those in a single well tested file - Handle those errors in the singleton which makes it easier for us to debug customer issues. Logger is outputing local storage warnings for feature we expose - Side effect of this is random crashes on studio when local storage isn't available or handled correctly <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Improved browser storage handling across the app for more reliable persistence and graceful behavior in restricted or non-browser environments (settings, previews, charts, tabs, sign-in/session flows, integrations, and UI state). * **New Features** * Introduced a safe storage layer to standardize and harden local/session persistence. * **Tests** * Added comprehensive tests covering the new safe storage behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
import { act, renderHook } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
// Mock localStorage
|
|
const localStorageMock = (() => {
|
|
let store: Record<string, string> = {}
|
|
|
|
return {
|
|
getItem: vi.fn((key: string) => store[key] || null),
|
|
setItem: vi.fn((key: string, value: string) => {
|
|
store[key] = value
|
|
}),
|
|
clear: vi.fn(() => {
|
|
store = {}
|
|
}),
|
|
}
|
|
})()
|
|
|
|
Object.defineProperty(window, 'localStorage', {
|
|
value: localStorageMock,
|
|
})
|
|
|
|
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
describe('useChartHoverState', () => {
|
|
let useChartHoverState: any
|
|
|
|
beforeEach(async () => {
|
|
localStorageMock.clear()
|
|
consoleWarnSpy.mockClear()
|
|
|
|
vi.resetModules()
|
|
const { useChartHoverState: importedHook } = await import('./useChartHoverState')
|
|
useChartHoverState = importedHook
|
|
})
|
|
|
|
afterEach(() => {
|
|
localStorageMock.clear()
|
|
})
|
|
|
|
describe('initialization', () => {
|
|
it('should initialize with default state', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
expect(result.current.hoveredIndex).toBe(null)
|
|
expect(result.current.syncHover).toBe(true)
|
|
expect(result.current.syncTooltip).toBe(true)
|
|
expect(result.current.hoveredChart).toBe(null)
|
|
expect(result.current.isHovered).toBe(false)
|
|
expect(result.current.isCurrentChart).toBe(false)
|
|
})
|
|
|
|
it('should load sync settings from localStorage on initialization', async () => {
|
|
localStorageMock.setItem('supabase-chart-hover-sync-enabled', 'true')
|
|
localStorageMock.setItem('supabase-chart-tooltip-sync-enabled', 'true')
|
|
|
|
vi.resetModules()
|
|
const { useChartHoverState: useChartHoverStateWithStorage } =
|
|
await import('./useChartHoverState')
|
|
|
|
const { result } = renderHook(() => useChartHoverStateWithStorage('chart1'))
|
|
|
|
expect(result.current.syncHover).toBe(true)
|
|
expect(result.current.syncTooltip).toBe(true)
|
|
})
|
|
|
|
it('should handle corrupted localStorage data gracefully', async () => {
|
|
localStorageMock.setItem('supabase-chart-hover-sync-enabled', 'invalid-json')
|
|
localStorageMock.setItem('supabase-chart-tooltip-sync-enabled', 'invalid-json')
|
|
|
|
vi.resetModules()
|
|
const { useChartHoverState: useChartHoverStateWithCorrupted } =
|
|
await import('./useChartHoverState')
|
|
|
|
const { result } = renderHook(() => useChartHoverStateWithCorrupted('chart1'))
|
|
|
|
expect(result.current.syncHover).toBe(true)
|
|
expect(result.current.syncTooltip).toBe(true)
|
|
expect(consoleWarnSpy).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('hover functionality', () => {
|
|
it('should set hover state locally when sync is disabled', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
act(() => {
|
|
result.current.setHover(5)
|
|
})
|
|
|
|
expect(result.current.hoveredIndex).toBe(5)
|
|
expect(result.current.hoveredChart).toBe('chart1')
|
|
expect(result.current.isHovered).toBe(true)
|
|
expect(result.current.isCurrentChart).toBe(true)
|
|
})
|
|
|
|
it('should clear hover state locally when sync is disabled', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
act(() => {
|
|
result.current.setHover(5)
|
|
})
|
|
|
|
act(() => {
|
|
result.current.clearHover()
|
|
})
|
|
|
|
expect(result.current.hoveredIndex).toBe(null)
|
|
expect(result.current.hoveredChart).toBe(null)
|
|
expect(result.current.isHovered).toBe(false)
|
|
expect(result.current.isCurrentChart).toBe(false)
|
|
})
|
|
|
|
it('should sync hover state globally when sync is enabled', () => {
|
|
const { result: result1 } = renderHook(() => useChartHoverState('chart1'))
|
|
const { result: result2 } = renderHook(() => useChartHoverState('chart2'))
|
|
|
|
act(() => {
|
|
result1.current.setSyncHover(true)
|
|
})
|
|
act(() => {
|
|
result1.current.setHover(3)
|
|
})
|
|
|
|
expect(result1.current.hoveredIndex).toBe(3)
|
|
expect(result1.current.hoveredChart).toBe('chart1')
|
|
expect(result1.current.isCurrentChart).toBe(true)
|
|
|
|
expect(result2.current.hoveredIndex).toBe(3)
|
|
expect(result2.current.hoveredChart).toBe('chart1')
|
|
expect(result2.current.isCurrentChart).toBe(false)
|
|
})
|
|
|
|
it('should clear synced hover state globally', () => {
|
|
const { result: result1 } = renderHook(() => useChartHoverState('chart1'))
|
|
const { result: result2 } = renderHook(() => useChartHoverState('chart2'))
|
|
|
|
act(() => {
|
|
result1.current.setSyncHover(true)
|
|
result1.current.setHover(3)
|
|
})
|
|
act(() => {
|
|
result2.current.clearHover()
|
|
})
|
|
|
|
expect(result1.current.hoveredIndex).toBe(null)
|
|
expect(result1.current.hoveredChart).toBe(null)
|
|
expect(result2.current.hoveredIndex).toBe(null)
|
|
expect(result2.current.hoveredChart).toBe(null)
|
|
})
|
|
})
|
|
|
|
describe('sync settings', () => {
|
|
it('should enable hover sync and save to localStorage', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
act(() => {
|
|
result.current.setSyncHover(true)
|
|
})
|
|
|
|
expect(result.current.syncHover).toBe(true)
|
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
|
'supabase-chart-hover-sync-enabled',
|
|
'true'
|
|
)
|
|
})
|
|
|
|
it('should disable hover sync and automatically disable tooltip sync', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
act(() => {
|
|
result.current.setSyncTooltip(true)
|
|
})
|
|
|
|
expect(result.current.syncHover).toBe(true)
|
|
expect(result.current.syncTooltip).toBe(true)
|
|
act(() => {
|
|
result.current.setSyncHover(false)
|
|
})
|
|
|
|
expect(result.current.syncHover).toBe(false)
|
|
expect(result.current.syncTooltip).toBe(false)
|
|
})
|
|
|
|
it('should enable tooltip sync and automatically enable hover sync', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
act(() => {
|
|
result.current.setSyncTooltip(true)
|
|
})
|
|
|
|
expect(result.current.syncHover).toBe(true)
|
|
expect(result.current.syncTooltip).toBe(true)
|
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
|
'supabase-chart-hover-sync-enabled',
|
|
'true'
|
|
)
|
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
|
'supabase-chart-tooltip-sync-enabled',
|
|
'true'
|
|
)
|
|
})
|
|
|
|
it('should handle localStorage errors gracefully when saving sync settings', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
localStorageMock.setItem.mockImplementationOnce(() => {
|
|
throw new Error('Storage quota exceeded')
|
|
})
|
|
|
|
act(() => {
|
|
result.current.setSyncHover(true)
|
|
})
|
|
|
|
// State still updates even though persistence failed. The storage error is
|
|
// swallowed and warned about by safeLocalStorage (covered by its own tests).
|
|
expect(result.current.syncHover).toBe(true)
|
|
expect(consoleWarnSpy).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('state synchronization between multiple hooks', () => {
|
|
it('should synchronize state changes between multiple hook instances', () => {
|
|
const { result: result1 } = renderHook(() => useChartHoverState('chart1'))
|
|
const { result: result2 } = renderHook(() => useChartHoverState('chart2'))
|
|
const { result: result3 } = renderHook(() => useChartHoverState('chart3'))
|
|
|
|
act(() => {
|
|
result1.current.setSyncHover(true)
|
|
})
|
|
|
|
expect(result1.current.syncHover).toBe(true)
|
|
expect(result2.current.syncHover).toBe(true)
|
|
expect(result3.current.syncHover).toBe(true)
|
|
|
|
act(() => {
|
|
result2.current.setHover(7)
|
|
})
|
|
expect(result1.current.hoveredIndex).toBe(7)
|
|
expect(result1.current.hoveredChart).toBe('chart2')
|
|
expect(result2.current.hoveredIndex).toBe(7)
|
|
expect(result2.current.hoveredChart).toBe('chart2')
|
|
expect(result3.current.hoveredIndex).toBe(7)
|
|
expect(result3.current.hoveredChart).toBe('chart2')
|
|
})
|
|
|
|
it('should correctly identify current chart vs synced charts', () => {
|
|
const { result: result1 } = renderHook(() => useChartHoverState('chart1'))
|
|
const { result: result2 } = renderHook(() => useChartHoverState('chart2'))
|
|
|
|
act(() => {
|
|
result1.current.setSyncHover(true)
|
|
result1.current.setHover(5)
|
|
})
|
|
|
|
expect(result1.current.isCurrentChart).toBe(true)
|
|
expect(result2.current.isCurrentChart).toBe(false)
|
|
expect(result1.current.isHovered).toBe(true)
|
|
expect(result2.current.isHovered).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle setting hover to null', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
act(() => {
|
|
result.current.setHover(5)
|
|
})
|
|
|
|
act(() => {
|
|
result.current.setHover(null)
|
|
})
|
|
|
|
expect(result.current.hoveredIndex).toBe(null)
|
|
expect(result.current.hoveredChart).toBe(null)
|
|
})
|
|
|
|
it('should handle rapid state changes', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
act(() => {
|
|
result.current.setSyncHover(true)
|
|
result.current.setHover(1)
|
|
result.current.setHover(2)
|
|
result.current.setHover(3)
|
|
result.current.clearHover()
|
|
result.current.setHover(4)
|
|
})
|
|
|
|
expect(result.current.hoveredIndex).toBe(4)
|
|
expect(result.current.hoveredChart).toBe('chart1')
|
|
})
|
|
|
|
it('should not trigger unnecessary state updates when state is the same', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
const initialSyncHover = result.current.syncHover
|
|
|
|
act(() => {
|
|
result.current.setSyncHover(initialSyncHover)
|
|
})
|
|
|
|
expect(result.current.syncHover).toBe(initialSyncHover)
|
|
})
|
|
})
|
|
|
|
describe('helper functions', () => {
|
|
it('should correctly calculate isHovered for current chart', () => {
|
|
const { result } = renderHook(() => useChartHoverState('chart1'))
|
|
|
|
expect(result.current.hoveredIndex).toBe(null)
|
|
expect(result.current.isHovered).toBe(false)
|
|
|
|
act(() => {
|
|
result.current.setHover(5)
|
|
})
|
|
|
|
expect(result.current.hoveredIndex).toBe(5)
|
|
expect(result.current.isHovered).toBe(true)
|
|
})
|
|
|
|
it('should correctly calculate isHovered for synced charts', () => {
|
|
const { result: result1 } = renderHook(() => useChartHoverState('chart1'))
|
|
const { result: result2 } = renderHook(() => useChartHoverState('chart2'))
|
|
|
|
act(() => {
|
|
result1.current.setSyncHover(true)
|
|
result1.current.setHover(5)
|
|
})
|
|
|
|
expect(result1.current.isHovered).toBe(true) // Current chart
|
|
expect(result2.current.isHovered).toBe(true) // Synced chart
|
|
})
|
|
|
|
it('should correctly identify current chart', () => {
|
|
const { result: result1 } = renderHook(() => useChartHoverState('chart1'))
|
|
const { result: result2 } = renderHook(() => useChartHoverState('chart2'))
|
|
|
|
act(() => {
|
|
result1.current.setSyncHover(true)
|
|
result1.current.setHover(5)
|
|
})
|
|
|
|
expect(result1.current.isCurrentChart).toBe(true)
|
|
expect(result2.current.isCurrentChart).toBe(false)
|
|
})
|
|
})
|
|
})
|