diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f956b5086..31ec20581 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,7 +33,7 @@ jobs: build-core: name: Build @affine/core runs-on: ubuntu-latest - + environment: ${{ github.event.inputs.flavor }} steps: - uses: actions/checkout@v4 - name: Setup Node.js @@ -50,6 +50,10 @@ jobs: SHOULD_REPORT_TRACE: true TRACE_REPORT_ENDPOINT: ${{ secrets.TRACE_REPORT_ENDPOINT }} CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - name: Upload core artifact uses: actions/upload-artifact@v3 with: diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 84dce785b..a8930db8c 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -69,8 +69,8 @@ jobs: env: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} RELEASE_VERSION: ${{ needs.set-build-version.outputs.version }} SKIP_PLUGIN_BUILD: 'true' SKIP_NX_CACHE: 'true' diff --git a/.github/workflows/release-desktop-app.yml b/.github/workflows/release-desktop-app.yml index 08723c41f..41347783d 100644 --- a/.github/workflows/release-desktop-app.yml +++ b/.github/workflows/release-desktop-app.yml @@ -40,6 +40,7 @@ env: jobs: before-make: runs-on: ubuntu-latest + environment: ${{ github.event.inputs.build-type || (github.ref_type == 'tag' && contains(github.ref, 'canary') && 'canary') }} outputs: RELEASE_VERSION: ${{ steps.get-canary-version.outputs.RELEASE_VERSION }} steps: @@ -65,6 +66,7 @@ jobs: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} RELEASE_VERSION: ${{ github.event.inputs.version || steps.get-canary-version.outputs.RELEASE_VERSION }} SKIP_PLUGIN_BUILD: 'true' SKIP_NX_CACHE: 'true' diff --git a/nx.json b/nx.json index da98371a7..69c3b1f5e 100644 --- a/nx.json +++ b/nx.json @@ -56,7 +56,7 @@ "env": "SENTRY_AUTH_TOKEN" }, { - "env": "NEXT_PUBLIC_SENTRY_DSN" + "env": "SENTRY_DSN" }, { "env": "DISTRIBUTION" diff --git a/packages/common/infra/src/blocksuite/initialization/index.ts b/packages/common/infra/src/blocksuite/initialization/index.ts index a45ca66c5..ca06c51e5 100644 --- a/packages/common/infra/src/blocksuite/initialization/index.ts +++ b/packages/common/infra/src/blocksuite/initialization/index.ts @@ -4,6 +4,10 @@ import type { createStore, WritableAtom } from 'jotai/vanilla'; import { nanoid } from 'nanoid'; import { migratePages } from '../migration/blocksuite'; +import { + checkWorkspaceCompatibility, + MigrationPoint, +} from '../migration/workspace'; export async function initEmptyPage(page: Page, title?: string) { await page.load(() => { @@ -244,46 +248,48 @@ export async function buildShowcaseWorkspace( {} as Record ); }); - await Promise.all( - data.map(async ([id, promise, newId]) => { - const { default: template } = await promise; - let json = JSON.stringify(template); - Object.entries(idMap).forEach(([oldId, newId]) => { - json = json.replaceAll(oldId, newId); - }); - json = JSON.parse(json); - await workspace - .importPageSnapshot(structuredClone(json), newId) - .catch(error => { - console.error('error importing page', id, error); - }); - const page = workspace.getPage(newId); - assertExists(page); - await page.load(); - workspace.schema.upgradePage( - 0, - { - 'affine:note': 1, - 'affine:bookmark': 1, - 'affine:database': 2, - 'affine:divider': 1, - 'affine:image': 1, - 'affine:list': 1, - 'affine:code': 1, - 'affine:page': 2, - 'affine:paragraph': 1, - 'affine:surface': 3, - }, - page.spaceDoc - ); - // The showcase building will create multiple pages once, and may skip the version writing. - // https://github.com/toeverything/blocksuite/blob/master/packages/store/src/workspace/page.ts#L662 - if (!workspace.meta.blockVersions) { - await migratePages(workspace.doc, workspace.schema); - } - }) - ); + // Import page one by one to prevent workspace meta race condition problem. + for (const [id, promise, newId] of data) { + const { default: template } = await promise; + let json = JSON.stringify(template); + Object.entries(idMap).forEach(([oldId, newId]) => { + json = json.replaceAll(oldId, newId); + }); + json = JSON.parse(json); + await workspace + .importPageSnapshot(structuredClone(json), newId) + .catch(error => { + console.error('error importing page', id, error); + }); + const page = workspace.getPage(newId); + assertExists(page); + await page.load(); + workspace.schema.upgradePage( + 0, + { + 'affine:note': 1, + 'affine:bookmark': 1, + 'affine:database': 2, + 'affine:divider': 1, + 'affine:image': 1, + 'affine:list': 1, + 'affine:code': 1, + 'affine:page': 2, + 'affine:paragraph': 1, + 'affine:surface': 3, + }, + page.spaceDoc + ); + } + + // The showcase building will create multiple pages once, and may skip the version writing. + // https://github.com/toeverything/blocksuite/blob/master/packages/store/src/workspace/page.ts#L662 + const compatibilityResult = checkWorkspaceCompatibility(workspace); + if (compatibilityResult === MigrationPoint.BlockVersion) { + await migratePages(workspace.doc, workspace.schema); + } + Object.entries(pageMetas).forEach(([oldId, meta]) => { const newId = idMap[oldId]; workspace.setPageMeta(newId, meta); diff --git a/packages/common/infra/src/blocksuite/migration/blocksuite.ts b/packages/common/infra/src/blocksuite/migration/blocksuite.ts index 10474d5b9..ef6139151 100644 --- a/packages/common/infra/src/blocksuite/migration/blocksuite.ts +++ b/packages/common/infra/src/blocksuite/migration/blocksuite.ts @@ -20,13 +20,18 @@ export async function migratePages( const meta = rootDoc.getMap('meta') as YMap; const versions = meta.get('blockVersions') as YMap; const oldVersions = versions?.toJSON() ?? {}; + spaces.forEach((space: YDoc) => { - try { - schema.upgradePage(0, oldVersions, space); - } catch (e) { - console.error(`page ${space.guid} upgrade failed`, e); - } + schema.upgradePage(0, oldVersions, space); }); + schema.upgradeWorkspace(rootDoc); + + // Hard code to upgrade page version to 2. + // Let e2e to ensure the data version is correct. + const pageVersion = meta.get('pageVersion'); + if (typeof pageVersion !== 'number' || pageVersion < 2) { + meta.set('pageVersion', 2); + } const newVersions = getLatestVersions(schema); meta.set('blockVersions', new YMap(Object.entries(newVersions))); diff --git a/packages/common/infra/src/blocksuite/migration/fixing.ts b/packages/common/infra/src/blocksuite/migration/fixing.ts index c677179cc..f46fd11c0 100644 --- a/packages/common/infra/src/blocksuite/migration/fixing.ts +++ b/packages/common/infra/src/blocksuite/migration/fixing.ts @@ -43,3 +43,25 @@ export function guidCompatibilityFix(rootDoc: YDoc) { }); return changed; } + +/** + * Hard code to fix workspace version to be compatible with legacy data. + * Let e2e to ensure the data version is correct. + */ +export function fixWorkspaceVersion(rootDoc: YDoc) { + const meta = rootDoc.getMap('meta') as YMap; + + /** + * It doesn't matter to upgrade workspace version from 1 or undefined to 2. + * Blocksuite just set the value, do nothing else. + */ + const workspaceVersion = meta.get('workspaceVersion'); + if (typeof workspaceVersion !== 'number' || workspaceVersion < 2) { + meta.set('workspaceVersion', 2); + + const pageVersion = meta.get('pageVersion'); + if (typeof pageVersion !== 'number') { + meta.set('pageVersion', 1); + } + } +} diff --git a/packages/common/infra/src/blocksuite/migration/workspace.ts b/packages/common/infra/src/blocksuite/migration/workspace.ts index 24d09671b..e26f64423 100644 --- a/packages/common/infra/src/blocksuite/migration/workspace.ts +++ b/packages/common/infra/src/blocksuite/migration/workspace.ts @@ -58,15 +58,25 @@ export function checkWorkspaceCompatibility( return MigrationPoint.SubDoc; } - // Sometimes, blocksuite will not write blockVersions to meta. - // Just fix it when user open the workspace. - const blockVersions = workspace.meta.blockVersions; - if (!blockVersions) { + const hasVersion = workspace.meta.hasVersion; + if (!hasVersion) { return MigrationPoint.BlockVersion; } + // TODO: Catch compatibility error from blocksuite to show upgrade page. + // Temporarily follow the check logic of blocksuite. + if ((workspace.meta.pages?.length ?? 0) <= 1) { + try { + workspace.meta.validateVersion(workspace); + } catch (e) { + console.info('validateVersion error', e); + return MigrationPoint.BlockVersion; + } + } + // From v2, we depend on blocksuite to check and migrate data. - for (const [flavour, version] of Object.entries(blockVersions)) { + const blockVersions = workspace.meta.blockVersions; + for (const [flavour, version] of Object.entries(blockVersions ?? {})) { const schema = workspace.schema.flavourSchemaMap.get(flavour); if (schema?.version !== version) { return MigrationPoint.BlockVersion; diff --git a/packages/frontend/component/src/components/block-suite-editor/index.tsx b/packages/frontend/component/src/components/block-suite-editor/index.tsx index d8b40e2a4..41a4b26c5 100644 --- a/packages/frontend/component/src/components/block-suite-editor/index.tsx +++ b/packages/frontend/component/src/components/block-suite-editor/index.tsx @@ -7,14 +7,12 @@ import type { CSSProperties, ReactElement } from 'react'; import { memo, Suspense, - useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react'; -import type { FallbackProps } from 'react-error-boundary'; -import { ErrorBoundary } from 'react-error-boundary'; +import { type Map as YMap } from 'yjs'; import { Skeleton } from '../../ui/skeleton'; import { @@ -77,6 +75,67 @@ const useBlockElementById = ( return blockElement; }; +/** + * TODO: Define error to unexpected state together in the future. + */ +export class NoPageRootError extends Error { + constructor(public page: Page) { + super('Page root not found when render editor!'); + + // Log info to let sentry collect more message + const hasExpectSpace = Array.from(page.doc.spaces.values()).some( + doc => page.spaceDoc.guid === doc.guid + ); + const blocks = page.spaceDoc.getMap('blocks') as YMap>; + const havePageBlock = Array.from(blocks.values()).some( + block => block.get('sys:flavour') === 'affine:page' + ); + console.info( + 'NoPageRootError current data: %s', + JSON.stringify({ + expectPageId: page.id, + expectGuid: page.spaceDoc.guid, + hasExpectSpace, + blockSize: blocks.size, + havePageBlock, + }) + ); + } +} + +/** + * TODO: Defined async cache to support suspense, instead of reflect symbol to provider persistent error cache. + */ +const PAGE_LOAD_KEY = Symbol('PAGE_LOAD'); +const PAGE_ROOT_KEY = Symbol('PAGE_ROOT'); +function usePageRoot(page: Page) { + let load$ = Reflect.get(page, PAGE_LOAD_KEY); + if (!load$) { + load$ = page.load(); + Reflect.set(page, PAGE_LOAD_KEY, load$); + } + use(load$); + + if (!page.root) { + let root$: Promise | undefined = Reflect.get(page, PAGE_ROOT_KEY); + if (!root$) { + root$ = new Promise((resolve, reject) => { + const disposable = page.slots.rootAdded.once(() => { + resolve(); + }); + window.setTimeout(() => { + disposable.dispose(); + reject(new NoPageRootError(page)); + }, 20 * 1000); + }); + Reflect.set(page, PAGE_ROOT_KEY, root$); + } + use(root$); + } + + return page.root; +} + const BlockSuiteEditorImpl = ({ mode, page, @@ -86,9 +145,8 @@ const BlockSuiteEditorImpl = ({ onModeChange, style, }: EditorProps): ReactElement => { - if (!page.loaded) { - use(page.waitForLoaded()); - } + usePageRoot(page); + assertExists(page, 'page should not be null'); const editorRef = useRef(null); if (editorRef.current === null) { @@ -176,27 +234,7 @@ const BlockSuiteEditorImpl = ({ ); }; -const BlockSuiteErrorFallback = ( - props: FallbackProps & ErrorBoundaryProps -): ReactElement => { - return ( -
-

Sorry.. there was an error

-
{props.error.message}
- -
- ); -}; - -export const BlockSuiteFallback = memo(function BlockSuiteFallback() { +export const EditorLoading = memo(function EditorLoading() { return (
( - - ), - [props.onReset] - )} - > - }> - - - + }> + + ); }); diff --git a/packages/frontend/component/src/components/page-detail-skeleton/index.tsx b/packages/frontend/component/src/components/page-detail-skeleton/index.tsx index 4b323ff01..889288085 100644 --- a/packages/frontend/component/src/components/page-detail-skeleton/index.tsx +++ b/packages/frontend/component/src/components/page-detail-skeleton/index.tsx @@ -1,4 +1,4 @@ -import { BlockSuiteFallback } from '../block-suite-editor'; +import { EditorLoading } from '../block-suite-editor'; import { pageDetailSkeletonStyle, pageDetailSkeletonTitleStyle, @@ -8,7 +8,7 @@ export const PageDetailSkeleton = () => { return (
- +
); }; diff --git a/packages/frontend/core/.webpack/config.ts b/packages/frontend/core/.webpack/config.ts index 2586b8ce8..15e1c1e46 100644 --- a/packages/frontend/core/.webpack/config.ts +++ b/packages/frontend/core/.webpack/config.ts @@ -351,6 +351,8 @@ export const createConfiguration: ( 'process.env.CAPTCHA_SITE_KEY': JSON.stringify( process.env.CAPTCHA_SITE_KEY ), + 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN), + 'process.env.BUILD_TYPE': JSON.stringify(process.env.BUILD_TYPE), runtimeConfig: JSON.stringify(runtimeConfig), }), new CopyPlugin({ diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index bf31b4204..fd30a0a01 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -45,6 +45,8 @@ "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@react-hookz/web": "^23.1.0", + "@sentry/integrations": "^7.83.0", + "@sentry/react": "^7.83.0", "@toeverything/components": "^0.0.46", "@toeverything/theme": "^0.7.20", "@vanilla-extract/css": "^1.13.0", diff --git a/packages/frontend/core/project.json b/packages/frontend/core/project.json index ae85c4b3a..a3d1d51dd 100644 --- a/packages/frontend/core/project.json +++ b/packages/frontend/core/project.json @@ -41,7 +41,7 @@ "env": "SENTRY_AUTH_TOKEN" }, { - "env": "NEXT_PUBLIC_SENTRY_DSN" + "env": "SENTRY_DSN" }, { "env": "DISTRIBUTION" diff --git a/packages/frontend/core/src/bootstrap/setup.ts b/packages/frontend/core/src/bootstrap/setup.ts index 27353eb87..36af51425 100644 --- a/packages/frontend/core/src/bootstrap/setup.ts +++ b/packages/frontend/core/src/bootstrap/setup.ts @@ -6,7 +6,15 @@ import { rootWorkspacesMetadataAtom, workspaceAdaptersAtom, } from '@affine/workspace/atom'; +import * as Sentry from '@sentry/react'; import type { createStore } from 'jotai/vanilla'; +import { useEffect } from 'react'; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom'; import { WorkspaceAdapters } from '../adapters/workspace'; import { performanceLogger } from '../shared'; @@ -51,6 +59,33 @@ export async function setup(store: ReturnType) { performanceSetupLogger.info('setup global'); setupGlobal(); + if (window.SENTRY_RELEASE || environment.isDebug) { + // https://docs.sentry.io/platforms/javascript/guides/react/#configure + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.BUILD_TYPE ?? 'development', + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.reactRouterV6Instrumentation( + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes + ), + }), + new Sentry.Replay(), + ], + // Set tracesSampleRate to 1.0 to capture 100% + // of transactions for performance monitoring. + tracesSampleRate: 1.0, + }); + Sentry.setTags({ + appVersion: runtimeConfig.appVersion, + editorVersion: runtimeConfig.editorVersion, + }); + } + performanceSetupLogger.info('get root workspace meta'); // do not read `rootWorkspacesMetadataAtom` before migration await store.get(rootWorkspacesMetadataAtom); diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary.tsx deleted file mode 100644 index cd576a0ec..000000000 --- a/packages/frontend/core/src/components/affine/affine-error-boundary.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import type { - QueryParamError, - Unreachable, - WorkspaceNotFoundError, -} from '@affine/env/constant'; -import { PageNotFoundError } from '@affine/env/constant'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; -import { Button } from '@toeverything/components/button'; -import { - currentPageIdAtom, - currentWorkspaceIdAtom, - getCurrentStore, -} from '@toeverything/infra/atom'; -import { useAtomValue } from 'jotai/react'; -import { Provider } from 'jotai/react'; -import type { ErrorInfo, ReactElement, ReactNode } from 'react'; -import type React from 'react'; -import { Component, useEffect } from 'react'; -import { useLocation, useParams } from 'react-router-dom'; - -import { - RecoverableError, - type SessionFetchErrorRightAfterLoginOrSignUp, -} from '../../unexpected-application-state/errors'; -import { - errorDescription, - errorDetailStyle, - errorDivider, - errorImage, - errorLayout, - errorRetryButton, - errorTitle, -} from './affine-error-boundary.css'; -import errorBackground from './error-status.assets.svg'; - -export type AffineErrorBoundaryProps = React.PropsWithChildren & { - height?: number | string; -}; - -type AffineError = - | QueryParamError - | Unreachable - | WorkspaceNotFoundError - | PageNotFoundError - | Error - | SessionFetchErrorRightAfterLoginOrSignUp; - -interface AffineErrorBoundaryState { - error: AffineError | null; - canRetryRecoveredError: boolean; -} - -export const DumpInfo = () => { - const location = useLocation(); - const metadata = useAtomValue(rootWorkspacesMetadataAtom); - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); - const currentPageId = useAtomValue(currentPageIdAtom); - const path = location.pathname; - const query = useParams(); - useEffect(() => { - console.info('DumpInfo', { - path, - query, - currentWorkspaceId, - currentPageId, - metadata, - }); - }, [path, query, currentWorkspaceId, currentPageId, metadata]); - return null; -}; - -export class AffineErrorBoundary extends Component< - AffineErrorBoundaryProps, - AffineErrorBoundaryState -> { - override state: AffineErrorBoundaryState = { - error: null, - canRetryRecoveredError: true, - }; - - private readonly handleRecoverableRetry = () => { - if (this.state.error instanceof RecoverableError) { - if (this.state.error.canRetry()) { - this.state.error.retry(); - this.setState({ - error: null, - canRetryRecoveredError: this.state.error.canRetry(), - }); - } else { - document.location.reload(); - } - } - }; - - private readonly handleRefresh = () => { - this.setState({ error: null }); - }; - - static getDerivedStateFromError( - error: AffineError - ): AffineErrorBoundaryState { - return { - error, - canRetryRecoveredError: - error instanceof RecoverableError ? error.canRetry() : true, - }; - } - - override componentDidCatch(error: AffineError, errorInfo: ErrorInfo) { - console.error('Uncaught error:', error, errorInfo); - } - - override render(): ReactNode { - if (this.state.error) { - let errorDetail: ReactElement | null = null; - const error = this.state.error; - if (error instanceof PageNotFoundError) { - errorDetail = ( - <> -

Sorry.. there was an error

- <> - Page error - - Cannot find page {error.pageId} in workspace{' '} - {error.workspace.id} - - - - ); - } else if (error instanceof RecoverableError) { - const retryButtonDesc = this.state.canRetryRecoveredError - ? 'Refetch' - : 'Reload'; - errorDetail = ( - <> -

Sorry.. there was an error

- {error.message} - - If you are still experiencing this issue, please{' '} - - contact us through the community. - - - - - ); - } else { - errorDetail = ( - <> -

Sorry.. there was an error

- - {error.message ?? error.toString()} - - - - ); - } - return ( -
-
{errorDetail}
- -
- - - -
- ); - } - - return this.props.children; - } -} diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/affine-error-fallback.css.ts b/packages/frontend/core/src/components/affine/affine-error-boundary/affine-error-fallback.css.ts new file mode 100644 index 000000000..d1026be77 --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/affine-error-fallback.css.ts @@ -0,0 +1,6 @@ +import { style } from '@vanilla-extract/css'; + +export const viewport = style({ + height: '100%', + width: '100%', +}); diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/affine-error-fallback.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/affine-error-fallback.tsx new file mode 100644 index 000000000..864d809db --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/affine-error-fallback.tsx @@ -0,0 +1,53 @@ +import { getCurrentStore } from '@toeverything/infra/atom'; +import { Provider } from 'jotai/react'; +import type { FC } from 'react'; +import { useMemo } from 'react'; + +import * as styles from './affine-error-fallback.css'; +import { + ERROR_REFLECT_KEY, + type FallbackProps, +} from './error-basic/fallback-creator'; +import { DumpInfo } from './error-basic/info-logger'; +import { AnyErrorFallback } from './error-fallbacks/any-error-fallback'; +import { NoPageRootFallback } from './error-fallbacks/no-page-root-fallback'; +import { PageNotFoundDetail } from './error-fallbacks/page-not-found-fallback'; +import { RecoverableErrorFallback } from './error-fallbacks/recoverable-error-fallback'; + +/** + * Register all fallback components here. + * If have new one just add it to the set. + */ +const fallbacks = new Set([ + PageNotFoundDetail, + RecoverableErrorFallback, + NoPageRootFallback, +]); + +function getErrorFallbackComponent(error: any): FC { + for (const Component of fallbacks) { + const ErrorConstructor = Reflect.get(Component, ERROR_REFLECT_KEY); + if (ErrorConstructor && error instanceof ErrorConstructor) { + return Component as FC; + } + } + return AnyErrorFallback; +} + +export interface AffineErrorFallbackProps extends FallbackProps { + height?: number | string; +} + +export const AffineErrorFallback: FC = props => { + const { error, resetError, height } = props; + const Component = useMemo(() => getErrorFallbackComponent(error), [error]); + + return ( +
+ + + + +
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/error-status.assets.svg b/packages/frontend/core/src/components/affine/affine-error-boundary/error-assets/404-status.assets.svg similarity index 100% rename from packages/frontend/core/src/components/affine/error-status.assets.svg rename to packages/frontend/core/src/components/affine/affine-error-boundary/error-assets/404-status.assets.svg diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-assets/500-status.assets.svg b/packages/frontend/core/src/components/affine/affine-error-boundary/error-assets/500-status.assets.svg new file mode 100644 index 000000000..69823de35 --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-assets/500-status.assets.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary.css.ts b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/error-detail.css.ts similarity index 89% rename from packages/frontend/core/src/components/affine/affine-error-boundary.css.ts rename to packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/error-detail.css.ts index 5999d7ec1..5586afb01 100644 --- a/packages/frontend/core/src/components/affine/affine-error-boundary.css.ts +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/error-detail.css.ts @@ -6,6 +6,7 @@ export const errorLayout = style({ alignItems: 'center', height: '100%', width: '100%', + gap: '20px', }); export const errorDetailStyle = style({ @@ -24,15 +25,15 @@ export const errorImage = style({ height: '178px', maxWidth: '400px', flexGrow: 1, + backgroundSize: 'cover', }); export const errorDescription = style({ marginTop: '24px', }); -export const errorRetryButton = style({ +export const errorFooter = style({ marginTop: '24px', - width: '94px', }); export const errorDivider = style({ diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/error-detail.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/error-detail.tsx new file mode 100644 index 000000000..2aa22a1f7 --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/error-detail.tsx @@ -0,0 +1,106 @@ +import { Trans } from '@affine/i18n'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { Button } from '@toeverything/components/button'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { + type FC, + type PropsWithChildren, + type ReactNode, + useState, +} from 'react'; + +import imageUrlFor404 from '../error-assets/404-status.assets.svg'; +import imageUrlFor500 from '../error-assets/500-status.assets.svg'; +import * as styles from './error-detail.css'; + +export enum ErrorStatus { + NotFound = 404, + Unexpected = 500, +} + +export interface ErrorDetailProps extends PropsWithChildren { + status?: ErrorStatus; + direction?: 'column' | 'row'; + title: string; + description: ReactNode | Array; + buttonText?: string; + onButtonClick?: () => void | Promise; + resetError?: () => void; + withoutImage?: boolean; +} + +const imageMap = new Map([ + [ErrorStatus.NotFound, imageUrlFor404], + [ErrorStatus.Unexpected, imageUrlFor500], +]); + +/** + * TODO: Unify with NotFoundPage. + */ +export const ErrorDetail: FC = props => { + const { + status = ErrorStatus.Unexpected, + direction = 'row', + description, + onButtonClick, + resetError, + withoutImage, + } = props; + const descriptions = Array.isArray(description) ? description : [description]; + const [isBtnLoading, setBtnLoading] = useState(false); + const t = useAFFiNEI18N(); + + const onBtnClick = useAsyncCallback(async () => { + try { + setBtnLoading(true); + await onButtonClick?.(); + resetError?.(); // Only reset when retry success. + } finally { + setBtnLoading(false); + } + }, [onButtonClick, resetError]); + + return ( +
+
+

{props.title}

+ {descriptions.map((item, i) => ( +

+ {item} +

+ ))} +
+ +
+
+ {withoutImage ? null : ( +
+ )} +
+ ); +}; + +export function ContactUS() { + return ( + + If you are still experiencing this issue, please{' '} + + contact us through the community. + + + ); +} diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/fallback-creator.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/fallback-creator.tsx new file mode 100644 index 000000000..062deac3d --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/fallback-creator.tsx @@ -0,0 +1,16 @@ +import type { FC } from 'react'; + +export interface FallbackProps { + error: T; + resetError: () => void; +} + +export const ERROR_REFLECT_KEY = Symbol('ERROR_REFLECT_KEY'); + +export function createErrorFallback( + ErrorConstructor: abstract new (...args: any[]) => T, + Component: FC> +): FC> { + Reflect.set(Component, ERROR_REFLECT_KEY, ErrorConstructor); + return Component; +} diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx new file mode 100644 index 000000000..ae8004d59 --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-basic/info-logger.tsx @@ -0,0 +1,31 @@ +import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; +import { + currentPageIdAtom, + currentWorkspaceIdAtom, +} from '@toeverything/infra/atom'; +import { useAtomValue } from 'jotai/react'; +import { useEffect } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; + +export interface DumpInfoProps { + error: any; +} + +export const DumpInfo = (_props: DumpInfoProps) => { + const location = useLocation(); + const metadata = useAtomValue(rootWorkspacesMetadataAtom); + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const currentPageId = useAtomValue(currentPageIdAtom); + const path = location.pathname; + const query = useParams(); + useEffect(() => { + console.info('DumpInfo', { + path, + query, + currentWorkspaceId, + currentPageId, + metadata, + }); + }, [path, query, currentWorkspaceId, currentPageId, metadata]); + return null; +}; diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/any-error-fallback.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/any-error-fallback.tsx new file mode 100644 index 000000000..759af6097 --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/any-error-fallback.tsx @@ -0,0 +1,26 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { type FC, useCallback } from 'react'; + +import { ErrorDetail } from '../error-basic/error-detail'; +import type { FallbackProps } from '../error-basic/fallback-creator'; + +/** + * TODO: Support reload and retry two reset actions in page error and area error. + */ +export const AnyErrorFallback: FC = props => { + const { error } = props; + const t = useAFFiNEI18N(); + + const reloadPage = useCallback(() => { + document.location.reload(); + }, []); + + return ( + + ); +}; diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx new file mode 100644 index 000000000..88bbe9d5e --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx @@ -0,0 +1,21 @@ +import { NoPageRootError } from '@affine/component/block-suite-editor'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; + +import { ContactUS, ErrorDetail } from '../error-basic/error-detail'; +import { createErrorFallback } from '../error-basic/fallback-creator'; + +export const NoPageRootFallback = createErrorFallback( + NoPageRootError, + props => { + const { resetError } = props; + const t = useAFFiNEI18N(); + + return ( + } + resetError={resetError} + /> + ); + } +); diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/page-not-found-fallback.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/page-not-found-fallback.tsx new file mode 100644 index 000000000..2c8ce3a70 --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/page-not-found-fallback.tsx @@ -0,0 +1,30 @@ +import { PageNotFoundError } from '@affine/env/constant'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useCallback } from 'react'; + +import { + RouteLogic, + useNavigateHelper, +} from '../../../../hooks/use-navigate-helper'; +import { ErrorDetail, ErrorStatus } from '../error-basic/error-detail'; +import { createErrorFallback } from '../error-basic/fallback-creator'; + +export const PageNotFoundDetail = createErrorFallback(PageNotFoundError, () => { + const t = useAFFiNEI18N(); + const { jumpToIndex } = useNavigateHelper(); + + const onBtnClick = useCallback( + () => jumpToIndex(RouteLogic.REPLACE), + [jumpToIndex] + ); + + return ( + + ); +}); diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/recoverable-error-fallback.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/recoverable-error-fallback.tsx new file mode 100644 index 000000000..5c872f71d --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/recoverable-error-fallback.tsx @@ -0,0 +1,41 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { useCallback, useMemo, useState } from 'react'; + +import { RecoverableError } from '../../../../unexpected-application-state/errors'; +import { ContactUS, ErrorDetail } from '../error-basic/error-detail'; +import { createErrorFallback } from '../error-basic/fallback-creator'; + +export const RecoverableErrorFallback = createErrorFallback( + RecoverableError, + props => { + const { error, resetError } = props; + const t = useAFFiNEI18N(); + const [count, rerender] = useState(0); + + const canRetry = error.canRetry(); + const buttonDesc = useMemo(() => { + if (canRetry) { + return t['com.affine.error.refetch'](); + } + return t['com.affine.error.reload'](); + }, [canRetry, t]); + const onRetry = useCallback(async () => { + if (canRetry) { + rerender(count + 1); + await error.retry(); + } else { + document.location.reload(); + } + }, [error, count, canRetry]); + + return ( + ]} + /> + ); + } +); diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-status.assets.svg b/packages/frontend/core/src/components/affine/affine-error-boundary/error-status.assets.svg new file mode 100644 index 000000000..f996812af --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-status.assets.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/index.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/index.tsx new file mode 100644 index 000000000..9ee5e1cc5 --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/index.tsx @@ -0,0 +1,34 @@ +import { ErrorBoundary } from '@sentry/react'; +import type { FC, PropsWithChildren } from 'react'; +import { useCallback } from 'react'; + +import { AffineErrorFallback } from './affine-error-fallback'; +import { type FallbackProps } from './error-basic/fallback-creator'; + +export { type FallbackProps } from './error-basic/fallback-creator'; + +export interface AffineErrorBoundaryProps extends PropsWithChildren { + height?: number | string; +} + +/** + * TODO: Unify with SWRErrorBoundary + */ +export const AffineErrorBoundary: FC = props => { + const fallbackRender = useCallback( + (fallbackProps: FallbackProps) => { + return ; + }, + [props.height] + ); + + const onError = useCallback((error: Error, componentStack: string) => { + console.error('Uncaught error:', error, componentStack); + }, []); + + return ( + + {props.children} + + ); +}; diff --git a/packages/frontend/core/src/components/affine/any-error-boundary/index.tsx b/packages/frontend/core/src/components/affine/any-error-boundary/index.tsx deleted file mode 100644 index 35a72366c..000000000 --- a/packages/frontend/core/src/components/affine/any-error-boundary/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { ReactElement } from 'react'; -import type { FallbackProps } from 'react-error-boundary'; - -export const AnyErrorBoundary = (props: FallbackProps): ReactElement => { - return ( -
-

Something went wrong:

-

{props.error.toString()}

-
- ); -}; diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx index 55926dbb3..5901dce95 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx @@ -29,7 +29,6 @@ import { useRef, useState, } from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; import { openSettingModalAtom } from '../../../atoms'; import type { CheckedUser } from '../../../hooks/affine/use-current-user'; @@ -39,7 +38,7 @@ import { useMemberCount } from '../../../hooks/affine/use-member-count'; import { type Member, useMembers } from '../../../hooks/affine/use-members'; import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission'; import { useUserSubscription } from '../../../hooks/use-subscription'; -import { AnyErrorBoundary } from '../any-error-boundary'; +import { AffineErrorBoundary } from '../affine-error-boundary'; import * as style from './style.css'; import type { WorkspaceSettingDetailProps } from './types'; @@ -362,10 +361,10 @@ export const MembersPanel = (props: MembersPanelProps): ReactElement | null => { return ; } return ( - + - + ); }; diff --git a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx index 220fe3ef2..8eb7150e8 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx @@ -1,7 +1,7 @@ import { Scrollable } from '@affine/component'; import { BlockSuiteEditor, - BlockSuiteFallback, + EditorLoading, } from '@affine/component/block-suite-editor'; import type { PageMode } from '@affine/core/atoms'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -147,7 +147,7 @@ const HistoryEditorPreview = ({ onModeChange={onModeChange} /> ) : ( - + )}
); @@ -410,7 +410,7 @@ export const PageHistoryModal = ({ return ( - }> + }>

- {t[UPGRADE_TIPS_KEYS[upgradeState]]()} + {error ? error.message : t[UPGRADE_TIPS_KEYS[upgradeState]]()}