From 6d9aeea28ba62e0b84ce47fb791fd651a3691870 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 10 Aug 2026 20:42:56 +0300 Subject: [PATCH] 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