Created
April 17, 2026 15:49
-
-
Save nfilzi/c676937c33993d80198743fe7f39091d to your computer and use it in GitHub Desktop.
Reproduction test for artwork generation polling bug (setInterval + TanStack Query v5 cancelRefetch)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Reproduction test for the artwork generation polling bug. | |
| * | |
| * Simulates the exact pattern from useArtworkGeneration.tsx: | |
| * 1. Create a query with enabled: false (createdArtwork is undefined → id is NaN) | |
| * 2. Update observer to new query key (simulating setCreatedArtwork triggering re-render) | |
| * 3. Run setInterval calling refetch().then(({ data }) => ...) to poll for generated: true | |
| * | |
| * The mock query function returns generated: false for the first N calls, | |
| * then generated: true — simulating the iris-artwork worker finishing. | |
| */ | |
| import { QueryClient, QueryObserver } from '@tanstack/react-query' | |
| function createMockQueryFn(generatedAfterMs: number) { | |
| const startTime = Date.now() | |
| let callCount = 0 | |
| return jest.fn(async () => { | |
| callCount++ | |
| const elapsed = Date.now() - startTime | |
| const generated = elapsed >= generatedAfterMs | |
| const artwork = { | |
| id: '14204', | |
| type: 'artwork', | |
| generated, | |
| preview: true, | |
| display: false, | |
| image: generated ? 'https://s3.example.com/preview.jpg' : null, | |
| catalogue_shape: 'square' as const, | |
| catalogue_orientation: 'horizontal' as const, | |
| aspectRatio: 1, | |
| } | |
| return artwork | |
| }) | |
| } | |
| function sleep(ms: number) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)) | |
| } | |
| describe('Artwork polling bug reproduction', () => { | |
| let queryClient: QueryClient | |
| beforeEach(() => { | |
| queryClient = new QueryClient({ | |
| defaultOptions: { | |
| queries: { | |
| retry: false, // disable retries for cleaner test | |
| }, | |
| }, | |
| }) | |
| }) | |
| afterEach(() => { | |
| queryClient.clear() | |
| }) | |
| it('reproduces the exact useArtworkGeneration polling pattern', async () => { | |
| const INTERVAL_MS = 200 // faster than production for test speed (prod: 1500ms) | |
| const GENERATED_AFTER_MS = 600 // simulates ~3-4s in production | |
| const MAX_ATTEMPTS = 8 | |
| const mockQueryFn = createMockQueryFn(GENERATED_AFTER_MS) | |
| // Step 1: Create observer with NaN id (simulating initial state before setCreatedArtwork) | |
| const observer = new QueryObserver(queryClient, { | |
| queryKey: ['getArtworkById', NaN], | |
| queryFn: () => mockQueryFn(), | |
| enabled: false, | |
| }) | |
| // Track results | |
| const log: string[] = [] | |
| let successCalled = false | |
| let failureCalled = false | |
| let attempts = 0 | |
| // Step 2: Simulate setCreatedArtwork → re-render → observer options update | |
| // (In production, this happens when React re-renders after setCreatedArtwork(artwork)) | |
| observer.setOptions({ | |
| queryKey: ['getArtworkById', 14204], | |
| queryFn: () => mockQueryFn(), | |
| enabled: true, | |
| }) | |
| // Step 3: Set up the exact same polling pattern as useArtworkGeneration.tsx lines 80-96 | |
| const intervalRef = { current: null as ReturnType<typeof setInterval> | null } | |
| const clearPolling = () => { | |
| if (intervalRef.current) { | |
| clearInterval(intervalRef.current) | |
| intervalRef.current = null | |
| } | |
| } | |
| intervalRef.current = setInterval(() => { | |
| observer | |
| .refetch() | |
| .then(({ data: updatedArtwork }) => { | |
| const generated = (updatedArtwork as any)?.generated | |
| log.push( | |
| `poll: generated=${generated}, data=${updatedArtwork ? 'present' : 'undefined'}`, | |
| ) | |
| if (generated) { | |
| clearPolling() | |
| successCalled = true | |
| log.push('SUCCESS: preview artwork received') | |
| } else { | |
| attempts++ | |
| if (attempts >= MAX_ATTEMPTS) { | |
| clearPolling() | |
| failureCalled = true | |
| log.push(`FAILURE: gave up after ${MAX_ATTEMPTS} attempts`) | |
| } | |
| } | |
| }) | |
| .catch((err) => { | |
| log.push(`CATCH: refetch rejected — ${err?.constructor?.name}: ${err?.message}`) | |
| // NOTE: the production code has NO .catch() — this is here to detect silent rejections | |
| }) | |
| }, INTERVAL_MS) | |
| // Wait for polling to complete (success or failure) | |
| const maxWaitMs = INTERVAL_MS * (MAX_ATTEMPTS + 2) + GENERATED_AFTER_MS + 500 | |
| const startTime = Date.now() | |
| while (!successCalled && !failureCalled && Date.now() - startTime < maxWaitMs) { | |
| await sleep(50) | |
| } | |
| clearPolling() | |
| // Print the log for debugging | |
| console.log('\n--- Polling log ---') | |
| for (const entry of log) { | |
| console.log(` ${entry}`) | |
| } | |
| console.log(`-------------------\n`) | |
| console.log(`mockQueryFn called ${mockQueryFn.mock.calls.length} times`) | |
| console.log(`successCalled: ${successCalled}`) | |
| console.log(`failureCalled: ${failureCalled}`) | |
| console.log(`attempts (generated=false count): ${attempts}`) | |
| // The test: did the polling detect generated: true? | |
| expect(successCalled).toBe(true) | |
| expect(failureCalled).toBe(false) | |
| }) | |
| it('shows what happens WITHOUT .catch() when refetch rejects', async () => { | |
| const INTERVAL_MS = 200 | |
| const MAX_ATTEMPTS = 8 | |
| // Query function that always fails | |
| const failingQueryFn = jest.fn(async () => { | |
| throw new Error('Network error') | |
| }) | |
| const observer = new QueryObserver(queryClient, { | |
| queryKey: ['getArtworkById', 14204], | |
| queryFn: failingQueryFn, | |
| enabled: true, | |
| }) | |
| const log: string[] = [] | |
| let failureCalled = false | |
| let attempts = 0 | |
| let unhandledRejections = 0 | |
| const intervalRef = { current: null as ReturnType<typeof setInterval> | null } | |
| const clearPolling = () => { | |
| if (intervalRef.current) { | |
| clearInterval(intervalRef.current) | |
| intervalRef.current = null | |
| } | |
| } | |
| // Reproduce production code exactly: .then() with NO .catch() | |
| intervalRef.current = setInterval(() => { | |
| observer.refetch().then(({ data: updatedArtwork }) => { | |
| const generated = (updatedArtwork as any)?.generated | |
| log.push( | |
| `poll: generated=${generated}, data=${updatedArtwork ? 'present' : 'undefined'}`, | |
| ) | |
| if (generated) { | |
| clearPolling() | |
| } else { | |
| attempts++ | |
| if (attempts >= MAX_ATTEMPTS) { | |
| clearPolling() | |
| failureCalled = true | |
| log.push(`FAILURE: gave up after ${MAX_ATTEMPTS} attempts`) | |
| } | |
| } | |
| }) | |
| // NO .catch() — matching production code | |
| }, INTERVAL_MS) | |
| // Track unhandled rejections | |
| const rejectionHandler = () => { unhandledRejections++ } | |
| process.on('unhandledRejection', rejectionHandler) | |
| const maxWaitMs = INTERVAL_MS * (MAX_ATTEMPTS + 4) + 500 | |
| const startTime = Date.now() | |
| while (!failureCalled && Date.now() - startTime < maxWaitMs) { | |
| await sleep(50) | |
| } | |
| clearPolling() | |
| process.removeListener('unhandledRejection', rejectionHandler) | |
| console.log('\n--- Error polling log ---') | |
| for (const entry of log) { | |
| console.log(` ${entry}`) | |
| } | |
| console.log(`-------------------\n`) | |
| console.log(`failingQueryFn called ${failingQueryFn.mock.calls.length} times`) | |
| console.log(`failureCalled: ${failureCalled}`) | |
| console.log(`attempts: ${attempts}`) | |
| console.log(`unhandledRejections: ${unhandledRejections}`) | |
| // This test documents the behavior: | |
| // If refetch() rejects (no .catch), does attempts ever increment? | |
| // If not, handleArtworkGenerationFailure never fires → infinite loading | |
| if (!failureCalled && attempts === 0) { | |
| console.log('\n*** BUG CONFIRMED: refetch() rejects silently, attempts never increments ***') | |
| console.log('*** The user would see infinite loading, not the generic error ***') | |
| } | |
| if (failureCalled) { | |
| console.log('\n*** refetch() resolves even on error, attempts incremented normally ***') | |
| } | |
| // We're documenting behavior, not asserting a specific outcome | |
| expect(true).toBe(true) | |
| }) | |
| it('reproduces the cancelRefetch interaction with setInterval', async () => { | |
| const INTERVAL_MS = 100 // very fast interval | |
| const MAX_ATTEMPTS = 8 | |
| let callCount = 0 | |
| // Slow query function — takes longer than the interval | |
| const slowQueryFn = jest.fn(async () => { | |
| callCount++ | |
| const thisCall = callCount | |
| await sleep(250) // takes 250ms, but interval fires every 100ms | |
| return { | |
| id: '14204', | |
| generated: thisCall >= 4, // becomes true on 4th actual execution | |
| preview: true, | |
| } | |
| }) | |
| const observer = new QueryObserver(queryClient, { | |
| queryKey: ['getArtworkById', 14204], | |
| queryFn: slowQueryFn, | |
| enabled: true, | |
| }) | |
| const log: string[] = [] | |
| let successCalled = false | |
| let failureCalled = false | |
| let attempts = 0 | |
| let catchCount = 0 | |
| const intervalRef = { current: null as ReturnType<typeof setInterval> | null } | |
| const clearPolling = () => { | |
| if (intervalRef.current) { | |
| clearInterval(intervalRef.current) | |
| intervalRef.current = null | |
| } | |
| } | |
| intervalRef.current = setInterval(() => { | |
| observer | |
| .refetch() | |
| .then(({ data: updatedArtwork }) => { | |
| const generated = (updatedArtwork as any)?.generated | |
| log.push(`then: generated=${generated}`) | |
| if (generated) { | |
| clearPolling() | |
| successCalled = true | |
| } else { | |
| attempts++ | |
| if (attempts >= MAX_ATTEMPTS) { | |
| clearPolling() | |
| failureCalled = true | |
| } | |
| } | |
| }) | |
| .catch((err) => { | |
| catchCount++ | |
| log.push(`catch: ${err?.constructor?.name} — ${err?.message || 'cancelled'}`) | |
| }) | |
| }, INTERVAL_MS) | |
| const maxWaitMs = 5000 | |
| const startTime = Date.now() | |
| while (!successCalled && !failureCalled && Date.now() - startTime < maxWaitMs) { | |
| await sleep(50) | |
| } | |
| clearPolling() | |
| console.log('\n--- cancelRefetch interaction log ---') | |
| for (const entry of log) { | |
| console.log(` ${entry}`) | |
| } | |
| console.log(`-------------------\n`) | |
| console.log(`slowQueryFn called: ${slowQueryFn.mock.calls.length}`) | |
| console.log(`then callbacks: ${log.filter((l) => l.startsWith('then')).length}`) | |
| console.log(`catch callbacks: ${catchCount}`) | |
| console.log(`attempts: ${attempts}`) | |
| console.log(`successCalled: ${successCalled}`) | |
| console.log(`failureCalled: ${failureCalled}`) | |
| if (catchCount > 0) { | |
| console.log('\n*** FOUND: refetch() rejects when cancelled by next interval tick ***') | |
| console.log('*** Production code has no .catch() → unhandled rejections ***') | |
| } | |
| if (failureCalled) { | |
| console.log('\n*** Polling timed out despite data being available ***') | |
| } | |
| // This MUST fail — it's the bug | |
| expect(failureCalled).toBe(true) | |
| }) | |
| it('setTimeout fix: waits for refetch to complete before scheduling next poll', async () => { | |
| const INTERVAL_MS = 100 | |
| const MAX_ATTEMPTS = 8 | |
| let callCount = 0 | |
| const slowQueryFn = jest.fn(async () => { | |
| callCount++ | |
| const thisCall = callCount | |
| await sleep(250) // still slower than interval | |
| return { | |
| id: '14204', | |
| generated: thisCall >= 4, | |
| preview: true, | |
| } | |
| }) | |
| const observer = new QueryObserver(queryClient, { | |
| queryKey: ['getArtworkById-fix', 14204], | |
| queryFn: slowQueryFn, | |
| enabled: true, | |
| }) | |
| const log: string[] = [] | |
| let successCalled = false | |
| let failureCalled = false | |
| let attempts = 0 | |
| const timeoutRef = { current: null as ReturnType<typeof setTimeout> | null } | |
| const clearPolling = () => { | |
| if (timeoutRef.current) { | |
| clearTimeout(timeoutRef.current) | |
| timeoutRef.current = null | |
| } | |
| } | |
| const schedulePoll = () => { | |
| timeoutRef.current = setTimeout(() => { | |
| observer | |
| .refetch() | |
| .then(({ data: updatedArtwork }) => { | |
| const generated = (updatedArtwork as any)?.generated | |
| log.push(`then: generated=${generated}`) | |
| if (generated) { | |
| clearPolling() | |
| successCalled = true | |
| } else { | |
| attempts++ | |
| if (attempts >= MAX_ATTEMPTS) { | |
| clearPolling() | |
| failureCalled = true | |
| } else { | |
| schedulePoll() | |
| } | |
| } | |
| }) | |
| .catch(() => { | |
| attempts++ | |
| if (attempts >= MAX_ATTEMPTS) { | |
| clearPolling() | |
| failureCalled = true | |
| } else { | |
| schedulePoll() | |
| } | |
| }) | |
| }, INTERVAL_MS) | |
| } | |
| schedulePoll() | |
| const maxWaitMs = 5000 | |
| const startTime = Date.now() | |
| while (!successCalled && !failureCalled && Date.now() - startTime < maxWaitMs) { | |
| await sleep(50) | |
| } | |
| clearPolling() | |
| console.log('\n--- setTimeout fix log ---') | |
| for (const entry of log) { | |
| console.log(` ${entry}`) | |
| } | |
| console.log(`-------------------\n`) | |
| console.log(`slowQueryFn called: ${slowQueryFn.mock.calls.length}`) | |
| console.log(`attempts: ${attempts}`) | |
| console.log(`successCalled: ${successCalled}`) | |
| console.log(`failureCalled: ${failureCalled}`) | |
| // This MUST succeed — each poll waits for the previous to complete | |
| expect(successCalled).toBe(true) | |
| expect(failureCalled).toBe(false) | |
| }) | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment