fix: prevent panel width localStorage pollution during viewport compression (#22745) (#22747)

This commit is contained in:
lyzno1
2025-07-22 08:11:01 +08:00
committed by GitHub
parent f70ff72a58
commit b5599b2945
4 changed files with 340 additions and 8 deletions

View File

@@ -0,0 +1,178 @@
/**
* Workflow Panel Width Persistence Tests
* Tests for GitHub issue #22745: Panel width persistence bug fix
*/
import '@testing-library/jest-dom'
type PanelWidthSource = 'user' | 'system'
// Mock localStorage for testing
const createMockLocalStorage = () => {
const storage: Record<string, string> = {}
return {
getItem: jest.fn((key: string) => storage[key] || null),
setItem: jest.fn((key: string, value: string) => {
storage[key] = value
}),
removeItem: jest.fn((key: string) => {
delete storage[key]
}),
clear: jest.fn(() => {
Object.keys(storage).forEach(key => delete storage[key])
}),
get storage() { return { ...storage } },
}
}
// Core panel width logic extracted from the component
const createPanelWidthManager = (storageKey: string) => {
return {
updateWidth: (width: number, source: PanelWidthSource = 'user') => {
const newValue = Math.max(400, Math.min(width, 800))
if (source === 'user')
localStorage.setItem(storageKey, `${newValue}`)
return newValue
},
getStoredWidth: () => {
const stored = localStorage.getItem(storageKey)
return stored ? Number.parseFloat(stored) : 400
},
}
}
describe('Workflow Panel Width Persistence', () => {
let mockLocalStorage: ReturnType<typeof createMockLocalStorage>
beforeEach(() => {
mockLocalStorage = createMockLocalStorage()
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true,
})
})
afterEach(() => {
jest.clearAllMocks()
})
describe('Node Panel Width Management', () => {
const storageKey = 'workflow-node-panel-width'
it('should save user resize to localStorage', () => {
const manager = createPanelWidthManager(storageKey)
const result = manager.updateWidth(500, 'user')
expect(result).toBe(500)
expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, '500')
})
it('should not save system compression to localStorage', () => {
const manager = createPanelWidthManager(storageKey)
const result = manager.updateWidth(200, 'system')
expect(result).toBe(400) // Respects minimum width
expect(localStorage.setItem).not.toHaveBeenCalled()
})
it('should enforce minimum width of 400px', () => {
const manager = createPanelWidthManager(storageKey)
// User tries to set below minimum
const userResult = manager.updateWidth(300, 'user')
expect(userResult).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, '400')
// System compression below minimum
const systemResult = manager.updateWidth(150, 'system')
expect(systemResult).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledTimes(1) // Only user call
})
it('should preserve user preferences during system compression', () => {
localStorage.setItem(storageKey, '600')
const manager = createPanelWidthManager(storageKey)
// System compresses panel
manager.updateWidth(200, 'system')
// User preference should remain unchanged
expect(localStorage.getItem(storageKey)).toBe('600')
})
})
describe('Bug Scenario Reproduction', () => {
it('should reproduce original bug behavior (for comparison)', () => {
const storageKey = 'workflow-node-panel-width'
// Original buggy behavior - always saves regardless of source
const buggyUpdate = (width: number) => {
localStorage.setItem(storageKey, `${width}`)
return Math.max(400, width)
}
localStorage.setItem(storageKey, '500') // User preference
buggyUpdate(200) // System compression pollutes localStorage
expect(localStorage.getItem(storageKey)).toBe('200') // Bug: corrupted state
})
it('should verify fix prevents localStorage pollution', () => {
const storageKey = 'workflow-node-panel-width'
const manager = createPanelWidthManager(storageKey)
localStorage.setItem(storageKey, '500') // User preference
manager.updateWidth(200, 'system') // System compression
expect(localStorage.getItem(storageKey)).toBe('500') // Fix: preserved state
})
})
describe('Edge Cases', () => {
it('should handle multiple rapid operations correctly', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
// Rapid system adjustments
manager.updateWidth(300, 'system')
manager.updateWidth(250, 'system')
manager.updateWidth(180, 'system')
// Single user adjustment
manager.updateWidth(550, 'user')
expect(localStorage.setItem).toHaveBeenCalledTimes(1)
expect(localStorage.setItem).toHaveBeenCalledWith('workflow-node-panel-width', '550')
})
it('should handle corrupted localStorage gracefully', () => {
localStorage.setItem('workflow-node-panel-width', '150') // Below minimum
const manager = createPanelWidthManager('workflow-node-panel-width')
const storedWidth = manager.getStoredWidth()
expect(storedWidth).toBe(150) // Returns raw value
// User can correct the preference
const correctedWidth = manager.updateWidth(500, 'user')
expect(correctedWidth).toBe(500)
expect(localStorage.getItem('workflow-node-panel-width')).toBe('500')
})
})
describe('TypeScript Type Safety', () => {
it('should enforce source parameter type', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
// Valid source values
manager.updateWidth(500, 'user')
manager.updateWidth(500, 'system')
// Default to 'user'
manager.updateWidth(500)
expect(localStorage.setItem).toHaveBeenCalledTimes(2) // user + default
})
})
})

View File

@@ -99,15 +99,18 @@ const BasePanel: FC<BasePanelProps> = ({
return Math.max(available, 400) return Math.max(available, 400)
}, [workflowCanvasWidth, otherPanelWidth]) }, [workflowCanvasWidth, otherPanelWidth])
const updateNodePanelWidth = useCallback((width: number) => { const updateNodePanelWidth = useCallback((width: number, source: 'user' | 'system' = 'user') => {
// Ensure the width is within the min and max range // Ensure the width is within the min and max range
const newValue = Math.max(400, Math.min(width, maxNodePanelWidth)) const newValue = Math.max(400, Math.min(width, maxNodePanelWidth))
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
if (source === 'user')
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
setNodePanelWidth(newValue) setNodePanelWidth(newValue)
}, [maxNodePanelWidth, setNodePanelWidth]) }, [maxNodePanelWidth, setNodePanelWidth])
const handleResize = useCallback((width: number) => { const handleResize = useCallback((width: number) => {
updateNodePanelWidth(width) updateNodePanelWidth(width, 'user')
}, [updateNodePanelWidth]) }, [updateNodePanelWidth])
const { const {
@@ -121,7 +124,10 @@ const BasePanel: FC<BasePanelProps> = ({
onResize: debounce(handleResize), onResize: debounce(handleResize),
}) })
const debounceUpdate = debounce(updateNodePanelWidth) const debounceUpdate = debounce((width: number) => {
updateNodePanelWidth(width, 'system')
})
useEffect(() => { useEffect(() => {
if (!workflowCanvasWidth) if (!workflowCanvasWidth)
return return
@@ -132,7 +138,7 @@ const BasePanel: FC<BasePanelProps> = ({
const target = Math.max(workflowCanvasWidth - otherPanelWidth - reservedCanvasWidth, 400) const target = Math.max(workflowCanvasWidth - otherPanelWidth - reservedCanvasWidth, 400)
debounceUpdate(target) debounceUpdate(target)
} }
}, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, updateNodePanelWidth]) }, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, debounceUpdate])
const { handleNodeSelect } = useNodesInteractions() const { handleNodeSelect } = useNodesInteractions()
const { nodesReadOnly } = useNodesReadOnly() const { nodesReadOnly } = useNodesReadOnly()

View File

@@ -0,0 +1,145 @@
/**
* Debug and Preview Panel Width Persistence Tests
* Tests for GitHub issue #22745: Panel width persistence bug fix
*/
import '@testing-library/jest-dom'
type PanelWidthSource = 'user' | 'system'
// Mock localStorage for testing
const createMockLocalStorage = () => {
const storage: Record<string, string> = {}
return {
getItem: jest.fn((key: string) => storage[key] || null),
setItem: jest.fn((key: string, value: string) => {
storage[key] = value
}),
removeItem: jest.fn((key: string) => {
delete storage[key]
}),
clear: jest.fn(() => {
Object.keys(storage).forEach(key => delete storage[key])
}),
get storage() { return { ...storage } },
}
}
// Preview panel width logic
const createPreviewPanelManager = () => {
const storageKey = 'debug-and-preview-panel-width'
return {
updateWidth: (width: number, source: PanelWidthSource = 'user') => {
const newValue = Math.max(400, Math.min(width, 800))
if (source === 'user')
localStorage.setItem(storageKey, `${newValue}`)
return newValue
},
getStoredWidth: () => {
const stored = localStorage.getItem(storageKey)
return stored ? Number.parseFloat(stored) : 400
},
}
}
describe('Debug and Preview Panel Width Persistence', () => {
let mockLocalStorage: ReturnType<typeof createMockLocalStorage>
beforeEach(() => {
mockLocalStorage = createMockLocalStorage()
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true,
})
})
afterEach(() => {
jest.clearAllMocks()
})
describe('Preview Panel Width Management', () => {
it('should save user resize to localStorage', () => {
const manager = createPreviewPanelManager()
const result = manager.updateWidth(450, 'user')
expect(result).toBe(450)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '450')
})
it('should not save system compression to localStorage', () => {
const manager = createPreviewPanelManager()
const result = manager.updateWidth(300, 'system')
expect(result).toBe(400) // Respects minimum width
expect(localStorage.setItem).not.toHaveBeenCalled()
})
it('should behave identically to Node Panel', () => {
const manager = createPreviewPanelManager()
// Both user and system operations should behave consistently
manager.updateWidth(500, 'user')
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500')
manager.updateWidth(200, 'system')
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
})
})
describe('Dual Panel Scenario', () => {
it('should maintain independence from Node Panel', () => {
localStorage.setItem('workflow-node-panel-width', '600')
localStorage.setItem('debug-and-preview-panel-width', '450')
const manager = createPreviewPanelManager()
// System compresses preview panel
manager.updateWidth(200, 'system')
// Only preview panel storage key should be unaffected
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('450')
expect(localStorage.getItem('workflow-node-panel-width')).toBe('600')
})
it('should handle F12 scenario consistently', () => {
const manager = createPreviewPanelManager()
// User sets preference
manager.updateWidth(500, 'user')
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
// F12 opens causing viewport compression
manager.updateWidth(180, 'system')
// User preference preserved
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
})
})
describe('Consistency with Node Panel', () => {
it('should enforce same minimum width rules', () => {
const manager = createPreviewPanelManager()
// Same 400px minimum as Node Panel
const result = manager.updateWidth(300, 'user')
expect(result).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '400')
})
it('should use same source parameter pattern', () => {
const manager = createPreviewPanelManager()
// Default to 'user' when source not specified
manager.updateWidth(500)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500')
// Explicit 'system' source
manager.updateWidth(300, 'system')
expect(localStorage.setItem).toHaveBeenCalledTimes(1) // Only user call
})
})
})

View File

@@ -53,8 +53,9 @@ const DebugAndPreview = () => {
const nodePanelWidth = useStore(s => s.nodePanelWidth) const nodePanelWidth = useStore(s => s.nodePanelWidth)
const panelWidth = useStore(s => s.previewPanelWidth) const panelWidth = useStore(s => s.previewPanelWidth)
const setPanelWidth = useStore(s => s.setPreviewPanelWidth) const setPanelWidth = useStore(s => s.setPreviewPanelWidth)
const handleResize = useCallback((width: number) => { const handleResize = useCallback((width: number, source: 'user' | 'system' = 'user') => {
localStorage.setItem('debug-and-preview-panel-width', `${width}`) if (source === 'user')
localStorage.setItem('debug-and-preview-panel-width', `${width}`)
setPanelWidth(width) setPanelWidth(width)
}, [setPanelWidth]) }, [setPanelWidth])
const maxPanelWidth = useMemo(() => { const maxPanelWidth = useMemo(() => {
@@ -74,7 +75,9 @@ const DebugAndPreview = () => {
triggerDirection: 'left', triggerDirection: 'left',
minWidth: 400, minWidth: 400,
maxWidth: maxPanelWidth, maxWidth: maxPanelWidth,
onResize: debounce(handleResize), onResize: debounce((width: number) => {
handleResize(width, 'user')
}),
}) })
return ( return (