Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix hydration error on access denied error pages #2212

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import "@fontsource/source-serif-pro/index.css";
import { i18nInstance } from "@ndla/ui";
import { getCookie, setCookie } from "@ndla/util";
import App from "./App";
import ResponseContext from "./components/ResponseContext";
import GQLErrorContext, { ErrorContextInfo } from "./components/GQLErrorContext";
import ResponseContext, { ResponseInfo } from "./components/ResponseContext";
import { VersionHashProvider } from "./components/VersionHashContext";
import { STORED_LANGUAGE_COOKIE_KEY } from "./constants";
import { getLocaleInfoFromPath, initializeI18n, isValidLocale, supportedLanguages } from "./i18n";
Expand All @@ -45,7 +46,7 @@ declare global {
}

const {
DATA: { config, serverPath, serverQuery, serverResponse },
DATA: { config, serverPath, serverQuery, serverResponse, serverErrorContext },
} = window;

initSentry(config);
Expand Down Expand Up @@ -179,17 +180,21 @@ const renderOrHydrate = (container: HTMLElement, children: ReactNode) => {
hydrateRoot(container, children);
}
};
const responseContext = new ResponseInfo(serverResponse);
const errorContext = new ErrorContextInfo(serverErrorContext);

renderOrHydrate(
document.getElementById("root")!,
<HelmetProvider>
<I18nextProvider i18n={i18n}>
<ApolloProvider client={client}>
<ResponseContext.Provider value={{ status: serverResponse }}>
<VersionHashProvider value={versionHash}>
<LanguageWrapper basename={basename} />
</VersionHashProvider>
</ResponseContext.Provider>
<GQLErrorContext.Provider value={errorContext}>
<ResponseContext.Provider value={responseContext}>
<VersionHashProvider value={versionHash}>
<LanguageWrapper basename={basename} />
</VersionHashProvider>
</ResponseContext.Provider>
</GQLErrorContext.Provider>
</ApolloProvider>
</I18nextProvider>
</HelmetProvider>,
Expand Down
57 changes: 57 additions & 0 deletions src/components/GQLErrorContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2024-present, NDLA.
*
* This source code is licensed under the GPLv3 license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import { createContext, useContext } from "react";
import { ApolloError, DocumentNode, OperationVariables, TypedDocumentNode } from "@apollo/client";
import handleError from "../util/handleError";

export interface ServerErrorContext {
error: string | undefined;
}

export class ErrorContextInfo {
error: { [key: string]: ApolloError };
constructor(serverErrorContext?: ServerErrorContext) {
this.error = serverErrorContext?.error ? JSON.parse(serverErrorContext.error) : {};
}

getError(key: string): ApolloError | undefined {
return this.error[key];
}

setError(key: string, error: ApolloError) {
this.error[key] = error;
}

serialize(): ServerErrorContext {
return { error: JSON.stringify(this.error) };
}
}

const GQLErrorContext = createContext<ErrorContextInfo>(new ErrorContextInfo());

export const useApolloErrors = <TData extends any = any, TVariables extends OperationVariables = OperationVariables>(
errors: ApolloError | undefined,
query: DocumentNode | TypedDocumentNode<TData, TVariables>,
): ApolloError | undefined => {
const operation = query.definitions.find((definition) => definition.kind === "OperationDefinition");
const queryKey = operation?.name?.value;
const errorContext = useContext(GQLErrorContext);
if (!queryKey) {
handleError(new Error("No operation name found when using useApolloErrors, seems like a bug..."));
return errors;
}

if (errors) {
errorContext.setError(queryKey, errors);
return errors;
}

return errorContext.getError(queryKey);
};
export default GQLErrorContext;
15 changes: 14 additions & 1 deletion src/components/ResponseContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,21 @@

import { createContext } from "react";

export interface ResponseInfo {
export class ResponseInfo {
status?: number;

constructor(status?: number) {
this.status = status;
}

isAccessDeniedError(): boolean {
return this.status === 401 || this.status === 403;
}

isGoneError(): boolean {
return this.status === 410;
}
}

const ResponseContext = createContext<ResponseInfo | undefined>(undefined);
export default ResponseContext;
9 changes: 3 additions & 6 deletions src/containers/PlainArticlePage/PlainArticlePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,9 @@ const PlainArticlePage = () => {
if (loading) {
return <ContentPlaceholder variant="article" />;
}
if (error?.graphQLErrors.some((err) => err.extensions.status === 410) && redirectContext) {
redirectContext.status = 410;
return <UnpublishedResourcePage />;
}

if (responseContext?.status === 410) {
const has410Error = error?.graphQLErrors.some((err) => err.extensions.status === 410);
if (has410Error || responseContext?.isGoneError()) {
if (redirectContext) redirectContext.status = 410;
return <UnpublishedResourcePage />;
}

Expand Down
4 changes: 2 additions & 2 deletions src/containers/ResourcePage/ResourcePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,12 @@ const ResourcePage = () => {
}

const accessDeniedErrors = findAccessDeniedErrors(error);
if (accessDeniedErrors) {
if (accessDeniedErrors || responseContext?.isAccessDeniedError()) {
const nonRecoverableError = accessDeniedErrors.some(
(e) => !e.path?.includes("coreResources") && !e.path?.includes("supplementaryResources"),
);

if (nonRecoverableError) {
if (nonRecoverableError || responseContext?.isAccessDeniedError()) {
return <AccessDeniedPage />;
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
*/
import { NormalizedCacheObject } from "@apollo/client";
import { ServerErrorContext } from "./components/GQLErrorContext";
import { ConfigType } from "./config";
import { LocaleValues } from "./constants";

Expand All @@ -30,6 +31,7 @@ export interface WindowData {
[key: string]: string | number | boolean | undefined | null;
};
serverResponse?: number;
serverErrorContext?: ServerErrorContext;
}

export interface NDLAWindow {
Expand Down
27 changes: 17 additions & 10 deletions src/server/render/defaultRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { i18nInstance } from "@ndla/ui";
import { getCookie } from "@ndla/util";
import { disableSSR } from "./renderHelpers";
import App from "../../App";
import GQLErrorContext, { ErrorContextInfo, ServerErrorContext } from "../../components/GQLErrorContext";
import RedirectContext, { RedirectInfo } from "../../components/RedirectContext";
import ResponseContext, { ResponseInfo } from "../../components/ResponseContext";
import { VersionHashProvider } from "../../components/VersionHashContext";
Expand Down Expand Up @@ -69,7 +70,8 @@ export const defaultRender: RenderFunc = async (req) => {
const client = createApolloClient(locale, versionHash, req.path);
const i18n = initializeI18n(i18nInstance, locale);
const redirectContext: RedirectInfo = {};
const responseContext: ResponseInfo = {};
const responseContext: ResponseInfo = new ResponseInfo();
const errorContext = new ErrorContextInfo();
// @ts-ignore
const helmetContext: FilledContext = {};

Expand All @@ -78,15 +80,17 @@ export const defaultRender: RenderFunc = async (req) => {
<HelmetProvider context={helmetContext}>
<I18nextProvider i18n={i18n}>
<ApolloProvider client={client}>
<ResponseContext.Provider value={responseContext}>
<VersionHashProvider value={versionHash}>
<UserAgentProvider value={userAgentSelectors}>
<StaticRouter basename={basename} location={req.url}>
<App key={locale} />
</StaticRouter>
</UserAgentProvider>
</VersionHashProvider>
</ResponseContext.Provider>
<GQLErrorContext.Provider value={errorContext}>
<ResponseContext.Provider value={responseContext}>
<VersionHashProvider value={versionHash}>
<UserAgentProvider value={userAgentSelectors}>
<StaticRouter basename={basename} location={req.url}>
<App key={locale} />
</StaticRouter>
</UserAgentProvider>
</VersionHashProvider>
</ResponseContext.Provider>
</GQLErrorContext.Provider>
</ApolloProvider>
</I18nextProvider>
</HelmetProvider>
Expand All @@ -104,13 +108,16 @@ export const defaultRender: RenderFunc = async (req) => {

const apolloState = client.extract();

const serverErrorContext: ServerErrorContext = errorContext.serialize();

return {
status: redirectContext.status ?? OK,
data: {
helmetContext,
htmlContent: html,
data: {
serverResponse: redirectContext.status ?? undefined,
serverErrorContext,
serverPath: req.path,
serverQuery: req.query,
apolloState,
Expand Down
8 changes: 7 additions & 1 deletion src/util/runQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ import {
TypedDocumentNode,
useQuery,
} from "@apollo/client";
import { useApolloErrors } from "../components/GQLErrorContext";

export function useGraphQuery<TData extends any = any, TVariables extends OperationVariables = OperationVariables>(
query: DocumentNode | TypedDocumentNode<TData, TVariables>,
options?: QueryHookOptions<TData, TVariables>,
): QueryResult<TData, TVariables> {
return useQuery(query, {
const result = useQuery(query, {
errorPolicy: "all",
ssr: true,
...options,
});

// Apollo client does not cache errors, we need some way to pass errors to the client if they occur on the server side
const error = useApolloErrors(result.error, query);

return { ...result, error };
}
Loading