Skip to content

Commit 357d6a2

Browse files
committed
fix(logs): recover cleanly from snapshot chunk failures
1 parent 8edeca6 commit 357d6a2

4 files changed

Lines changed: 109 additions & 11 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockToastError } = vi.hoisted(() => ({
9+
mockToastError: vi.fn(),
10+
}))
11+
12+
vi.mock('@sim/emcn', () => ({
13+
toast: { error: mockToastError },
14+
}))
15+
16+
import { SnapshotBoundary } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary'
17+
18+
const LOAD_ERROR = new Error('snapshot chunk failed')
19+
20+
function ThrowingSnapshot() {
21+
throw LOAD_ERROR
22+
}
23+
24+
describe('SnapshotBoundary', () => {
25+
let container: HTMLDivElement
26+
let root: Root
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
container = document.createElement('div')
31+
document.body.appendChild(container)
32+
act(() => {
33+
root = createRoot(container)
34+
})
35+
})
36+
37+
afterEach(() => {
38+
act(() => root.unmount())
39+
container.remove()
40+
})
41+
42+
it('contains a background pre-warm failure without notifying or closing', () => {
43+
const onLoadError = vi.fn()
44+
45+
act(() => {
46+
root.render(
47+
<SnapshotBoundary isOpen={false} onLoadError={onLoadError}>
48+
<ThrowingSnapshot />
49+
</SnapshotBoundary>
50+
)
51+
})
52+
53+
expect(container.childNodes).toHaveLength(0)
54+
expect(mockToastError).not.toHaveBeenCalled()
55+
expect(onLoadError).not.toHaveBeenCalled()
56+
})
57+
58+
it('notifies and closes an explicitly opened snapshot after a load failure', () => {
59+
const onLoadError = vi.fn()
60+
61+
act(() => {
62+
root.render(
63+
<SnapshotBoundary isOpen onLoadError={onLoadError}>
64+
<ThrowingSnapshot />
65+
</SnapshotBoundary>
66+
)
67+
})
68+
69+
expect(container.childNodes).toHaveLength(0)
70+
expect(mockToastError).toHaveBeenCalledWith(
71+
'Could not load the workflow snapshot. Refresh and try again.'
72+
)
73+
expect(onLoadError).toHaveBeenCalledOnce()
74+
})
75+
})

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ const logger = createLogger('ExecutionSnapshotBoundary')
88

99
interface SnapshotBoundaryProps {
1010
children: ReactNode
11+
isOpen: boolean
12+
onLoadError: () => void
1113
}
1214

1315
interface SnapshotBoundaryState {
1416
hasError: boolean
1517
}
1618

19+
const reportedErrors = new WeakSet<Error>()
20+
1721
/**
1822
* Error boundary for the lazily loaded execution snapshot.
1923
*
@@ -22,11 +26,15 @@ interface SnapshotBoundaryState {
2226
* unwind to the route-level boundary and replace the whole logs page with an
2327
* error view over an optional modal. Mirrors `PreviewErrorBoundary` in the
2428
* file viewer: contain, log, degrade. The snapshot is an overlay, so the
25-
* degraded state renders nothing and a toast explains why it didn't open.
29+
* degraded state renders nothing. Closed snapshots are mounted to pre-warm
30+
* their chunk and data, so a background failure is logged without interrupting
31+
* the user. If the user actually opens a failed snapshot, the caller closes
32+
* the modal state and a toast explains why it did not open.
2633
*
27-
* Callers must `key` this boundary by the snapshot's identity (execution id)
28-
* — the error state resets only via remount, so a tripped boundary would
29-
* otherwise stay stuck for every later log.
34+
* Callers must remount this boundary when the snapshot identity changes and
35+
* when a pre-warmed snapshot is explicitly opened. Error boundaries reset only
36+
* via remount; without both transitions, a failed pre-warm would leave the
37+
* later open action stuck in the already-tripped state.
3038
*/
3139
export class SnapshotBoundary extends Component<SnapshotBoundaryProps, SnapshotBoundaryState> {
3240
public state: SnapshotBoundaryState = { hasError: false }
@@ -36,11 +44,18 @@ export class SnapshotBoundary extends Component<SnapshotBoundaryProps, SnapshotB
3644
}
3745

3846
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
39-
logger.error('Execution snapshot failed to load', {
40-
error: error.message,
41-
componentStack: errorInfo.componentStack,
42-
})
43-
toast.error('Could not load the workflow snapshot. Refresh and try again.')
47+
if (!reportedErrors.has(error)) {
48+
reportedErrors.add(error)
49+
logger.error('Execution snapshot failed to load', {
50+
error: error.message,
51+
componentStack: errorInfo.componentStack,
52+
})
53+
}
54+
55+
if (this.props.isOpen) {
56+
toast.error('Could not load the workflow snapshot. Refresh and try again.')
57+
this.props.onLoadError()
58+
}
4459
}
4560

4661
public render() {

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,11 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
703703

704704
{/* Frozen Canvas Modal */}
705705
{log.executionId && (
706-
<SnapshotBoundary key={log.executionId}>
706+
<SnapshotBoundary
707+
key={`${log.executionId}:${isExecutionSnapshotOpen ? 'open' : 'closed'}`}
708+
isOpen={isExecutionSnapshotOpen}
709+
onLoadError={() => setIsExecutionSnapshotOpen(false)}
710+
>
707711
<Suspense fallback={null}>
708712
<ExecutionSnapshot
709713
executionId={log.executionId}

apps/sim/app/workspace/[workspaceId]/logs/logs.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1276,7 +1276,11 @@ export default function Logs() {
12761276
/>
12771277

12781278
{previewLogId !== null && previewDetailQuery.data?.executionId && (
1279-
<SnapshotBoundary key={previewDetailQuery.data.executionId}>
1279+
<SnapshotBoundary
1280+
key={previewDetailQuery.data.executionId}
1281+
isOpen
1282+
onLoadError={handleClosePreview}
1283+
>
12801284
<Suspense fallback={null}>
12811285
<ExecutionSnapshot
12821286
executionId={previewDetailQuery.data.executionId}

0 commit comments

Comments
 (0)