openpencil/packages/vue/src/document/workspace/use.ts
Danila Poyarkov b21509cc8d 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
2026-08-10 22:30:54 +03:00

130 lines
3.8 KiB
TypeScript

import { useEventListener, useIntervalFn } from '@vueuse/core'
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
updatedAt: string
}
export interface DocumentWorkspaceSource<Item extends DocumentWorkspaceItem> {
refresh(): Promise<Item[]>
loadPreview(id: string): Promise<Uint8Array | null>
subscribe?(listener: () => void): () => void
}
export type UseDocumentWorkspaceOptions<Item extends DocumentWorkspaceItem> = {
source: DocumentWorkspaceSource<Item>
refreshInterval?: number
refreshOnFocus?: boolean
refreshOnReconnect?: boolean
previewConcurrency?: number
previewMimeType?: string
onPreviewError?: (id: string, error: unknown) => void
}
export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
options: UseDocumentWorkspaceOptions<Item>
) {
const documents = shallowRef<Item[]>([])
const loading = ref(false)
const error = shallowRef<unknown>(null)
const lastRefreshedAt = shallowRef<Date | null>(null)
const previews = createDocumentPreviews({
documents,
source: options.source,
previewConcurrency: options.previewConcurrency,
previewMimeType: options.previewMimeType,
onPreviewError: options.onPreviewError
? (id, error) => options.onPreviewError?.(id, error)
: undefined
})
let refreshPromise: Promise<void> | null = null
let refreshQueued = false
let disposed = false
function refresh(): Promise<void> {
if (refreshPromise) return refreshPromise
loading.value = true
error.value = null
const nextRefresh = options.source
.refresh()
.then((items) => {
if (!disposed) {
previews.reconcile(documents.value, 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<void> {
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: true, immediateCallback: false }
)
}
let unsubscribeSource: (() => void) | null = null
onMounted(() => {
unsubscribeSource = options.source.subscribe?.(() => void invalidate()) ?? null
void refresh()
})
onBeforeUnmount(() => {
unsubscribeSource?.()
disposed = true
previews.dispose()
})
return {
documents: readonly(documents) as Readonly<Ref<readonly Item[]>>,
loading: readonly(loading),
error: readonly(error),
lastRefreshedAt: readonly(lastRefreshedAt),
previewUrls: previews.previewUrls,
previewErrors: previews.previewErrors,
hasDocuments: computed(() => documents.value.length > 0),
refresh,
invalidate,
clearPreviews: previews.clearPreviews,
loadPreview: previews.loadPreview,
previewDirective: previews.previewDirective,
previewURL: previews.previewURL
}
}