diff --git a/apps/core/src/app.tsx b/apps/core/src/app.tsx index bd3f72a972..bc8783d705 100644 --- a/apps/core/src/app.tsx +++ b/apps/core/src/app.tsx @@ -5,117 +5,13 @@ import { AffineContext } from '@affine/component/context'; import { WorkspaceFallback } from '@affine/component/workspace'; import { createI18n, setUpLanguage } from '@affine/i18n'; import { CacheProvider } from '@emotion/react'; -import type { RouterState } from '@remix-run/router'; -import { - currentPageIdAtom, - currentWorkspaceIdAtom, -} from '@toeverything/plugin-infra/manager'; import type { PropsWithChildren, ReactElement } from 'react'; import { lazy, memo, Suspense, useEffect } from 'react'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { RouterProvider } from 'react-router-dom'; -import { historyBaseAtom, MAX_HISTORY } from './atoms/history'; +import { router } from './router'; import createEmotionCache from './utils/create-emotion-cache'; -const router = createBrowserRouter([ - { - path: '/', - lazy: () => import('./pages/index'), - }, - { - path: '/404', - lazy: () => import('./pages/404'), - }, - { - path: '/workspace/:workspaceId/all', - lazy: () => import('./pages/workspace/all-page'), - }, - { - path: '/workspace/:workspaceId/trash', - lazy: () => import('./pages/workspace/trash-page'), - }, - { - path: '/workspace/:workspaceId/:pageId', - lazy: () => import('./pages/workspace/detail-page'), - }, -]); - -//#region atoms bootstrap - -currentWorkspaceIdAtom.onMount = set => { - const callback = (state: RouterState) => { - const value = state.location.pathname.split('/')[2]; - if (value) { - set(value); - localStorage.setItem('last_workspace_id', value); - } - }; - callback(router.state); - - const unsubscribe = router.subscribe(callback); - return () => { - unsubscribe(); - }; -}; - -currentPageIdAtom.onMount = set => { - const callback = (state: RouterState) => { - const value = state.location.pathname.split('/')[3]; - if (value) { - set(value); - } - }; - callback(router.state); - - const unsubscribe = router.subscribe(callback); - return () => { - unsubscribe(); - }; -}; - -historyBaseAtom.onMount = set => { - const unsubscribe = router.subscribe(state => { - set(prev => { - const url = state.location.pathname; - console.log('push', url, prev.skip, prev.stack.length, prev.current); - if (prev.skip) { - return { - stack: [...prev.stack], - current: prev.current, - skip: false, - }; - } else { - if (prev.current < prev.stack.length - 1) { - const newStack = prev.stack.slice(0, prev.current); - newStack.push(url); - if (newStack.length > MAX_HISTORY) { - newStack.shift(); - } - return { - stack: newStack, - current: newStack.length - 1, - skip: false, - }; - } else { - const newStack = [...prev.stack, url]; - if (newStack.length > MAX_HISTORY) { - newStack.shift(); - } - return { - stack: newStack, - current: newStack.length - 1, - skip: false, - }; - } - } - }); - }); - return () => { - unsubscribe(); - }; -}; -//#endregion - const i18n = createI18n(); const cache = createEmotionCache(); @@ -132,6 +28,10 @@ const DebugProvider = ({ children }: PropsWithChildren): ReactElement => { ); }; +const future = { + v7_startTransition: true, +} as const; + export const App = memo(function App() { useEffect(() => { document.documentElement.lang = i18n.language; @@ -144,9 +44,11 @@ export const App = memo(function App() { - }> - - + } + router={router} + future={future} + /> diff --git a/apps/core/src/atoms/history.ts b/apps/core/src/atoms/history.ts index 592c72b41d..9d38c8fcf4 100644 --- a/apps/core/src/atoms/history.ts +++ b/apps/core/src/atoms/history.ts @@ -1,8 +1,10 @@ import { useAtom } from 'jotai'; -import { atomWithStorage } from 'jotai/utils'; +import { atomWithStorage, createJSONStorage } from 'jotai/utils'; import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; +import { router } from '../router'; + export type History = { stack: string[]; current: number; @@ -11,11 +13,62 @@ export type History = { export const MAX_HISTORY = 50; -export const historyBaseAtom = atomWithStorage('router-history', { - stack: [], - current: 0, - skip: false, -}); +const historyBaseAtom = atomWithStorage( + 'router-history', + { + stack: [], + current: 0, + skip: false, + }, + createJSONStorage(() => sessionStorage) +); + +historyBaseAtom.onMount = set => { + const unsubscribe = router.subscribe(state => { + set(prev => { + const url = state.location.pathname; + + // if stack top is the same as current, skip + if (prev.stack[prev.current] === url) { + return prev; + } + + if (prev.skip) { + return { + stack: [...prev.stack], + current: prev.current, + skip: false, + }; + } else { + if (prev.current < prev.stack.length - 1) { + const newStack = prev.stack.slice(0, prev.current); + newStack.push(url); + if (newStack.length > MAX_HISTORY) { + newStack.shift(); + } + return { + stack: newStack, + current: newStack.length - 1, + skip: false, + }; + } else { + const newStack = [...prev.stack, url]; + if (newStack.length > MAX_HISTORY) { + newStack.shift(); + } + return { + stack: newStack, + current: newStack.length - 1, + skip: false, + }; + } + } + }); + }); + return () => { + unsubscribe(); + }; +}; export function useHistoryAtom() { const navigate = useNavigate(); diff --git a/apps/core/src/layouts/workspace-layout.tsx b/apps/core/src/layouts/workspace-layout.tsx index 246e83c483..fb8f9a5376 100644 --- a/apps/core/src/layouts/workspace-layout.tsx +++ b/apps/core/src/layouts/workspace-layout.tsx @@ -10,7 +10,6 @@ import { ToolContainer, WorkspaceFallback, } from '@affine/component/workspace'; -import { DEFAULT_HELLO_WORLD_PAGE_ID_SUFFIX } from '@affine/env/constant'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { rootBlockHubAtom, @@ -30,14 +29,11 @@ import { } from '@dnd-kit/core'; import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper'; import { usePassiveWorkspaceEffect } from '@toeverything/plugin-infra/__internal__/react'; -import { - currentPageIdAtom, - currentWorkspaceIdAtom, -} from '@toeverything/plugin-infra/manager'; +import { currentWorkspaceIdAtom } from '@toeverything/plugin-infra/manager'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import type { FC, PropsWithChildren, ReactElement } from 'react'; -import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { lazy, Suspense, useCallback, useMemo } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { WorkspaceAdapters } from '../adapters/workspace'; import { @@ -111,20 +107,6 @@ export const CurrentWorkspaceContext = ({ const workspaceId = useAtomValue(currentWorkspaceIdAtom); const metadata = useAtomValue(rootWorkspacesMetadataAtom); const exist = metadata.find(m => m.id === workspaceId); - const navigate = useNavigate(); - // fixme(himself65): this is not a good way to handle this, - // need a better way to check whether this workspace really exist. - useEffect(() => { - const id = setTimeout(() => { - if (!exist) { - navigate('/'); - globalThis.HALTING_PROBLEM_TIMEOUT <<= 1; - } - }, globalThis.HALTING_PROBLEM_TIMEOUT); - return () => { - clearTimeout(id); - }; - }, [exist, metadata.length, navigate]); if (metadata.length === 0) { return ; } @@ -171,24 +153,10 @@ export const WorkspaceLayout: FC = export const WorkspaceLayoutInner: FC = ({ children }) => { const [currentWorkspace] = useCurrentWorkspace(); - const [currentPageId, setCurrentPageId] = useAtom(currentPageIdAtom); - const { jumpToPage, openPage } = useNavigateHelper(); + const { openPage } = useNavigateHelper(); usePassiveWorkspaceEffect(currentWorkspace.blockSuiteWorkspace); - useEffect(() => { - const page = currentWorkspace.blockSuiteWorkspace.getPage( - `${currentWorkspace.blockSuiteWorkspace.id}-${DEFAULT_HELLO_WORLD_PAGE_ID_SUFFIX}` - ); - if (page && page.meta.jumpOnce) { - currentWorkspace.blockSuiteWorkspace.meta.setPageMeta(page.id, { - jumpOnce: false, - }); - setCurrentPageId(currentPageId); - jumpToPage(currentWorkspace.id, page.id); - } - }, [currentPageId, currentWorkspace, jumpToPage, setCurrentPageId]); - const [, setOpenWorkspacesModal] = useAtom(openWorkspacesModalAtom); const helper = useBlockSuiteWorkspaceHelper( currentWorkspace.blockSuiteWorkspace diff --git a/apps/core/src/pages/index.tsx b/apps/core/src/pages/index.tsx index a1d5f51694..c2437e9558 100644 --- a/apps/core/src/pages/index.tsx +++ b/apps/core/src/pages/index.tsx @@ -1,12 +1,10 @@ import { DebugLogger } from '@affine/debug'; -import { WorkspaceSubPath } from '@affine/env/workspace'; import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; import { getWorkspace } from '@toeverything/plugin-infra/__internal__/workspace'; -import { useAtomValue } from 'jotai'; -import { lazy, useEffect, useRef } from 'react'; - -import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; -import { useWorkspace } from '../hooks/use-workspace'; +import { rootStore } from '@toeverything/plugin-infra/manager'; +import { lazy } from 'react'; +import type { LoaderFunction } from 'react-router-dom'; +import { redirect } from 'react-router-dom'; const AllWorkspaceModals = lazy(() => import('../providers/modal-provider').then(({ AllWorkspaceModals }) => ({ @@ -14,81 +12,35 @@ const AllWorkspaceModals = lazy(() => })) ); -type WorkspaceLoaderProps = { - id: string; -}; +const logger = new DebugLogger('index-page'); -const WorkspaceLoader = (props: WorkspaceLoaderProps): null => { - useWorkspace(props.id); +export const loader: LoaderFunction = async () => { + const meta = await rootStore.get(rootWorkspacesMetadataAtom); + const lastId = localStorage.getItem('last_workspace_id'); + const lastPageId = localStorage.getItem('last_page_id'); + const target = (lastId && meta.find(({ id }) => id === lastId)) || meta.at(0); + if (target) { + const targetWorkspace = getWorkspace(target.id); + const nonTrashPages = targetWorkspace.meta.pageMetas.filter( + ({ trash }) => !trash + ); + const pageId = + nonTrashPages.find(({ id }) => id === lastPageId)?.id ?? + nonTrashPages.at(0)?.id; + if (pageId) { + logger.debug('Found target workspace. Jump to page', pageId); + return redirect(`/workspace/${targetWorkspace.id}/${pageId}`); + } else { + logger.debug('Found target workspace. Jump to all page'); + return redirect(`/workspace/${targetWorkspace.id}/all`); + } + } return null; }; -const logger = new DebugLogger('index-page'); - export const Component = () => { - const meta = useAtomValue(rootWorkspacesMetadataAtom); - const navigateHelper = useNavigateHelper(); - const jumpOnceRef = useRef(false); - useEffect(() => { - if (jumpOnceRef.current) { - return; - } - const lastId = localStorage.getItem('last_workspace_id'); - const lastPageId = localStorage.getItem('last_page_id'); - const target = - (lastId && meta.find(({ id }) => id === lastId)) || meta.at(0); - if (target) { - const targetWorkspace = getWorkspace(target.id); - const nonTrashPages = targetWorkspace.meta.pageMetas.filter( - ({ trash }) => !trash - ); - const pageId = - nonTrashPages.find(({ id }) => id === lastPageId)?.id ?? - nonTrashPages.at(0)?.id; - if (pageId) { - logger.debug('Found target workspace. Jump to page', pageId); - navigateHelper.jumpToPage( - targetWorkspace.id, - pageId, - RouteLogic.REPLACE - ); - jumpOnceRef.current = true; - } else { - const clearId = setTimeout(() => { - dispose.dispose(); - logger.debug('Found target workspace. Jump to all pages'); - navigateHelper.jumpToSubPath( - targetWorkspace.id, - WorkspaceSubPath.ALL, - RouteLogic.REPLACE - ); - jumpOnceRef.current = true; - }, 1000); - const dispose = targetWorkspace.slots.pageAdded.once(pageId => { - clearTimeout(clearId); - navigateHelper.jumpToPage( - targetWorkspace.id, - pageId, - RouteLogic.REPLACE - ); - jumpOnceRef.current = true; - }); - return () => { - clearTimeout(clearId); - dispose.dispose(); - jumpOnceRef.current = false; - }; - } - } else { - console.warn('No workspace found'); - } - return; - }, [meta, navigateHelper]); return ( <> - {meta.map(({ id }) => ( - - ))} ); diff --git a/apps/core/src/pages/workspace/all-page.tsx b/apps/core/src/pages/workspace/all-page.tsx index 1a88c601f4..b501c81313 100644 --- a/apps/core/src/pages/workspace/all-page.tsx +++ b/apps/core/src/pages/workspace/all-page.tsx @@ -1,15 +1,41 @@ import { useCollectionManager } from '@affine/component/page-list'; +import { DEFAULT_HELLO_WORLD_PAGE_ID_SUFFIX } from '@affine/env/constant'; import { WorkspaceSubPath } from '@affine/env/workspace'; import { assertExists } from '@blocksuite/global/utils'; -import { useCallback } from 'react'; +import { getActiveBlockSuiteWorkspaceAtom } from '@toeverything/plugin-infra/__internal__/workspace'; +import { + currentPageIdAtom, + rootStore, +} from '@toeverything/plugin-infra/manager'; +import { useAtom } from 'jotai/react'; +import { useCallback, useEffect } from 'react'; +import type { LoaderFunction } from 'react-router-dom'; +import { redirect } from 'react-router-dom'; import { getUIAdapter } from '../../adapters/workspace'; import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; -import { WorkspaceLayout } from '../../layouts/workspace-layout'; -const AllPage = () => { +export const loader: LoaderFunction = async args => { + const workspaceId = args.params.workspaceId; + assertExists(workspaceId); + const workspaceAtom = getActiveBlockSuiteWorkspaceAtom(workspaceId); + const workspace = await rootStore.get(workspaceAtom); + const page = workspace.getPage( + `${workspace.id}-${DEFAULT_HELLO_WORLD_PAGE_ID_SUFFIX}` + ); + if (page && page.meta.jumpOnce) { + workspace.meta.setPageMeta(page.id, { + jumpOnce: false, + }); + return redirect(`/workspace/${workspace.id}/${page.id}`); + } + return null; +}; + +export const AllPage = () => { const { jumpToPage } = useNavigateHelper(); + const [currentPageId, setCurrentPageId] = useAtom(currentPageIdAtom); const [currentWorkspace] = useCurrentWorkspace(); const setting = useCollectionManager(currentWorkspace.id); const onClickPage = useCallback( @@ -23,6 +49,18 @@ const AllPage = () => { }, [currentWorkspace, jumpToPage] ); + useEffect(() => { + const page = currentWorkspace.blockSuiteWorkspace.getPage( + `${currentWorkspace.blockSuiteWorkspace.id}-${DEFAULT_HELLO_WORLD_PAGE_ID_SUFFIX}` + ); + if (page && page.meta.jumpOnce) { + currentWorkspace.blockSuiteWorkspace.meta.setPageMeta(page.id, { + jumpOnce: false, + }); + setCurrentPageId(currentPageId); + jumpToPage(currentWorkspace.id, page.id); + } + }, [currentPageId, currentWorkspace, jumpToPage, setCurrentPageId]); const { PageList, Header } = getUIAdapter(currentWorkspace.flavour); return ( <> @@ -42,9 +80,5 @@ const AllPage = () => { }; export const Component = () => { - return ( - - - - ); + return ; }; diff --git a/apps/core/src/pages/workspace/detail-page.tsx b/apps/core/src/pages/workspace/detail-page.tsx index 35aa2e1e4e..e830175bfa 100644 --- a/apps/core/src/pages/workspace/detail-page.tsx +++ b/apps/core/src/pages/workspace/detail-page.tsx @@ -7,18 +7,21 @@ import { WorkspaceSubPath } from '@affine/env/workspace'; import type { EditorContainer } from '@blocksuite/editor'; import { assertExists } from '@blocksuite/global/utils'; import type { Page } from '@blocksuite/store'; -import { currentPageIdAtom } from '@toeverything/plugin-infra/manager'; +import { + currentPageIdAtom, + rootStore, +} from '@toeverything/plugin-infra/manager'; import { useAtomValue } from 'jotai'; import { useAtom } from 'jotai/react'; import { type ReactElement, useCallback, useEffect } from 'react'; +import type { LoaderFunction } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { getUIAdapter } from '../../adapters/workspace'; import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; -import { WorkspaceLayout } from '../../layouts/workspace-layout'; -const WorkspaceDetailPageImpl = (): ReactElement => { +const DetailPageImpl = (): ReactElement => { const { openPage, jumpToSubPath } = useNavigateHelper(); const currentPageId = useAtomValue(currentPageIdAtom); const [currentWorkspace] = useCurrentWorkspace(); @@ -68,7 +71,7 @@ const WorkspaceDetailPageImpl = (): ReactElement => { ); }; -const WorkspaceDetailPage = (): ReactElement => { +export const DetailPage = (): ReactElement => { const { workspaceId, pageId } = useParams(); const location = useLocation(); const navigate = useNavigate(); @@ -92,6 +95,13 @@ const WorkspaceDetailPage = (): ReactElement => { currentWorkspace.blockSuiteWorkspace.getPage(currentPageId); if (!page) { navigate('/404'); + } else { + // fixme: cleanup jumpOnce in the right time + if (page.meta.jumpOnce) { + currentWorkspace.blockSuiteWorkspace.setPageMeta(currentPageId, { + jumpOnce: false, + }); + } } } } @@ -110,13 +120,17 @@ const WorkspaceDetailPage = (): ReactElement => { if (!currentPageId || !page) { return ; } - return ; + return ; +}; + +export const loader: LoaderFunction = args => { + if (args.params.pageId) { + localStorage.setItem('last_page_id', args.params.pageId); + rootStore.set(currentPageIdAtom, args.params.pageId); + } + return null; }; export const Component = () => { - return ( - - - - ); + return ; }; diff --git a/apps/core/src/pages/workspace/index.tsx b/apps/core/src/pages/workspace/index.tsx new file mode 100644 index 0000000000..f2406b7c7a --- /dev/null +++ b/apps/core/src/pages/workspace/index.tsx @@ -0,0 +1,29 @@ +import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; +import { + currentWorkspaceIdAtom, + rootStore, +} from '@toeverything/plugin-infra/manager'; +import type { ReactElement } from 'react'; +import { type LoaderFunction, Outlet, redirect } from 'react-router-dom'; + +import { WorkspaceLayout } from '../../layouts/workspace-layout'; + +export const loader: LoaderFunction = async args => { + const meta = await rootStore.get(rootWorkspacesMetadataAtom); + if (!meta.some(({ id }) => id === args.params.workspaceId)) { + return redirect('/404'); + } + if (args.params.workspaceId) { + localStorage.setItem('last_workspace_id', args.params.workspaceId); + rootStore.set(currentWorkspaceIdAtom, args.params.workspaceId); + } + return null; +}; + +export const Component = (): ReactElement => { + return ( + + + + ); +}; diff --git a/apps/core/src/pages/workspace/trash-page.tsx b/apps/core/src/pages/workspace/trash-page.tsx index e6197f003f..85d7d49604 100644 --- a/apps/core/src/pages/workspace/trash-page.tsx +++ b/apps/core/src/pages/workspace/trash-page.tsx @@ -6,9 +6,8 @@ import { getUIAdapter } from '../../adapters/workspace'; import { BlockSuitePageList } from '../../components/blocksuite/block-suite-page-list'; import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../../hooks/use-navigate-helper'; -import { WorkspaceLayout } from '../../layouts/workspace-layout'; -const TrashPage = () => { +export const TrashPage = () => { const { jumpToPage } = useNavigateHelper(); const [currentWorkspace] = useCurrentWorkspace(); const onClickPage = useCallback( @@ -44,9 +43,5 @@ const TrashPage = () => { }; export const Component = () => { - return ( - - - - ); + return ; }; diff --git a/apps/core/src/router.ts b/apps/core/src/router.ts new file mode 100644 index 0000000000..444e5fe7dc --- /dev/null +++ b/apps/core/src/router.ts @@ -0,0 +1,37 @@ +import { createBrowserRouter } from 'react-router-dom'; + +export const router = createBrowserRouter( + [ + { + path: '/', + lazy: () => import('./pages/index'), + }, + { + path: '/workspace/:workspaceId', + lazy: () => import('./pages/workspace/index'), + children: [ + { + path: 'all', + lazy: () => import('./pages/workspace/all-page'), + }, + { + path: 'trash', + lazy: () => import('./pages/workspace/trash-page'), + }, + { + path: ':pageId', + lazy: () => import('./pages/workspace/detail-page'), + }, + ], + }, + { + path: '/404', + lazy: () => import('./pages/404'), + }, + ], + { + future: { + v7_normalizeFormMethod: true, + }, + } +); diff --git a/apps/electron/src/helper/db/workspace-db-adapter.ts b/apps/electron/src/helper/db/workspace-db-adapter.ts index 18d929cdd2..63f1bbbb38 100644 --- a/apps/electron/src/helper/db/workspace-db-adapter.ts +++ b/apps/electron/src/helper/db/workspace-db-adapter.ts @@ -52,12 +52,7 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter { }; setupListener(docId?: string) { - logger.debug( - 'WorkspaceSQLiteDB:setupListener', - this.workspaceId, - docId, - this.getWorkspaceName() - ); + logger.debug('WorkspaceSQLiteDB:setupListener', this.workspaceId, docId); const doc = this.getDoc(docId); if (doc) { const onUpdate = async (update: Uint8Array, origin: YOrigin) => { diff --git a/packages/workspace/src/atom.ts b/packages/workspace/src/atom.ts index fc826a3562..8cc7294130 100644 --- a/packages/workspace/src/atom.ts +++ b/packages/workspace/src/atom.ts @@ -1,11 +1,12 @@ import type { WorkspaceAdapter } from '@affine/env/workspace'; import { WorkspaceFlavour, WorkspaceVersion } from '@affine/env/workspace'; -import { getOrCreateWorkspace } from '@affine/workspace/manager'; import type { BlockHub } from '@blocksuite/blocks'; import { assertExists } from '@blocksuite/global/utils'; import { atom } from 'jotai'; import { z } from 'zod'; +import { getOrCreateWorkspace } from './manager'; + const rootWorkspaceMetadataV1Schema = z.object({ id: z.string(), flavour: z.nativeEnum(WorkspaceFlavour), diff --git a/packages/workspace/src/manager/index.ts b/packages/workspace/src/manager/index.ts index 308eaaecdd..da82455eb7 100644 --- a/packages/workspace/src/manager/index.ts +++ b/packages/workspace/src/manager/index.ts @@ -1,10 +1,6 @@ import { isBrowser, isDesktop } from '@affine/env/constant'; import type { BlockSuiteFeatureFlags } from '@affine/env/global'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { - createAffineProviders, - createLocalProviders, -} from '@affine/workspace/providers'; import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; import type { DocProviderCreator, StoreOptions } from '@blocksuite/store'; import { @@ -18,6 +14,7 @@ import type { Transaction } from 'yjs'; import { createStaticStorage } from '../blob/local-static-storage'; import { createSQLiteStorage } from '../blob/sqlite-blob-storage'; +import { createAffineProviders, createLocalProviders } from '../providers'; function setEditorFlags(workspace: Workspace) { Object.entries(runtimeConfig.editorFlags).forEach(([key, value]) => { diff --git a/packages/workspace/src/providers/datasource-doc-adapter.ts b/packages/workspace/src/providers/datasource-doc-adapter.ts deleted file mode 100644 index c0af541c75..0000000000 --- a/packages/workspace/src/providers/datasource-doc-adapter.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface DatasourceDocAdapter { - // request diff update from other clients - queryDocState: ( - guid: string, - options?: { - stateVector?: Uint8Array; - targetClientId?: number; - } - ) => Promise; - - // send update to the datasource - sendDocUpdate: (guid: string, update: Uint8Array) => Promise; - - // listen to update from the datasource. Returns a function to unsubscribe. - // this is optional because some datasource might not support it - onDocUpdate?( - callback: (guid: string, update: Uint8Array) => void - ): () => void; -} diff --git a/packages/workspace/src/providers/lazy-provider.ts b/packages/workspace/src/providers/lazy-provider.ts deleted file mode 100644 index 376c61669c..0000000000 --- a/packages/workspace/src/providers/lazy-provider.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { PassiveDocProvider } from '@blocksuite/store'; -import { - applyUpdate, - type Doc, - encodeStateAsUpdate, - encodeStateVectorFromUpdate, -} from 'yjs'; - -import type { DatasourceDocAdapter } from './datasource-doc-adapter'; - -const selfUpdateOrigin = 'lazy-provider-self-origin'; - -function getDoc(doc: Doc, guid: string): Doc | undefined { - if (doc.guid === guid) { - return doc; - } - for (const subdoc of doc.subdocs) { - const found = getDoc(subdoc, guid); - if (found) { - return found; - } - } - return undefined; -} - -/** - * Creates a lazy provider that connects to a datasource and synchronizes a root document. - */ -export const createLazyProvider = ( - rootDoc: Doc, - datasource: DatasourceDocAdapter -): Omit => { - let connected = false; - const pendingMap = new Map(); // guid -> pending-updates - const disposableMap = new Map void>>(); - let datasourceUnsub: (() => void) | undefined; - - async function syncDoc(doc: Doc) { - const guid = doc.guid; - // perf: optimize me - const currentUpdate = encodeStateAsUpdate(doc); - - const remoteUpdate = await datasource.queryDocState(guid, { - stateVector: encodeStateVectorFromUpdate(currentUpdate), - }); - - const updates = [currentUpdate]; - pendingMap.set(guid, []); - - if (remoteUpdate) { - applyUpdate(doc, remoteUpdate, selfUpdateOrigin); - const newUpdate = encodeStateAsUpdate( - doc, - encodeStateVectorFromUpdate(remoteUpdate) - ); - updates.push(newUpdate); - await datasource.sendDocUpdate(guid, newUpdate); - } - } - - function setupDocListener(doc: Doc) { - const disposables = new Set<() => void>(); - disposableMap.set(doc.guid, disposables); - const updateHandler = async (update: Uint8Array, origin: unknown) => { - if (origin === selfUpdateOrigin) { - return; - } - datasource.sendDocUpdate(doc.guid, update).catch(console.error); - }; - - const subdocLoadHandler = (event: { loaded: Set }) => { - event.loaded.forEach(subdoc => { - connectDoc(subdoc).catch(console.error); - }); - }; - - doc.on('update', updateHandler); - doc.on('subdocs', subdocLoadHandler); - // todo: handle destroy? - disposables.add(() => { - doc.off('update', updateHandler); - doc.off('subdocs', subdocLoadHandler); - }); - } - - function setupDatasourceListeners() { - datasourceUnsub = datasource.onDocUpdate?.((guid, update) => { - const doc = getDoc(rootDoc, guid); - if (doc) { - applyUpdate(doc, update); - // - if (pendingMap.has(guid)) { - pendingMap.get(guid)?.forEach(update => applyUpdate(doc, update)); - pendingMap.delete(guid); - } - } else { - // This case happens when the father doc is not yet updated, - // so that the child doc is not yet created. - // We need to put it into cache so that it can be applied later. - console.warn('idb: doc not found', guid); - pendingMap.set(guid, (pendingMap.get(guid) ?? []).concat(update)); - } - }); - } - - // when a subdoc is loaded, we need to sync it with the datasource and setup listeners - async function connectDoc(doc: Doc) { - setupDocListener(doc); - await syncDoc(doc); - await Promise.all( - [...doc.subdocs] - .filter(subdoc => subdoc.shouldLoad) - .map(subdoc => connectDoc(subdoc)) - ); - } - - function disposeAll() { - disposableMap.forEach(disposables => { - disposables.forEach(dispose => dispose()); - }); - disposableMap.clear(); - } - - function connect() { - connected = true; - - // root doc should be already loaded, - // but we want to populate the cache for later update events - connectDoc(rootDoc).catch(console.error); - setupDatasourceListeners(); - } - - async function disconnect() { - connected = false; - disposeAll(); - datasourceUnsub?.(); - datasourceUnsub = undefined; - } - - return { - get connected() { - return connected; - }, - passive: true, - connect, - disconnect, - }; -}; diff --git a/packages/workspace/src/providers/sqlite-providers.ts b/packages/workspace/src/providers/sqlite-providers.ts index 6344056f48..b3e651dc91 100644 --- a/packages/workspace/src/providers/sqlite-providers.ts +++ b/packages/workspace/src/providers/sqlite-providers.ts @@ -2,6 +2,7 @@ import type { SQLiteDBDownloadProvider, SQLiteProvider, } from '@affine/env/workspace'; +import { getDoc } from '@affine/y-provider'; import { assertExists } from '@blocksuite/global/utils'; import type { DocProviderCreator } from '@blocksuite/store'; import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; @@ -19,6 +20,28 @@ type SubDocsEvent = { loaded: Set; }; +// workaround: there maybe new updates before SQLite is connected +// we need to exchange them with the SQLite db +// will be removed later when we have lazy load doc provider +const syncDiff = async (rootDoc: Doc, subdocId?: string) => { + try { + const workspaceId = rootDoc.guid; + const doc = subdocId ? getDoc(rootDoc, subdocId) : rootDoc; + if (!doc) { + logger.error('doc not found', workspaceId, subdocId); + return; + } + const update = await window.apis?.db.getDocAsUpdates(workspaceId, subdocId); + const diff = Y.encodeStateAsUpdate( + doc, + Y.encodeStateVectorFromUpdate(update) + ); + await window.apis.db.applyDocUpdate(workspaceId, diff, subdocId); + } catch (err) { + logger.error('failed to sync diff', err); + } +}; + /** * A provider that is responsible for syncing updates the workspace with the local SQLite database. */ @@ -74,6 +97,9 @@ export const createSQLiteProvider: DocProviderCreator = ( }; function trackDoc(doc: Doc) { + syncDiff(rootDoc, rootDoc !== doc ? doc.guid : undefined).catch( + logger.error + ); doc.on('update', createOrHandleUpdate(doc)); doc.on('subdocs', createOrGetHandleSubDocs(doc)); doc.subdocs.forEach(doc => { @@ -93,6 +119,9 @@ export const createSQLiteProvider: DocProviderCreator = ( let connected = false; const connect = () => { + if (connected) { + return; + } logger.info('connecting sqlite provider', id); trackDoc(rootDoc); @@ -161,7 +190,7 @@ export const createSQLiteDBDownloadProvider: DocProviderCreator = ( }); async function syncUpdates(doc: Doc) { - logger.info('syncing updates from sqlite', id); + logger.info('syncing updates from sqlite', doc.guid); const subdocId = doc.guid === id ? undefined : doc.guid; const updates = await apis.db.getDocAsUpdates(id, subdocId); @@ -173,7 +202,10 @@ export const createSQLiteDBDownloadProvider: DocProviderCreator = ( Y.applyUpdate(doc, updates, sqliteOrigin); } - const mergedUpdates = Y.encodeStateAsUpdate(doc); + const mergedUpdates = Y.encodeStateAsUpdate( + doc, + Y.encodeStateVectorFromUpdate(updates) + ); // also apply updates to sqlite await apis.db.applyDocUpdate(id, mergedUpdates, subdocId); diff --git a/packages/y-provider/src/index.ts b/packages/y-provider/src/index.ts index 2397bb712e..33a55725d7 100644 --- a/packages/y-provider/src/index.ts +++ b/packages/y-provider/src/index.ts @@ -1,2 +1,3 @@ export * from './lazy-provider'; export * from './types'; +export * from './utils'; diff --git a/tests/affine-local/e2e/local-first-collections-items.spec.ts b/tests/affine-local/e2e/local-first-collections-items.spec.ts index 41f6a60a6f..9ecb7b6f1c 100644 --- a/tests/affine-local/e2e/local-first-collections-items.spec.ts +++ b/tests/affine-local/e2e/local-first-collections-items.spec.ts @@ -42,6 +42,7 @@ const createAndPinCollection = async ( await page.getByTestId('collection-bar-option-pin').click(); await page.waitForTimeout(100); }; + test('Show collections items in sidebar', async ({ page }) => { await createAndPinCollection(page); const collections = page.getByTestId('collections'); diff --git a/tests/affine-local/e2e/router.spec.ts b/tests/affine-local/e2e/router.spec.ts index 0d969a9666..a632abc374 100644 --- a/tests/affine-local/e2e/router.spec.ts +++ b/tests/affine-local/e2e/router.spec.ts @@ -15,10 +15,9 @@ test('goto not found page', async ({ page }) => { test('goto not found workspace', async ({ page }) => { await openHomePage(page); await waitEditorLoad(page); - const currentUrl = page.url(); // if doesn't wait for timeout, data won't be saved into indexedDB await page.waitForTimeout(1000); await page.goto(new URL('/workspace/invalid/all', webUrl).toString()); - await waitEditorLoad(page); - expect(page.url()).toEqual(currentUrl); + await page.waitForTimeout(1000); + expect(page.url()).toBe(new URL('/404', webUrl).toString()); });