fix(core): handle the getSession network error properly (#4909)

If network offline or API error happens, the `session` returned by the `useSession` hook will be null, so we can't assume it is not null.

There should be following changes:
1. create a page in ErrorBoundary to let the user refetch the session.
2. The `SessionProvider` stop to pull the new session once the session is null, we need to figure out a way to pull the new session when the network is back or the user click the refetch button.
This commit is contained in:
LongYinan
2023-11-17 16:50:48 +08:00
committed by 李华桥
parent 57d42bf491
commit 7f09652cca
14 changed files with 385 additions and 157 deletions

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
export interface User {
id: string;
name: string;
email: string;
image?: string | null;
}

View File

@@ -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)$/,

View File

@@ -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%',
});

View File

@@ -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 = (
<>
<h1>Sorry.. there was an error</h1>
<>
<span> Page error </span>
<span>
Cannot find page {error.pageId} in workspace{' '}
{error.workspace.id}
</span>
</>
</>
);
} else if (error instanceof SessionFetchErrorRightAfterLoginOrSignUp) {
const retryButtonDesc = this.state.canRetryRecoveredError
? 'Refetch'
: 'Reload';
errorDetail = (
<>
<h1 className={errorTitle}>Sorry.. there was an error</h1>
<span className={errorDescription}> Fetching session failed </span>
<span className={errorDescription}>
If you are still experiencing this issue, please{' '}
<a
style={{ color: 'var(--affine-primary-color)' }}
href="https://community.affine.pro"
target="__blank"
>
contact us through the community.
</a>
</span>
<Button
className={errorRetryButton}
onClick={this.handleRecoverableRetry}
type="primary"
>
{retryButtonDesc}
</Button>
</>
);
} else {
errorDetail = (
<>
<h1>Sorry.. there was an error</h1>
{error.message ?? error.toString()}
</>
);
}
return (
<div className={errorLayout}>
<div className={errorDetailStyle}>{errorDetail}</div>
<span className={errorDivider} />
<div
className={errorImage}
style={{ backgroundImage: `url(${errorBackground})` }}
/>
<Provider key="JotaiProvider" store={getCurrentStore()}>
<DumpInfo />
</Provider>
</div>
);
}
return this.props.children;
}
}

View File

@@ -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 (
<>
<div>
Please copy the following information and send it to the developer.
</div>
<div
style={{
border: '1px solid red',
}}
>
<div>path: {path}</div>
<div>query: {JSON.stringify(query)}</div>
<div>currentWorkspaceId: {currentWorkspaceId}</div>
<div>currentPageId: {currentPageId}</div>
<div>metadata: {JSON.stringify(metadata)}</div>
</div>
</>
);
};
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 = (
<>
<h1>Sorry.. there was an error</h1>
<>
<span> Page error </span>
<span>
Cannot find page {error.pageId} in workspace{' '}
{error.workspace.id}
</span>
</>
</>
);
} else {
errorDetail = (
<>
<h1>Sorry.. there was an error</h1>
{error.message ?? error.toString()}
</>
);
}
return (
<>
{errorDetail}
<Provider key="JotaiProvider" store={getCurrentStore()}>
<DumpInfo />
</Provider>
</>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,25 @@
<svg width="402" height="178" viewBox="0 0 402 178" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M93.7434 129.308H1L71.7965 15.1021V167.142" stroke="#121212" stroke-width="2" stroke-linecap="square" stroke-linejoin="bevel" />
<ellipse cx="71.4426" cy="14.7483" rx="3.89381" ry="3.88938" fill="#121212" />
<ellipse cx="93.3894" cy="129.308" rx="3.89381" ry="3.88938" fill="#121212" />
<path d="M140.357 27.1235L264.717 151.342" stroke="#E3E2E4" />
<rect x="140.392" y="27.1582" width="124.291" height="124.149" stroke="#E3E2E4" />
<path d="M264.339 89.4652C264.895 85.8242 265.183 82.0954 265.183 78.2995C265.183 37.7024 232.235 4.79199 191.592 4.79199C150.948 4.79199 118 37.7024 118 78.2995C118 118.897 150.948 151.807 191.592 151.807C195.23 151.807 198.807 151.543 202.304 151.034" stroke="#E3E2E4" />
<path d="M202.304 150.964C205.949 151.519 209.682 151.807 213.483 151.807C254.126 151.807 287.074 118.897 287.074 78.2995C287.074 37.7024 254.126 4.79199 213.483 4.79199C172.839 4.79199 139.892 37.7024 139.892 78.2995C139.892 82.0955 140.18 85.8242 140.735 89.4652" stroke="#E3E2E4" />
<path d="M140.2 89.4652C139.69 92.9584 139.426 96.5312 139.426 100.166C139.426 140.763 172.374 173.673 213.017 173.673C253.66 173.673 286.608 140.763 286.608 100.166C286.608 59.5686 253.66 26.6582 213.017 26.6582C209.378 26.6582 205.801 26.922 202.304 27.4314" stroke="#E3E2E4" />
<path d="M202.304 27.4314C198.807 26.922 195.23 26.6582 191.592 26.6582C150.948 26.6582 118 59.5686 118 100.166C118 140.763 150.948 173.673 191.592 173.673C232.235 173.673 265.183 140.763 265.183 100.166C265.183 96.5312 264.919 92.9584 264.409 89.4652" stroke="#E3E2E4" />
<path d="M264.717 27.1235L140.357 151.342" stroke="#E3E2E4" />
<path d="M139.892 89.4653H264.717" stroke="#E3E2E4" />
<path d="M202.304 26.6582V151.807" stroke="#E3E2E4" />
<ellipse cx="264.717" cy="89.4651" rx="3.72614" ry="3.7219" fill="#121212" />
<ellipse cx="264.717" cy="27.1233" rx="3.72614" ry="3.7219" fill="#121212" />
<ellipse cx="264.717" cy="151.807" rx="3.72614" ry="3.7219" fill="#121212" />
<ellipse cx="140.357" cy="151.807" rx="3.72613" ry="3.7219" fill="#121212" />
<ellipse cx="202.304" cy="89.4651" rx="3.72613" ry="3.7219" fill="#121212" />
<ellipse cx="202.304" cy="27.1233" rx="3.72613" ry="3.7219" fill="#121212" />
<ellipse cx="140.357" cy="89.4651" rx="3.72613" ry="3.7219" fill="#121212" />
<ellipse cx="140.357" cy="27.1233" rx="3.72613" ry="3.7219" fill="#121212" />
<ellipse cx="202.304" cy="151.807" rx="3.72613" ry="3.7219" fill="#121212" />
<path d="M401 127.187H308.257L379.053 12.9805V165.02" stroke="#121212" stroke-width="2" stroke-linecap="square" stroke-linejoin="bevel" />
<ellipse cx="379.407" cy="127.187" rx="3.89381" ry="3.88938" fill="#121212" />
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -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<typeof useSession>['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<NonNullable<DefaultSession['user']>, '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,
};

View File

@@ -23,6 +23,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 { useCurrentSyncEngineStatus } from '../../hooks/current/use-current-sync-engine';
@@ -214,5 +215,9 @@ export const Component = () => {
}
}, [params, setContentLayout, setCurrentPageId, setCurrentWorkspaceId]);
return <DetailPage />;
return (
<AffineErrorBoundary>
<DetailPage />
</AffineErrorBoundary>
);
};

View File

@@ -8,3 +8,8 @@ declare module '*.md' {
const text: string;
export default text;
}
declare module '*.assets.svg' {
const url: string;
export default url;
}

View File

@@ -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--;
}
}
}