diff --git a/packages/frontend/component/src/components/auth-components/change-password-page.tsx b/packages/frontend/component/src/components/auth-components/change-password-page.tsx index f48bc56f8c..95aa6e72e3 100644 --- a/packages/frontend/component/src/components/auth-components/change-password-page.tsx +++ b/packages/frontend/component/src/components/auth-components/change-password-page.tsx @@ -7,12 +7,7 @@ import { useCallback, useState } from 'react'; import { pushNotificationAtom } from '../notification-center'; import { AuthPageContainer } from './auth-page-container'; import { SetPassword } from './set-password'; -type User = { - id: string; - name: string; - email: string; - image: string; -}; +import type { User } from './type'; export const ChangePasswordPage: FC<{ user: User; diff --git a/packages/frontend/component/src/components/auth-components/index.tsx b/packages/frontend/component/src/components/auth-components/index.tsx index ae57041a1f..6f2a661e29 100644 --- a/packages/frontend/component/src/components/auth-components/index.tsx +++ b/packages/frontend/component/src/components/auth-components/index.tsx @@ -13,3 +13,4 @@ export * from './set-password-page'; export * from './sign-in-page-container'; export * from './sign-in-success-page'; export * from './sign-up-page'; +export type { User } from './type'; diff --git a/packages/frontend/component/src/components/auth-components/set-password-page.tsx b/packages/frontend/component/src/components/auth-components/set-password-page.tsx index 026462fd72..d420533d73 100644 --- a/packages/frontend/component/src/components/auth-components/set-password-page.tsx +++ b/packages/frontend/component/src/components/auth-components/set-password-page.tsx @@ -7,13 +7,7 @@ import { useCallback, useState } from 'react'; import { pushNotificationAtom } from '../notification-center'; import { AuthPageContainer } from './auth-page-container'; import { SetPassword } from './set-password'; - -type User = { - id: string; - name: string; - email: string; - image: string; -}; +import type { User } from './type'; export const SetPasswordPage: FC<{ user: User; diff --git a/packages/frontend/component/src/components/auth-components/sign-up-page.tsx b/packages/frontend/component/src/components/auth-components/sign-up-page.tsx index f8055f11ec..7fb6eba910 100644 --- a/packages/frontend/component/src/components/auth-components/sign-up-page.tsx +++ b/packages/frontend/component/src/components/auth-components/sign-up-page.tsx @@ -7,12 +7,7 @@ import { useCallback, useState } from 'react'; import { pushNotificationAtom } from '../notification-center'; import { AuthPageContainer } from './auth-page-container'; import { SetPassword } from './set-password'; -type User = { - id: string; - name: string; - email: string; - image: string; -}; +import type { User } from './type'; export const SignUpPage: FC<{ user: User; diff --git a/packages/frontend/component/src/components/auth-components/type.ts b/packages/frontend/component/src/components/auth-components/type.ts new file mode 100644 index 0000000000..d297b4bb4b --- /dev/null +++ b/packages/frontend/component/src/components/auth-components/type.ts @@ -0,0 +1,6 @@ +export interface User { + id: string; + name: string; + email: string; + image?: string | null; +} diff --git a/packages/frontend/core/.webpack/config.ts b/packages/frontend/core/.webpack/config.ts index d55865bf06..47f29871bf 100644 --- a/packages/frontend/core/.webpack/config.ts +++ b/packages/frontend/core/.webpack/config.ts @@ -114,7 +114,10 @@ export const createConfiguration: ( buildFlags.mode === 'production' ? 'js/chunk.[name]-[contenthash:8].js' : 'js/chunk.[name].js', - assetModuleFilename: 'assets/[name]-[contenthash:8][ext][query]', + assetModuleFilename: + buildFlags.mode === 'production' + ? 'assets/[name]-[contenthash:8][ext][query]' + : '[name][ext]', devtoolModuleFilenameTemplate: 'webpack://[namespace]/[resource-path]', hotUpdateChunkFilename: 'hot/[id].[fullhash].js', hotUpdateMainFilename: 'hot/[runtime].[fullhash].json', @@ -292,7 +295,7 @@ export const createConfiguration: ( }, }, ], - exclude: [/node_modules/], + exclude: [/node_modules/, /\.assets\.svg$/], }, { test: /\.(png|jpg|gif|svg|webp|mp4)$/, diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary.css.ts b/packages/frontend/core/src/components/affine/affine-error-boundary.css.ts new file mode 100644 index 0000000000..145b0c6aec --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary.css.ts @@ -0,0 +1,41 @@ +import { style } from '@vanilla-extract/css'; + +export const errorLayout = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + width: '100%', +}); + +export const errorDetailStyle = style({ + display: 'flex', + flexDirection: 'column', + maxWidth: '420px', +}); + +export const errorTitle = style({ + fontSize: '36px', + lineHeight: '44px', + fontWeight: 700, +}); + +export const errorImage = style({ + height: '178px', + maxWidth: '400px', + flexGrow: 1, +}); + +export const errorDescription = style({ + marginTop: '24px', +}); + +export const errorRetryButton = style({ + marginTop: '24px', + width: '94px', +}); + +export const errorDivider = style({ + width: '20px', + height: '100%', +}); diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary.tsx new file mode 100644 index 0000000000..648e5f1e00 --- /dev/null +++ b/packages/frontend/core/src/components/affine/affine-error-boundary.tsx @@ -0,0 +1,176 @@ +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, + 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; + +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: this.state.error, + canRetryRecoveredError: this.state.error.canRetry(), + }); + } else { + document.location.reload(); + } + } + }; + + 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 SessionFetchErrorRightAfterLoginOrSignUp) { + const retryButtonDesc = this.state.canRetryRecoveredError + ? 'Refetch' + : 'Reload'; + errorDetail = ( + <> +

Sorry.. there was an error

+ Fetching session failed + + 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-eoundary.tsx b/packages/frontend/core/src/components/affine/affine-error-eoundary.tsx deleted file mode 100644 index aa3d5304dc..0000000000 --- a/packages/frontend/core/src/components/affine/affine-error-eoundary.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import type { - QueryParamError, - Unreachable, - WorkspaceNotFoundError, -} from '@affine/env/constant'; -import { PageNotFoundError } from '@affine/env/constant'; -import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; -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 } from 'react'; -import { useLocation, useParams } from 'react-router-dom'; -export type AffineErrorBoundaryProps = React.PropsWithChildren; - -type AffineError = - | QueryParamError - | Unreachable - | WorkspaceNotFoundError - | PageNotFoundError - | Error; - -interface AffineErrorBoundaryState { - error: AffineError | null; -} - -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(); - return ( - <> -
- Please copy the following information and send it to the developer. -
-
-
path: {path}
-
query: {JSON.stringify(query)}
-
currentWorkspaceId: {currentWorkspaceId}
-
currentPageId: {currentPageId}
-
metadata: {JSON.stringify(metadata)}
-
- - ); -}; - -export class AffineErrorBoundary extends Component< - AffineErrorBoundaryProps, - AffineErrorBoundaryState -> { - public override state: AffineErrorBoundaryState = { - error: null, - }; - - public static getDerivedStateFromError( - error: AffineError - ): AffineErrorBoundaryState { - return { error }; - } - - public override componentDidCatch(error: AffineError, errorInfo: ErrorInfo) { - console.error('Uncaught error:', error, errorInfo); - } - - public 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 { - 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/error-status.assets.svg b/packages/frontend/core/src/components/affine/error-status.assets.svg new file mode 100644 index 0000000000..f996812afa --- /dev/null +++ b/packages/frontend/core/src/components/affine/error-status.assets.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/core/src/hooks/affine/use-current-user.ts b/packages/frontend/core/src/hooks/affine/use-current-user.ts index c9a210be8d..a20bf6650e 100644 --- a/packages/frontend/core/src/hooks/affine/use-current-user.ts +++ b/packages/frontend/core/src/hooks/affine/use-current-user.ts @@ -1,44 +1,107 @@ -import type { DefaultSession } from 'next-auth'; +import { type User } from '@affine/component/auth-components'; +import type { DefaultSession, Session } from 'next-auth'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; -export type CheckedUser = { - id: string; - name: string; - email: string; - image: string; +import { getSession, useSession } from 'next-auth/react'; +import { useEffect, useReducer } from 'react'; + +import { SessionFetchErrorRightAfterLoginOrSignUp } from '../../unexpected-application-state/errors'; + +export type CheckedUser = User & { hasPassword: boolean; update: ReturnType['update']; }; -// FIXME: Should this namespace be here? declare module 'next-auth' { interface Session { user: { + name: string; + email: string; id: string; hasPassword: boolean; - } & DefaultSession['user']; + } & Omit, 'name' | 'email'>; + } +} + +type UpdateSessionAction = + | { + type: 'update'; + payload: Session; + } + | { + type: 'fetchError'; + payload: null; + }; + +function updateSessionReducer(prevState: Session, action: UpdateSessionAction) { + const { type, payload } = action; + switch (type) { + case 'update': + return payload; + case 'fetchError': + return prevState; } } /** * This hook checks if the user is logged in. - * If not, it will throw an error. + * If so, the user object will be cached and returned. + * If not, and there is no cache, it will throw an error. + * If network error or API response error, it will use the cached value. */ export function useCurrentUser(): CheckedUser { - const { data: session, status, update } = useSession(); - // If you are seeing this error, it means that you are not logged in. - // This should be prohibited in the development environment, please re-write your component logic. - if (status === 'unauthenticated') { - throw new Error('session.status should be authenticated'); - } + const { data, update } = useSession(); - const user = session?.user; + const [session, dispatcher] = useReducer( + updateSessionReducer, + data, + firstSession => { + if (!firstSession) { + // barely possible. + // login succeed but the session request failed then. + // also need a error boundary to handle this error. + throw new SessionFetchErrorRightAfterLoginOrSignUp( + 'First session should not be null', + () => { + getSession() + .then(session => { + if (session) { + dispatcher({ + type: 'update', + payload: session, + }); + } + }) + .catch(err => { + console.error(err); + }); + } + ); + } + return firstSession; + } + ); + + useEffect(() => { + if (data) { + dispatcher({ + type: 'update', + payload: data, + }); + } else { + dispatcher({ + type: 'fetchError', + payload: null, + }); + } + }, [data, update]); + + const user = session.user; return { - id: user?.id ?? 'REPLACE_ME_DEFAULT_ID', - name: user?.name ?? 'REPLACE_ME_DEFAULT_NAME', - email: user?.email ?? 'REPLACE_ME_DEFAULT_EMAIL', - image: user?.image ?? 'REPLACE_ME_DEFAULT_URL', + id: user.id, + name: user.name, + email: user.email, + image: user.image, hasPassword: user?.hasPassword ?? false, update, }; diff --git a/packages/frontend/core/src/pages/workspace/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page.tsx index 5a546028f1..2507bc066a 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page.tsx @@ -25,6 +25,7 @@ import { getUIAdapter } from '../../adapters/workspace'; import { setPageModeAtom } from '../../atoms'; import { collectionsCRUDAtom } from '../../atoms/collections'; import { currentModeAtom } from '../../atoms/mode'; +import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary'; import { WorkspaceHeader } from '../../components/workspace-header'; import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands'; import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; @@ -148,5 +149,9 @@ export const loader: LoaderFunction = async args => { export const Component = () => { performanceRenderLogger.info('DetailPage'); - return ; + return ( + + + + ); }; diff --git a/packages/frontend/core/src/types/types.d.ts b/packages/frontend/core/src/types/types.d.ts index d8f47f3769..59513b2af9 100644 --- a/packages/frontend/core/src/types/types.d.ts +++ b/packages/frontend/core/src/types/types.d.ts @@ -8,3 +8,8 @@ declare module '*.md' { const text: string; export default text; } + +declare module '*.assets.svg' { + const url: string; + export default url; +} diff --git a/packages/frontend/core/src/unexpected-application-state/errors.ts b/packages/frontend/core/src/unexpected-application-state/errors.ts new file mode 100644 index 0000000000..137bb06171 --- /dev/null +++ b/packages/frontend/core/src/unexpected-application-state/errors.ts @@ -0,0 +1,33 @@ +export abstract class RecoverableError extends Error { + protected ttl = 3; + + canRetry(): boolean { + return this.ttl > 0; + } + + abstract retry(): void; +} + +// the first session request failed after login or signup succeed. +// should give a hint to the user to refetch the session. +export class SessionFetchErrorRightAfterLoginOrSignUp extends RecoverableError { + constructor( + message: string, + private readonly onRetry: () => void + ) { + super(message); + } + + retry(): void { + if (this.ttl <= 0) { + return; + } + try { + this.onRetry(); + } catch (e) { + console.error('Retry error', e); + } finally { + this.ttl--; + } + } +}