@bycrux/editor 0.8.4 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -61,6 +61,16 @@ const STEPPER_PHASES: RenderPhase[] = RENDER_PHASES.filter(p => p !== 'done')
61
61
 
62
62
  const POLL_INTERVAL_MS = 2500
63
63
 
64
+ /**
65
+ * Consecutive status-poll failures tolerated before the modal declares the
66
+ * render failed. A multi-minute 4K render polls many times; a transient network
67
+ * blip (a Cloudflare-tunnel hiccup, a backend redeploy, a flaky hop) must not be
68
+ * mistaken for a render failure — the render keeps running server-side. ~12 ×
69
+ * 2.5s ≈ 30s of continuous unreachability before giving up. An explicit backend
70
+ * `error` status is still terminal immediately.
71
+ */
72
+ const MAX_POLL_FAILURES = 12
73
+
64
74
  // ── Stepper ───────────────────────────────────────────────────────────────────
65
75
 
66
76
  function PhaseStepper({ current }: { current: RenderPhase }) {
@@ -144,16 +154,28 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
144
154
  await adapter.renderAsync!(projectId)
145
155
  if (unmountedRef.current || cancelledRef.current) return
146
156
 
157
+ let pollFailures = 0
147
158
  const tick = async () => {
148
159
  if (unmountedRef.current || cancelledRef.current) return
149
160
  let snap: RenderStatus
150
161
  try {
151
162
  snap = await adapter.getRenderStatus!(projectId)
163
+ pollFailures = 0
152
164
  } catch (e) {
153
165
  if (unmountedRef.current || cancelledRef.current) return
154
- stopPolling()
155
- setError(e instanceof Error ? e.message : String(e))
156
- setStatus('error')
166
+ // A transient network blip (Cloudflare-tunnel hiccup, a backend
167
+ // redeploy, a flaky hop) must NOT be mistaken for a render
168
+ // failure — the render is still running server-side. This is what
169
+ // surfaced as "Render failed: Failed to fetch" while the render
170
+ // actually completed and was delivered. Keep polling; only give
171
+ // up after MAX_POLL_FAILURES consecutive failures. An explicit
172
+ // { status: 'error' } reply is still terminal (handled below).
173
+ pollFailures += 1
174
+ if (pollFailures >= MAX_POLL_FAILURES) {
175
+ stopPolling()
176
+ setError(e instanceof Error ? e.message : String(e))
177
+ setStatus('error')
178
+ }
157
179
  return
158
180
  }
159
181
  if (unmountedRef.current || cancelledRef.current) return
@@ -172,6 +172,56 @@ describe('RenderModal (poll-driven)', () => {
172
172
  expect(screen.getByText(/interrupted on the server/i)).toBeTruthy()
173
173
  expect(screen.getByText('Close')).toBeTruthy()
174
174
  })
175
+
176
+ it('tolerates a transient poll failure and still completes', async () => {
177
+ vi.useFakeTimers()
178
+ // poll #1 throws (network blip), then it recovers and finishes. The modal
179
+ // must NOT flip to "Render failed" on the transient throw — the render is
180
+ // still running server-side.
181
+ const steps: Array<() => RenderStatus> = [
182
+ () => { throw new Error('Failed to fetch') },
183
+ () => ({ status: 'running', phase: 'rendering' } as RenderStatus),
184
+ () => ({ status: 'done', phase: 'done', media: [DONE_MEDIA] } as RenderStatus),
185
+ ]
186
+ let call = 0
187
+ const adapter = baseAdapter()
188
+ adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
189
+ adapter.getRenderStatus = vi.fn(async () => steps[Math.min(call++, steps.length - 1)]())
190
+
191
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
192
+
193
+ // Kick + the first poll (which throws) — still "Rendering…", not failed.
194
+ await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve() })
195
+ expect(screen.queryByText('Render failed')).toBeNull()
196
+ expect(screen.getByText('Rendering…')).toBeTruthy()
197
+
198
+ // Subsequent polls recover → running → done.
199
+ await act(async () => { await vi.advanceTimersByTimeAsync(2500) })
200
+ await act(async () => { await vi.advanceTimersByTimeAsync(2500) })
201
+
202
+ vi.useRealTimers()
203
+ await waitFor(() => {
204
+ expect(document.querySelector('video')?.getAttribute('src')).toBe('https://r2/x.mp4')
205
+ })
206
+ })
207
+
208
+ it('gives up only after sustained consecutive poll failures', async () => {
209
+ vi.useFakeTimers()
210
+ const adapter = baseAdapter()
211
+ adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
212
+ adapter.getRenderStatus = vi.fn(async () => { throw new Error('Failed to fetch') })
213
+
214
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
215
+
216
+ // First poll throws — still running, NOT failed (one blip is tolerated).
217
+ await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve() })
218
+ expect(screen.queryByText('Render failed')).toBeNull()
219
+
220
+ // Keep failing past MAX_POLL_FAILURES (~12 × 2.5s) → terminal error.
221
+ await act(async () => { await vi.advanceTimersByTimeAsync(2500 * 15) })
222
+ expect(screen.getByText('Render failed')).toBeTruthy()
223
+ expect(screen.getByText('Failed to fetch')).toBeTruthy()
224
+ })
175
225
  })
176
226
 
177
227
  // ── Component (SSE fallback when poll methods absent) ──────────────────────────