fix(infra): memory leak (#9013)

This commit is contained in:
EYHN
2024-12-16 16:55:49 +00:00
parent 55f1cc4b1e
commit b36b398957
30 changed files with 399 additions and 135 deletions

View File

@@ -48,16 +48,16 @@ export class EventBus {
this.listeners[event.id].push(listener); this.listeners[event.id].push(listener);
const off = this.parent?.on(event, listener); const off = this.parent?.on(event, listener);
return () => { return () => {
this.off(event, listener);
off?.(); off?.();
this.off(event.id, listener);
}; };
} }
off<T>(event: FrameworkEvent<T>, listener: (event: T) => void) { private off(eventId: string, listener: (event: any) => void) {
if (!this.listeners[event.id]) { if (!this.listeners[eventId]) {
return; return;
} }
this.listeners[event.id] = this.listeners[event.id].filter( this.listeners[eventId] = this.listeners[eventId].filter(
l => l !== listener 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 { interface EventHandler {

View File

@@ -130,6 +130,7 @@ export class ComponentCachePool {
} }
} }
} }
this.cache.clear();
} }
[Symbol.dispose]() { [Symbol.dispose]() {
@@ -318,5 +319,6 @@ export class BasicFrameworkProvider extends FrameworkProvider {
} }
this.disposed = true; this.disposed = true;
this.cache.dispose(); this.cache.dispose();
this.eventBus.dispose();
} }
} }

View File

@@ -82,4 +82,8 @@ export class Workspace extends Entity {
}), }),
undefined undefined
); );
override dispose(): void {
this.docCollection.dispose();
}
} }

View File

@@ -49,7 +49,7 @@ export class WorkspaceRepositoryService extends Service {
return { return {
workspace, workspace,
dispose: () => { dispose: () => {
workspace.dispose(); workspace.scope.dispose();
}, },
}; };
} }

View File

@@ -10,4 +10,8 @@ export class WorkspaceService extends Service {
} }
return this._workspace; return this._workspace;
} }
override dispose(): void {
this._workspace?.dispose();
}
} }

View File

@@ -70,6 +70,8 @@ class TestingWorkspaceLocalProvider implements WorkspaceFlavourProvider {
const list = this.workspaceListStore.get<WorkspaceMetadata[]>('list') ?? []; const list = this.workspaceListStore.get<WorkspaceMetadata[]>('list') ?? [];
this.workspaceListStore.set('list', [...list, meta]); this.workspaceListStore.set('list', [...list, meta]);
docCollection.dispose();
return { id, flavour: 'local' }; return { id, flavour: 'local' };
} }
workspaces$ = LiveData.from<WorkspaceMetadata[]>( workspaces$ = LiveData.from<WorkspaceMetadata[]>(
@@ -94,6 +96,8 @@ class TestingWorkspaceLocalProvider implements WorkspaceFlavourProvider {
applyUpdate(bs.doc, data); applyUpdate(bs.doc, data);
bs.dispose();
return { return {
name: bs.meta.name, name: bs.meta.name,
avatar: bs.meta.avatar, avatar: bs.meta.avatar,

View File

@@ -1,3 +1,4 @@
export * from './observe-intersection';
export * from './observe-resize'; export * from './observe-resize';
export { startScopedViewTransition } from './view-transition'; export { startScopedViewTransition } from './view-transition';
export * from './with-unit'; export * from './with-unit';

View File

@@ -0,0 +1,77 @@
type ObserveIntersection = {
callback: (entity: IntersectionObserverEntry) => void;
dispose: () => void;
};
let _intersectionObserver: IntersectionObserver | null = null;
const elementsMap = new WeakMap<Element, Array<ObserveIntersection>>();
// 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;
};

View File

@@ -194,6 +194,7 @@ export async function markDownToDoc(
const collection = new DocCollection({ const collection = new DocCollection({
schema, schema,
}); });
collection.awarenessStore.awareness.destroy();
collection.meta.initialize(); collection.meta.initialize();
const middlewares = [defaultImageProxyMiddleware]; const middlewares = [defaultImageProxyMiddleware];
if (additionalMiddlewares) { if (additionalMiddlewares) {

View File

@@ -54,6 +54,8 @@ export class AISlidesRenderer extends WithDisposable(LitElement) {
private _doc!: Doc; private _doc!: Doc;
private _docCollection: DocCollection | null = null;
@query('editor-host') @query('editor-host')
private accessor _editorHost!: EditorHost; private accessor _editorHost!: EditorHost;
@@ -233,6 +235,12 @@ export class AISlidesRenderer extends WithDisposable(LitElement) {
doc.resetHistory(); doc.resetHistory();
this._doc = doc; this._doc = doc;
this._docCollection = collection;
}
override disconnectedCallback(): void {
this._docCollection?.dispose();
super.disconnectedCallback();
} }
} }

View File

@@ -12,9 +12,8 @@ import {
import { DisposableGroup } from '@blocksuite/affine/global/utils'; import { DisposableGroup } from '@blocksuite/affine/global/utils';
import type { AffineEditorContainer } from '@blocksuite/affine/presets'; import type { AffineEditorContainer } from '@blocksuite/affine/presets';
import type { Doc } from '@blocksuite/affine/store'; import type { Doc } from '@blocksuite/affine/store';
import { use } from 'foxact/use';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { Suspense, useEffect } from 'react'; import { useEffect, useState } from 'react';
import type { DefaultOpenProperty } from '../../doc-properties'; import type { DefaultOpenProperty } from '../../doc-properties';
import { BlocksuiteEditorContainer } from './blocksuite-editor-container'; import { BlocksuiteEditorContainer } from './blocksuite-editor-container';
@@ -31,24 +30,6 @@ export type EditorProps = {
className?: string; className?: string;
}; };
function usePageRoot(page: Doc) {
if (!page.root) {
use(
new Promise<void>((resolve, reject) => {
const disposable = page.slots.rootAdded.once(() => {
resolve();
});
window.setTimeout(() => {
disposable.dispose();
reject(new NoPageRootError(page));
}, 20 * 1000);
})
);
}
return page.root;
}
const BlockSuiteEditorImpl = ({ const BlockSuiteEditorImpl = ({
mode, mode,
page, page,
@@ -58,8 +39,6 @@ const BlockSuiteEditorImpl = ({
onEditorReady, onEditorReady,
defaultOpenProperty, defaultOpenProperty,
}: EditorProps) => { }: EditorProps) => {
usePageRoot(page);
useEffect(() => { useEffect(() => {
const disposable = page.slots.blockUpdated.once(() => { const disposable = page.slots.blockUpdated.once(() => {
page.collection.setDocMeta(page.id, { page.collection.setDocMeta(page.id, {
@@ -142,9 +121,33 @@ const BlockSuiteEditorImpl = ({
}; };
export const BlockSuiteEditor = (props: EditorProps) => { export const BlockSuiteEditor = (props: EditorProps) => {
return ( const [isLoading, setIsLoading] = useState(true);
<Suspense fallback={<EditorLoading />}> const [error, setError] = useState<Error | null>(null);
<BlockSuiteEditorImpl key={props.page.id} {...props} />
</Suspense> 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 ? (
<EditorLoading />
) : (
<BlockSuiteEditorImpl key={props.page.id} {...props} />
); );
}; };

View File

@@ -1,32 +1,31 @@
import type { DocCollection } from '@blocksuite/affine/store'; import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useAtomValue } from 'jotai'; import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { type ReactNode, Suspense } from 'react'; import { type ReactNode, useMemo } from 'react';
import { useBlockSuitePagePreview } from './use-block-suite-page-preview';
import { useDocCollectionPage } from './use-block-suite-workspace-page';
interface PagePreviewProps { interface PagePreviewProps {
docCollection: DocCollection;
pageId: string; pageId: string;
emptyFallback?: ReactNode; emptyFallback?: ReactNode;
fallback?: ReactNode;
} }
const PagePreviewInner = ({ const PagePreviewInner = ({
docCollection: workspace,
pageId, pageId,
emptyFallback, emptyFallback,
fallback,
}: PagePreviewProps) => { }: PagePreviewProps) => {
const page = useDocCollectionPage(workspace, pageId); const docSummary = useService(DocsSearchService);
const previewAtom = useBlockSuitePagePreview(page); const summary = useLiveData(
const preview = useAtomValue(previewAtom); useMemo(
const res = preview ? preview : null; () => LiveData.from(docSummary.watchDocSummary(pageId), null),
return res || emptyFallback; [docSummary, pageId]
)
);
const res =
summary === null ? fallback : summary === '' ? emptyFallback : summary;
return res;
}; };
export const PagePreview = (props: PagePreviewProps) => { export const PagePreview = (props: PagePreviewProps) => {
return ( return <PagePreviewInner {...props} />;
<Suspense>
<PagePreviewInner {...props} />
</Suspense>
);
}; };

View File

@@ -317,9 +317,7 @@ function pageMetaToListItemProp(
pageId: item.id, pageId: item.id,
pageIds, pageIds,
title: <PageTitle id={item.id} />, title: <PageTitle id={item.id} />,
preview: ( preview: <PagePreview pageId={item.id} />,
<PagePreview docCollection={props.docCollection} pageId={item.id} />
),
createDate: new Date(item.createDate), createDate: new Date(item.createDate),
updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined, updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined,
to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined, to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined,

View File

@@ -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 { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
import { PagePreview } from '@affine/core/components/page-list/page-content-preview'; import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
@@ -10,9 +10,16 @@ import {
} from '@affine/core/modules/workbench'; } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store'; 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 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 * as styles from './styles.css';
import { DocCardTags } from './tag'; import { DocCardTags } from './tag';
@@ -38,11 +45,11 @@ export interface DocCardProps extends Omit<WorkbenchLinkProps, 'to'> {
export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>( export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
function DocCard( function DocCard(
{ showTags = true, meta, className, autoHeightById, ...attrs }, { showTags = true, meta, className, autoHeightById, ...attrs },
ref outerRef
) { ) {
const containerRef = useRef<HTMLAnchorElement | null>(null);
const t = useI18n(); const t = useI18n();
const favAdapter = useService(CompatibleFavoriteItemsAdapter); const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const workspace = useService(WorkspaceService).workspace;
const docDisplayService = useService(DocDisplayMetaService); const docDisplayService = useService(DocDisplayMetaService);
const titleInfo = useLiveData(docDisplayService.title$(meta.id)); const titleInfo = useLiveData(docDisplayService.title$(meta.id));
const title = const title =
@@ -64,13 +71,35 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
return { height: `${rows * 18}px` }; return { height: `${rows * 18}px` };
}, [autoHeightById, meta.id]); }, [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 ( return (
<WorkbenchLink <WorkbenchLink
to={`/${meta.id}`} to={`/${meta.id}`}
ref={ref} ref={ref => {
containerRef.current = ref;
if (typeof outerRef === 'function') {
outerRef(ref);
} else if (outerRef) {
outerRef.current = ref;
}
}}
className={clsx(styles.card, className)} className={clsx(styles.card, className)}
data-testid="doc-card" data-testid="doc-card"
data-doc-id={meta.id} data-doc-id={meta.id}
data-visible={visible}
{...attrs} {...attrs}
> >
<header className={styles.head} data-testid="doc-card-header"> <header className={styles.head} data-testid="doc-card-header">
@@ -83,11 +112,18 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
/> />
</header> </header>
<main className={styles.content} style={contentStyle}> <main className={styles.content} style={contentStyle}>
<PagePreview {visible && (
docCollection={workspace.docCollection} <PagePreview
pageId={meta.id} fallback={
emptyFallback={<div className={styles.contentEmpty}>Empty</div>} <>
/> <Skeleton />
<Skeleton width={'60%'} />
</>
}
pageId={meta.id}
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
/>
)}
</main> </main>
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null} {showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
</WorkbenchLink> </WorkbenchLink>

View File

@@ -36,7 +36,7 @@ export class DocsIndexer extends Entity {
/** /**
* increase this number to re-index all docs * increase this number to re-index all docs
*/ */
static INDEXER_VERSION = 10; static INDEXER_VERSION = 11;
private readonly jobQueue: JobQueue<IndexerJobPayload> = private readonly jobQueue: JobQueue<IndexerJobPayload> =
new IndexedDBJobQueue<IndexerJobPayload>( new IndexedDBJobQueue<IndexerJobPayload>(
@@ -85,24 +85,26 @@ export class DocsIndexer extends Entity {
} }
setupListener() { setupListener() {
this.workspaceEngine.doc.storage.eventBus.on(event => { this.disposables.push(
if (WorkspaceDBService.isDBDocId(event.docId)) { this.workspaceEngine.doc.storage.eventBus.on(event => {
// skip db doc if (WorkspaceDBService.isDBDocId(event.docId)) {
return; // skip db doc
} return;
if (event.clientId === this.workspaceEngine.doc.clientId) { }
this.jobQueue if (event.clientId === this.workspaceEngine.doc.clientId) {
.enqueue([ this.jobQueue
{ .enqueue([
batchKey: event.docId, {
payload: { storageDocId: event.docId }, batchKey: event.docId,
}, payload: { storageDocId: event.docId },
]) },
.catch(err => { ])
console.error('Error enqueueing job', err); .catch(err => {
}); console.error('Error enqueueing job', err);
} });
}); }
})
);
} }
async execJob(jobs: Job<IndexerJobPayload>[], signal: AbortSignal) { async execJob(jobs: Job<IndexerJobPayload>[], signal: AbortSignal) {
@@ -298,6 +300,8 @@ export class DocsIndexer extends Entity {
} }
override dispose(): void { override dispose(): void {
super.dispose();
this.runner.stop(); this.runner.stop();
this.worker?.dispose();
} }
} }

View File

@@ -1,6 +1,7 @@
import { defineSchema } from '@toeverything/infra'; import { defineSchema } from '@toeverything/infra';
export const docIndexSchema = defineSchema({ export const docIndexSchema = defineSchema({
docId: 'String',
title: 'FullText', title: 'FullText',
// summary of the doc, used for preview // summary of the doc, used for preview
summary: { type: 'String', index: false }, summary: { type: 'String', index: false },

View File

@@ -632,10 +632,31 @@ export class DocsSearchService extends Service {
); );
} }
async getDocTitle(docId: string) { watchDocSummary(docId: string) {
const doc = await this.indexer.docIndex.get(docId); return this.indexer.docIndex
const title = doc?.get('title'); .search$(
return typeof title === 'string' ? title : title?.[0]; {
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 { override dispose(): void {

View File

@@ -105,6 +105,11 @@ const bookmarkFlavours = new Set([
'affine:embed-loom', 'affine:embed-loom',
]); ]);
const markdownPreviewDocCollection = new DocCollection({
id: 'indexer',
schema: blocksuiteSchema,
});
function generateMarkdownPreviewBuilder( function generateMarkdownPreviewBuilder(
yRootDoc: YDoc, yRootDoc: YDoc,
workspaceId: string, workspaceId: string,
@@ -164,10 +169,7 @@ function generateMarkdownPreviewBuilder(
const markdownAdapter = new MarkdownAdapter( const markdownAdapter = new MarkdownAdapter(
new Job({ new Job({
collection: new DocCollection({ collection: markdownPreviewDocCollection,
id: 'indexer',
schema: blocksuiteSchema,
}),
middlewares: [docLinkBaseURLMiddleware, titleMiddleware], middlewares: [docLinkBaseURLMiddleware, titleMiddleware],
}) })
); );
@@ -875,6 +877,7 @@ async function crawlingDocData({
{ {
id: docId, id: docId,
doc: Document.from<DocIndexSchema>(docId, { doc: Document.from<DocIndexSchema>(docId, {
docId,
title: docTitle, title: docTitle,
summary, summary,
}), }),

View File

@@ -95,8 +95,8 @@ export async function createWorker(abort: AbortSignal) {
}); });
}, },
dispose: () => { dispose: () => {
worker.terminate();
terminateAbort.abort(MANUALLY_STOP); terminateAbort.abort(MANUALLY_STOP);
worker.terminate();
}, },
}; };
} }

View File

@@ -159,4 +159,8 @@ export class WorkspacePermission extends Entity {
permission permission
); );
} }
override dispose(): void {
this.revalidate.unsubscribe();
}
} }

View File

@@ -15,6 +15,10 @@ export class WorkspacePermissionService extends Service {
super(); super();
} }
override dispose(): void {
this.permission?.dispose();
}
async leaveWorkspace() { async leaveWorkspace() {
await this.store.leaveWorkspace(this.workspaceService.workspace.id); await this.store.leaveWorkspace(this.workspaceService.workspace.id);
this.workspacesService.list.revalidate(); this.workspacesService.list.revalidate();

View File

@@ -4,4 +4,8 @@ import { WorkspaceQuota } from '../entities/quota';
export class WorkspaceQuotaService extends Service { export class WorkspaceQuotaService extends Service {
quota = this.framework.createEntity(WorkspaceQuota); quota = this.framework.createEntity(WorkspaceQuota);
override dispose(): void {
this.quota.dispose();
}
} }

View File

@@ -66,4 +66,8 @@ export class ShareDocsList extends Entity {
) )
) )
); );
override dispose(): void {
this.revalidate.unsubscribe();
}
} }

View File

@@ -12,4 +12,8 @@ export class ShareDocsListService extends Service {
this.workspaceService.workspace.flavour !== 'local' this.workspaceService.workspace.flavour !== 'local'
? this.framework.createEntity(ShareDocsList) ? this.framework.createEntity(ShareDocsList)
: null; : null;
override dispose(): void {
this.shareDocs?.dispose();
}
} }

View File

@@ -30,7 +30,7 @@ import {
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { EMPTY, map, mergeMap, Observable, switchMap } from 'rxjs'; import { EMPTY, map, mergeMap, Observable, switchMap } from 'rxjs';
import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { encodeStateAsUpdate } from 'yjs';
import type { Server, ServersService } from '../../cloud'; import type { Server, ServersService } from '../../cloud';
import { import {
@@ -48,6 +48,7 @@ import { CloudBlobStorage } from './engine/blob-cloud';
import { StaticBlobStorage } from './engine/blob-static'; import { StaticBlobStorage } from './engine/blob-static';
import { CloudDocEngineServer } from './engine/doc-cloud'; import { CloudDocEngineServer } from './engine/doc-cloud';
import { CloudStaticDocStorage } from './engine/doc-cloud-static'; import { CloudStaticDocStorage } from './engine/doc-cloud-static';
import { getWorkspaceProfileWorker } from './out-worker';
const getCloudWorkspaceCacheKey = (serverId: string) => { const getCloudWorkspaceCacheKey = (serverId: string) => {
if (serverId === 'affine-cloud') { if (serverId === 'affine-cloud') {
@@ -123,21 +124,25 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
}, },
}); });
// apply initial state try {
await initial(docCollection, blobStorage, docStorage); // apply initial state
await initial(docCollection, blobStorage, docStorage);
// save workspace to local storage, should be vary fast // save workspace to local storage, should be vary fast
await docStorage.doc.set( await docStorage.doc.set(
workspaceId, workspaceId,
encodeStateAsUpdate(docCollection.doc) encodeStateAsUpdate(docCollection.doc)
); );
for (const subdocs of docCollection.doc.getSubdocs()) { for (const subdocs of docCollection.doc.getSubdocs()) {
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
}
this.revalidate();
await this.waitForLoaded();
} finally {
docCollection.dispose();
} }
this.revalidate();
await this.waitForLoaded();
return { return {
id: workspaceId, id: workspaceId,
flavour: this.server.id, flavour: this.server.id,
@@ -229,7 +234,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
const docStorage = this.storageProvider.getDocStorage(id); const docStorage = this.storageProvider.getDocStorage(id);
// download root doc // download root doc
const localData = await docStorage.doc.get(id); 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); const info = await this.getWorkspaceInfo(id, signal);
@@ -241,17 +246,16 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
}; };
} }
const bs = new DocCollection({ const client = getWorkspaceProfileWorker();
id,
schema: getAFFiNEWorkspaceSchema(),
});
if (localData) applyUpdate(bs.doc, localData); const result = await client.call(
if (cloudData) applyUpdate(bs.doc, cloudData.data); 'renderWorkspaceProfile',
[localData, cloudData].filter(Boolean) as Uint8Array[]
);
return { return {
name: bs.meta.name, name: result.name,
avatar: bs.meta.avatar, avatar: result.avatar,
isOwner: info.isOwner, isOwner: info.isOwner,
isAdmin: info.isAdmin, isAdmin: info.isAdmin,
isTeam: info.workspace.team, isTeam: info.workspace.team,

View File

@@ -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<WorkerOps>(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();

View File

@@ -18,12 +18,13 @@ import {
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { encodeStateAsUpdate } from 'yjs';
import { DesktopApiService } from '../../desktop-api'; import { DesktopApiService } from '../../desktop-api';
import type { WorkspaceEngineStorageProvider } from '../providers/engine'; import type { WorkspaceEngineStorageProvider } from '../providers/engine';
import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel'; import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel';
import { StaticBlobStorage } from './engine/blob-static'; import { StaticBlobStorage } from './engine/blob-static';
import { getWorkspaceProfileWorker } from './out-worker';
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace'; export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY = const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY =
@@ -97,21 +98,25 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
blobSources: { main: blobStorage }, blobSources: { main: blobStorage },
}); });
// apply initial state try {
await initial(docCollection, blobStorage, docStorage); // apply initial state
await initial(docCollection, blobStorage, docStorage);
// save workspace to local storage, should be vary fast // save workspace to local storage, should be vary fast
await docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc)); await docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc));
for (const subdocs of docCollection.doc.getSubdocs()) { for (const subdocs of docCollection.doc.getSubdocs()) {
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs)); 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' }; return { id, flavour: 'local' };
} }
workspaces$ = LiveData.from( workspaces$ = LiveData.from(
@@ -158,16 +163,16 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
}; };
} }
const bs = new DocCollection({ const client = getWorkspaceProfileWorker();
id,
schema: getAFFiNEWorkspaceSchema(),
});
if (localData) applyUpdate(bs.doc, localData); const result = await client.call(
'renderWorkspaceProfile',
[localData].filter(Boolean) as Uint8Array[]
);
return { return {
name: bs.meta.name, name: result.name,
avatar: bs.meta.avatar, avatar: result.avatar,
isOwner: true, isOwner: true,
}; };
} }

View File

@@ -0,0 +1,22 @@
import { OpClient } from '@toeverything/infra/op';
import type { WorkerOps } from './worker-ops';
let worker: OpClient<WorkerOps> | 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<WorkerOps>(rawWorker);
worker.listen();
return worker;
}

View File

@@ -0,0 +1,5 @@
import type { OpSchema } from '@toeverything/infra/op';
export interface WorkerOps extends OpSchema {
renderWorkspaceProfile: [Uint8Array[], { name?: string; avatar?: string }];
}

View File

@@ -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')); .setInputFiles(resolve(rootDir, 'tests', 'fixtures', 'blue.png'));
await page.mouse.click(0, 0); await page.mouse.click(0, 0);
await page.getByTestId('workspace-name').click(); 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.waitForTimeout(1000);
await page.getByTestId('workspace-name').click(); 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 const avatarCanvas = await page
.getByTestId('workspace-avatar') .getByTestId('workspace-avatar')
.locator('canvas') .locator('canvas')