diff --git a/packages/common/infra/src/framework/core/event.ts b/packages/common/infra/src/framework/core/event.ts index 6fe5b6f39..c5b2b8e3b 100644 --- a/packages/common/infra/src/framework/core/event.ts +++ b/packages/common/infra/src/framework/core/event.ts @@ -48,16 +48,16 @@ export class EventBus { this.listeners[event.id].push(listener); const off = this.parent?.on(event, listener); return () => { - this.off(event, listener); off?.(); + this.off(event.id, listener); }; } - off(event: FrameworkEvent, listener: (event: T) => void) { - if (!this.listeners[event.id]) { + private off(eventId: string, listener: (event: any) => void) { + if (!this.listeners[eventId]) { return; } - this.listeners[event.id] = this.listeners[event.id].filter( + this.listeners[eventId] = this.listeners[eventId].filter( l => l !== listener ); } @@ -76,6 +76,15 @@ export class EventBus { } }); } + + dispose(): void { + for (const eventId of Object.keys(this.listeners)) { + for (const listener of this.listeners[eventId]) { + this.parent?.off(eventId, listener); + } + } + this.listeners = {}; + } } interface EventHandler { diff --git a/packages/common/infra/src/framework/core/provider.ts b/packages/common/infra/src/framework/core/provider.ts index d64c75a65..2c8cd662e 100644 --- a/packages/common/infra/src/framework/core/provider.ts +++ b/packages/common/infra/src/framework/core/provider.ts @@ -130,6 +130,7 @@ export class ComponentCachePool { } } } + this.cache.clear(); } [Symbol.dispose]() { @@ -318,5 +319,6 @@ export class BasicFrameworkProvider extends FrameworkProvider { } this.disposed = true; this.cache.dispose(); + this.eventBus.dispose(); } } diff --git a/packages/common/infra/src/modules/workspace/entities/workspace.ts b/packages/common/infra/src/modules/workspace/entities/workspace.ts index 8eaac10a3..0d01fc62d 100644 --- a/packages/common/infra/src/modules/workspace/entities/workspace.ts +++ b/packages/common/infra/src/modules/workspace/entities/workspace.ts @@ -82,4 +82,8 @@ export class Workspace extends Entity { }), undefined ); + + override dispose(): void { + this.docCollection.dispose(); + } } diff --git a/packages/common/infra/src/modules/workspace/services/repo.ts b/packages/common/infra/src/modules/workspace/services/repo.ts index 5ff50fcf0..1fee2f1cf 100644 --- a/packages/common/infra/src/modules/workspace/services/repo.ts +++ b/packages/common/infra/src/modules/workspace/services/repo.ts @@ -49,7 +49,7 @@ export class WorkspaceRepositoryService extends Service { return { workspace, dispose: () => { - workspace.dispose(); + workspace.scope.dispose(); }, }; } diff --git a/packages/common/infra/src/modules/workspace/services/workspace.ts b/packages/common/infra/src/modules/workspace/services/workspace.ts index f431deef3..40ae067de 100644 --- a/packages/common/infra/src/modules/workspace/services/workspace.ts +++ b/packages/common/infra/src/modules/workspace/services/workspace.ts @@ -10,4 +10,8 @@ export class WorkspaceService extends Service { } return this._workspace; } + + override dispose(): void { + this._workspace?.dispose(); + } } diff --git a/packages/common/infra/src/modules/workspace/testing/testing-provider.ts b/packages/common/infra/src/modules/workspace/testing/testing-provider.ts index 8ae30dcbc..d71b78532 100644 --- a/packages/common/infra/src/modules/workspace/testing/testing-provider.ts +++ b/packages/common/infra/src/modules/workspace/testing/testing-provider.ts @@ -70,6 +70,8 @@ class TestingWorkspaceLocalProvider implements WorkspaceFlavourProvider { const list = this.workspaceListStore.get('list') ?? []; this.workspaceListStore.set('list', [...list, meta]); + docCollection.dispose(); + return { id, flavour: 'local' }; } workspaces$ = LiveData.from( @@ -94,6 +96,8 @@ class TestingWorkspaceLocalProvider implements WorkspaceFlavourProvider { applyUpdate(bs.doc, data); + bs.dispose(); + return { name: bs.meta.name, avatar: bs.meta.avatar, diff --git a/packages/frontend/component/src/utils/index.ts b/packages/frontend/component/src/utils/index.ts index 6029e6564..8aab883c1 100644 --- a/packages/frontend/component/src/utils/index.ts +++ b/packages/frontend/component/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './observe-intersection'; export * from './observe-resize'; export { startScopedViewTransition } from './view-transition'; export * from './with-unit'; diff --git a/packages/frontend/component/src/utils/observe-intersection.ts b/packages/frontend/component/src/utils/observe-intersection.ts new file mode 100644 index 000000000..b42860e12 --- /dev/null +++ b/packages/frontend/component/src/utils/observe-intersection.ts @@ -0,0 +1,77 @@ +type ObserveIntersection = { + callback: (entity: IntersectionObserverEntry) => void; + dispose: () => void; +}; + +let _intersectionObserver: IntersectionObserver | null = null; +const elementsMap = new WeakMap>(); + +// for debugging +if (typeof window !== 'undefined') { + (window as any)._intersectionObserverElementsMap = elementsMap; +} + +/** + * @internal get or initialize the IntersectionObserver instance + */ +const getIntersectionObserver = () => + (_intersectionObserver ??= new IntersectionObserver(entries => { + entries.forEach(entry => { + const listeners = elementsMap.get(entry.target) ?? []; + listeners.forEach(({ callback }) => callback(entry)); + }); + })); + +/** + * @internal remove element's specific listener + */ +const removeListener = (element: Element, listener: ObserveIntersection) => { + if (!element) return; + const listeners = elementsMap.get(element) ?? []; + const observer = getIntersectionObserver(); + // remove the listener from the element + if (listeners.includes(listener)) { + elementsMap.set( + element, + listeners.filter(l => l !== listener) + ); + } + // if no more listeners, unobserve the element + if (elementsMap.get(element)?.length === 0) { + observer.unobserve(element); + elementsMap.delete(element); + } +}; + +/** + * A function to observe the intersection of an element use global IntersectionObserver. + * + * ```ts + * useEffect(() => { + * const dispose1 = observeIntersection(elRef1.current, (entry) => {}); + * const dispose2 = observeIntersection(elRef2.current, (entry) => {}); + * + * return () => { + * dispose1(); + * dispose2(); + * }; + * }, []) + * ``` + * @return A function to dispose the observer. + */ +export const observeIntersection = ( + element: Element, + callback: ObserveIntersection['callback'] +) => { + const observer = getIntersectionObserver(); + if (!elementsMap.has(element)) { + observer.observe(element); + } + const prevListeners = elementsMap.get(element) ?? []; + const listener = { callback, dispose: () => {} }; + listener.dispose = () => removeListener(element, listener); + + elementsMap.set(element, [...prevListeners, listener]); + + return listener.dispose; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/_common/utils/markdown-utils.ts b/packages/frontend/core/src/blocksuite/presets/_common/utils/markdown-utils.ts index b67975588..3892190d3 100644 --- a/packages/frontend/core/src/blocksuite/presets/_common/utils/markdown-utils.ts +++ b/packages/frontend/core/src/blocksuite/presets/_common/utils/markdown-utils.ts @@ -194,6 +194,7 @@ export async function markDownToDoc( const collection = new DocCollection({ schema, }); + collection.awarenessStore.awareness.destroy(); collection.meta.initialize(); const middlewares = [defaultImageProxyMiddleware]; if (additionalMiddlewares) { diff --git a/packages/frontend/core/src/blocksuite/presets/ai/messages/slides-renderer.ts b/packages/frontend/core/src/blocksuite/presets/ai/messages/slides-renderer.ts index a6d680e1a..708ff7aa0 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/messages/slides-renderer.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/messages/slides-renderer.ts @@ -54,6 +54,8 @@ export class AISlidesRenderer extends WithDisposable(LitElement) { private _doc!: Doc; + private _docCollection: DocCollection | null = null; + @query('editor-host') private accessor _editorHost!: EditorHost; @@ -233,6 +235,12 @@ export class AISlidesRenderer extends WithDisposable(LitElement) { doc.resetHistory(); this._doc = doc; + this._docCollection = collection; + } + + override disconnectedCallback(): void { + this._docCollection?.dispose(); + super.disconnectedCallback(); } } diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx index 504166e46..85809fa7a 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx @@ -12,9 +12,8 @@ import { import { DisposableGroup } from '@blocksuite/affine/global/utils'; import type { AffineEditorContainer } from '@blocksuite/affine/presets'; import type { Doc } from '@blocksuite/affine/store'; -import { use } from 'foxact/use'; import type { CSSProperties } from 'react'; -import { Suspense, useEffect } from 'react'; +import { useEffect, useState } from 'react'; import type { DefaultOpenProperty } from '../../doc-properties'; import { BlocksuiteEditorContainer } from './blocksuite-editor-container'; @@ -31,24 +30,6 @@ export type EditorProps = { className?: string; }; -function usePageRoot(page: Doc) { - if (!page.root) { - use( - new Promise((resolve, reject) => { - const disposable = page.slots.rootAdded.once(() => { - resolve(); - }); - window.setTimeout(() => { - disposable.dispose(); - reject(new NoPageRootError(page)); - }, 20 * 1000); - }) - ); - } - - return page.root; -} - const BlockSuiteEditorImpl = ({ mode, page, @@ -58,8 +39,6 @@ const BlockSuiteEditorImpl = ({ onEditorReady, defaultOpenProperty, }: EditorProps) => { - usePageRoot(page); - useEffect(() => { const disposable = page.slots.blockUpdated.once(() => { page.collection.setDocMeta(page.id, { @@ -142,9 +121,33 @@ const BlockSuiteEditorImpl = ({ }; export const BlockSuiteEditor = (props: EditorProps) => { - return ( - }> - - + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (props.page.root) { + setIsLoading(false); + return; + } + const disposable = props.page.slots.rootAdded.once(() => { + setIsLoading(false); + }); + window.setTimeout(() => { + disposable.dispose(); + setError(new NoPageRootError(props.page)); + }, 20 * 1000); + return () => { + disposable.dispose(); + }; + }, [props.page]); + + if (error) { + throw error; + } + + return isLoading ? ( + + ) : ( + ); }; diff --git a/packages/frontend/core/src/components/page-list/page-content-preview.tsx b/packages/frontend/core/src/components/page-list/page-content-preview.tsx index 644d807df..bae1604b6 100644 --- a/packages/frontend/core/src/components/page-list/page-content-preview.tsx +++ b/packages/frontend/core/src/components/page-list/page-content-preview.tsx @@ -1,32 +1,31 @@ -import type { DocCollection } from '@blocksuite/affine/store'; -import { useAtomValue } from 'jotai'; -import { type ReactNode, Suspense } from 'react'; - -import { useBlockSuitePagePreview } from './use-block-suite-page-preview'; -import { useDocCollectionPage } from './use-block-suite-workspace-page'; +import { DocsSearchService } from '@affine/core/modules/docs-search'; +import { LiveData, useLiveData, useService } from '@toeverything/infra'; +import { type ReactNode, useMemo } from 'react'; interface PagePreviewProps { - docCollection: DocCollection; pageId: string; emptyFallback?: ReactNode; + fallback?: ReactNode; } const PagePreviewInner = ({ - docCollection: workspace, pageId, emptyFallback, + fallback, }: PagePreviewProps) => { - const page = useDocCollectionPage(workspace, pageId); - const previewAtom = useBlockSuitePagePreview(page); - const preview = useAtomValue(previewAtom); - const res = preview ? preview : null; - return res || emptyFallback; + const docSummary = useService(DocsSearchService); + const summary = useLiveData( + useMemo( + () => LiveData.from(docSummary.watchDocSummary(pageId), null), + [docSummary, pageId] + ) + ); + + const res = + summary === null ? fallback : summary === '' ? emptyFallback : summary; + return res; }; export const PagePreview = (props: PagePreviewProps) => { - return ( - - - - ); + return ; }; diff --git a/packages/frontend/core/src/components/page-list/page-group.tsx b/packages/frontend/core/src/components/page-list/page-group.tsx index 9f2a7984e..7f6524c92 100644 --- a/packages/frontend/core/src/components/page-list/page-group.tsx +++ b/packages/frontend/core/src/components/page-list/page-group.tsx @@ -317,9 +317,7 @@ function pageMetaToListItemProp( pageId: item.id, pageIds, title: , - preview: ( - - ), + preview: , createDate: new Date(item.createDate), updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined, to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined, diff --git a/packages/frontend/core/src/mobile/components/doc-card/index.tsx b/packages/frontend/core/src/mobile/components/doc-card/index.tsx index 7c4256bd2..c4dcec17f 100644 --- a/packages/frontend/core/src/mobile/components/doc-card/index.tsx +++ b/packages/frontend/core/src/mobile/components/doc-card/index.tsx @@ -1,4 +1,4 @@ -import { IconButton } from '@affine/component'; +import { IconButton, observeIntersection, Skeleton } from '@affine/component'; import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook'; import { PagePreview } from '@affine/core/components/page-list/page-content-preview'; import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; @@ -10,9 +10,16 @@ import { } from '@affine/core/modules/workbench'; import { useI18n } from '@affine/i18n'; import type { DocMeta } from '@blocksuite/affine/store'; -import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; +import { useLiveData, useService } from '@toeverything/infra'; import clsx from 'clsx'; -import { forwardRef, type ReactNode, useMemo } from 'react'; +import { + forwardRef, + type ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import * as styles from './styles.css'; import { DocCardTags } from './tag'; @@ -38,11 +45,11 @@ export interface DocCardProps extends Omit { export const DocCard = forwardRef( function DocCard( { showTags = true, meta, className, autoHeightById, ...attrs }, - ref + outerRef ) { + const containerRef = useRef(null); const t = useI18n(); const favAdapter = useService(CompatibleFavoriteItemsAdapter); - const workspace = useService(WorkspaceService).workspace; const docDisplayService = useService(DocDisplayMetaService); const titleInfo = useLiveData(docDisplayService.title$(meta.id)); const title = @@ -64,13 +71,35 @@ export const DocCard = forwardRef( return { height: `${rows * 18}px` }; }, [autoHeightById, meta.id]); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!containerRef.current) return; + + const dispose = observeIntersection(containerRef.current, entry => { + setVisible(entry.isIntersecting); + }); + + return () => { + dispose(); + }; + }, []); + return ( { + containerRef.current = ref; + if (typeof outerRef === 'function') { + outerRef(ref); + } else if (outerRef) { + outerRef.current = ref; + } + }} className={clsx(styles.card, className)} data-testid="doc-card" data-doc-id={meta.id} + data-visible={visible} {...attrs} >
@@ -83,11 +112,18 @@ export const DocCard = forwardRef( />
- Empty} - /> + {visible && ( + + + + + } + pageId={meta.id} + emptyFallback={
Empty
} + /> + )}
{showTags ? : null}
diff --git a/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts b/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts index 205e5920f..392613cd1 100644 --- a/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts +++ b/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts @@ -36,7 +36,7 @@ export class DocsIndexer extends Entity { /** * increase this number to re-index all docs */ - static INDEXER_VERSION = 10; + static INDEXER_VERSION = 11; private readonly jobQueue: JobQueue = new IndexedDBJobQueue( @@ -85,24 +85,26 @@ export class DocsIndexer extends Entity { } setupListener() { - this.workspaceEngine.doc.storage.eventBus.on(event => { - if (WorkspaceDBService.isDBDocId(event.docId)) { - // skip db doc - return; - } - if (event.clientId === this.workspaceEngine.doc.clientId) { - this.jobQueue - .enqueue([ - { - batchKey: event.docId, - payload: { storageDocId: event.docId }, - }, - ]) - .catch(err => { - console.error('Error enqueueing job', err); - }); - } - }); + this.disposables.push( + this.workspaceEngine.doc.storage.eventBus.on(event => { + if (WorkspaceDBService.isDBDocId(event.docId)) { + // skip db doc + return; + } + if (event.clientId === this.workspaceEngine.doc.clientId) { + this.jobQueue + .enqueue([ + { + batchKey: event.docId, + payload: { storageDocId: event.docId }, + }, + ]) + .catch(err => { + console.error('Error enqueueing job', err); + }); + } + }) + ); } async execJob(jobs: Job[], signal: AbortSignal) { @@ -298,6 +300,8 @@ export class DocsIndexer extends Entity { } override dispose(): void { + super.dispose(); this.runner.stop(); + this.worker?.dispose(); } } diff --git a/packages/frontend/core/src/modules/docs-search/schema.ts b/packages/frontend/core/src/modules/docs-search/schema.ts index f7f1e6dc4..d50d52b63 100644 --- a/packages/frontend/core/src/modules/docs-search/schema.ts +++ b/packages/frontend/core/src/modules/docs-search/schema.ts @@ -1,6 +1,7 @@ import { defineSchema } from '@toeverything/infra'; export const docIndexSchema = defineSchema({ + docId: 'String', title: 'FullText', // summary of the doc, used for preview summary: { type: 'String', index: false }, diff --git a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts index f95f353cd..0eb1af2ce 100644 --- a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts +++ b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts @@ -632,10 +632,31 @@ export class DocsSearchService extends Service { ); } - async getDocTitle(docId: string) { - const doc = await this.indexer.docIndex.get(docId); - const title = doc?.get('title'); - return typeof title === 'string' ? title : title?.[0]; + watchDocSummary(docId: string) { + return this.indexer.docIndex + .search$( + { + type: 'match', + field: 'docId', + match: docId, + }, + { + fields: ['summary'], + pagination: { + limit: 1, + }, + } + ) + .pipe( + map(({ nodes }) => { + const node = nodes.at(0); + return ( + (typeof node?.fields.summary === 'string' + ? node?.fields.summary + : node?.fields.summary[0]) ?? null + ); + }) + ); } override dispose(): void { diff --git a/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts b/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts index ae2ca53e4..067c503f1 100644 --- a/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts +++ b/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts @@ -105,6 +105,11 @@ const bookmarkFlavours = new Set([ 'affine:embed-loom', ]); +const markdownPreviewDocCollection = new DocCollection({ + id: 'indexer', + schema: blocksuiteSchema, +}); + function generateMarkdownPreviewBuilder( yRootDoc: YDoc, workspaceId: string, @@ -164,10 +169,7 @@ function generateMarkdownPreviewBuilder( const markdownAdapter = new MarkdownAdapter( new Job({ - collection: new DocCollection({ - id: 'indexer', - schema: blocksuiteSchema, - }), + collection: markdownPreviewDocCollection, middlewares: [docLinkBaseURLMiddleware, titleMiddleware], }) ); @@ -875,6 +877,7 @@ async function crawlingDocData({ { id: docId, doc: Document.from(docId, { + docId, title: docTitle, summary, }), diff --git a/packages/frontend/core/src/modules/docs-search/worker/out-worker.ts b/packages/frontend/core/src/modules/docs-search/worker/out-worker.ts index a41d3a313..4c2de8721 100644 --- a/packages/frontend/core/src/modules/docs-search/worker/out-worker.ts +++ b/packages/frontend/core/src/modules/docs-search/worker/out-worker.ts @@ -95,8 +95,8 @@ export async function createWorker(abort: AbortSignal) { }); }, dispose: () => { - worker.terminate(); terminateAbort.abort(MANUALLY_STOP); + worker.terminate(); }, }; } diff --git a/packages/frontend/core/src/modules/permissions/entities/permission.ts b/packages/frontend/core/src/modules/permissions/entities/permission.ts index 3f2c2c424..412ced8e7 100644 --- a/packages/frontend/core/src/modules/permissions/entities/permission.ts +++ b/packages/frontend/core/src/modules/permissions/entities/permission.ts @@ -159,4 +159,8 @@ export class WorkspacePermission extends Entity { permission ); } + + override dispose(): void { + this.revalidate.unsubscribe(); + } } diff --git a/packages/frontend/core/src/modules/permissions/services/permission.ts b/packages/frontend/core/src/modules/permissions/services/permission.ts index 35560c094..092e08fad 100644 --- a/packages/frontend/core/src/modules/permissions/services/permission.ts +++ b/packages/frontend/core/src/modules/permissions/services/permission.ts @@ -15,6 +15,10 @@ export class WorkspacePermissionService extends Service { super(); } + override dispose(): void { + this.permission?.dispose(); + } + async leaveWorkspace() { await this.store.leaveWorkspace(this.workspaceService.workspace.id); this.workspacesService.list.revalidate(); diff --git a/packages/frontend/core/src/modules/quota/services/quota.ts b/packages/frontend/core/src/modules/quota/services/quota.ts index a92997e04..808a6e733 100644 --- a/packages/frontend/core/src/modules/quota/services/quota.ts +++ b/packages/frontend/core/src/modules/quota/services/quota.ts @@ -4,4 +4,8 @@ import { WorkspaceQuota } from '../entities/quota'; export class WorkspaceQuotaService extends Service { quota = this.framework.createEntity(WorkspaceQuota); + + override dispose(): void { + this.quota.dispose(); + } } diff --git a/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts b/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts index d0bb50532..6a29d4b93 100644 --- a/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts +++ b/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts @@ -66,4 +66,8 @@ export class ShareDocsList extends Entity { ) ) ); + + override dispose(): void { + this.revalidate.unsubscribe(); + } } diff --git a/packages/frontend/core/src/modules/share-doc/services/share-docs-list.ts b/packages/frontend/core/src/modules/share-doc/services/share-docs-list.ts index 714b5e018..b291fcf87 100644 --- a/packages/frontend/core/src/modules/share-doc/services/share-docs-list.ts +++ b/packages/frontend/core/src/modules/share-doc/services/share-docs-list.ts @@ -12,4 +12,8 @@ export class ShareDocsListService extends Service { this.workspaceService.workspace.flavour !== 'local' ? this.framework.createEntity(ShareDocsList) : null; + + override dispose(): void { + this.shareDocs?.dispose(); + } } diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts index 1292242ff..19559bfd6 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts @@ -30,7 +30,7 @@ import { import { isEqual } from 'lodash-es'; import { nanoid } from 'nanoid'; import { EMPTY, map, mergeMap, Observable, switchMap } from 'rxjs'; -import { applyUpdate, encodeStateAsUpdate } from 'yjs'; +import { encodeStateAsUpdate } from 'yjs'; import type { Server, ServersService } from '../../cloud'; import { @@ -48,6 +48,7 @@ import { CloudBlobStorage } from './engine/blob-cloud'; import { StaticBlobStorage } from './engine/blob-static'; import { CloudDocEngineServer } from './engine/doc-cloud'; import { CloudStaticDocStorage } from './engine/doc-cloud-static'; +import { getWorkspaceProfileWorker } from './out-worker'; const getCloudWorkspaceCacheKey = (serverId: string) => { if (serverId === 'affine-cloud') { @@ -123,21 +124,25 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { }, }); - // apply initial state - await initial(docCollection, blobStorage, docStorage); + try { + // apply initial state + await initial(docCollection, blobStorage, docStorage); - // save workspace to local storage, should be vary fast - await docStorage.doc.set( - workspaceId, - encodeStateAsUpdate(docCollection.doc) - ); - for (const subdocs of docCollection.doc.getSubdocs()) { - await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); + // save workspace to local storage, should be vary fast + await docStorage.doc.set( + workspaceId, + encodeStateAsUpdate(docCollection.doc) + ); + for (const subdocs of docCollection.doc.getSubdocs()) { + await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); + } + + this.revalidate(); + await this.waitForLoaded(); + } finally { + docCollection.dispose(); } - this.revalidate(); - await this.waitForLoaded(); - return { id: workspaceId, flavour: this.server.id, @@ -229,7 +234,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { const docStorage = this.storageProvider.getDocStorage(id); // download root doc const localData = await docStorage.doc.get(id); - const cloudData = await cloudStorage.pull(id); + const cloudData = (await cloudStorage.pull(id))?.data; const info = await this.getWorkspaceInfo(id, signal); @@ -241,17 +246,16 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { }; } - const bs = new DocCollection({ - id, - schema: getAFFiNEWorkspaceSchema(), - }); + const client = getWorkspaceProfileWorker(); - if (localData) applyUpdate(bs.doc, localData); - if (cloudData) applyUpdate(bs.doc, cloudData.data); + const result = await client.call( + 'renderWorkspaceProfile', + [localData, cloudData].filter(Boolean) as Uint8Array[] + ); return { - name: bs.meta.name, - avatar: bs.meta.avatar, + name: result.name, + avatar: result.avatar, isOwner: info.isOwner, isAdmin: info.isAdmin, isTeam: info.workspace.team, diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/in-worker.ts b/packages/frontend/core/src/modules/workspace-engine/impls/in-worker.ts new file mode 100644 index 000000000..e84d32ffb --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-engine/impls/in-worker.ts @@ -0,0 +1,25 @@ +import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op'; +import { applyUpdate, Doc as YDoc } from 'yjs'; + +import type { WorkerOps } from './worker-ops'; + +const consumer = new OpConsumer(globalThis as MessageCommunicapable); + +consumer.register('renderWorkspaceProfile', data => { + const doc = new YDoc({ + guid: 'workspace', + }); + for (const update of data) { + applyUpdate(doc, update); + } + const meta = doc.getMap('meta'); + const name = meta.get('name'); + const avatar = meta.get('avatar'); + + return { + name: typeof name === 'string' ? name : undefined, + avatar: typeof avatar === 'string' ? avatar : undefined, + }; +}); + +consumer.listen(); diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/local.ts b/packages/frontend/core/src/modules/workspace-engine/impls/local.ts index 79fe8c719..9fe869d14 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/local.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/local.ts @@ -18,12 +18,13 @@ import { import { isEqual } from 'lodash-es'; import { nanoid } from 'nanoid'; import { Observable } from 'rxjs'; -import { applyUpdate, encodeStateAsUpdate } from 'yjs'; +import { encodeStateAsUpdate } from 'yjs'; import { DesktopApiService } from '../../desktop-api'; import type { WorkspaceEngineStorageProvider } from '../providers/engine'; import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel'; import { StaticBlobStorage } from './engine/blob-static'; +import { getWorkspaceProfileWorker } from './out-worker'; export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace'; const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY = @@ -97,21 +98,25 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider { blobSources: { main: blobStorage }, }); - // apply initial state - await initial(docCollection, blobStorage, docStorage); + try { + // apply initial state + await initial(docCollection, blobStorage, docStorage); - // save workspace to local storage, should be vary fast - await docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc)); - for (const subdocs of docCollection.doc.getSubdocs()) { - await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); + // save workspace to local storage, should be vary fast + await docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc)); + for (const subdocs of docCollection.doc.getSubdocs()) { + await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); + } + + // save workspace id to local storage + setLocalWorkspaceIds(ids => [...ids, id]); + + // notify all browser tabs, so they can update their workspace list + this.notifyChannel.postMessage(id); + } finally { + docCollection.dispose(); } - // save workspace id to local storage - setLocalWorkspaceIds(ids => [...ids, id]); - - // notify all browser tabs, so they can update their workspace list - this.notifyChannel.postMessage(id); - return { id, flavour: 'local' }; } workspaces$ = LiveData.from( @@ -158,16 +163,16 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider { }; } - const bs = new DocCollection({ - id, - schema: getAFFiNEWorkspaceSchema(), - }); + const client = getWorkspaceProfileWorker(); - if (localData) applyUpdate(bs.doc, localData); + const result = await client.call( + 'renderWorkspaceProfile', + [localData].filter(Boolean) as Uint8Array[] + ); return { - name: bs.meta.name, - avatar: bs.meta.avatar, + name: result.name, + avatar: result.avatar, isOwner: true, }; } diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/out-worker.ts b/packages/frontend/core/src/modules/workspace-engine/impls/out-worker.ts new file mode 100644 index 000000000..46a10b18a --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-engine/impls/out-worker.ts @@ -0,0 +1,22 @@ +import { OpClient } from '@toeverything/infra/op'; + +import type { WorkerOps } from './worker-ops'; + +let worker: OpClient | undefined; + +export function getWorkspaceProfileWorker() { + if (worker) { + return worker; + } + + const rawWorker = new Worker( + new URL( + /* webpackChunkName: "workspace-profile-worker" */ './in-worker.ts', + import.meta.url + ) + ); + + worker = new OpClient(rawWorker); + worker.listen(); + return worker; +} diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/worker-ops.ts b/packages/frontend/core/src/modules/workspace-engine/impls/worker-ops.ts new file mode 100644 index 000000000..fd8e425e1 --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-engine/impls/worker-ops.ts @@ -0,0 +1,5 @@ +import type { OpSchema } from '@toeverything/infra/op'; + +export interface WorkerOps extends OpSchema { + renderWorkspaceProfile: [Uint8Array[], { name?: string; avatar?: string }]; +} diff --git a/tests/affine-local/e2e/local-first-avatar.spec.ts b/tests/affine-local/e2e/local-first-avatar.spec.ts index e145f6c3a..f2f3577fa 100644 --- a/tests/affine-local/e2e/local-first-avatar.spec.ts +++ b/tests/affine-local/e2e/local-first-avatar.spec.ts @@ -32,10 +32,18 @@ test('should create a page with a local first avatar and remove it', async ({ .setInputFiles(resolve(rootDir, 'tests', 'fixtures', 'blue.png')); await page.mouse.click(0, 0); await page.getByTestId('workspace-name').click(); - await page.getByTestId('workspace-card').nth(0).click(); + await page + .getByTestId('workspace-card') + .nth(0) + .getByTestId('workspace-avatar') + .click(); await page.waitForTimeout(1000); await page.getByTestId('workspace-name').click(); - await page.getByTestId('workspace-card').nth(1).click(); + await page + .getByTestId('workspace-card') + .nth(1) + .getByTestId('workspace-avatar') + .click(); const avatarCanvas = await page .getByTestId('workspace-avatar') .locator('canvas')