From dde7376bd301cf43e172c0a4a69bd988f101725b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 10 Aug 2026 17:18:12 +0300 Subject: [PATCH 1/6] feat(storage): show workspace document previews - Load embedded Figma thumbnails through bounded S3 byte-range requests - Add a headless Vue workspace composable with lazy previews and refresh lifecycle - Cache local previews and refresh the workspace after saves and synchronization --- CHANGELOG.md | 4 +- packages/fig/src/index.ts | 5 + packages/fig/src/thumbnail.ts | 122 ++++++++++ packages/vue/src/document/workspace/use.ts | 248 ++++++++++++++++++++ packages/vue/src/index.ts | 6 + src/app/integrations/storage/s3/adapter.ts | 23 +- src/app/integrations/storage/s3/client.ts | 32 +++ src/app/storage/sync/engine.ts | 6 + src/app/storage/sync/persist.ts | 15 ++ src/app/storage/workspace/events.ts | 18 ++ src/app/storage/workspace/source.ts | 84 +++++++ src/views/StorageView.vue | 146 +++++------- tests/e2e/storage/workspace.spec.ts | 42 +++- tests/engine/app/storage/persist.test.ts | 19 ++ tests/engine/io/fig/thumbnail-range.test.ts | 41 ++++ tests/engine/vue/document-workspace.test.ts | 196 ++++++++++++++++ 16 files changed, 911 insertions(+), 96 deletions(-) create mode 100644 packages/fig/src/thumbnail.ts create mode 100644 packages/vue/src/document/workspace/use.ts create mode 100644 src/app/storage/workspace/events.ts create mode 100644 src/app/storage/workspace/source.ts create mode 100644 tests/engine/io/fig/thumbnail-range.test.ts create mode 100644 tests/engine/vue/document-workspace.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a85eb31dd..e034b34f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,10 +32,10 @@ - Test OpenAI-compatible provider connections from AI settings with clearer setup errors. - Configure separate Design, Review, Fast, and Vision models, providers, endpoints, and credentials from AI settings. - Manage AI, agent, media, and storage credentials from unified Settings, using the system credential store on desktop and encrypted browser storage by default, with a session-only browser option. -- Connect an S3-compatible storage workspace with local-first saves and background synchronization. +- Connect an S3-compatible storage workspace with local-first saves, background synchronization, embedded `.fig` previews loaded without downloading full documents, and automatic refresh while the workspace is active. - Add Japanese localization and improve menu translations across the existing supported languages. (#367) - Author richer Design JSX with components, instances, variables, gradients, structured fills, shadows, blur effects, masks, and inline SVG vectors. -- Build custom property panels with new Vue SDK number fields, bindable values, property sections, responsive grids, segmented controls, property lists, color models, fill controls, and gradient primitives. +- Build custom property panels and document workspaces with new Vue SDK number fields, bindable values, property sections, responsive grids, segmented controls, property lists, color models, fill controls, gradient primitives, and the headless `useDocumentWorkspace()` composable. - Use `useColorModel()` in the Vue SDK for extensible color formats and shared RGB, HSL, HSB, and OkHCL channel behavior. - Add dedicated SceneGraph, Pen, Kiwi, Fig, and DOM/CSS packages with documented public entry points for building on OpenPencil. diff --git a/packages/fig/src/index.ts b/packages/fig/src/index.ts index 72eb879af..8ff1b00f8 100644 --- a/packages/fig/src/index.ts +++ b/packages/fig/src/index.ts @@ -6,6 +6,11 @@ export { type FigParseResult, type WriteFigArchiveInput } from './archive' +export { + extractFigThumbnailFromReader, + type FigRangeReader, + type FigThumbnailLimits +} from './thumbnail' export { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload, diff --git a/packages/fig/src/thumbnail.ts b/packages/fig/src/thumbnail.ts new file mode 100644 index 000000000..eb495b863 --- /dev/null +++ b/packages/fig/src/thumbnail.ts @@ -0,0 +1,122 @@ +import { inflateSync } from 'fflate' + +export interface FigRangeReader { + readonly size: number + read(start: number, endExclusive: number): Promise +} + +export type FigThumbnailLimits = { + maxTailBytes?: number + maxCompressedBytes?: number + maxOutputBytes?: number +} + +type ThumbnailEntry = { + method: number + compressedSize: number + localOffset: number +} + +const EOCD_SIGNATURE = 0x06054b50 +const CENTRAL_SIGNATURE = 0x02014b50 +const LOCAL_SIGNATURE = 0x04034b50 +const EOCD_MIN_SIZE = 22 +const MAX_ZIP_COMMENT = 65_535 +const DEFAULT_MAX_TAIL = 4 * 1024 * 1024 +const DEFAULT_MAX_COMPRESSED = 8 * 1024 * 1024 +const DEFAULT_MAX_OUTPUT = 16 * 1024 * 1024 +const THUMBNAIL_NAME = 'thumbnail.png' + +function view(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) +} + +function findEOCD(bytes: Uint8Array): number { + const data = view(bytes) + for (let offset = bytes.byteLength - EOCD_MIN_SIZE; offset >= 0; offset--) { + if (data.getUint32(offset, true) === EOCD_SIGNATURE) return offset + } + return -1 +} + +function boundedLimit(value: number | undefined, fallback: number): number { + return Number.isFinite(value) && value && value > 0 ? value : fallback +} + +function findThumbnailEntry( + central: Uint8Array, + maxCompressed: number, + maxOutput: number +): ThumbnailEntry | null { + const data = view(central) + const decoder = new TextDecoder() + for (let offset = 0; offset + 46 <= central.byteLength; ) { + if (data.getUint32(offset, true) !== CENTRAL_SIGNATURE) return null + const method = data.getUint16(offset + 10, true) + const compressedSize = data.getUint32(offset + 20, true) + const outputSize = data.getUint32(offset + 24, true) + const nameLength = data.getUint16(offset + 28, true) + const next = + offset + + 46 + + nameLength + + data.getUint16(offset + 30, true) + + data.getUint16(offset + 32, true) + if (next > central.byteLength) return null + const name = decoder.decode(central.subarray(offset + 46, offset + 46 + nameLength)) + if (name === THUMBNAIL_NAME) { + if (compressedSize > maxCompressed || outputSize > maxOutput) return null + return { method, compressedSize, localOffset: data.getUint32(offset + 42, true) } + } + offset = next + } + return null +} + +async function readEntryPayload( + reader: FigRangeReader, + entry: ThumbnailEntry, + maxOutput: number +): Promise { + const header = await reader.read(entry.localOffset, Math.min(reader.size, entry.localOffset + 30)) + if (header.byteLength < 30 || view(header).getUint32(0, true) !== LOCAL_SIGNATURE) return null + const headerView = view(header) + const dataStart = + entry.localOffset + 30 + headerView.getUint16(26, true) + headerView.getUint16(28, true) + if (dataStart + entry.compressedSize > reader.size) return null + const compressed = await reader.read(dataStart, dataStart + entry.compressedSize) + if (entry.method === 0) return compressed.byteLength <= maxOutput ? compressed : null + if (entry.method !== 8) return null + const output = inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) }) + return output.byteLength <= maxOutput ? output : null +} + +/** + * Extract Figma's canonical `thumbnail.png` from a remote `.fig` ZIP through + * bounded range reads. The complete document is never requested. + */ +export async function extractFigThumbnailFromReader( + reader: FigRangeReader, + limits: FigThumbnailLimits = {} +): Promise { + if (!Number.isSafeInteger(reader.size) || reader.size < EOCD_MIN_SIZE) return null + const maxCentral = boundedLimit(limits.maxTailBytes, DEFAULT_MAX_TAIL) + const maxCompressed = boundedLimit(limits.maxCompressedBytes, DEFAULT_MAX_COMPRESSED) + const maxOutput = boundedLimit(limits.maxOutputBytes, DEFAULT_MAX_OUTPUT) + const tailSize = Math.min(reader.size, EOCD_MIN_SIZE + MAX_ZIP_COMMENT) + const tailStart = reader.size - tailSize + const tail = await reader.read(tailStart, reader.size) + const eocd = findEOCD(tail) + if (eocd < 0) return null + + const tailView = view(tail) + const centralSize = tailView.getUint32(eocd + 12, true) + const centralOffset = tailView.getUint32(eocd + 16, true) + if (centralSize > maxCentral || centralOffset + centralSize > reader.size) return null + const central = + centralOffset >= tailStart && centralOffset + centralSize <= reader.size + ? tail.subarray(centralOffset - tailStart, centralOffset - tailStart + centralSize) + : await reader.read(centralOffset, centralOffset + centralSize) + const entry = findThumbnailEntry(central, maxCompressed, maxOutput) + return entry ? readEntryPayload(reader, entry, maxOutput) : null +} diff --git a/packages/vue/src/document/workspace/use.ts b/packages/vue/src/document/workspace/use.ts new file mode 100644 index 000000000..9745c078c --- /dev/null +++ b/packages/vue/src/document/workspace/use.ts @@ -0,0 +1,248 @@ +import { useEventListener, useIntervalFn } from '@vueuse/core' +import { + computed, + onBeforeUnmount, + onMounted, + readonly, + ref, + shallowRef, + type Directive, + type Ref +} from 'vue' + +import { IS_BROWSER } from '@open-pencil/core/constants' + +export type DocumentWorkspaceItem = { + id: string + name: string + updatedAt: string +} + +export interface DocumentWorkspaceSource { + refresh(): Promise + loadPreview(id: string): Promise + subscribe?(listener: () => void): () => void +} + +export type UseDocumentWorkspaceOptions = { + source: DocumentWorkspaceSource + refreshInterval?: number + refreshOnFocus?: boolean + refreshOnReconnect?: boolean + previewConcurrency?: number + previewMimeType?: string +} + +export function useDocumentWorkspace( + options: UseDocumentWorkspaceOptions +) { + const documents = shallowRef([]) + const loading = ref(false) + const error = shallowRef(null) + const lastRefreshedAt = shallowRef(null) + const previewUrls = ref>({}) + const previewCleanups = new WeakMap void>() + const previewGenerations = new Map() + const previewQueue: string[] = [] + const queued = new Set() + const activePreviews = new Set() + const concurrency = Math.max(1, Math.floor(options.previewConcurrency ?? 6)) + let refreshPromise: Promise | null = null + let refreshQueued = false + let disposed = false + + function removePreviewURL(id: string): void { + previewGenerations.set(id, (previewGenerations.get(id) ?? 0) + 1) + const url = previewUrls.value[id] + if (!url) return + URL.revokeObjectURL(url) + previewUrls.value = Object.fromEntries( + Object.entries(previewUrls.value).filter(([previewId]) => previewId !== id) + ) + } + + function reconcilePreviewUrls(items: readonly Item[]): void { + const previousItems = new Map(documents.value.map((item) => [item.id, item.updatedAt])) + const currentItems = new Map(items.map((item) => [item.id, item.updatedAt])) + for (const id of Object.keys(previewUrls.value)) { + if (previousItems.get(id) !== currentItems.get(id)) removePreviewURL(id) + } + } + + function clearPreviews(): void { + const ids = new Set([ + ...Object.keys(previewUrls.value), + ...activePreviews, + ...queued, + ...previewGenerations.keys() + ]) + for (const id of ids) removePreviewURL(id) + previewQueue.length = 0 + queued.clear() + } + + function replacePreviewURL(id: string, bytes: Uint8Array): void { + if (disposed) return + const previous = previewUrls.value[id] + if (previous) URL.revokeObjectURL(previous) + const blobBytes = Uint8Array.from(bytes) + previewUrls.value = { + ...previewUrls.value, + [id]: URL.createObjectURL( + new Blob([blobBytes.buffer], { type: options.previewMimeType ?? 'image/png' }) + ) + } + } + + function drainPreviewQueue(): void { + while (activePreviews.size < concurrency) { + const id = previewQueue.shift() + if (!id) break + queued.delete(id) + if (activePreviews.has(id) || previewUrls.value[id]) continue + activePreviews.add(id) + const generation = previewGenerations.get(id) ?? 0 + void options.source + .loadPreview(id) + .then((bytes) => { + if (bytes?.byteLength && generation === (previewGenerations.get(id) ?? 0)) { + replacePreviewURL(id, bytes) + } + return undefined + }) + .catch(() => null) + .finally(() => { + activePreviews.delete(id) + drainPreviewQueue() + }) + } + } + + function loadPreview(id: string): void { + if (previewUrls.value[id] || activePreviews.has(id) || queued.has(id)) return + queued.add(id) + previewQueue.push(id) + drainPreviewQueue() + } + + function previewURL(id: string): string | null { + return previewUrls.value[id] ?? null + } + + function observePreview(element: Element | null, id: string): () => void { + if (!element || typeof IntersectionObserver === 'undefined') { + loadPreview(id) + return () => undefined + } + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + loadPreview(id) + observer.disconnect() + } + }, + { rootMargin: '240px' } + ) + observer.observe(element) + return () => observer.disconnect() + } + + function stopObservingPreview(element: Element): void { + previewCleanups.get(element)?.() + previewCleanups.delete(element) + } + + const previewDirective: Directive = { + mounted(element, binding) { + previewCleanups.set(element, observePreview(element, binding.value)) + }, + updated(element, binding) { + if (binding.value === binding.oldValue) return + stopObservingPreview(element) + previewCleanups.set(element, observePreview(element, binding.value)) + }, + unmounted(element) { + stopObservingPreview(element) + } + } + + function refresh(): Promise { + if (refreshPromise) return refreshPromise + loading.value = true + error.value = null + const nextRefresh = options.source + .refresh() + .then((items) => { + if (!disposed) { + reconcilePreviewUrls(items) + documents.value = items + lastRefreshedAt.value = new Date() + } + return undefined + }) + .catch((reason: unknown) => { + if (!disposed) error.value = reason + }) + .finally(() => { + loading.value = false + refreshPromise = null + if (refreshQueued && !disposed) { + refreshQueued = false + void refresh() + } + }) + refreshPromise = nextRefresh + return nextRefresh + } + + function invalidate(): Promise { + if (!refreshPromise) return refresh() + refreshQueued = true + return refreshPromise + } + + if (options.refreshOnFocus !== false && IS_BROWSER) { + useEventListener(window, 'focus', () => void invalidate()) + } + if (options.refreshOnReconnect !== false && IS_BROWSER) { + useEventListener(window, 'online', () => void invalidate()) + } + if (options.refreshInterval && options.refreshInterval > 0) { + useIntervalFn( + () => { + if (typeof document === 'undefined' || document.visibilityState === 'visible') { + void invalidate() + } + }, + options.refreshInterval, + { immediate: false } + ) + } + + let unsubscribeSource: (() => void) | null = null + onMounted(() => { + unsubscribeSource = options.source.subscribe?.(() => void invalidate()) ?? null + void refresh() + }) + onBeforeUnmount(() => { + unsubscribeSource?.() + disposed = true + clearPreviews() + }) + + return { + documents: readonly(documents) as Readonly>, + loading: readonly(loading), + error: readonly(error), + lastRefreshedAt: readonly(lastRefreshedAt), + previewUrls: readonly(previewUrls), + hasDocuments: computed(() => documents.value.length > 0), + refresh, + invalidate, + clearPreviews, + loadPreview, + observePreview, + previewDirective, + previewURL + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index c82e631ec..9f2d4056b 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -82,6 +82,12 @@ export { useAppearance } from '#vue/controls/appearance/use' export { useMask } from '#vue/controls/mask/use' export { useTypography } from '#vue/controls/typography/use' export type { UseTypographyOptions } from '#vue/controls/typography/use' +export { useDocumentWorkspace } from '#vue/document/workspace/use' +export type { + DocumentWorkspaceItem, + DocumentWorkspaceSource, + UseDocumentWorkspaceOptions +} from '#vue/document/workspace/use' export { useExport } from '#vue/document/export/use' export type { ExportFormatId, ExportSetting } from '#vue/document/export/use' export { useFillControls } from '#vue/controls/fill/use' diff --git a/src/app/integrations/storage/s3/adapter.ts b/src/app/integrations/storage/s3/adapter.ts index 572a86c19..1694f3d9b 100644 --- a/src/app/integrations/storage/s3/adapter.ts +++ b/src/app/integrations/storage/s3/adapter.ts @@ -1,3 +1,5 @@ +import { extractFigThumbnailFromReader } from '@open-pencil/fig' + import { isTauri } from '@/app/tauri/env' import { @@ -16,7 +18,16 @@ import type { StorageDocumentMetadata, StorageProviderRuntime } from '../types' -import { S3HttpError, deleteObject, getObject, headObject, listObjects, putObject } from './client' +import { + S3HttpError, + deleteObject, + getObject, + getObjectRange, + headObject, + headObjectSize, + listObjects, + putObject +} from './client' import { CloudCORSError, formatBrowserCORSHelpMessage, isLikelyCORSOrNetworkError } from './cors' import type { S3CompatibleConfig, S3ConnectionResult } from './types' @@ -244,7 +255,15 @@ export function createS3StorageAdapter(runtime: StorageProviderRuntime): S3Stora async getThumbnail(id) { const config = await resolveConfig(runtime) - return getObject(config, documentThumbnailKey(id)) + const figKey = documentFigKey(id) + const size = await headObjectSize(config, figKey) + if (size == null) return null + return extractFigThumbnailFromReader({ + size, + async read(start: number, endExclusive: number) { + return (await getObjectRange(config, figKey, start, endExclusive)) ?? new Uint8Array() + } + }) } } } diff --git a/src/app/integrations/storage/s3/client.ts b/src/app/integrations/storage/s3/client.ts index 1ee925c8c..a13ff39ca 100644 --- a/src/app/integrations/storage/s3/client.ts +++ b/src/app/integrations/storage/s3/client.ts @@ -164,6 +164,38 @@ export async function headObject(config: S3CompatibleConfig, key: string): Promi return true } +export async function headObjectSize( + config: S3CompatibleConfig, + key: string +): Promise { + const res = await s3Request(config, objectURL(config, key), { method: 'HEAD' }) + if (res.status === 404) return null + const size = Number(res.headers.get('content-length')) + return Number.isSafeInteger(size) && size >= 0 ? size : null +} + +export async function getObjectRange( + config: S3CompatibleConfig, + key: string, + start: number, + endExclusive: number +): Promise { + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(endExclusive) || + endExclusive <= start + ) { + throw new Error('Invalid S3 byte range') + } + const res = await s3Request(config, objectURL(config, key), { + method: 'GET', + headers: { Range: `bytes=${start}-${endExclusive - 1}` } + }) + if (res.status === 404) return null + if (res.status !== 206) throw new Error('Storage provider did not honor the thumbnail byte range') + return new Uint8Array(await res.arrayBuffer()) +} + export async function putObject( config: S3CompatibleConfig, key: string, diff --git a/src/app/storage/sync/engine.ts b/src/app/storage/sync/engine.ts index 9ff0c629e..78e46967e 100644 --- a/src/app/storage/sync/engine.ts +++ b/src/app/storage/sync/engine.ts @@ -13,6 +13,7 @@ import { getOutbox } from '@/app/storage/sync/outbox' import { setUploadProgress } from '@/app/storage/sync/progress' import { setPendingSyncCount, setSyncUI } from '@/app/storage/sync/status' import type { OutboxJob } from '@/app/storage/sync/types' +import { emitStorageWorkspaceEvent } from '@/app/storage/workspace/events' const MAX_ATTEMPTS = 8 const BASE_BACKOFF_MS = 1500 @@ -124,6 +125,11 @@ async function runJob(job: OutboxJob): Promise { { expectedRevision: job.revision } ) await evictLocalFigCache(new Set([job.canvasId])) + emitStorageWorkspaceEvent({ + providerId: providerID, + documentId: job.canvasId, + kind: 'synced' + }) } return } diff --git a/src/app/storage/sync/persist.ts b/src/app/storage/sync/persist.ts index d9c454828..3bbb4c1e3 100644 --- a/src/app/storage/sync/persist.ts +++ b/src/app/storage/sync/persist.ts @@ -1,8 +1,11 @@ +import { extractFigThumbnailFromReader } from '@open-pencil/fig' + import type { StorageProviderID } from '@/app/integrations/storage/types' import { evictLocalFigCache } from '@/app/storage/cache-eviction' import { getLocalCanvasStore } from '@/app/storage/local-store' import type { LocalCanvasStore } from '@/app/storage/local-store/store' import { enqueuePutCanvas } from '@/app/storage/sync/engine' +import { emitStorageWorkspaceEvent } from '@/app/storage/workspace/events' export type StoragePersistenceDependencies = { store: LocalCanvasStore @@ -25,14 +28,26 @@ export async function persistStorageCanvasLocally( store: getLocalCanvasStore(), enqueueCanvas: enqueuePutCanvas } + const thumbnailBytes = await extractFigThumbnailFromReader({ + size: options.figBytes.byteLength, + async read(start, endExclusive) { + return options.figBytes.subarray(start, endExclusive) + } + }) const metadata = await runtime.store.writeCanvas({ id: options.canvasId, providerId: options.providerId, name: options.name, figBytes: options.figBytes, + thumbBytes: thumbnailBytes, syncStatus: 'pending' }) await runtime.enqueueCanvas(options.canvasId, metadata.revision) + emitStorageWorkspaceEvent({ + providerId: options.providerId, + documentId: options.canvasId, + kind: 'changed' + }) return { revision: metadata.revision } } diff --git a/src/app/storage/workspace/events.ts b/src/app/storage/workspace/events.ts new file mode 100644 index 000000000..47589ff98 --- /dev/null +++ b/src/app/storage/workspace/events.ts @@ -0,0 +1,18 @@ +export type StorageWorkspaceEvent = { + providerId: string + documentId?: string + kind: 'changed' | 'synced' +} + +type StorageWorkspaceListener = (event: StorageWorkspaceEvent) => void + +const listeners = new Set() + +export function emitStorageWorkspaceEvent(event: StorageWorkspaceEvent): void { + for (const listener of listeners) listener(event) +} + +export function onStorageWorkspaceEvent(listener: StorageWorkspaceListener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/app/storage/workspace/source.ts b/src/app/storage/workspace/source.ts new file mode 100644 index 000000000..6548ab36b --- /dev/null +++ b/src/app/storage/workspace/source.ts @@ -0,0 +1,84 @@ +import type { StorageDocument } from '@/app/integrations/storage' +import { + activeStorageProviderID, + createActiveStorageAdapter, + storageCredentialStatuses, + storagePreferencesComplete, + storageProviderRegistry +} from '@/app/integrations/storage' +import { getLocalCanvasStore } from '@/app/storage/local-store' +import { reconcileStorageDocuments } from '@/app/storage/reconcile' +import { onStorageWorkspaceEvent } from '@/app/storage/workspace/events' + +export type StorageWorkspaceSnapshot = { + documents: StorageDocument[] + configured: boolean +} + +export function createStorageWorkspaceSource( + onSnapshot: (snapshot: StorageWorkspaceSnapshot) => void +) { + return { + subscribe(listener: () => void): () => void { + return onStorageWorkspaceEvent((event) => { + if (event.providerId === activeStorageProviderID.value) listener() + }) + }, + + async refresh(): Promise { + const providerID = activeStorageProviderID.value + const provider = storageProviderRegistry.get(providerID) + const statuses = await storageCredentialStatuses(providerID) + const configured = + storagePreferencesComplete(providerID) && + provider.credentialFields.every( + (field) => !field.required || statuses[field.id] === 'configured' + ) + const localStore = getLocalCanvasStore() + const local = (await localStore.listMetas(true)).filter( + (metadata) => metadata.providerId === providerID + ) + if (!configured) { + const documents = local + .filter((metadata) => !metadata.tombstoned) + .map((metadata) => ({ + id: metadata.id, + name: metadata.name, + updatedAt: metadata.updatedAt, + metadataAuthoritative: true + })) + onSnapshot({ documents, configured }) + return documents + } + + const remote = await createActiveStorageAdapter(providerID).listDocuments() + const reconciliation = reconcileStorageDocuments(local, remote) + for (const id of reconciliation.localIdsToPurge) await localStore.remove(id) + for (const document of reconciliation.remoteDocumentsToSeed) { + await localStore.upsertIndexMeta({ + id: document.id, + providerId: providerID, + name: document.name, + updatedAt: document.updatedAt, + syncStatus: 'synced', + lastSyncedAt: document.updatedAt, + lastSyncError: null + }) + } + onSnapshot({ documents: reconciliation.documents, configured }) + return reconciliation.documents + }, + + async loadPreview(id: string): Promise { + const localStore = getLocalCanvasStore() + const local = await localStore.readThumb(id) + if (local?.byteLength) return local + const adapter = createActiveStorageAdapter() + if (!adapter.getThumbnail) return null + const remote = await adapter.getThumbnail(id) + if (!remote?.byteLength) return null + await localStore.writeThumb(id, remote) + return remote + } + } +} diff --git a/src/views/StorageView.vue b/src/views/StorageView.vue index 039e7b6a6..e33eb77ad 100644 --- a/src/views/StorageView.vue +++ b/src/views/StorageView.vue @@ -1,87 +1,38 @@ @@ -146,18 +96,21 @@ onMounted(() => {
- - + + +
{ :data-document-id="document.id" @click="openDocument(document)" > -
+
+ + +

{{ document.name }}

diff --git a/tests/e2e/storage/workspace.spec.ts b/tests/e2e/storage/workspace.spec.ts index b36d4e2e0..157515045 100644 --- a/tests/e2e/storage/workspace.spec.ts +++ b/tests/e2e/storage/workspace.spec.ts @@ -4,8 +4,12 @@ import { expect, test } from '@playwright/test' import { CanvasHelper } from '#tests/helpers/canvas' -test('configured storage lists and opens a remote document', async ({ page }) => { +test('configured storage lists previews through ranges before opening the document', async ({ + page +}) => { const fixture = readFileSync('tests/fixtures/gold-preview.fig') + let fullDocumentGets = 0 + let rangeGets = 0 await page.route('https://s3.example.com/**', async (route) => { const url = new URL(route.request().url()) if (url.searchParams.get('list-type') === '2') { @@ -29,7 +33,37 @@ test('configured storage lists and opens a remote document', async ({ page }) => }) return } + if (url.pathname.endsWith('/remote-1.fig') && route.request().headers().range) { + rangeGets++ + const match = route + .request() + .headers() + .range?.match(/^bytes=(\d+)-(\d+)$/) + if (!match) { + await route.fulfill({ status: 416 }) + return + } + const start = Number(match[1]) + const end = Number(match[2]) + await route.fulfill({ + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${fixture.byteLength}` + }, + contentType: 'application/octet-stream', + body: fixture.subarray(start, end + 1) + }) + return + } + if (url.pathname.endsWith('/remote-1.fig') && route.request().method() === 'HEAD') { + await route.fulfill({ + status: 200, + headers: { 'Content-Length': String(fixture.byteLength) } + }) + return + } if (url.pathname.endsWith('/remote-1.fig')) { + fullDocumentGets++ await route.fulfill({ contentType: 'application/octet-stream', body: fixture }) return } @@ -54,11 +88,17 @@ test('configured storage lists and opens a remote document', async ({ page }) => await page.getByTestId('settings-storage-open-workspace').click() await expect(page.getByTestId('storage-workspace')).toBeVisible() await expect(page.getByText('Remote design')).toBeVisible() + const preview = page.locator('[data-document-id="remote-1"] img') + await expect(preview).toBeVisible() + await expect(preview).toHaveAttribute('src', /^blob:/) + expect(rangeGets).toBeGreaterThan(0) + expect(fullDocumentGets).toBe(0) await page.locator('[data-document-id="remote-1"]').click() await expect(page).toHaveURL(/\/$/) await canvas.waitForInit() await expect(page.getByText('Remote design').first()).toBeVisible() + expect(fullDocumentGets).toBe(1) }) test('storage workspace directs unconfigured users to Settings', async ({ page }) => { diff --git a/tests/engine/app/storage/persist.test.ts b/tests/engine/app/storage/persist.test.ts index c32e84b6b..d4cc4ff8c 100644 --- a/tests/engine/app/storage/persist.test.ts +++ b/tests/engine/app/storage/persist.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, vi } from 'bun:test' +import { readFileSync } from 'node:fs' import { createMemoryLocalCanvasStore } from '@/app/storage/local-store' import { persistStorageCanvasLocally } from '@/app/storage/sync/persist' @@ -30,4 +31,22 @@ describe('local-first storage persistence', () => { providerId: 's3-compatible' }) }) + + test('stores the embedded preview with the document', async () => { + const store = createMemoryLocalCanvasStore() + const enqueueCanvas = vi.fn(() => Promise.resolve()) + const figBytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig')) + + await persistStorageCanvasLocally( + { + providerId: 's3-compatible', + canvasId: 'canvas-preview', + name: 'Preview design', + figBytes + }, + { store, enqueueCanvas } + ) + + expect((await store.readThumb('canvas-preview'))?.byteLength).toBeGreaterThan(0) + }) }) diff --git a/tests/engine/io/fig/thumbnail-range.test.ts b/tests/engine/io/fig/thumbnail-range.test.ts new file mode 100644 index 000000000..d652177c2 --- /dev/null +++ b/tests/engine/io/fig/thumbnail-range.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' + +import { extractFigThumbnailFromReader } from '@open-pencil/fig' + +function memoryReader(bytes: Uint8Array, ranges: Array<[number, number]>) { + return { + size: bytes.byteLength, + async read(start: number, endExclusive: number) { + ranges.push([start, endExclusive]) + return bytes.slice(start, endExclusive) + } + } +} + +describe('fig ranged thumbnail extraction', () => { + test('extracts thumbnail.png without reading the complete fig', async () => { + const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig')) + const ranges: Array<[number, number]> = [] + const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, ranges), { + maxTailBytes: 4 * 1024 * 1024 + }) + + expect(thumbnail?.subarray(0, 8)).toEqual( + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ) + expect(ranges.length).toBeGreaterThanOrEqual(2) + expect(ranges.every(([start, end]) => start !== 0 || end !== bytes.byteLength)).toBe(true) + expect(ranges.reduce((total, [start, end]) => total + end - start, 0)).toBeLessThan( + bytes.byteLength + ) + }) + + test('rejects thumbnails above configured output limits', async () => { + const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig')) + const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, []), { + maxOutputBytes: 32 + }) + expect(thumbnail).toBeNull() + }) +}) diff --git a/tests/engine/vue/document-workspace.test.ts b/tests/engine/vue/document-workspace.test.ts new file mode 100644 index 000000000..a21765e63 --- /dev/null +++ b/tests/engine/vue/document-workspace.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, test, vi } from 'bun:test' + +import { createRenderer, defineComponent, h, type ComponentPublicInstance } from 'vue' + +import { + useDocumentWorkspace, + type DocumentWorkspaceItem, + type DocumentWorkspaceSource +} from '@open-pencil/vue' + +type HostNode = { + children: HostNode[] + parent: HostNode | null + text: string +} + +type Deferred = { + promise: Promise + resolve(value: Value): void +} + +function deferred(): Deferred { + let resolvePromise: ((value: Value) => void) | null = null + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { + promise, + resolve(value) { + resolvePromise?.(value) + } + } +} + +function hostNode(text = ''): HostNode { + return { children: [], parent: null, text } +} + +const renderer = createRenderer({ + patchProp() { + return undefined + }, + insert(child, parent, anchor) { + child.parent = parent + const index = anchor ? parent.children.indexOf(anchor) : -1 + if (index < 0) parent.children.push(child) + else parent.children.splice(index, 0, child) + }, + remove(child) { + const parent = child.parent + if (!parent) return + const index = parent.children.indexOf(child) + if (index !== -1) parent.children.splice(index, 1) + child.parent = null + }, + createElement() { + return hostNode() + }, + createText: hostNode, + createComment: hostNode, + setText(node, text) { + node.text = text + }, + setElementText(node, text) { + node.text = text + }, + parentNode(node) { + return node.parent + }, + nextSibling(node) { + const parent = node.parent + if (!parent) return null + return parent.children[parent.children.indexOf(node) + 1] ?? null + }, + querySelector() { + return null + }, + setScopeId() { + return undefined + }, + insertStaticContent(content, parent, anchor) { + const node = hostNode(content) + this.insert(node, parent, anchor) + return [node, node] + } +}) + +type Workspace = ReturnType> + +type WorkspaceHolder = { current: Workspace | null } + +async function flushTasks(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) +} + +function mountWorkspace( + source: DocumentWorkspaceSource, + options: { previewConcurrency?: number } = {} +): { workspace: Workspace; unmount(): void } { + const holder: WorkspaceHolder = { current: null } + const component = defineComponent({ + setup() { + holder.current = useDocumentWorkspace({ + source, + previewConcurrency: options.previewConcurrency, + refreshOnFocus: false, + refreshOnReconnect: false + }) + return () => h('div') + } + }) + const app = renderer.createApp(component) + app.mount(hostNode()) as ComponentPublicInstance + const workspace = holder.current + if (workspace == null) throw new Error('Workspace composable did not initialize') + return { workspace, unmount: () => app.unmount() } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useDocumentWorkspace', () => { + test('shares an in-flight refresh and responds to source events', async () => { + const firstRefresh = deferred() + let sourceListener: (() => void) | null = null + const refresh = vi + .fn<() => Promise>() + .mockImplementationOnce(() => firstRefresh.promise) + .mockResolvedValueOnce([{ id: 'second', name: 'Second', updatedAt: '2026-08-10' }]) + const mounted = mountWorkspace({ + refresh, + loadPreview: async () => null, + subscribe(listener) { + sourceListener = listener + return () => { + sourceListener = null + } + } + }) + + const inFlightA = mounted.workspace.refresh() + const inFlightB = mounted.workspace.refresh() + expect(inFlightA).toBe(inFlightB) + expect(refresh).toHaveBeenCalledTimes(1) + + sourceListener?.() + sourceListener?.() + firstRefresh.resolve([{ id: 'first', name: 'First', updatedAt: '2026-08-09' }]) + await inFlightA + await flushTasks() + expect(refresh).toHaveBeenCalledTimes(2) + expect(mounted.workspace.documents.value.map(({ id }) => id)).toEqual(['second']) + + mounted.unmount() + expect(sourceListener).toBeNull() + }) + + test('deduplicates previews, limits concurrency, and revokes URLs on unmount', async () => { + const loads = new Map>() + const loadPreview = vi.fn((id: string) => { + const load = deferred() + loads.set(id, load) + return load.promise + }) + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) + const mounted = mountWorkspace( + { refresh: async () => [], loadPreview }, + { previewConcurrency: 2 } + ) + + mounted.workspace.loadPreview('one') + mounted.workspace.loadPreview('one') + mounted.workspace.loadPreview('two') + mounted.workspace.loadPreview('three') + expect(loadPreview.mock.calls.map((call) => call[0])).toEqual(['one', 'two']) + + loads.get('one')?.resolve(new Uint8Array([1])) + await loads.get('one')?.promise + await flushTasks() + expect(loadPreview.mock.calls.map((call) => call[0])).toEqual(['one', 'two', 'three']) + expect(createObjectURL).toHaveBeenCalledTimes(1) + + mounted.unmount() + expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview') + + loads.get('two')?.resolve(new Uint8Array([2])) + loads.get('three')?.resolve(new Uint8Array([3])) + await Promise.all([loads.get('two')?.promise, loads.get('three')?.promise]) + await flushTasks() + expect(createObjectURL).toHaveBeenCalledTimes(1) + }) +}) From 4fe27dd1e6cf602fed54d968805b4251ddc0e74b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 10 Aug 2026 20:02:58 +0300 Subject: [PATCH 2/6] fix(storage): harden workspace previews - Validate ranged thumbnail payloads and S3 bounds - Invalidate stale previews and expose loading errors - Document the public document workspace composable --- .../programmable/sdk/api/composables/index.md | 4 ++ .../api/composables/use-document-workspace.md | 72 +++++++++++++++++++ packages/fig/src/thumbnail.ts | 21 +++++- packages/vue/src/document/workspace/use.ts | 42 ++++++++++- src/app/integrations/storage/s3/client.ts | 5 +- src/app/storage/workspace/events.ts | 4 +- src/app/storage/workspace/source.ts | 5 +- tests/e2e/storage/workspace.spec.ts | 28 +++++--- tests/engine/app/storage/persist.test.ts | 6 +- tests/engine/io/fig/thumbnail-range.test.ts | 10 +++ tests/engine/vue/document-workspace.test.ts | 56 +++++++++++++++ 11 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 packages/docs/programmable/sdk/api/composables/use-document-workspace.md diff --git a/packages/docs/programmable/sdk/api/composables/index.md b/packages/docs/programmable/sdk/api/composables/index.md index 925e916d0..ff08b7d8f 100644 --- a/packages/docs/programmable/sdk/api/composables/index.md +++ b/packages/docs/programmable/sdk/api/composables/index.md @@ -38,6 +38,10 @@ These are the main composables most `@open-pencil/vue` consumers will use. - [useStrokeControls](./use-stroke-controls) - [useEffectsControls](./use-effects-controls) +## Document workspaces + +- [useDocumentWorkspace](./use-document-workspace) + ## Variables, navigation, and localization - [useVariablesEditor](./use-variables-editor) diff --git a/packages/docs/programmable/sdk/api/composables/use-document-workspace.md b/packages/docs/programmable/sdk/api/composables/use-document-workspace.md new file mode 100644 index 000000000..dfca6ad1b --- /dev/null +++ b/packages/docs/programmable/sdk/api/composables/use-document-workspace.md @@ -0,0 +1,72 @@ +--- +title: useDocumentWorkspace +description: Manage document lists, refreshes, lazy previews, and workspace events. +--- + +# useDocumentWorkspace + +`useDocumentWorkspace()` provides headless state for document browsers backed by local or remote storage. + +It manages: + +- initial, manual, focused-window, reconnect, and interval refreshes +- deduplicated refresh requests and source invalidation events +- lazy preview loading with bounded concurrency +- preview object URL creation and cleanup +- per-preview errors + +## Usage + +```ts +import { useDocumentWorkspace } from '@open-pencil/vue' + +const workspace = useDocumentWorkspace({ + source: { + refresh: () => documentService.list(), + loadPreview: (id) => documentService.loadPreview(id), + subscribe: (listener) => documentService.subscribe(listener), + }, + refreshInterval: 60_000, +}) +``` + +The source must return items with `id`, `name`, and `updatedAt`. A changed `updatedAt` value invalidates an existing or in-flight preview. + +## Lazy previews + +Apply `previewDirective` to the element that should trigger loading, then resolve the current object URL with `previewURL()`: + +```vue + + + +``` + +When `IntersectionObserver` is unavailable, the directive loads the preview immediately. + +## Errors + +`error` contains the latest document-list refresh failure. Preview failures are available by document ID through `previewErrors`; use `onPreviewError` when errors should also be reported to an application service. + +```ts +const workspace = useDocumentWorkspace({ + source, + onPreviewError(id, error) { + reportPreviewError({ id, error }) + }, +}) +``` + +Calling `loadPreview(id)` retries a failed preview. Successful loads clear the corresponding preview error. + +## Cleanup + +The composable revokes generated object URLs and unsubscribes from the source when its component unmounts. Call `clearPreviews()` when switching an external workspace or provider without unmounting the component. diff --git a/packages/fig/src/thumbnail.ts b/packages/fig/src/thumbnail.ts index eb495b863..c181d7654 100644 --- a/packages/fig/src/thumbnail.ts +++ b/packages/fig/src/thumbnail.ts @@ -14,6 +14,7 @@ export type FigThumbnailLimits = { type ThumbnailEntry = { method: number compressedSize: number + outputSize: number localOffset: number } @@ -26,6 +27,7 @@ const DEFAULT_MAX_TAIL = 4 * 1024 * 1024 const DEFAULT_MAX_COMPRESSED = 8 * 1024 * 1024 const DEFAULT_MAX_OUTPUT = 16 * 1024 * 1024 const THUMBNAIL_NAME = 'thumbnail.png' +const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) function view(bytes: Uint8Array): DataView { return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) @@ -43,6 +45,10 @@ function boundedLimit(value: number | undefined, fallback: number): number { return Number.isFinite(value) && value && value > 0 ? value : fallback } +function hasPNGSignature(bytes: Uint8Array): boolean { + return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte) +} + function findThumbnailEntry( central: Uint8Array, maxCompressed: number, @@ -66,7 +72,12 @@ function findThumbnailEntry( const name = decoder.decode(central.subarray(offset + 46, offset + 46 + nameLength)) if (name === THUMBNAIL_NAME) { if (compressedSize > maxCompressed || outputSize > maxOutput) return null - return { method, compressedSize, localOffset: data.getUint32(offset + 42, true) } + return { + method, + compressedSize, + outputSize, + localOffset: data.getUint32(offset + 42, true) + } } offset = next } @@ -85,10 +96,14 @@ async function readEntryPayload( entry.localOffset + 30 + headerView.getUint16(26, true) + headerView.getUint16(28, true) if (dataStart + entry.compressedSize > reader.size) return null const compressed = await reader.read(dataStart, dataStart + entry.compressedSize) - if (entry.method === 0) return compressed.byteLength <= maxOutput ? compressed : null + if (entry.method === 0) { + return compressed.byteLength === entry.outputSize && hasPNGSignature(compressed) + ? compressed + : null + } if (entry.method !== 8) return null const output = inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) }) - return output.byteLength <= maxOutput ? output : null + return output.byteLength === entry.outputSize && hasPNGSignature(output) ? output : null } /** diff --git a/packages/vue/src/document/workspace/use.ts b/packages/vue/src/document/workspace/use.ts index 9745c078c..100b6f223 100644 --- a/packages/vue/src/document/workspace/use.ts +++ b/packages/vue/src/document/workspace/use.ts @@ -31,6 +31,7 @@ export type UseDocumentWorkspaceOptions = { refreshOnReconnect?: boolean previewConcurrency?: number previewMimeType?: string + onPreviewError?(id: string, error: unknown): void } export function useDocumentWorkspace( @@ -41,6 +42,7 @@ export function useDocumentWorkspace( const error = shallowRef(null) const lastRefreshedAt = shallowRef(null) const previewUrls = ref>({}) + const previewErrors = shallowRef>({}) const previewCleanups = new WeakMap void>() const previewGenerations = new Map() const previewQueue: string[] = [] @@ -64,9 +66,17 @@ export function useDocumentWorkspace( function reconcilePreviewUrls(items: readonly Item[]): void { const previousItems = new Map(documents.value.map((item) => [item.id, item.updatedAt])) const currentItems = new Map(items.map((item) => [item.id, item.updatedAt])) - for (const id of Object.keys(previewUrls.value)) { - if (previousItems.get(id) !== currentItems.get(id)) removePreviewURL(id) + const trackedIds = new Set([...Object.keys(previewUrls.value), ...activePreviews, ...queued]) + for (const id of trackedIds) { + if (previousItems.get(id) === currentItems.get(id)) continue + removePreviewURL(id) + if (!currentItems.has(id) || activePreviews.has(id)) continue + if (!queued.has(id)) { + queued.add(id) + previewQueue.push(id) + } } + drainPreviewQueue() } function clearPreviews(): void { @@ -94,6 +104,18 @@ export function useDocumentWorkspace( } } + function clearPreviewError(id: string): void { + if (!(id in previewErrors.value)) return + previewErrors.value = Object.fromEntries( + Object.entries(previewErrors.value).filter(([previewId]) => previewId !== id) + ) + } + + function recordPreviewError(id: string, error: unknown): void { + previewErrors.value = { ...previewErrors.value, [id]: error } + options.onPreviewError?.(id, error) + } + function drainPreviewQueue(): void { while (activePreviews.size < concurrency) { const id = previewQueue.shift() @@ -106,13 +128,25 @@ export function useDocumentWorkspace( .loadPreview(id) .then((bytes) => { if (bytes?.byteLength && generation === (previewGenerations.get(id) ?? 0)) { + clearPreviewError(id) replacePreviewURL(id, bytes) } return undefined }) - .catch(() => null) + .catch((error: unknown) => { + if (!disposed && generation === (previewGenerations.get(id) ?? 0)) { + recordPreviewError(id, error) + } + }) .finally(() => { activePreviews.delete(id) + if ( + !disposed && + generation !== (previewGenerations.get(id) ?? 0) && + documents.value.some((item) => item.id === id) + ) { + loadPreview(id) + } drainPreviewQueue() }) } @@ -120,6 +154,7 @@ export function useDocumentWorkspace( function loadPreview(id: string): void { if (previewUrls.value[id] || activePreviews.has(id) || queued.has(id)) return + clearPreviewError(id) queued.add(id) previewQueue.push(id) drainPreviewQueue() @@ -236,6 +271,7 @@ export function useDocumentWorkspace( error: readonly(error), lastRefreshedAt: readonly(lastRefreshedAt), previewUrls: readonly(previewUrls), + previewErrors: readonly(previewErrors), hasDocuments: computed(() => documents.value.length > 0), refresh, invalidate, diff --git a/src/app/integrations/storage/s3/client.ts b/src/app/integrations/storage/s3/client.ts index a13ff39ca..9b8e9f7f3 100644 --- a/src/app/integrations/storage/s3/client.ts +++ b/src/app/integrations/storage/s3/client.ts @@ -170,7 +170,9 @@ export async function headObjectSize( ): Promise { const res = await s3Request(config, objectURL(config, key), { method: 'HEAD' }) if (res.status === 404) return null - const size = Number(res.headers.get('content-length')) + const sizeHeader = res.headers.get('content-length') + if (sizeHeader == null) return null + const size = Number(sizeHeader) return Number.isSafeInteger(size) && size >= 0 ? size : null } @@ -182,6 +184,7 @@ export async function getObjectRange( ): Promise { if ( !Number.isSafeInteger(start) || + start < 0 || !Number.isSafeInteger(endExclusive) || endExclusive <= start ) { diff --git a/src/app/storage/workspace/events.ts b/src/app/storage/workspace/events.ts index 47589ff98..e6d112999 100644 --- a/src/app/storage/workspace/events.ts +++ b/src/app/storage/workspace/events.ts @@ -1,5 +1,7 @@ +import type { StorageProviderID } from '@/app/integrations/storage/types' + export type StorageWorkspaceEvent = { - providerId: string + providerId: StorageProviderID documentId?: string kind: 'changed' | 'synced' } diff --git a/src/app/storage/workspace/source.ts b/src/app/storage/workspace/source.ts index 6548ab36b..d93a50ee0 100644 --- a/src/app/storage/workspace/source.ts +++ b/src/app/storage/workspace/source.ts @@ -70,14 +70,15 @@ export function createStorageWorkspaceSource( }, async loadPreview(id: string): Promise { + const providerID = activeStorageProviderID.value const localStore = getLocalCanvasStore() const local = await localStore.readThumb(id) if (local?.byteLength) return local - const adapter = createActiveStorageAdapter() + const adapter = createActiveStorageAdapter(providerID) if (!adapter.getThumbnail) return null const remote = await adapter.getThumbnail(id) if (!remote?.byteLength) return null - await localStore.writeThumb(id, remote) + if (activeStorageProviderID.value === providerID) await localStore.writeThumb(id, remote) return remote } } diff --git a/tests/e2e/storage/workspace.spec.ts b/tests/e2e/storage/workspace.spec.ts index 157515045..232b1ed92 100644 --- a/tests/e2e/storage/workspace.spec.ts +++ b/tests/e2e/storage/workspace.spec.ts @@ -34,17 +34,26 @@ test('configured storage lists previews through ranges before opening the docume return } if (url.pathname.endsWith('/remote-1.fig') && route.request().headers().range) { - rangeGets++ - const match = route - .request() - .headers() - .range?.match(/^bytes=(\d+)-(\d+)$/) - if (!match) { + const range = route.request().headers().range + const explicit = range?.match(/^bytes=(\d+)-(\d+)$/) + const suffix = range?.match(/^bytes=-(\d+)$/) + let start: number + let end: number + if (explicit) { + start = Number(explicit[1]) + end = Math.min(Number(explicit[2]), fixture.byteLength - 1) + } else if (suffix) { + const length = Math.min(Number(suffix[1]), fixture.byteLength) + start = fixture.byteLength - length + end = fixture.byteLength - 1 + } else { + await route.fulfill({ status: 416 }) + return + } + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) { await route.fulfill({ status: 416 }) return } - const start = Number(match[1]) - const end = Number(match[2]) await route.fulfill({ status: 206, headers: { @@ -53,6 +62,7 @@ test('configured storage lists previews through ranges before opening the docume contentType: 'application/octet-stream', body: fixture.subarray(start, end + 1) }) + rangeGets++ return } if (url.pathname.endsWith('/remote-1.fig') && route.request().method() === 'HEAD') { @@ -91,7 +101,7 @@ test('configured storage lists previews through ranges before opening the docume const preview = page.locator('[data-document-id="remote-1"] img') await expect(preview).toBeVisible() await expect(preview).toHaveAttribute('src', /^blob:/) - expect(rangeGets).toBeGreaterThan(0) + expect(rangeGets).toBe(3) expect(fullDocumentGets).toBe(0) await page.locator('[data-document-id="remote-1"]').click() diff --git a/tests/engine/app/storage/persist.test.ts b/tests/engine/app/storage/persist.test.ts index d4cc4ff8c..6061844cb 100644 --- a/tests/engine/app/storage/persist.test.ts +++ b/tests/engine/app/storage/persist.test.ts @@ -47,6 +47,10 @@ describe('local-first storage persistence', () => { { store, enqueueCanvas } ) - expect((await store.readThumb('canvas-preview'))?.byteLength).toBeGreaterThan(0) + const thumbnail = await store.readThumb('canvas-preview') + expect(thumbnail?.byteLength).toBeGreaterThan(0) + expect(thumbnail?.subarray(0, 8)).toEqual( + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ) }) }) diff --git a/tests/engine/io/fig/thumbnail-range.test.ts b/tests/engine/io/fig/thumbnail-range.test.ts index d652177c2..e40596715 100644 --- a/tests/engine/io/fig/thumbnail-range.test.ts +++ b/tests/engine/io/fig/thumbnail-range.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' +import { zipSync } from 'fflate' + import { extractFigThumbnailFromReader } from '@open-pencil/fig' function memoryReader(bytes: Uint8Array, ranges: Array<[number, number]>) { @@ -31,6 +33,14 @@ describe('fig ranged thumbnail extraction', () => { ) }) + test('rejects malformed thumbnail payloads', async () => { + const bytes = zipSync({ + 'canvas.fig': new Uint8Array([1]), + 'thumbnail.png': new TextEncoder().encode('not a png') + }) + 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, []), { diff --git a/tests/engine/vue/document-workspace.test.ts b/tests/engine/vue/document-workspace.test.ts index a21765e63..1eb2523c5 100644 --- a/tests/engine/vue/document-workspace.test.ts +++ b/tests/engine/vue/document-workspace.test.ts @@ -158,6 +158,62 @@ describe('useDocumentWorkspace', () => { expect(sourceListener).toBeNull() }) + test('invalidates an in-flight preview when the document changes', async () => { + const previewLoad = deferred() + const freshPreviewLoad = deferred() + const refresh = vi + .fn<() => Promise>() + .mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'first' }]) + .mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'second' }]) + const loadPreview = vi + .fn<() => Promise>() + .mockImplementationOnce(() => previewLoad.promise) + .mockImplementationOnce(() => freshPreviewLoad.promise) + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') + const mounted = mountWorkspace({ refresh, loadPreview }) + await flushTasks() + + mounted.workspace.loadPreview('one') + expect(loadPreview).toHaveBeenCalledTimes(1) + await mounted.workspace.refresh() + previewLoad.resolve(new Uint8Array([1])) + await previewLoad.promise + await flushTasks() + + expect(createObjectURL).not.toHaveBeenCalled() + expect(loadPreview).toHaveBeenCalledTimes(2) + freshPreviewLoad.resolve(new Uint8Array([2])) + await freshPreviewLoad.promise + await flushTasks() + expect(createObjectURL).toHaveBeenCalledTimes(1) + mounted.unmount() + }) + + test('surfaces preview failures to consumers', async () => { + const failure = new Error('preview failed') + const onPreviewError = vi.fn() + const holder: WorkspaceHolder = { current: null } + const component = defineComponent({ + setup() { + holder.current = useDocumentWorkspace({ + source: { refresh: async () => [], loadPreview: async () => Promise.reject(failure) }, + refreshOnFocus: false, + refreshOnReconnect: false, + onPreviewError + }) + return () => h('div') + } + }) + const app = renderer.createApp(component) + app.mount(hostNode()) + holder.current?.loadPreview('one') + await flushTasks() + + expect(holder.current?.previewErrors.value.one).toBe(failure) + expect(onPreviewError).toHaveBeenCalledWith('one', failure) + app.unmount() + }) + test('deduplicates previews, limits concurrency, and revokes URLs on unmount', async () => { const loads = new Map>() const loadPreview = vi.fn((id: string) => { From 6d9aeea28ba62e0b84ce47fb791fd651a3691870 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 10 Aug 2026 20:42:56 +0300 Subject: [PATCH 3/6] refactor(vue): split workspace preview lifecycle --- .../vue/src/document/workspace/previews.ts | 182 +++++++++++++++++ packages/vue/src/document/workspace/use.ts | 193 ++---------------- tests/engine/vue/document-workspace.test.ts | 7 +- 3 files changed, 206 insertions(+), 176 deletions(-) create mode 100644 packages/vue/src/document/workspace/previews.ts diff --git a/packages/vue/src/document/workspace/previews.ts b/packages/vue/src/document/workspace/previews.ts new file mode 100644 index 000000000..2810ef0bf --- /dev/null +++ b/packages/vue/src/document/workspace/previews.ts @@ -0,0 +1,182 @@ +import { readonly, ref, shallowRef, type Directive, type Ref } from 'vue' + +import type { + DocumentWorkspaceItem, + DocumentWorkspaceSource, + UseDocumentWorkspaceOptions +} from './use' + +type DocumentPreviewsOptions = Pick< + UseDocumentWorkspaceOptions, + 'onPreviewError' | 'previewConcurrency' | 'previewMimeType' +> & { + documents: Readonly> + source: DocumentWorkspaceSource +} + +export function createDocumentPreviews( + options: DocumentPreviewsOptions +) { + const previewUrls = ref>({}) + const previewErrors = shallowRef>({}) + const previewCleanups = new WeakMap void>() + const previewGenerations = new Map() + const previewQueue: string[] = [] + const queued = new Set() + const active = new Set() + const concurrency = Math.max(1, Math.floor(options.previewConcurrency ?? 6)) + let disposed = false + + function removeURL(id: string): void { + previewGenerations.set(id, (previewGenerations.get(id) ?? 0) + 1) + const url = previewUrls.value[id] + if (!url) return + URL.revokeObjectURL(url) + previewUrls.value = Object.fromEntries( + Object.entries(previewUrls.value).filter(([previewId]) => previewId !== id) + ) + } + + function replaceURL(id: string, bytes: Uint8Array): void { + if (disposed) return + const previous = previewUrls.value[id] + if (previous) URL.revokeObjectURL(previous) + const blobBytes = Uint8Array.from(bytes) + previewUrls.value = { + ...previewUrls.value, + [id]: URL.createObjectURL( + new Blob([blobBytes.buffer], { type: options.previewMimeType ?? 'image/png' }) + ) + } + } + + function clearError(id: string): void { + if (!(id in previewErrors.value)) return + previewErrors.value = Object.fromEntries( + Object.entries(previewErrors.value).filter(([previewId]) => previewId !== id) + ) + } + + async function runLoad(id: string, generation: number): Promise { + try { + const bytes = await options.source.loadPreview(id) + if (bytes?.byteLength && generation === (previewGenerations.get(id) ?? 0)) { + clearError(id) + replaceURL(id, bytes) + } + } catch (error: unknown) { + if (!disposed && generation === (previewGenerations.get(id) ?? 0)) { + previewErrors.value = { ...previewErrors.value, [id]: error } + options.onPreviewError?.(id, error) + } + } finally { + active.delete(id) + if ( + !disposed && + generation !== (previewGenerations.get(id) ?? 0) && + options.documents.value.some((item) => item.id === id) + ) { + loadPreview(id) + } + drainQueue() + } + } + + function drainQueue(): void { + while (active.size < concurrency) { + const id = previewQueue.shift() + if (!id) break + queued.delete(id) + if (active.has(id) || previewUrls.value[id]) continue + active.add(id) + void runLoad(id, previewGenerations.get(id) ?? 0) + } + } + + function loadPreview(id: string): void { + if (previewUrls.value[id] || active.has(id) || queued.has(id)) return + clearError(id) + queued.add(id) + previewQueue.push(id) + drainQueue() + } + + function reconcile(previousItems: readonly Item[], items: readonly Item[]): void { + const previous = new Map(previousItems.map((item) => [item.id, item.updatedAt])) + const current = new Map(items.map((item) => [item.id, item.updatedAt])) + const trackedIds = new Set([...Object.keys(previewUrls.value), ...active, ...queued]) + 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 + queued.add(id) + previewQueue.push(id) + } + drainQueue() + } + + function clearPreviews(): void { + const ids = new Set([ + ...Object.keys(previewUrls.value), + ...active, + ...queued, + ...previewGenerations.keys() + ]) + for (const id of ids) removeURL(id) + previewQueue.length = 0 + queued.clear() + } + + function observePreview(element: Element | null, id: string): () => void { + if (!element || typeof IntersectionObserver === 'undefined') { + loadPreview(id) + return () => undefined + } + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + loadPreview(id) + observer.disconnect() + } + }, + { rootMargin: '240px' } + ) + observer.observe(element) + return () => observer.disconnect() + } + + function stopObserving(element: Element): void { + previewCleanups.get(element)?.() + previewCleanups.delete(element) + } + + const previewDirective: Directive = { + mounted(element, binding) { + previewCleanups.set(element, observePreview(element, binding.value)) + }, + updated(element, binding) { + if (binding.value === binding.oldValue) return + stopObserving(element) + previewCleanups.set(element, observePreview(element, binding.value)) + }, + unmounted(element) { + stopObserving(element) + } + } + + function dispose(): void { + disposed = true + clearPreviews() + } + + return { + previewUrls: readonly(previewUrls), + previewErrors: readonly(previewErrors), + clearPreviews, + dispose, + loadPreview, + previewDirective, + previewURL: (id: string) => previewUrls.value[id] ?? null, + reconcile + } +} diff --git a/packages/vue/src/document/workspace/use.ts b/packages/vue/src/document/workspace/use.ts index 100b6f223..4f9c5eb1e 100644 --- a/packages/vue/src/document/workspace/use.ts +++ b/packages/vue/src/document/workspace/use.ts @@ -1,17 +1,10 @@ import { useEventListener, useIntervalFn } from '@vueuse/core' -import { - computed, - onBeforeUnmount, - onMounted, - readonly, - ref, - shallowRef, - type Directive, - type Ref -} from 'vue' +import { computed, onBeforeUnmount, onMounted, readonly, ref, shallowRef, type Ref } from 'vue' import { IS_BROWSER } from '@open-pencil/core/constants' +import { createDocumentPreviews } from './previews' + export type DocumentWorkspaceItem = { id: string name: string @@ -41,166 +34,17 @@ export function useDocumentWorkspace( const loading = ref(false) const error = shallowRef(null) const lastRefreshedAt = shallowRef(null) - const previewUrls = ref>({}) - const previewErrors = shallowRef>({}) - const previewCleanups = new WeakMap void>() - const previewGenerations = new Map() - const previewQueue: string[] = [] - const queued = new Set() - const activePreviews = new Set() - const concurrency = Math.max(1, Math.floor(options.previewConcurrency ?? 6)) + const previews = createDocumentPreviews({ + documents: readonly(documents), + source: options.source, + previewConcurrency: options.previewConcurrency, + previewMimeType: options.previewMimeType, + onPreviewError: options.onPreviewError + }) let refreshPromise: Promise | null = null let refreshQueued = false let disposed = false - function removePreviewURL(id: string): void { - previewGenerations.set(id, (previewGenerations.get(id) ?? 0) + 1) - const url = previewUrls.value[id] - if (!url) return - URL.revokeObjectURL(url) - previewUrls.value = Object.fromEntries( - Object.entries(previewUrls.value).filter(([previewId]) => previewId !== id) - ) - } - - function reconcilePreviewUrls(items: readonly Item[]): void { - const previousItems = new Map(documents.value.map((item) => [item.id, item.updatedAt])) - const currentItems = new Map(items.map((item) => [item.id, item.updatedAt])) - const trackedIds = new Set([...Object.keys(previewUrls.value), ...activePreviews, ...queued]) - for (const id of trackedIds) { - if (previousItems.get(id) === currentItems.get(id)) continue - removePreviewURL(id) - if (!currentItems.has(id) || activePreviews.has(id)) continue - if (!queued.has(id)) { - queued.add(id) - previewQueue.push(id) - } - } - drainPreviewQueue() - } - - function clearPreviews(): void { - const ids = new Set([ - ...Object.keys(previewUrls.value), - ...activePreviews, - ...queued, - ...previewGenerations.keys() - ]) - for (const id of ids) removePreviewURL(id) - previewQueue.length = 0 - queued.clear() - } - - function replacePreviewURL(id: string, bytes: Uint8Array): void { - if (disposed) return - const previous = previewUrls.value[id] - if (previous) URL.revokeObjectURL(previous) - const blobBytes = Uint8Array.from(bytes) - previewUrls.value = { - ...previewUrls.value, - [id]: URL.createObjectURL( - new Blob([blobBytes.buffer], { type: options.previewMimeType ?? 'image/png' }) - ) - } - } - - function clearPreviewError(id: string): void { - if (!(id in previewErrors.value)) return - previewErrors.value = Object.fromEntries( - Object.entries(previewErrors.value).filter(([previewId]) => previewId !== id) - ) - } - - function recordPreviewError(id: string, error: unknown): void { - previewErrors.value = { ...previewErrors.value, [id]: error } - options.onPreviewError?.(id, error) - } - - function drainPreviewQueue(): void { - while (activePreviews.size < concurrency) { - const id = previewQueue.shift() - if (!id) break - queued.delete(id) - if (activePreviews.has(id) || previewUrls.value[id]) continue - activePreviews.add(id) - const generation = previewGenerations.get(id) ?? 0 - void options.source - .loadPreview(id) - .then((bytes) => { - if (bytes?.byteLength && generation === (previewGenerations.get(id) ?? 0)) { - clearPreviewError(id) - replacePreviewURL(id, bytes) - } - return undefined - }) - .catch((error: unknown) => { - if (!disposed && generation === (previewGenerations.get(id) ?? 0)) { - recordPreviewError(id, error) - } - }) - .finally(() => { - activePreviews.delete(id) - if ( - !disposed && - generation !== (previewGenerations.get(id) ?? 0) && - documents.value.some((item) => item.id === id) - ) { - loadPreview(id) - } - drainPreviewQueue() - }) - } - } - - function loadPreview(id: string): void { - if (previewUrls.value[id] || activePreviews.has(id) || queued.has(id)) return - clearPreviewError(id) - queued.add(id) - previewQueue.push(id) - drainPreviewQueue() - } - - function previewURL(id: string): string | null { - return previewUrls.value[id] ?? null - } - - function observePreview(element: Element | null, id: string): () => void { - if (!element || typeof IntersectionObserver === 'undefined') { - loadPreview(id) - return () => undefined - } - const observer = new IntersectionObserver( - (entries) => { - if (entries.some((entry) => entry.isIntersecting)) { - loadPreview(id) - observer.disconnect() - } - }, - { rootMargin: '240px' } - ) - observer.observe(element) - return () => observer.disconnect() - } - - function stopObservingPreview(element: Element): void { - previewCleanups.get(element)?.() - previewCleanups.delete(element) - } - - const previewDirective: Directive = { - mounted(element, binding) { - previewCleanups.set(element, observePreview(element, binding.value)) - }, - updated(element, binding) { - if (binding.value === binding.oldValue) return - stopObservingPreview(element) - previewCleanups.set(element, observePreview(element, binding.value)) - }, - unmounted(element) { - stopObservingPreview(element) - } - } - function refresh(): Promise { if (refreshPromise) return refreshPromise loading.value = true @@ -209,7 +53,7 @@ export function useDocumentWorkspace( .refresh() .then((items) => { if (!disposed) { - reconcilePreviewUrls(items) + previews.reconcile(documents.value, items) documents.value = items lastRefreshedAt.value = new Date() } @@ -262,7 +106,7 @@ export function useDocumentWorkspace( onBeforeUnmount(() => { unsubscribeSource?.() disposed = true - clearPreviews() + previews.dispose() }) return { @@ -270,15 +114,14 @@ export function useDocumentWorkspace( loading: readonly(loading), error: readonly(error), lastRefreshedAt: readonly(lastRefreshedAt), - previewUrls: readonly(previewUrls), - previewErrors: readonly(previewErrors), + previewUrls: previews.previewUrls, + previewErrors: previews.previewErrors, hasDocuments: computed(() => documents.value.length > 0), refresh, invalidate, - clearPreviews, - loadPreview, - observePreview, - previewDirective, - previewURL + clearPreviews: previews.clearPreviews, + loadPreview: previews.loadPreview, + previewDirective: previews.previewDirective, + previewURL: previews.previewURL } } diff --git a/tests/engine/vue/document-workspace.test.ts b/tests/engine/vue/document-workspace.test.ts index 1eb2523c5..6ff36a83c 100644 --- a/tests/engine/vue/document-workspace.test.ts +++ b/tests/engine/vue/document-workspace.test.ts @@ -196,7 +196,12 @@ describe('useDocumentWorkspace', () => { const component = defineComponent({ setup() { holder.current = useDocumentWorkspace({ - source: { refresh: async () => [], loadPreview: async () => Promise.reject(failure) }, + source: { + refresh: async () => [], + loadPreview: async () => { + throw failure + } + }, refreshOnFocus: false, refreshOnReconnect: false, onPreviewError From c2bcb21fcf5633c8fa6068d3f487ffe071e418ac Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 10 Aug 2026 21:18:36 +0300 Subject: [PATCH 4/6] fix(vue): preserve workspace item types --- packages/vue/src/document/workspace/use.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/vue/src/document/workspace/use.ts b/packages/vue/src/document/workspace/use.ts index 4f9c5eb1e..92f02c055 100644 --- a/packages/vue/src/document/workspace/use.ts +++ b/packages/vue/src/document/workspace/use.ts @@ -24,7 +24,7 @@ export type UseDocumentWorkspaceOptions = { refreshOnReconnect?: boolean previewConcurrency?: number previewMimeType?: string - onPreviewError?(id: string, error: unknown): void + onPreviewError?: (id: string, error: unknown) => void } export function useDocumentWorkspace( @@ -35,11 +35,13 @@ export function useDocumentWorkspace( const error = shallowRef(null) const lastRefreshedAt = shallowRef(null) const previews = createDocumentPreviews({ - documents: readonly(documents), + documents, source: options.source, previewConcurrency: options.previewConcurrency, previewMimeType: options.previewMimeType, onPreviewError: options.onPreviewError + ? (id, error) => options.onPreviewError?.(id, error) + : undefined }) let refreshPromise: Promise | null = null let refreshQueued = false From b21509cc8d015d05531aec324cd89a3eb70b20fc Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 10 Aug 2026 22:30:54 +0300 Subject: [PATCH 5/6] 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 --- packages/fig/src/thumbnail.ts | 10 +++++-- .../vue/src/document/workspace/previews.ts | 9 +++++-- packages/vue/src/document/workspace/use.ts | 2 +- src/app/storage/workspace/events.ts | 8 +++++- src/app/storage/workspace/source.ts | 2 ++ tests/engine/io/fig/thumbnail-range.test.ts | 26 +++++++++++++++++++ 6 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/fig/src/thumbnail.ts b/packages/fig/src/thumbnail.ts index c181d7654..ac1e77562 100644 --- a/packages/fig/src/thumbnail.ts +++ b/packages/fig/src/thumbnail.ts @@ -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 } /** diff --git a/packages/vue/src/document/workspace/previews.ts b/packages/vue/src/document/workspace/previews.ts index 2810ef0bf..67b45c171 100644 --- a/packages/vue/src/document/workspace/previews.ts +++ b/packages/vue/src/document/workspace/previews.ts @@ -86,7 +86,7 @@ export function createDocumentPreviews( 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( 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( for (const id of ids) removeURL(id) previewQueue.length = 0 queued.clear() + previewErrors.value = {} } function observePreview(element: Element | null, id: string): () => void { diff --git a/packages/vue/src/document/workspace/use.ts b/packages/vue/src/document/workspace/use.ts index 92f02c055..26df816cc 100644 --- a/packages/vue/src/document/workspace/use.ts +++ b/packages/vue/src/document/workspace/use.ts @@ -96,7 +96,7 @@ export function useDocumentWorkspace( } }, options.refreshInterval, - { immediate: false } + { immediate: true, immediateCallback: false } ) } diff --git a/src/app/storage/workspace/events.ts b/src/app/storage/workspace/events.ts index e6d112999..57e7b1e83 100644 --- a/src/app/storage/workspace/events.ts +++ b/src/app/storage/workspace/events.ts @@ -11,7 +11,13 @@ type StorageWorkspaceListener = (event: StorageWorkspaceEvent) => void const listeners = new Set() 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 { diff --git a/src/app/storage/workspace/source.ts b/src/app/storage/workspace/source.ts index d93a50ee0..a89e390bf 100644 --- a/src/app/storage/workspace/source.ts +++ b/src/app/storage/workspace/source.ts @@ -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 }, diff --git a/tests/engine/io/fig/thumbnail-range.test.ts b/tests/engine/io/fig/thumbnail-range.test.ts index e40596715..ff527920c 100644 --- a/tests/engine/io/fig/thumbnail-range.test.ts +++ b/tests/engine/io/fig/thumbnail-range.test.ts @@ -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, []), { From a6842fde47c066bbcc24ecac0fa552b52c310956 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 10 Aug 2026 22:47:00 +0300 Subject: [PATCH 6/6] fix(vue): discard stale workspace results --- .../vue/src/document/workspace/previews.ts | 19 ++++++++++++++++--- packages/vue/src/document/workspace/use.ts | 4 ++-- src/app/storage/workspace/source.ts | 6 +++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/vue/src/document/workspace/previews.ts b/packages/vue/src/document/workspace/previews.ts index 67b45c171..e4a613abf 100644 --- a/packages/vue/src/document/workspace/previews.ts +++ b/packages/vue/src/document/workspace/previews.ts @@ -24,7 +24,10 @@ export function createDocumentPreviews( const previewQueue: string[] = [] const queued = new Set() const active = new Set() - const concurrency = Math.max(1, Math.floor(options.previewConcurrency ?? 6)) + const requestedConcurrency = options.previewConcurrency ?? 6 + const concurrency = Number.isFinite(requestedConcurrency) + ? Math.max(1, Math.floor(requestedConcurrency)) + : 6 let disposed = false function removeURL(id: string): void { @@ -67,7 +70,11 @@ export function createDocumentPreviews( } catch (error: unknown) { if (!disposed && generation === (previewGenerations.get(id) ?? 0)) { previewErrors.value = { ...previewErrors.value, [id]: error } - options.onPreviewError?.(id, error) + try { + options.onPreviewError?.(id, error) + } catch (callbackError) { + console.error('[Vue] Preview error callback failed:', callbackError) + } } } finally { active.delete(id) @@ -104,10 +111,16 @@ export function createDocumentPreviews( function reconcile(previousItems: readonly Item[], items: readonly Item[]): void { const previous = new Map(previousItems.map((item) => [item.id, item.updatedAt])) const current = new Map(items.map((item) => [item.id, item.updatedAt])) - const trackedIds = new Set([...Object.keys(previewUrls.value), ...active, ...queued]) + const trackedIds = new Set([ + ...Object.keys(previewUrls.value), + ...active, + ...queued, + ...Object.keys(previewErrors.value) + ]) for (const id of trackedIds) { if (previous.get(id) === current.get(id)) continue removeURL(id) + clearError(id) if (!current.has(id)) { queued.delete(id) continue diff --git a/packages/vue/src/document/workspace/use.ts b/packages/vue/src/document/workspace/use.ts index 26df816cc..b364f1905 100644 --- a/packages/vue/src/document/workspace/use.ts +++ b/packages/vue/src/document/workspace/use.ts @@ -12,7 +12,7 @@ export type DocumentWorkspaceItem = { } export interface DocumentWorkspaceSource { - refresh(): Promise + refresh(): Promise loadPreview(id: string): Promise subscribe?(listener: () => void): () => void } @@ -54,7 +54,7 @@ export function useDocumentWorkspace( const nextRefresh = options.source .refresh() .then((items) => { - if (!disposed) { + if (!disposed && items) { previews.reconcile(documents.value, items) documents.value = items lastRefreshedAt.value = new Date() diff --git a/src/app/storage/workspace/source.ts b/src/app/storage/workspace/source.ts index a89e390bf..ab43cc272 100644 --- a/src/app/storage/workspace/source.ts +++ b/src/app/storage/workspace/source.ts @@ -25,7 +25,7 @@ export function createStorageWorkspaceSource( }) }, - async refresh(): Promise { + async refresh(): Promise { const providerID = activeStorageProviderID.value const provider = storageProviderRegistry.get(providerID) const statuses = await storageCredentialStatuses(providerID) @@ -47,7 +47,7 @@ export function createStorageWorkspaceSource( updatedAt: metadata.updatedAt, metadataAuthoritative: true })) - if (activeStorageProviderID.value !== providerID) return [] + if (activeStorageProviderID.value !== providerID) return null onSnapshot({ documents, configured }) return documents } @@ -66,7 +66,7 @@ export function createStorageWorkspaceSource( lastSyncError: null }) } - if (activeStorageProviderID.value !== providerID) return [] + if (activeStorageProviderID.value !== providerID) return null onSnapshot({ documents: reconciliation.documents, configured }) return reconciliation.documents },