diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index 7182788a5c..e0ecfd77c7 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -86,7 +86,8 @@ export const EditorManagerProvider: FC = ({ const { t } = useTranslation() const { reportError, eventEmitter, projectId, setOutOfSync } = useIdeReactContext() - const { socket, closeConnection, connectionState } = useConnectionContext() + const { socket, closeConnection, connectionState, isConnected } = + useConnectionContext() const { view, setView, setOpenFile } = useLayoutContext() const { showGenericMessageModal, @@ -587,6 +588,13 @@ export const EditorManagerProvider: FC = ({ document.doc_id ) if (hasOfflineBackup) { + if (!isConnected) { + // Still offline, so nothing has been rejected yet: this is the + // fatal op timeout running out while the outage is ongoing. Keep + // the backup and let recovery on the next load report the outcome, + // rather than declaring a failed sync mid-outage. + return + } eventEmitter.emit('ide:unableToSyncOfflineChanges', { docId: document.doc_id, editorContent: editorContent || '', @@ -624,6 +632,7 @@ export const EditorManagerProvider: FC = ({ setOutOfSync, t, projectId, + isConnected, ]) useEffect(() => { diff --git a/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx b/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx index 4ae11b3681..7c87f73c66 100644 --- a/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx +++ b/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx @@ -1,7 +1,11 @@ -import { FC, useEffect } from 'react' +import { FC, PropsWithChildren, useEffect, useState } from 'react' import EventEmitter from '@/utils/EventEmitter' import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { ConnectionContext } from '@/features/ide-react/context/connection-context' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' +import { SocketIOMock } from '@/ide/connection/SocketIoShim' +import type { Socket } from '@/features/ide-react/connection/types/socket' +import type { SocketDebuggingInfo } from '@/features/ide-react/connection/types/connection-state' import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter' import { OfflineDocBackup } from '@/features/ide-react/editor/offline-doc-backup' import { GlobalToasts } from '@/features/ide-react/components/global-toasts' @@ -71,6 +75,38 @@ function plantBackup(docId: string, { trackChanges = false } = {}) { return key } +// The default harness reports a healthy connection, which stands in for the +// post-reconnect sync failure. This one stands in for the fatal op timeout +// firing while the outage is still ongoing. +const OfflineConnectionProvider: FC = ({ children }) => { + const [value] = useState(() => ({ + socket: new SocketIOMock() as any as Socket, + connectionState: { + readyState: WebSocket.CLOSED, + forceDisconnected: false, + inactiveDisconnect: false, + reconnectAt: null, + forcedDisconnectDelay: 0, + lastConnectionAttempt: 0, + error: '' as const, + }, + isConnected: false, + isStillReconnecting: false, + secondsUntilReconnect: () => 0, + tryReconnectNow: () => {}, + registerUserActivity: () => {}, + disconnect: () => {}, + closeConnection: () => {}, + getSocketDebuggingInfo: () => ({}) as SocketDebuggingInfo, + })) + + return ( + + {children} + + ) +} + function setSplitTest(enabled: boolean) { window.metaAttributesCache.set('ol-splitTestVariants', { 'intermittent-connection-improvements': enabled ? 'enabled' : 'default', @@ -92,7 +128,9 @@ describe('EditorManagerProvider docError sync modals', function () { window.sessionStorage.clear() }) - const mount = () => { + const mount = ( + extraProviders: Record> = {} + ) => { cy.mount( @@ -139,6 +178,32 @@ describe('EditorManagerProvider docError sync modals', function () { }) }) + it('shows nothing and keeps the backup when the failure happens while offline', function () { + let key: string + cy.then(() => { + setSplitTest(true) + key = plantBackup(CURRENT_DOC_ID) + }) + + mount({ ConnectionProvider: OfflineConnectionProvider }) + + cy.then(() => { + currentDoc.trigger( + 'error', + new Error('forced'), + {}, + 'offline edits content' + ) + }) + + cy.findByRole('dialog').should('not.exist') + + // The outcome is still undetermined, so recovery on the next load decides. + cy.then(() => { + expect(window.sessionStorage.getItem(key!)).to.not.equal(null) + }) + }) + it('passes editorContent and docName through to the download button', function () { cy.then(() => { setSplitTest(true)