Skip to content

Instantly share code, notes, and snippets.

@nfilzi
Created April 17, 2026 15:49
Show Gist options
  • Select an option

  • Save nfilzi/c676937c33993d80198743fe7f39091d to your computer and use it in GitHub Desktop.

Select an option

Save nfilzi/c676937c33993d80198743fe7f39091d to your computer and use it in GitHub Desktop.
Reproduction test for artwork generation polling bug (setInterval + TanStack Query v5 cancelRefetch)
/**
* 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