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.
+
+
+
+ {retryButtonDesc}
+
+ >
+ );
+ } 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--;
+ }
+ }
+}