Skip to content

Commit 404ffde

Browse files
cursoragentignaciodelcano+dcl
andcommitted
fix(video3d): ignore slower first library confirm
Paging clears the in-flight opening lock without bumping generation, so a later Open scene could lose to the first fetch and replace the current project (wiping undo). Advance generation on each confirm and on page changes. Co-authored-by: ignaciodelcano+dcl <ignaciodelcano+dcl@gmail.com>
1 parent 661d241 commit 404ffde

2 files changed

Lines changed: 68 additions & 2 deletions

File tree

ui/src/components/Sidebar/SceneLibraryDialog.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export function SceneLibraryDialog({
9393
})
9494

9595
const openItem = async (file: ApiOutput) => {
96+
// A newer confirm must invalidate any in-flight open. Paging clears
97+
// `opening`, so two fetches can overlap with the same generation and the
98+
// slower first confirm would still replace the project (and wipe undo).
99+
generationRef.current += 1
96100
const capture = { generation: generationRef.current, workspaceId: workspaceRef.current, purpose }
97101
const commit = commitLibraryChoice(liveChoice(), capture, file)
98102
if (commit.action === 'ignore') return
@@ -186,8 +190,8 @@ export function SceneLibraryDialog({
186190
<div className="flex items-center justify-between gap-2 border-t border-border px-4 py-2">
187191
<span className="text-[10px] text-text-muted">{t('library.savedPage', { total, current: Math.min(page + 1, pages), pages })}</span>
188192
<div className="flex gap-1">
189-
<button type="button" aria-label={t('library.previousPage')} disabled={page <= 0} onClick={() => setPage(value => Math.max(0, value - 1))} className="rounded border border-border p-1.5 disabled:opacity-30"><ChevronLeft size={13} /></button>
190-
<button type="button" aria-label={t('library.nextPage')} disabled={page + 1 >= pages} onClick={() => setPage(value => value + 1)} className="rounded border border-border p-1.5 disabled:opacity-30"><ChevronRight size={13} /></button>
193+
<button type="button" aria-label={t('library.previousPage')} disabled={page <= 0} onClick={() => { generationRef.current += 1; setPage(value => Math.max(0, value - 1)) }} className="rounded border border-border p-1.5 disabled:opacity-30"><ChevronLeft size={13} /></button>
194+
<button type="button" aria-label={t('library.nextPage')} disabled={page + 1 >= pages} onClick={() => { generationRef.current += 1; setPage(value => value + 1) }} className="rounded border border-border p-1.5 disabled:opacity-30"><ChevronRight size={13} /></button>
191195
</div>
192196
</div>
193197
{error && <p className="border-t border-border px-4 py-2 text-[10px] text-red-300">{error}</p>}

ui/tests/sceneLibraryDialog.test.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,65 @@ test('closing the library before a scene fetch settles does not replace the curr
132132
globalThis.fetch = originalFetch
133133
}
134134
})
135+
136+
test('a later library confirm wins over a slower first scene fetch', { concurrency: false }, async () => {
137+
const { render, screen, fireEvent, waitFor, cleanup } = await import('@testing-library/react')
138+
const { SceneLibraryDialog } = await import('../src/components/Sidebar/SceneLibraryDialog.tsx')
139+
const originalFetch = globalThis.fetch
140+
const scenes = Array.from({ length: 9 }, (_, index) => ({
141+
name: `2026-08-25-22h21m0${index}s_Station-loop-${index}_aaaaaa.scene.json`,
142+
type: 'scene',
143+
mode: null,
144+
size: 12,
145+
created_at: 1000 + index,
146+
url: `/api/v1/file/scene-${index}.scene.json`,
147+
thumbnail_url: `/api/v1/file/scene-${index}.scene.preview.png`,
148+
}))
149+
const sceneFor = (index: number) => ({
150+
version: 1,
151+
name: `Station loop ${index}`,
152+
width: 1280,
153+
height: 720,
154+
duration: 10,
155+
layers: [{
156+
id: 'plate', name: 'Plate', type: 'image', source: '/api/v1/file/plate.jpg', visible: true, z: 0,
157+
transform: { x: 50, y: 50, scale: 1, opacity: 1 },
158+
animation: { start: { x: 50, y: 50, scale: 1 }, end: { x: 50, y: 50, scale: 1 }, duration: 10, curve: 'linear' },
159+
}],
160+
})
161+
let releaseFirst: ((value: Response) => void) | undefined
162+
let releaseSecond: ((value: Response) => void) | undefined
163+
const firstGate = new Promise<Response>(resolve => { releaseFirst = resolve })
164+
const secondGate = new Promise<Response>(resolve => { releaseSecond = resolve })
165+
globalThis.fetch = (async (input: string | URL | Request) => {
166+
const url = String(input)
167+
if (url.includes('/api/v1/outputs') && url.includes('media_type=scene')) {
168+
const parsed = new URL(url, 'http://localhost')
169+
const offset = Number(parsed.searchParams.get('offset') || 0)
170+
return new Response(JSON.stringify({ outputs: scenes.slice(offset, offset + 8), total: scenes.length }), { headers: { 'content-type': 'application/json' } })
171+
}
172+
if (url.includes('/api/v1/file/scene-0.scene.json')) return firstGate
173+
if (url.includes('/api/v1/file/scene-8.scene.json')) return secondGate
174+
throw new Error(`Unexpected request: ${url}`)
175+
}) as typeof fetch
176+
const opened: string[] = []
177+
try {
178+
render(<SceneLibraryDialog open workspace="default" onClose={() => undefined} onPickFile={() => undefined} onOpenScene={scene => opened.push(scene.name)} />)
179+
await waitFor(() => assert.ok(screen.getAllByText('Station loop 0').length >= 1))
180+
fireEvent.click(screen.getAllByText('Station loop 0')[0])
181+
fireEvent.click(screen.getByRole('button', { name: 'Open scene' }))
182+
fireEvent.click(screen.getByLabelText('Next page'))
183+
await waitFor(() => assert.ok(screen.getAllByText('Station loop 8').length >= 1))
184+
fireEvent.click(screen.getAllByText('Station loop 8')[0])
185+
fireEvent.click(screen.getByRole('button', { name: 'Open scene' }))
186+
releaseFirst?.(new Response(JSON.stringify(sceneFor(0)), { headers: { 'content-type': 'application/json' } }))
187+
await Promise.resolve()
188+
await Promise.resolve()
189+
assert.deepEqual(opened, [])
190+
releaseSecond?.(new Response(JSON.stringify(sceneFor(8)), { headers: { 'content-type': 'application/json' } }))
191+
await waitFor(() => assert.deepEqual(opened, ['Station loop 8']))
192+
} finally {
193+
cleanup()
194+
globalThis.fetch = originalFetch
195+
}
196+
})

0 commit comments

Comments
 (0)