fix(storage): isolate workspace refresh state

- Discard stale provider refreshes and queued previews

- Guard malformed deflate data and listener failures

- Start periodic refresh without an immediate callback
This commit is contained in:
Danila Poyarkov 2026-08-10 22:30:54 +03:00
parent c2bcb21fcf
commit b21509cc8d
6 changed files with 51 additions and 6 deletions

View file

@ -102,8 +102,14 @@ async function readEntryPayload(
: null
}
if (entry.method !== 8) return null
const output = inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) })
return output.byteLength === entry.outputSize && hasPNGSignature(output) ? output : null
const output = (() => {
try {
return inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) })
} catch {
return null
}
})()
return output?.byteLength === entry.outputSize && hasPNGSignature(output) ? output : null
}
/**

View file

@ -86,7 +86,7 @@ export function createDocumentPreviews<Item extends DocumentWorkspaceItem>(
while (active.size < concurrency) {
const id = previewQueue.shift()
if (!id) break
queued.delete(id)
if (!queued.delete(id)) continue
if (active.has(id) || previewUrls.value[id]) continue
active.add(id)
void runLoad(id, previewGenerations.get(id) ?? 0)
@ -108,7 +108,11 @@ export function createDocumentPreviews<Item extends DocumentWorkspaceItem>(
for (const id of trackedIds) {
if (previous.get(id) === current.get(id)) continue
removeURL(id)
if (!current.has(id) || active.has(id) || queued.has(id)) continue
if (!current.has(id)) {
queued.delete(id)
continue
}
if (active.has(id) || queued.has(id)) continue
queued.add(id)
previewQueue.push(id)
}
@ -125,6 +129,7 @@ export function createDocumentPreviews<Item extends DocumentWorkspaceItem>(
for (const id of ids) removeURL(id)
previewQueue.length = 0
queued.clear()
previewErrors.value = {}
}
function observePreview(element: Element | null, id: string): () => void {

View file

@ -96,7 +96,7 @@ export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
}
},
options.refreshInterval,
{ immediate: false }
{ immediate: true, immediateCallback: false }
)
}

View file

@ -11,7 +11,13 @@ type StorageWorkspaceListener = (event: StorageWorkspaceEvent) => void
const listeners = new Set<StorageWorkspaceListener>()
export function emitStorageWorkspaceEvent(event: StorageWorkspaceEvent): void {
for (const listener of listeners) listener(event)
for (const listener of listeners) {
try {
listener(event)
} catch (error) {
console.error('[Storage] Workspace event listener failed:', error)
}
}
}
export function onStorageWorkspaceEvent(listener: StorageWorkspaceListener): () => void {

View file

@ -47,6 +47,7 @@ export function createStorageWorkspaceSource(
updatedAt: metadata.updatedAt,
metadataAuthoritative: true
}))
if (activeStorageProviderID.value !== providerID) return []
onSnapshot({ documents, configured })
return documents
}
@ -65,6 +66,7 @@ export function createStorageWorkspaceSource(
lastSyncError: null
})
}
if (activeStorageProviderID.value !== providerID) return []
onSnapshot({ documents: reconciliation.documents, configured })
return reconciliation.documents
},

View file

@ -41,6 +41,32 @@ describe('fig ranged thumbnail extraction', () => {
expect(await extractFigThumbnailFromReader(memoryReader(bytes, []))).toBeNull()
})
test('returns null when deflated thumbnail data is corrupt', async () => {
const png = new Uint8Array([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
...Array.from({ length: 64 }, () => 1)
])
const bytes = zipSync({ 'thumbnail.png': png })
const name = new TextEncoder().encode('thumbnail.png')
const nameOffset = bytes.findIndex((byte, index) =>
name.every((nameByte, nameIndex) => bytes[index + nameIndex] === nameByte)
)
const headerOffset = nameOffset - 30
const header = new DataView(bytes.buffer, bytes.byteOffset + headerOffset, 30)
expect(header.getUint16(8, true)).toBe(8)
const dataOffset = nameOffset + name.byteLength + header.getUint16(28, true)
bytes.fill(0xff, dataOffset, dataOffset + header.getUint32(18, true))
expect(await extractFigThumbnailFromReader(memoryReader(bytes, []))).toBeNull()
})
test('rejects thumbnails above configured output limits', async () => {
const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, []), {