fix: replace sendBeacon with fetch keepalive for autosave on page close (#32088)

Signed-off-by: Varun Chawla <varun_6april@hotmail.com>
This commit is contained in:
Varun Chawla 2026-02-10 00:59:02 -08:00 committed by GitHub
parent 18f14c04dc
commit 6d9665578b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 63 additions and 39 deletions

View File

@ -68,23 +68,20 @@ vi.mock('@/config', () => ({
API_PREFIX: '/api',
}))
// Mock postWithKeepalive from service/fetch
const mockPostWithKeepalive = vi.fn()
vi.mock('@/service/fetch', () => ({
postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args),
}))
// ============================================================================
// Tests
// ============================================================================
describe('useNodesSyncDraft', () => {
const mockSendBeacon = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Setup navigator.sendBeacon mock
Object.defineProperty(navigator, 'sendBeacon', {
value: mockSendBeacon,
writable: true,
configurable: true,
})
// Default store state
mockStoreGetState.mockReturnValue({
getNodes: mockGetNodes,
@ -134,7 +131,7 @@ describe('useNodesSyncDraft', () => {
})
describe('syncWorkflowDraftWhenPageClose', () => {
it('should not call sendBeacon when nodes are read only', () => {
it('should not call postWithKeepalive when nodes are read only', () => {
mockGetNodesReadOnly.mockReturnValue(true)
const { result } = renderHook(() => useNodesSyncDraft())
@ -143,10 +140,10 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).not.toHaveBeenCalled()
expect(mockPostWithKeepalive).not.toHaveBeenCalled()
})
it('should call sendBeacon with correct URL and params', () => {
it('should call postWithKeepalive with correct URL and params', () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
@ -158,13 +155,16 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).toHaveBeenCalledWith(
expect(mockPostWithKeepalive).toHaveBeenCalledWith(
'/api/rag/pipelines/test-pipeline-id/workflows/draft',
expect.any(String),
expect.objectContaining({
graph: expect.any(Object),
hash: 'test-hash',
}),
)
})
it('should not call sendBeacon when pipelineId is missing', () => {
it('should not call postWithKeepalive when pipelineId is missing', () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: undefined,
environmentVariables: [],
@ -178,10 +178,10 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).not.toHaveBeenCalled()
expect(mockPostWithKeepalive).not.toHaveBeenCalled()
})
it('should not call sendBeacon when nodes array is empty', () => {
it('should not call postWithKeepalive when nodes array is empty', () => {
mockGetNodes.mockReturnValue([])
const { result } = renderHook(() => useNodesSyncDraft())
@ -190,7 +190,7 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).not.toHaveBeenCalled()
expect(mockPostWithKeepalive).not.toHaveBeenCalled()
})
it('should filter out temp nodes', () => {
@ -204,8 +204,8 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
// Should not call sendBeacon because after filtering temp nodes, array is empty
expect(mockSendBeacon).not.toHaveBeenCalled()
// Should not call postWithKeepalive because after filtering temp nodes, array is empty
expect(mockPostWithKeepalive).not.toHaveBeenCalled()
})
it('should remove underscore-prefixed data keys from nodes', () => {
@ -219,9 +219,9 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).toHaveBeenCalled()
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.graph.nodes[0].data._privateData).toBeUndefined()
expect(mockPostWithKeepalive).toHaveBeenCalled()
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
expect(sentParams.graph.nodes[0].data._privateData).toBeUndefined()
})
})
@ -395,8 +395,8 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.graph.viewport).toEqual({ x: 100, y: 200, zoom: 1.5 })
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
expect(sentParams.graph.viewport).toEqual({ x: 100, y: 200, zoom: 1.5 })
})
it('should include environment variables in params', () => {
@ -418,8 +418,8 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.environment_variables).toEqual([{ key: 'API_KEY', value: 'secret' }])
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
expect(sentParams.environment_variables).toEqual([{ key: 'API_KEY', value: 'secret' }])
})
it('should include rag pipeline variables in params', () => {
@ -441,8 +441,8 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }])
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
expect(sentParams.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }])
})
it('should remove underscore-prefixed keys from edges', () => {
@ -461,9 +461,9 @@ describe('useNodesSyncDraft', () => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.graph.edges[0].data._hidden).toBeUndefined()
expect(sentData.graph.edges[0].data.visible).toBe(false)
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
expect(sentParams.graph.edges[0].data._hidden).toBeUndefined()
expect(sentParams.graph.edges[0].data.visible).toBe(false)
})
})
})

View File

@ -9,6 +9,7 @@ import {
useWorkflowStore,
} from '@/app/components/workflow/store'
import { API_PREFIX } from '@/config'
import { postWithKeepalive } from '@/service/fetch'
import { syncWorkflowDraft } from '@/service/workflow'
import { usePipelineRefreshDraft } from '.'
@ -76,12 +77,8 @@ export const useNodesSyncDraft = () => {
return
const postParams = getPostParams()
if (postParams) {
navigator.sendBeacon(
`${API_PREFIX}${postParams.url}`,
JSON.stringify(postParams.params),
)
}
if (postParams)
postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params)
}, [getPostParams, getNodesReadOnly])
const performSync = useCallback(async (

View File

@ -6,6 +6,7 @@ import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-seri
import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { API_PREFIX } from '@/config'
import { postWithKeepalive } from '@/service/fetch'
import { syncWorkflowDraft } from '@/service/workflow'
import { useWorkflowRefreshDraft } from '.'
@ -85,7 +86,7 @@ export const useNodesSyncDraft = () => {
const postParams = getPostParams()
if (postParams)
navigator.sendBeacon(`${API_PREFIX}${postParams.url}`, JSON.stringify(postParams.params))
postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params)
}, [getPostParams, getNodesReadOnly])
const performSync = useCallback(async (

View File

@ -240,4 +240,30 @@ async function base<T>(url: string, options: FetchOptionType = {}, otherOptions:
return await res.json() as T
}
/**
* Fire-and-forget POST with `keepalive: true` for use during page unload.
* Includes credentials, Authorization (if available), and CSRF header
* so the request is authenticated, matching the headers sent by the
* standard `base()` fetch wrapper.
*/
export function postWithKeepalive(url: string, body: Record<string, unknown>): void {
const headers: Record<string, string> = {
'Content-Type': ContentType.json,
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
}
// Add Authorization header if an access token is available
const accessToken = getWebAppAccessToken()
if (accessToken)
headers.Authorization = `Bearer ${accessToken}`
globalThis.fetch(url, {
method: 'POST',
keepalive: true,
credentials: 'include',
headers,
body: JSON.stringify(body),
}).catch(() => {})
}
export { base }