From f7b07f421632bede2d64ebc34f5fc0d5b75c5aee Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Thu, 29 Jun 2023 16:48:12 +0800 Subject: [PATCH] refactor: rootWorkspacesMetadataAtom loading logic (#2882) --- apps/web/src/adapters/affine/index.tsx | 4 +- apps/web/src/adapters/local/index.tsx | 2 +- apps/web/src/adapters/type.tsx | 21 -- apps/web/src/adapters/workspace.ts | 7 +- apps/web/src/atoms/__tests__/atom.spec.ts | 17 +- apps/web/src/atoms/index.ts | 76 ------ apps/web/src/atoms/root.ts | 33 +-- apps/web/src/bootstrap/index.ts | 151 +++++++----- .../src/components/root-app-sidebar/index.tsx | 11 +- apps/web/src/hooks/__tests__/index.spec.tsx | 38 ++- .../affine/use-toggle-workspace-publish.ts | 2 +- apps/web/src/hooks/use-transform-workspace.ts | 2 +- apps/web/src/hooks/use-workspaces.ts | 6 +- apps/web/src/layouts/workspace-layout.tsx | 126 ++-------- apps/web/src/pages/_app.tsx | 37 +-- apps/web/src/pages/_debug/migration.tsx | 5 +- apps/web/src/providers/modal-provider.tsx | 6 +- packages/env/src/workspace.ts | 11 + packages/workspace/src/affine/sync.ts | 2 +- packages/workspace/src/atom.ts | 222 ++++++++++++++++-- packages/workspace/src/migration/index.ts | 2 +- packages/workspace/src/utils.ts | 8 +- 22 files changed, 436 insertions(+), 353 deletions(-) delete mode 100644 apps/web/src/adapters/type.tsx diff --git a/apps/web/src/adapters/affine/index.tsx b/apps/web/src/adapters/affine/index.tsx index 915cae9c78..4a20f3f2b4 100644 --- a/apps/web/src/adapters/affine/index.tsx +++ b/apps/web/src/adapters/affine/index.tsx @@ -9,6 +9,7 @@ import type { AffineLegacyCloudWorkspace, LocalIndexedDBDownloadProvider, } from '@affine/env/workspace'; +import type { WorkspaceAdapter } from '@affine/env/workspace'; import { LoadPriority, ReleaseType, @@ -49,7 +50,6 @@ import { WorkspaceHeader, WorkspaceSettingDetail, } from '../shared'; -import type { WorkspaceAdapter } from '../type'; import { QueryKey } from './fetcher'; const storage = createJSONStorage(() => localStorage); @@ -126,7 +126,7 @@ export const AffineAdapter: WorkspaceAdapter = { console.warn('Legacy cloud is disabled'); return; } - rootStore.set(rootWorkspacesMetadataAtom, workspaces => + await rootStore.set(rootWorkspacesMetadataAtom, workspaces => workspaces.filter( workspace => workspace.flavour !== WorkspaceFlavour.AFFINE ) diff --git a/apps/web/src/adapters/local/index.tsx b/apps/web/src/adapters/local/index.tsx index d94b986220..e0e1a59eee 100644 --- a/apps/web/src/adapters/local/index.tsx +++ b/apps/web/src/adapters/local/index.tsx @@ -6,6 +6,7 @@ import { PageNotFoundError, } from '@affine/env/constant'; import type { LocalIndexedDBDownloadProvider } from '@affine/env/workspace'; +import type { WorkspaceAdapter } from '@affine/env/workspace'; import { LoadPriority, ReleaseType, @@ -26,7 +27,6 @@ import { WorkspaceHeader, WorkspaceSettingDetail, } from '../shared'; -import type { WorkspaceAdapter } from '../type'; const logger = new DebugLogger('use-create-first-workspace'); diff --git a/apps/web/src/adapters/type.tsx b/apps/web/src/adapters/type.tsx deleted file mode 100644 index 7d69225257..0000000000 --- a/apps/web/src/adapters/type.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { - AppEvents, - WorkspaceCRUD, - WorkspaceUISchema, -} from '@affine/env/workspace'; -import type { - LoadPriority, - ReleaseType, - WorkspaceFlavour, -} from '@affine/env/workspace'; - -export interface WorkspaceAdapter { - releaseType: ReleaseType; - flavour: Flavour; - // Plugin will be loaded according to the priority - loadPriority: LoadPriority; - Events: Partial; - // Fetch necessary data for the first render - CRUD: WorkspaceCRUD; - UI: WorkspaceUISchema; -} diff --git a/apps/web/src/adapters/workspace.ts b/apps/web/src/adapters/workspace.ts index 448e119156..fa7e4f8ad6 100644 --- a/apps/web/src/adapters/workspace.ts +++ b/apps/web/src/adapters/workspace.ts @@ -1,5 +1,9 @@ import { Unreachable } from '@affine/env/constant'; -import type { AppEvents, WorkspaceUISchema } from '@affine/env/workspace'; +import type { + AppEvents, + WorkspaceAdapter, + WorkspaceUISchema, +} from '@affine/env/workspace'; import { LoadPriority, ReleaseType, @@ -8,7 +12,6 @@ import { import { AffineAdapter } from './affine'; import { LocalAdapter } from './local'; -import type { WorkspaceAdapter } from './type'; const unimplemented = () => { throw new Error('Not implemented'); diff --git a/apps/web/src/atoms/__tests__/atom.spec.ts b/apps/web/src/atoms/__tests__/atom.spec.ts index b58814d9bd..2b1ff0f75f 100644 --- a/apps/web/src/atoms/__tests__/atom.spec.ts +++ b/apps/web/src/atoms/__tests__/atom.spec.ts @@ -4,11 +4,15 @@ import 'fake-indexeddb/auto'; import { initEmptyPage } from '@affine/env/blocksuite'; -import type { LocalIndexedDBBackgroundProvider } from '@affine/env/workspace'; +import type { + LocalIndexedDBBackgroundProvider, + WorkspaceAdapter, +} from '@affine/env/workspace'; import { WorkspaceFlavour, WorkspaceVersion } from '@affine/env/workspace'; import { rootCurrentWorkspaceIdAtom, rootWorkspacesMetadataAtom, + workspaceAdaptersAtom, } from '@affine/workspace/atom'; import { createIndexedDBBackgroundProvider } from '@affine/workspace/providers'; import { @@ -63,6 +67,13 @@ describe('page mode atom', () => { describe('currentWorkspace atom', () => { test('should be defined', async () => { const store = createStore(); + store.set( + workspaceAdaptersAtom, + WorkspaceAdapters as Record< + WorkspaceFlavour, + WorkspaceAdapter + > + ); let id: string; { const workspace = createEmptyBlockSuiteWorkspace( @@ -92,7 +103,7 @@ describe('currentWorkspace atom', () => { const workspaceId = await WorkspaceAdapters[ WorkspaceFlavour.LOCAL ].CRUD.create(workspace); - store.set(rootWorkspacesMetadataAtom, [ + await store.set(rootWorkspacesMetadataAtom, [ { id: workspaceId, flavour: WorkspaceFlavour.LOCAL, @@ -103,7 +114,7 @@ describe('currentWorkspace atom', () => { } store.set( rootCurrentWorkspaceIdAtom, - store.get(rootWorkspacesMetadataAtom)[0].id + (await store.get(rootWorkspacesMetadataAtom))[0].id ); const workspace = await store.get(rootCurrentWorkspaceAtom); expect(workspace).toBeDefined(); diff --git a/apps/web/src/atoms/index.ts b/apps/web/src/atoms/index.ts index 959a5d89b8..85d074406f 100644 --- a/apps/web/src/atoms/index.ts +++ b/apps/web/src/atoms/index.ts @@ -1,84 +1,8 @@ -import { DebugLogger } from '@affine/debug'; -import { WorkspaceFlavour, WorkspaceVersion } from '@affine/env/workspace'; -import type { RootWorkspaceMetadataV2 } from '@affine/workspace/atom'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; import { atom } from 'jotai'; import { atomFamily, atomWithStorage } from 'jotai/utils'; -import { WorkspaceAdapters } from '../adapters/workspace'; import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal'; -const logger = new DebugLogger('web:atoms'); - -// workspace necessary atoms -// todo(himself65): move this to the workspace package -rootWorkspacesMetadataAtom.onMount = setAtom => { - function createFirst(): RootWorkspaceMetadataV2[] { - const Plugins = Object.values(WorkspaceAdapters).sort( - (a, b) => a.loadPriority - b.loadPriority - ); - - return Plugins.flatMap(Plugin => { - return Plugin.Events['app:init']?.().map( - id => - ({ - id, - flavour: Plugin.flavour, - // new workspace should all support sub-doc feature - version: WorkspaceVersion.SubDoc, - } satisfies RootWorkspaceMetadataV2) - ); - }).filter((ids): ids is RootWorkspaceMetadataV2 => !!ids); - } - - const abortController = new AbortController(); - - if (!environment.isServer) { - // next tick to make sure the hydration is correct - setTimeout(() => { - setAtom(metadata => { - if (abortController.signal.aborted) return metadata; - if ( - metadata.length === 0 && - localStorage.getItem('is-first-open') === null - ) { - localStorage.setItem('is-first-open', 'false'); - const newMetadata = createFirst(); - logger.info('create first workspace', newMetadata); - return newMetadata; - } - return metadata; - }); - }, 0); - } - - if (environment.isDesktop && runtimeConfig.enableSQLiteProvider) { - window.apis?.workspace - .list() - .then(workspaceIDs => { - if (abortController.signal.aborted) return; - const newMetadata = workspaceIDs.map(w => ({ - id: w[0], - flavour: WorkspaceFlavour.LOCAL, - version: undefined, - })); - setAtom(metadata => { - return [ - ...metadata, - ...newMetadata.filter(m => !metadata.find(m2 => m2.id === m.id)), - ]; - }); - }) - .catch(err => { - console.error(err); - }); - } - - return () => { - abortController.abort(); - }; -}; - // modal atoms export const openWorkspacesModalAtom = atom(false); export const openCreateWorkspaceModalAtom = atom(false); diff --git a/apps/web/src/atoms/root.ts b/apps/web/src/atoms/root.ts index 4223b218c4..4cba0e05f0 100644 --- a/apps/web/src/atoms/root.ts +++ b/apps/web/src/atoms/root.ts @@ -1,6 +1,9 @@ //#region async atoms that to load the real workspace data import { DebugLogger } from '@affine/debug'; -import type { WorkspaceRegistry } from '@affine/env/workspace'; +import type { + WorkspaceAdapter, + WorkspaceRegistry, +} from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { rootCurrentWorkspaceIdAtom, @@ -23,7 +26,7 @@ export const workspacesAtom = atom>( const flavours: string[] = Object.values(WorkspaceAdapters).map( plugin => plugin.flavour ); - const jotaiWorkspaces = get(rootWorkspacesMetadataAtom) + const jotaiWorkspaces = (await get(rootWorkspacesMetadataAtom)) .filter( workspace => flavours.includes(workspace.flavour) // TODO: remove this when we remove the legacy cloud @@ -33,7 +36,7 @@ export const workspacesAtom = atom>( ? workspace.flavour !== WorkspaceFlavour.AFFINE : true ); - if (jotaiWorkspaces.some(meta => meta.version === undefined)) { + if (jotaiWorkspaces.some(meta => !('version' in meta))) { // wait until all workspaces have migrated to v2 await new Promise((resolve, reject) => { signal.addEventListener('abort', reject); @@ -44,12 +47,11 @@ export const workspacesAtom = atom>( } const workspaces = await Promise.all( jotaiWorkspaces.map(workspace => { - const plugin = - WorkspaceAdapters[ - workspace.flavour as keyof typeof WorkspaceAdapters - ]; - assertExists(plugin); - const { CRUD } = plugin; + const adapter = WorkspaceAdapters[ + workspace.flavour + ] as WorkspaceAdapter; + assertExists(adapter); + const { CRUD } = adapter; return CRUD.get(workspace.id).then(workspace => { if (workspace === null) { console.warn( @@ -93,7 +95,7 @@ export const workspacesAtom = atom>( export const rootCurrentWorkspaceAtom = atom>( async (get, { signal }) => { const { WorkspaceAdapters } = await import('../adapters/workspace'); - const metadata = get(rootWorkspacesMetadataAtom); + const metadata = await get(rootWorkspacesMetadataAtom); const targetId = get(rootCurrentWorkspaceIdAtom); if (targetId === null) { throw new Error( @@ -105,7 +107,7 @@ export const rootCurrentWorkspaceAtom = atom>( throw new Error(`cannot find the workspace with id ${targetId}.`); } - if (!targetWorkspace.version) { + if (!('version' in targetWorkspace)) { // wait until the workspace has migrated to v2 await new Promise((resolve, reject) => { signal.addEventListener('abort', reject); @@ -115,9 +117,12 @@ export const rootCurrentWorkspaceAtom = atom>( }); } - const workspace = await WorkspaceAdapters[targetWorkspace.flavour].CRUD.get( - targetWorkspace.id - ); + const adapter = WorkspaceAdapters[ + targetWorkspace.flavour + ] as WorkspaceAdapter; + assertExists(adapter); + + const workspace = await adapter.CRUD.get(targetWorkspace.id); if (!workspace) { throw new Error( `cannot find the workspace with id ${targetId} in the plugin ${targetWorkspace.flavour}.` diff --git a/apps/web/src/bootstrap/index.ts b/apps/web/src/bootstrap/index.ts index d27cfd3716..2a99b55070 100644 --- a/apps/web/src/bootstrap/index.ts +++ b/apps/web/src/bootstrap/index.ts @@ -1,10 +1,12 @@ import { migrateToSubdoc } from '@affine/env/blocksuite'; -import { isDesktop, isServer } from '@affine/env/constant'; import { setupGlobal } from '@affine/env/global'; -import type { LocalIndexedDBDownloadProvider } from '@affine/env/workspace'; -import { WorkspaceFlavour, WorkspaceVersion } from '@affine/env/workspace'; +import type { + LocalIndexedDBDownloadProvider, + WorkspaceAdapter, +} from '@affine/env/workspace'; +import { WorkspaceFlavour } from '@affine/env/workspace'; import type { RootWorkspaceMetadata } from '@affine/workspace/atom'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; +import { workspaceAdaptersAtom } from '@affine/workspace/atom'; import { migrateLocalBlobStorage, upgradeV1ToV2, @@ -17,19 +19,27 @@ import { WorkspaceAdapters } from '../adapters/workspace'; setupGlobal(); +rootStore.set( + workspaceAdaptersAtom, + WorkspaceAdapters as Record< + WorkspaceFlavour, + WorkspaceAdapter + > +); + if (process.env.NODE_ENV === 'development') { console.log('Runtime Preset', runtimeConfig); } -if (runtimeConfig.enablePlugin && !isServer) { +if (runtimeConfig.enablePlugin && !environment.isServer) { import('@affine/copilot'); } -if (!isServer) { +if (!environment.isServer) { import('@affine/bookmark-block'); } -if (!isDesktop && !isServer) { +if (!environment.isDesktop && !environment.isServer) { // Polyfill Electron const unimplemented = () => { throw new Error('AFFiNE Plugin Web will be supported in the future'); @@ -52,65 +62,76 @@ if (!isDesktop && !isServer) { }); } -rootStore.sub(rootWorkspacesMetadataAtom, () => { - const metadata = rootStore.get(rootWorkspacesMetadataAtom); - metadata.forEach(oldMeta => { - if (!oldMeta.version) { - const adapter = WorkspaceAdapters[oldMeta.flavour]; - assertExists(adapter); - const upgrade = async () => { - const workspace = await adapter.CRUD.get(oldMeta.id); - if (!workspace) { - console.warn('cannot find workspace', oldMeta.id); - return; - } - if (workspace.flavour !== WorkspaceFlavour.LOCAL) { - console.warn('not supported'); - return; - } - const doc = workspace.blockSuiteWorkspace.doc; - const provider = createIndexedDBDownloadProvider(workspace.id, doc, { - awareness: workspace.blockSuiteWorkspace.awarenessStore.awareness, - }) as LocalIndexedDBDownloadProvider; - provider.sync(); - await provider.whenReady; - const newDoc = migrateToSubdoc(doc); - if (doc === newDoc) { - console.log('doc not changed'); - rootStore.set(rootWorkspacesMetadataAtom, metadata => - metadata.map(newMeta => - newMeta.id === oldMeta.id - ? { - ...newMeta, - version: WorkspaceVersion.SubDoc, - } - : newMeta - ) - ); - return; - } - const newWorkspace = upgradeV1ToV2(workspace); +if (environment.isBrowser) { + const value = localStorage.getItem('jotai-workspaces'); + if (value) { + try { + const metadata = JSON.parse(value) as RootWorkspaceMetadata[]; + const promises: Promise[] = []; + metadata.forEach(oldMeta => { + if (!('version' in oldMeta)) { + const adapter = WorkspaceAdapters[oldMeta.flavour]; + assertExists(adapter); + const upgrade = async () => { + const workspace = await adapter.CRUD.get(oldMeta.id); + if (!workspace) { + console.warn('cannot find workspace', oldMeta.id); + return; + } + if (workspace.flavour !== WorkspaceFlavour.LOCAL) { + console.warn('not supported'); + return; + } + const doc = workspace.blockSuiteWorkspace.doc; + const provider = createIndexedDBDownloadProvider( + workspace.id, + doc, + { + awareness: + workspace.blockSuiteWorkspace.awarenessStore.awareness, + } + ) as LocalIndexedDBDownloadProvider; + provider.sync(); + await provider.whenReady; + const newDoc = migrateToSubdoc(doc); + if (doc === newDoc) { + console.log('doc not changed'); + return; + } + const newWorkspace = upgradeV1ToV2(workspace); - const newId = await adapter.CRUD.create( - newWorkspace.blockSuiteWorkspace - ); + const newId = await adapter.CRUD.create( + newWorkspace.blockSuiteWorkspace + ); - await adapter.CRUD.delete(workspace as any); - await migrateLocalBlobStorage(workspace.id, newId); - rootStore.set(rootWorkspacesMetadataAtom, metadata => [ - ...metadata - .map(newMeta => (newMeta.id === oldMeta.id ? null : newMeta)) - .filter((meta): meta is RootWorkspaceMetadata => !!meta), - { - id: newId, - flavour: oldMeta.flavour, - version: WorkspaceVersion.SubDoc, - }, - ]); - }; + await adapter.CRUD.delete(workspace as any); + await migrateLocalBlobStorage(workspace.id, newId); + }; - // create a new workspace and push it to metadata - upgrade().catch(console.error); + // create a new workspace and push it to metadata + promises.push(upgrade()); + } + }); + + Promise.all(promises) + .then(() => { + console.log('migration done'); + }) + .catch(() => { + console.error('migration failed'); + }) + .finally(() => { + window.dispatchEvent(new CustomEvent('migration-done')); + }); + } catch (e) { + console.error('error when migrating data', e); } - }); -}); + } +} + +declare global { + // global Events + interface WindowEventMap { + 'migration-done': CustomEvent; + } +} diff --git a/apps/web/src/components/root-app-sidebar/index.tsx b/apps/web/src/components/root-app-sidebar/index.tsx index d8ba38d50e..cf43bd2724 100644 --- a/apps/web/src/components/root-app-sidebar/index.tsx +++ b/apps/web/src/components/root-app-sidebar/index.tsx @@ -21,6 +21,7 @@ import { } from '@blocksuite/icons'; import type { Page } from '@blocksuite/store'; import { useDroppable } from '@dnd-kit/core'; +import { NoSsr } from '@mui/material'; import { useAtom } from 'jotai'; import type { ReactElement } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react'; @@ -154,10 +155,12 @@ export const RootAppSidebar = ({ hasBackground={!appSettings.disableBlurBackground} > - + + + { async function getJotaiContext() { const store = createStore(); + store.set( + workspaceAdaptersAtom, + WorkspaceAdapters as Record< + WorkspaceFlavour, + WorkspaceAdapter + > + ); const ProviderWrapper: React.FC = function ProviderWrapper({ children }) { return {children}; }; const workspaces = await store.get(workspacesAtom); - expect(workspaces.length).toBe(0); + expect(workspaces.length).toBe(1); return { store, ProviderWrapper, @@ -182,7 +194,13 @@ describe('useWorkspaces', () => { const { result } = renderHook(() => useWorkspaces(), { wrapper: ProviderWrapper, }); - expect(result.current).toEqual([]); + expect(result.current).toEqual([ + { + id: expect.stringContaining(''), + flavour: WorkspaceFlavour.LOCAL, + blockSuiteWorkspace: expect.anything(), + }, + ]); }); test('mutation', async () => { @@ -192,20 +210,20 @@ describe('useWorkspaces', () => { }); { const workspaces = await store.get(workspacesAtom); - expect(workspaces.length).toEqual(0); + expect(workspaces.length).toEqual(1); } await result.current.createLocalWorkspace('test'); { const workspaces = await store.get(workspacesAtom); - expect(workspaces.length).toEqual(1); + expect(workspaces.length).toEqual(2); } const { result: result2 } = renderHook(() => useWorkspaces(), { wrapper: ProviderWrapper, }); - expect(result2.current.length).toEqual(1); - const firstWorkspace = result2.current[0]; - expect(firstWorkspace.flavour).toBe('local'); - assert(firstWorkspace.flavour === WorkspaceFlavour.LOCAL); - expect(firstWorkspace.blockSuiteWorkspace.meta.name).toBe('test'); + expect(result2.current.length).toEqual(2); + const secondWorkspace = result2.current[1]; + expect(secondWorkspace.flavour).toBe('local'); + assert(secondWorkspace.flavour === WorkspaceFlavour.LOCAL); + expect(secondWorkspace.blockSuiteWorkspace.meta.name).toBe('test'); }); }); diff --git a/apps/web/src/hooks/affine/use-toggle-workspace-publish.ts b/apps/web/src/hooks/affine/use-toggle-workspace-publish.ts index 1c5951da4b..98514235fc 100644 --- a/apps/web/src/hooks/affine/use-toggle-workspace-publish.ts +++ b/apps/web/src/hooks/affine/use-toggle-workspace-publish.ts @@ -19,7 +19,7 @@ export function useToggleWorkspacePublish( }); await mutate(QueryKey.getWorkspaces); // fixme: remove force update - rootStore.set(rootWorkspacesMetadataAtom, ws => [...ws]); + await rootStore.set(rootWorkspacesMetadataAtom, ws => [...ws]); }, [mutate, workspace.id] ); diff --git a/apps/web/src/hooks/use-transform-workspace.ts b/apps/web/src/hooks/use-transform-workspace.ts index 6366c54f24..6e5a6521db 100644 --- a/apps/web/src/hooks/use-transform-workspace.ts +++ b/apps/web/src/hooks/use-transform-workspace.ts @@ -25,7 +25,7 @@ export function useTransformWorkspace() { workspace.blockSuiteWorkspace ); await WorkspaceAdapters[from].CRUD.delete(workspace as any); - set(workspaces => { + await set(workspaces => { const idx = workspaces.findIndex(ws => ws.id === workspace.id); workspaces.splice(idx, 1, { id: newId, diff --git a/apps/web/src/hooks/use-workspaces.ts b/apps/web/src/hooks/use-workspaces.ts index af1441ac4d..65f8b454b1 100644 --- a/apps/web/src/hooks/use-workspaces.ts +++ b/apps/web/src/hooks/use-workspaces.ts @@ -29,7 +29,7 @@ export function useAppHelper() { addLocalWorkspace: useCallback( async (workspaceId: string): Promise => { saveWorkspaceToLocalStorage(workspaceId); - set(workspaces => [ + await set(workspaces => [ ...workspaces, { id: workspaceId, @@ -50,7 +50,7 @@ export function useAppHelper() { ); blockSuiteWorkspace.meta.setName(name); const id = await LocalAdapter.CRUD.create(blockSuiteWorkspace); - set(workspaces => [ + await set(workspaces => [ ...workspaces, { id, @@ -79,7 +79,7 @@ export function useAppHelper() { targetWorkspace as any ); // delete workspace from jotai storage - set(workspaces => workspaces.filter(ws => ws.id !== workspaceId)); + await set(workspaces => workspaces.filter(ws => ws.id !== workspaceId)); }, [jotaiWorkspaces, set, workspaces] ), diff --git a/apps/web/src/layouts/workspace-layout.tsx b/apps/web/src/layouts/workspace-layout.tsx index f70a1083bb..8990167783 100644 --- a/apps/web/src/layouts/workspace-layout.tsx +++ b/apps/web/src/layouts/workspace-layout.tsx @@ -8,13 +8,9 @@ import { ToolContainer, WorkspaceFallback, } from '@affine/component/workspace'; -import { DebugLogger } from '@affine/debug'; import { initEmptyPage, initPageWithPreloading } from '@affine/env/blocksuite'; import { DEFAULT_HELLO_WORLD_PAGE_ID, isDesktop } from '@affine/env/constant'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { setUpLanguage, useI18N } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { createAffineGlobalChannel } from '@affine/workspace/affine/sync'; import { rootCurrentPageIdAtom, rootCurrentWorkspaceIdAtom, @@ -33,7 +29,6 @@ import { useSensors, } from '@dnd-kit/core'; import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper'; -import { rootStore } from '@toeverything/plugin-infra/manager'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import Head from 'next/head'; import { useRouter } from 'next/router'; @@ -144,12 +139,6 @@ export const Setting: FC = () => { ); }; -const logger = new DebugLogger('workspace-layout'); - -const affineGlobalChannel = createAffineGlobalChannel( - WorkspaceAdapters[WorkspaceFlavour.AFFINE].CRUD -); - export const AllWorkspaceContext = ({ children, }: PropsWithChildren): ReactElement => { @@ -226,14 +215,6 @@ export const CurrentWorkspaceContext = ({ export const WorkspaceLayout: FC = function WorkspacesSuspense({ children }) { - const i18n = useI18N(); - useEffect(() => { - document.documentElement.lang = i18n.language; - // todo(himself65): this is a hack, we should use a better way to set the language - setUpLanguage(i18n)?.catch(error => { - console.error(error); - }); - }, [i18n]); useTrackRouterHistoryEffect(); const currentWorkspaceId = useAtomValue(rootCurrentWorkspaceIdAtom); const jotaiWorkspaces = useAtomValue(rootWorkspacesMetadataAtom); @@ -241,67 +222,6 @@ export const WorkspaceLayout: FC = () => jotaiWorkspaces.find(x => x.id === currentWorkspaceId), [currentWorkspaceId, jotaiWorkspaces] ); - const set = useSetAtom(rootWorkspacesMetadataAtom); - useEffect(() => { - logger.info('mount'); - const controller = new AbortController(); - const lists = Object.values(WorkspaceAdapters) - .sort((a, b) => a.loadPriority - b.loadPriority) - .map(({ CRUD }) => CRUD.list); - - async function fetch() { - const jotaiWorkspaces = rootStore.get(rootWorkspacesMetadataAtom); - const items = []; - for (const list of lists) { - try { - const item = await list(); - if (jotaiWorkspaces.length) { - item.sort((a, b) => { - return ( - jotaiWorkspaces.findIndex(x => x.id === a.id) - - jotaiWorkspaces.findIndex(x => x.id === b.id) - ); - }); - } - items.push( - ...item.map(x => ({ - id: x.id, - flavour: x.flavour, - version: undefined, - })) - ); - } catch (e) { - logger.error('list data error:', e); - } - } - if (controller.signal.aborted) { - return; - } - set([...items]); - logger.info('mount first data:', items); - } - - fetch().catch(e => { - logger.error('fetch error:', e); - }); - return () => { - controller.abort(); - logger.info('unmount'); - }; - }, [set]); - - useEffect(() => { - const flavour = jotaiWorkspaces.find( - x => x.id === currentWorkspaceId - )?.flavour; - if (flavour === WorkspaceFlavour.AFFINE) { - affineGlobalChannel.connect(); - return () => { - affineGlobalChannel.disconnect(); - }; - } - return; - }, [currentWorkspaceId, jotaiWorkspaces]); const Provider = (meta && WorkspaceAdapters[meta.flavour].UI.Provider) ?? DefaultProvider; @@ -335,31 +255,35 @@ export const WorkspaceLayoutInner: FC = ({ children }) => { const router = useRouter(); const { jumpToPage } = useRouterHelper(router); - // fixme(himself65): - // we should move the page into jotai atom since it's an async value - //#region init workspace - if (currentWorkspace.blockSuiteWorkspace.isEmpty) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + if (currentWorkspace.blockSuiteWorkspace.meta._proxy.isEmpty !== true) { // this is a new workspace, so we should redirect to the new page const pageId = DEFAULT_HELLO_WORLD_PAGE_ID; - const page = currentWorkspace.blockSuiteWorkspace.createPage({ - id: pageId, - }); - assertEquals(page.id, pageId); - if (runtimeConfig.enablePreloading) { - initPageWithPreloading(page).catch(error => { - console.error('import error:', error); - }); - } else { - initEmptyPage(page).catch(error => { - console.error('init empty page error', error); - }); - } - if (!router.query.pageId) { - setCurrentPageId(pageId); - jumpToPage(currentWorkspace.id, pageId).catch(err => { - console.error(err); + if (currentWorkspace.blockSuiteWorkspace.getPage(pageId) === null) { + const page = currentWorkspace.blockSuiteWorkspace.createPage({ + id: pageId, }); + assertEquals(page.id, pageId); + if (runtimeConfig.enablePreloading) { + initPageWithPreloading(page).catch(error => { + console.error('import error:', error); + }); + } else { + initEmptyPage(page).catch(error => { + console.error('init empty page error', error); + }); + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + currentWorkspace.blockSuiteWorkspace.meta._proxy.isEmpty = false; + if (!router.query.pageId) { + setCurrentPageId(pageId); + jumpToPage(currentWorkspace.id, pageId).catch(err => { + console.error(err); + }); + } } } //#endregion diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index d4109dda3b..3304290887 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -5,14 +5,14 @@ import '../bootstrap'; import { AffineContext } from '@affine/component/context'; import { WorkspaceFallback } from '@affine/component/workspace'; -import { createI18n, I18nextProvider } from '@affine/i18n'; +import { createI18n, I18nextProvider, setUpLanguage } from '@affine/i18n'; import type { EmotionCache } from '@emotion/cache'; import { CacheProvider } from '@emotion/react'; import type { AppProps } from 'next/app'; import Head from 'next/head'; import { useRouter } from 'next/router'; import type { PropsWithChildren, ReactElement } from 'react'; -import React, { lazy, Suspense } from 'react'; +import React, { lazy, Suspense, useEffect } from 'react'; import { AffineErrorBoundary } from '../components/affine/affine-error-eoundary'; import { MessageCenter } from '../components/pure/message-center'; @@ -49,6 +49,13 @@ const App = function App({ }: AppPropsWithLayout & { emotionCache?: EmotionCache; }) { + useEffect(() => { + document.documentElement.lang = i18n.language; + // todo(himself65): this is a hack, we should use a better way to set the language + setUpLanguage(i18n)?.catch(error => { + console.error(error); + }); + }, []); const getLayout = Component.getLayout || EmptyLayout; return ( @@ -56,20 +63,20 @@ const App = function App({ - }> - - - AFFiNE - - - + + + AFFiNE + + + + }> {getLayout()} - - - + + + diff --git a/apps/web/src/pages/_debug/migration.tsx b/apps/web/src/pages/_debug/migration.tsx index 672dffe225..5e1ed4b0d9 100644 --- a/apps/web/src/pages/_debug/migration.tsx +++ b/apps/web/src/pages/_debug/migration.tsx @@ -119,14 +119,13 @@ const MigrationInner = () => { const ids = useAtomValue(workspaceIdsAtom); const [id, setId] = useAtom(targetIdAtom); const router = useRouter(); - const onWriteIntoProduction = useCallback(() => { + const onWriteIntoProduction = useCallback(async () => { assertExists(id); const metadata: RootWorkspaceMetadataV1 = { id, flavour: WorkspaceFlavour.LOCAL, - version: undefined, }; - rootStore.set(rootWorkspacesMetadataAtom, [metadata]); + await rootStore.set(rootWorkspacesMetadataAtom, [metadata]); router.push('/').catch(console.error); }, [id, router]); const writeIntoProductionNode = id && ( diff --git a/apps/web/src/providers/modal-provider.tsx b/apps/web/src/providers/modal-provider.tsx index cb87cb07da..a443af3b73 100644 --- a/apps/web/src/providers/modal-provider.tsx +++ b/apps/web/src/providers/modal-provider.tsx @@ -116,11 +116,11 @@ export const AllWorkspaceModals = (): ReactElement => { (activeId, overId) => { const oldIndex = workspaces.findIndex(w => w.id === activeId); const newIndex = workspaces.findIndex(w => w.id === overId); - transition(() => + transition(() => { setWorkspaces(workspaces => arrayMove(workspaces, oldIndex, newIndex) - ) - ); + ).catch(console.error); + }); }, [setWorkspaces, workspaces] )} diff --git a/packages/env/src/workspace.ts b/packages/env/src/workspace.ts index 7d0dd87110..05361c8db0 100644 --- a/packages/env/src/workspace.ts +++ b/packages/env/src/workspace.ts @@ -206,3 +206,14 @@ export interface AppEvents { // request to revoke access to workspace plugin 'workspace:revoke': () => Promise; } + +export interface WorkspaceAdapter { + releaseType: ReleaseType; + flavour: Flavour; + // The Adapter will be loaded according to the priority + loadPriority: LoadPriority; + Events: Partial; + // Fetch necessary data for the first render + CRUD: WorkspaceCRUD; + UI: WorkspaceUISchema; +} diff --git a/packages/workspace/src/affine/sync.ts b/packages/workspace/src/affine/sync.ts index 29502a5f48..f12ea9e013 100644 --- a/packages/workspace/src/affine/sync.ts +++ b/packages/workspace/src/affine/sync.ts @@ -52,7 +52,7 @@ export function createAffineGlobalChannel( // If the workspace is not in the current workspace list, remove it if (workspaceIndex === -1) { - rootStore.set(rootWorkspacesMetadataAtom, workspaces => { + await rootStore.set(rootWorkspacesMetadataAtom, workspaces => { const idx = workspaces.findIndex(workspace => workspace.id === id); workspaces.splice(idx, 1); return [...workspaces]; diff --git a/packages/workspace/src/atom.ts b/packages/workspace/src/atom.ts index efe7c9bb5a..3fc2b6c367 100644 --- a/packages/workspace/src/atom.ts +++ b/packages/workspace/src/atom.ts @@ -1,28 +1,54 @@ -import { isBrowser } from '@affine/env/constant'; -import type { WorkspaceFlavour } from '@affine/env/workspace'; -import type { WorkspaceVersion } from '@affine/env/workspace'; +import type { WorkspaceAdapter } from '@affine/env/workspace'; +import { WorkspaceFlavour, WorkspaceVersion } from '@affine/env/workspace'; import type { EditorContainer } from '@blocksuite/editor'; +import { assertExists } from '@blocksuite/global/utils'; import { atom } from 'jotai'; -import { atomWithStorage } from 'jotai/utils'; import Router from 'next/router'; +import { z } from 'zod'; -export type RootWorkspaceMetadataV2 = { - id: string; - flavour: WorkspaceFlavour; - version: WorkspaceVersion; -}; +const rootWorkspaceMetadataV1Schema = z.object({ + id: z.string(), + flavour: z.nativeEnum(WorkspaceFlavour), +}); -export type RootWorkspaceMetadataV1 = { - id: string; - flavour: WorkspaceFlavour; - // force type check - version: undefined; -}; +const rootWorkspaceMetadataV2Schema = rootWorkspaceMetadataV1Schema.extend({ + version: z.nativeEnum(WorkspaceVersion), +}); + +const rootWorkspaceMetadataArraySchema = z.array( + z.union([rootWorkspaceMetadataV1Schema, rootWorkspaceMetadataV2Schema]) +); + +export type RootWorkspaceMetadataV2 = z.infer< + typeof rootWorkspaceMetadataV2Schema +>; + +export type RootWorkspaceMetadataV1 = z.infer< + typeof rootWorkspaceMetadataV1Schema +>; export type RootWorkspaceMetadata = | RootWorkspaceMetadataV1 | RootWorkspaceMetadataV2; +export const workspaceAdaptersAtom = atom< + Record< + WorkspaceFlavour, + Pick< + WorkspaceAdapter, + 'CRUD' | 'Events' | 'flavour' | 'loadPriority' + > + > +>( + null as unknown as Record< + WorkspaceFlavour, + Pick< + WorkspaceAdapter, + 'CRUD' | 'Events' | 'flavour' | 'loadPriority' + > + > +); + // #region root atoms // root primitive atom that stores the necessary data for the whole app // be careful when you use this atom, @@ -32,20 +58,170 @@ export type RootWorkspaceMetadata = * this atom stores the metadata of all workspaces, * which is `id` and `flavor`, that is enough to load the real workspace data */ -export const rootWorkspacesMetadataAtom = atomWithStorage< - RootWorkspaceMetadata[] +const METADATA_STORAGE_KEY = 'jotai-workspaces'; +const rootWorkspacesMetadataPrimitiveAtom = atom< + RootWorkspaceMetadata[] | null +>(null); +const rootWorkspacesMetadataPromiseAtom = atom< + Promise +>(async (get, { signal }) => { + const WorkspaceAdapters = get(workspaceAdaptersAtom); + assertExists(WorkspaceAdapters, 'workspace adapter should be defined'); + const maybeMetadata = get(rootWorkspacesMetadataPrimitiveAtom); + if (maybeMetadata !== null) { + return maybeMetadata; + } + const createFirst = (): RootWorkspaceMetadataV2[] => { + if (signal.aborted) { + return []; + } + + const Plugins = Object.values(WorkspaceAdapters).sort( + (a, b) => a.loadPriority - b.loadPriority + ); + + return Plugins.flatMap(Plugin => { + return Plugin.Events['app:init']?.().map( + id => + ({ + id, + flavour: Plugin.flavour, + // new workspace should all support sub-doc feature + version: WorkspaceVersion.SubDoc, + } satisfies RootWorkspaceMetadataV2) + ); + }).filter((ids): ids is RootWorkspaceMetadataV2 => !!ids); + }; + + if (environment.isServer) { + // return a promise in SSR to avoid the hydration mismatch + return Promise.resolve([]); + } else { + const metadata: RootWorkspaceMetadata[] = []; + + // fixme(himself65): we might not need step 1 + // step 1: try load metadata from localStorage + { + // don't change this key, + // otherwise it will cause the data loss in the production + const primitiveMetadata = localStorage.getItem(METADATA_STORAGE_KEY); + if (primitiveMetadata) { + try { + const items = JSON.parse(primitiveMetadata) as z.infer< + typeof rootWorkspaceMetadataArraySchema + >; + rootWorkspaceMetadataArraySchema.parse(items); + metadata.push(...items); + } catch (e) { + console.error('cannot parse worksapce', e); + } + } + + // migration step, only data in `METADATA_STORAGE_KEY` will be migrated + if (metadata.some(meta => !('version' in meta))) { + await new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(), { once: true }); + window.addEventListener('migration-done', () => resolve(), { + once: true, + }); + }); + } + } + // step 2: fetch from adapters + { + const lists = Object.values(WorkspaceAdapters) + .sort((a, b) => a.loadPriority - b.loadPriority) + .map(({ CRUD }) => CRUD.list); + + for (const list of lists) { + try { + const item = await list(); + if (metadata.length) { + item.sort((a, b) => { + return ( + metadata.findIndex(x => x.id === a.id) - + metadata.findIndex(x => x.id === b.id) + ); + }); + } + metadata.push( + ...item.map(x => ({ + id: x.id, + flavour: x.flavour, + version: WorkspaceVersion.SubDoc, + })) + ); + } catch (e) { + console.error('list data error:', e); + } + } + } + // step 3: create initial workspaces + { + if ( + metadata.length === 0 && + localStorage.getItem('is-first-open') === null + ) { + metadata.push(...createFirst()); + console.info('create first workspace', metadata); + localStorage.setItem('is-first-open', 'false'); + } + } + const metadataMap = new Map(metadata.map(x => [x.id, x])); + return Array.from(metadataMap.values()); + } +}); + +type SetStateAction = Value | ((prev: Value) => Value); + +export const rootWorkspacesMetadataAtom = atom< + Promise, + [SetStateAction], + Promise >( - // don't change this key, - // otherwise it will cause the data loss in the production - 'jotai-workspaces', - [] + async get => { + if (environment.isServer) { + return Promise.resolve([]); + } + const maybeMetadata = get(rootWorkspacesMetadataPrimitiveAtom); + if (maybeMetadata !== null) { + return maybeMetadata; + } + return get(rootWorkspacesMetadataPromiseAtom); + }, + async (get, set, action) => { + // get metadata + let metadata: RootWorkspaceMetadata[]; + const maybeMetadata = get(rootWorkspacesMetadataPrimitiveAtom); + if (maybeMetadata !== null) { + metadata = maybeMetadata; + } else { + metadata = await get(rootWorkspacesMetadataPromiseAtom); + } + + // update metadata + if (typeof action === 'function') { + metadata = action(metadata); + } else { + metadata = action; + } + + const metadataMap = new Map(metadata.map(x => [x.id, x])); + metadata = Array.from(metadataMap.values()); + + // write back to localStorage + rootWorkspaceMetadataArraySchema.parse(metadata); + localStorage.setItem(METADATA_STORAGE_KEY, JSON.stringify(metadata)); + set(rootWorkspacesMetadataPrimitiveAtom, metadata); + return metadata; + } ); // two more atoms to store the current workspace and page export const rootCurrentWorkspaceIdAtom = atom(null); rootCurrentWorkspaceIdAtom.onMount = set => { - if (isBrowser) { + if (environment.isBrowser) { const callback = (url: string) => { const value = url.split('/')[2]; if (value) { @@ -67,7 +243,7 @@ rootCurrentWorkspaceIdAtom.onMount = set => { export const rootCurrentPageIdAtom = atom(null); rootCurrentPageIdAtom.onMount = set => { - if (isBrowser) { + if (environment.isBrowser) { const callback = (url: string) => { const value = url.split('/')[3]; if (value) { diff --git a/packages/workspace/src/migration/index.ts b/packages/workspace/src/migration/index.ts index 1077a8e56f..89080dcd10 100644 --- a/packages/workspace/src/migration/index.ts +++ b/packages/workspace/src/migration/index.ts @@ -26,7 +26,7 @@ export function upgradeV1ToV2(oldWorkspace: LocalWorkspace): LocalWorkspace { } }); }); - console.log(newBlockSuiteWorkspace.doc.toJSON()); + console.log('migration result', newBlockSuiteWorkspace.doc.toJSON()); return { blockSuiteWorkspace: newBlockSuiteWorkspace, diff --git a/packages/workspace/src/utils.ts b/packages/workspace/src/utils.ts index 28f4d3e65a..c9cafd55df 100644 --- a/packages/workspace/src/utils.ts +++ b/packages/workspace/src/utils.ts @@ -20,9 +20,11 @@ import { createAffineBlobStorage } from './blob'; import { createSQLiteStorage } from './blob/sqlite-blob-storage'; export function cleanupWorkspace(flavour: WorkspaceFlavour) { - rootStore.set(rootWorkspacesMetadataAtom, metas => - metas.filter(meta => meta.flavour !== flavour) - ); + rootStore + .set(rootWorkspacesMetadataAtom, metas => + metas.filter(meta => meta.flavour !== flavour) + ) + .catch(console.error); } function setEditorFlags(workspace: Workspace) {