From 6d9665578b3681e1d3db8c6e2610ab53d3008267 Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:59:02 -0800 Subject: [PATCH] fix: replace sendBeacon with fetch keepalive for autosave on page close (#32088) Signed-off-by: Varun Chawla --- .../hooks/use-nodes-sync-draft.spec.ts | 64 +++++++++---------- .../hooks/use-nodes-sync-draft.ts | 9 +-- .../hooks/use-nodes-sync-draft.ts | 3 +- web/service/fetch.ts | 26 ++++++++ 4 files changed, 63 insertions(+), 39 deletions(-) diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts index 5817d187ac..5788c860d1 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts @@ -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) }) }) }) diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts index f30e22cc23..640da5e8f8 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts @@ -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 ( diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index f3538a5abb..5dc0741324 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -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 ( diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 04dfe74cc2..a8f29263d7 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -240,4 +240,30 @@ async function base(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): void { + const headers: Record = { + '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 }