Files
supabase/apps/studio/components/ui/Charts/useChartHoverState.test.tsx
Ali Waseem 1c2d28d5b3 chore: wrap local storage into helper methods that are safer (#46628)
## 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 -->
2026-06-04 07:41:28 -06:00

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)
})
})
})