feat(client): migrate to Gatsby v5 and React 18 (#65729)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Sem Bauke
2026-02-11 19:15:32 +01:00
committed by GitHub
parent 1ecd36c440
commit 30bcf40381
65 changed files with 3979 additions and 5867 deletions

View File

@@ -8,7 +8,7 @@ const config = {
loose: true,
modules: false,
useBuiltIns: 'usage',
corejs: 2,
corejs: 3,
shippedProposals: true,
targets: {
browsers: ['>0.25%', 'not dead']

View File

@@ -1,3 +1,4 @@
import { LocationProvider } from '@gatsbyjs/reach-router';
import cookies from 'browser-cookies';
import PropTypes from 'prop-types';
import React from 'react';
@@ -16,15 +17,17 @@ const store = createStore();
export const wrapRootElement = ({ element }) => {
return (
<Provider store={store}>
<I18nextProvider i18n={i18n}>
<GrowthBookProvider>
<AppMountNotifier>
<Elements stripe={stripe}>{element}</Elements>
</AppMountNotifier>
</GrowthBookProvider>
</I18nextProvider>
</Provider>
<LocationProvider>
<Provider store={store}>
<I18nextProvider i18n={i18n}>
<GrowthBookProvider>
<AppMountNotifier>
<Elements stripe={stripe}>{element}</Elements>
</AppMountNotifier>
</GrowthBookProvider>
</I18nextProvider>
</Provider>
</LocationProvider>
);
};
@@ -36,6 +39,10 @@ export const wrapPageElement = layoutSelector;
export const disableCorePrefetching = () => true;
export const onRouteUpdate = () => {
store.dispatch({ type: 'app.routeUpdated' });
};
export const onClientEntry = () => {
// Letting the users' browsers expire the cookie seems to have caused issues
// for some users. Until we have time to investigate further, we should remove

View File

@@ -15,13 +15,14 @@ module.exports = {
flags: {
DEV_SSR: false
},
trailingSlash: 'ignore',
siteMetadata: {
title: 'freeCodeCamp',
siteUrl: homeLocation
},
pathPrefix: pathPrefix,
plugins: [
'gatsby-plugin-pnpm',
'gatsby-plugin-pnpm-gatsby-5',
{
resolve: 'gatsby-plugin-webpack-bundle-analyser-v2',
options: {
@@ -43,18 +44,6 @@ module.exports = {
}
}
},
{
resolve: 'gatsby-plugin-create-client-paths',
options: {
prefixes: [
'/certification/*',
'/unsubscribed/*',
'/user/*',
'/settings/*',
'/n/*'
]
}
},
{
resolve: require.resolve(
'../tools/client-plugins/gatsby-source-challenges'

View File

@@ -59,7 +59,13 @@ exports.createPages = async function createPages({
const result = await graphql(`
{
allChallengeNode {
allChallengeNode(
sort: [
{ challenge: { superOrder: ASC } }
{ challenge: { order: ASC } }
{ challenge: { challengeOrder: ASC } }
]
) {
edges {
node {
challenge {
@@ -150,9 +156,9 @@ exports.onCreateWebpackConfig = ({ stage, actions }) => {
})
];
// The monaco editor relies on some browser only globals so should not be
// involved in SSR. Also, if the plugin is used during the 'build-html' stage
// it overwrites the minfied files with ordinary ones.
if (stage !== 'build-html') {
// involved in SSR. Also, if the plugin is used during the 'build-html' or
// 'develop-html' stage it overwrites the minfied files with ordinary ones.
if (stage !== 'build-html' && stage !== 'develop-html') {
newPlugins.push(
new MonacoWebpackPlugin({ filename: '[name].worker-[contenthash].js' })
);
@@ -192,15 +198,3 @@ exports.onCreateBabelConfig = ({ actions }) => {
name: '@babel/plugin-proposal-export-default-from'
});
};
exports.onCreatePage = async ({ page, actions }) => {
const { createPage } = actions;
// Only update the `/challenges` page.
if (page.path.match(/^\/challenges/)) {
// page.matchPath is a special key that's used for matching pages
// with corresponding routes only on the client.
page.matchPath = '/challenges/*';
// Update the page.
createPage(page);
}
};

View File

@@ -74,15 +74,13 @@
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
"final-form": "4.20.10",
"gatsby": "3.15.0",
"gatsby-cli": "3.15.0",
"gatsby-plugin-create-client-paths": "3.15.0",
"gatsby-plugin-pnpm": "^1.2.10",
"gatsby-plugin-postcss": "4.15.0",
"gatsby-plugin-react-helmet": "4.15.0",
"gatsby": "5.16.0",
"gatsby-cli": "5.16.0",
"gatsby-plugin-postcss": "6.16.0",
"gatsby-plugin-react-helmet": "6.16.0",
"gatsby-plugin-remove-serviceworker": "1.0.0",
"gatsby-source-filesystem": "3.15.0",
"gatsby-transformer-remark": "5.25.1",
"gatsby-source-filesystem": "5.16.0",
"gatsby-transformer-remark": "6.16.0",
"i18next": "25.2.1",
"instantsearch.js": "4.75.3",
"lodash": "4.17.21",
@@ -99,9 +97,9 @@
"prop-types": "15.8.1",
"qrcode.react": "^3.1.0",
"query-string": "7.1.3",
"react": "17.0.2",
"react": "18.2.0",
"react-calendar-heatmap": "1.9.0",
"react-dom": "17.0.2",
"react-dom": "18.2.0",
"react-final-form": "6.5.9",
"react-gtm-module": "2.0.11",
"react-helmet": "6.1.0",
@@ -110,7 +108,7 @@
"react-instantsearch": "7.13.6",
"react-instantsearch-core": "7.13.6",
"react-monaco-editor": "0.48.0",
"react-redux": "7.2.9",
"react-redux": "8.1.3",
"react-reflex": "4.1.0",
"react-responsive": "9.0.2",
"react-scroll": "1.9.0",
@@ -143,8 +141,7 @@
"@freecodecamp/eslint-config": "workspace:*",
"@freecodecamp/shared": "workspace:*",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/react": "14.3.1",
"@testing-library/user-event": "14.6.1",
"@total-typescript/ts-reset": "^0.5.0",
"@types/canvas-confetti": "^1.6.0",
@@ -154,8 +151,8 @@
"@types/lodash-es": "^4.17.6",
"@types/node-fetch": "2",
"@types/prismjs": "^1.26.0",
"@types/react": "17.0.83",
"@types/react-dom": "17.0.19",
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",
"@types/react-gtm-module": "2.0.3",
"@types/react-helmet": "6.1.11",
"@types/react-redux": "7.1.33",
@@ -171,18 +168,19 @@
"@vitest/ui": "^4.0.15",
"autoprefixer": "10.4.17",
"babel-plugin-macros": "3.1.0",
"core-js": "2.6.12",
"core-js": "3.37.1",
"dotenv": "16.4.5",
"eslint": "^9.39.1",
"eslint-plugin-flowtype": "^8.0.3",
"gatsby-plugin-schema-snapshot": "2.15.0",
"gatsby-plugin-schema-snapshot": "4.16.0",
"gatsby-plugin-webpack-bundle-analyser-v2": "1.1.32",
"gatsby-plugin-pnpm-gatsby-5": "1.2.11",
"i18next-fs-backend": "2.6.0",
"joi": "17.12.2",
"js-yaml": "4.1.0",
"monaco-editor-webpack-plugin": "7.0.1",
"monaco-editor-webpack-plugin": "7.1.1",
"node-fetch": "2.7.0",
"react-test-renderer": "17.0.2",
"react-test-renderer": "18.2.0",
"readdirp": "3.6.0",
"redux-saga-test-plan": "4.0.6",
"serve": "13.0.4",

View File

@@ -454,31 +454,29 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
: 'certification.fulltext'
}
title={t(`certification.title.${certSlug}`, certTitle)}
values={{
user: displayName,
title: t(`certification.title.${certSlug}`, certTitle),
time: certDate.toLocaleString([localeCode, 'en-US'], {
year: 'numeric',
month: 'long',
day: 'numeric'
}),
completionTime
}}
>
<h3>placeholder</h3>
<h1>
<strong>{{ user: displayName }}</strong>
<strong>{'{{user}}'}</strong>
</h1>
<h3 data-playwright-test-label='successful-completion'>
placeholder
</h3>
<h1 data-playwright-test-label='certification-title'>
<strong>
{{
title: t(`certification.title.${certSlug}`, certTitle)
}}
</strong>
<strong>{'{{title}}'}</strong>
</h1>
<h4 data-playwright-test-label='issue-date'>
{{
time: certDate.toLocaleString([localeCode, 'en-US'], {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}}
</h4>
<h5 style={{ marginTop: '15px' }}>{{ completionTime }}</h5>
<h4 data-playwright-test-label='issue-date'>{'{{time}}'}</h4>
<h5 style={{ marginTop: '15px' }}>{'{{completionTime}}'}</h5>
</Trans>
</div>
</main>

View File

@@ -106,8 +106,8 @@ export function ShowUser({
<Row className='overflow-fix'>
<Col sm={6} smOffset={3} xs={12}>
<p>
<Trans i18nKey='report.notify-1'>
<strong>{{ email: user.email }}</strong>
<Trans i18nKey='report.notify-1' values={{ email: user.email }}>
<strong>{'{{email}}'}</strong>
</Trans>
</p>
<p>{t('report.notify-2')}</p>

View File

@@ -168,13 +168,13 @@ function DonationFormRow({
);
}
const MultiTierDonationForm: React.FC<MultiTierDonationFormProps> = ({
const MultiTierDonationForm = ({
handleProcessing,
setShowHeaderAndFooter,
isMinimalForm,
paymentContext,
isAnimationEnabled
}) => {
}: MultiTierDonationFormProps) => {
const replace20With25 = useFeature('replace-20-with-25').on;
const [donationAmount, setDonationAmount] = useState(
replace20With25 ? defaultTierAmountB : defaultTierAmount

View File

@@ -30,12 +30,9 @@ const LanguageList = ({ t, navigate }: LanguageListProps): JSX.Element => {
const [showList, setShowList] = useState(false);
const listButtonRef = useRef<HTMLButtonElement>(null);
const handleClick = (): void => {
if (showList) {
setShowList(false);
return;
}
setShowList(true);
const handleClick = (event: React.MouseEvent): void => {
event.stopPropagation();
setShowList(prev => !prev);
};
const handleClickOutside = () => {

View File

@@ -31,7 +31,8 @@ const MenuButton = ({
}
};
const handleClick = (): void => {
const handleClick = (event: React.MouseEvent): void => {
event.stopPropagation();
if (displayMenu) {
hideMenu();
return;

View File

@@ -168,13 +168,11 @@ const useGetAllChallengeData = () => {
} = useStaticQuery(graphql`
query getBlockNode {
allChallengeNode(
sort: {
fields: [
challenge___superOrder
challenge___order
challenge___challengeOrder
]
}
sort: [
{ challenge: { superOrder: ASC } }
{ challenge: { order: ASC } }
{ challenge: { challengeOrder: ASC } }
]
) {
nodes {
challenge {

View File

@@ -6,6 +6,7 @@ import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
interface SEOProps {
title?: string;
children?: React.ReactNode;
}
interface SiteData {
@@ -42,7 +43,7 @@ interface StructuredData {
itemListElement: ListItem[];
}
const SEO: React.FC<SEOProps> = ({ title, children }) => {
const SEO = ({ title, children }: SEOProps) => {
const { t } = useTranslation();
const {
site: {

View File

@@ -50,7 +50,7 @@ exports[`<Honesty /> > <Honesty /> snapshot when isHonest is false > Honesty 1`]
<a
href="mailto:support@freecodecamp.org"
>
support@freecodecamp.org
{{email}}
</a>
</p>
</div>
@@ -117,7 +117,7 @@ exports[`<Honesty /> > <Honesty /> snapshot when isHonest is true > HonestyAccep
<a
href="mailto:support@freecodecamp.org"
>
support@freecodecamp.org
{{email}}
</a>
</p>
</div>

View File

@@ -36,9 +36,9 @@ function DeleteModal(props: DeleteModalProps): JSX.Element {
<p>{t('settings.danger.delete-p1')}</p>
<p>{t('settings.danger.delete-p2')}</p>
<p>
<Trans i18nKey='settings.danger.delete-p3'>
<Trans i18nKey='settings.danger.delete-p3' values={{ email }}>
<a href={`mailto:${email}`} title={email}>
{{ email }}
{'{{email}}'}
</a>
</Trans>
</p>

View File

@@ -31,8 +31,8 @@ const Honesty = ({ isHonest, updateIsHonest }: HonestyProps): JSX.Element => {
<p>{t('settings.honesty.p5')}</p>
<p>{t('settings.honesty.p6')}</p>
<p>
<Trans i18nKey='settings.honesty.p7'>
<a href={`mailto:${email}`}>{{ email }}</a>
<Trans i18nKey='settings.honesty.p7' values={{ email }}>
<a href={`mailto:${email}`}>{'{{email}}'}</a>
</Trans>
</p>
</Panel>

View File

@@ -1,4 +1,4 @@
import { renderHook } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react';
import { useTranslation } from 'react-i18next';
import { describe, test, expect } from 'vitest';
import {

View File

@@ -1,22 +1,10 @@
/* eslint-disable filenames-simple/naming-convention */
import { Router } from '@gatsbyjs/reach-router';
import { withPrefix } from 'gatsby';
import React from 'react';
import ShowProfileOrFourOhFour from '../client-only-routes/show-profile-or-four-oh-four';
import FourOhFour from '../components/FourOhFour';
function FourOhFourPage(): JSX.Element {
return (
<Router>
{/* Error from installing @types/react-helmet and @types/react-redux */}
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<ShowProfileOrFourOhFour path={withPrefix('/:maybeUser')} />
<FourOhFour default />
</Router>
);
return <FourOhFour />;
}
FourOhFourPage.displayName = 'FourOhFourPage';

View File

@@ -0,0 +1,19 @@
/* eslint-disable filenames-simple/naming-convention */
import React from 'react';
import ShowProfileOrFourOhFour from '../client-only-routes/show-profile-or-four-oh-four';
interface ProfilePageProps {
params: {
maybeUser: string;
};
}
const ProfilePage = ({ params: { maybeUser } }: ProfilePageProps) => {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Redux connect() provides remaining props
<ShowProfileOrFourOhFour maybeUser={maybeUser} />
);
};
export default ProfilePage;

View File

@@ -1,25 +0,0 @@
import { Router } from '@gatsbyjs/reach-router';
import { withPrefix } from 'gatsby';
import React from 'react';
import ShowCertification from '../client-only-routes/show-certification';
import RedirectHome from '../components/redirect-home';
import './certification.css';
function Certification(): JSX.Element {
return (
<Router>
<ShowCertification
// Error from installing @types/react-helmet and @types/react-redux
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={withPrefix('/certification/:username/:certSlug')}
/>
<RedirectHome default />
</Router>
);
}
export default Certification;

View File

@@ -0,0 +1,29 @@
/* eslint-disable filenames-simple/naming-convention */
import React from 'react';
import ShowCertification from '../../../client-only-routes/show-certification';
import '../certification.css';
interface CertificationPageProps {
params: {
username: string;
certSlug: string;
};
location: {
pathname: string;
};
}
const CertificationPage = ({ params, location }: CertificationPageProps) => {
const { username, certSlug } = params;
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Redux connect() provides remaining props
<ShowCertification
username={username}
certSlug={certSlug}
location={location}
/>
);
};
export default CertificationPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import RedirectHome from '../../components/redirect-home';
// Redirect to home if no username/certSlug provided
const Certification = () => <RedirectHome />;
export default Certification;

View File

@@ -1,59 +1,36 @@
import {
createHistory,
createMemorySource,
LocationProvider
} from '@gatsbyjs/reach-router';
import { render } from '@testing-library/react';
import { navigate, withPrefix } from 'gatsby';
import React from 'react';
import { describe, it, expect } from 'vitest';
import Challenges from './challenges';
import Challenges from './challenges/index';
import ChallengesRedirect from './challenges/[...]';
describe('Challenges', () => {
// Source: https://testing-library.com/docs/example-reach-router/
function renderWithRouterWrapper({
route = '/',
history = createHistory(createMemorySource(route))
} = {}) {
return {
...render(
<LocationProvider history={history}>
<Challenges />
</LocationProvider>
),
// adding `history` to the returned utilities to allow us
// to reference it in our tests (just try to avoid using
// this to test implementation details).
history
};
}
const challenges = withPrefix('/challenges');
const learn = withPrefix('/learn');
it('should handle redirect to /learn', () => {
renderWithRouterWrapper({ route: challenges });
render(<Challenges />);
expect(navigate).toHaveBeenLastCalledWith(learn);
});
it('should handle redirect to /learn/:super-block', () => {
renderWithRouterWrapper({ route: `${challenges}/super-block` });
render(<ChallengesRedirect params={{ '*': 'super-block' }} />);
expect(navigate).toHaveBeenLastCalledWith(`${learn}/super-block`);
});
it('should handle redirect to /learn/:super-block/:block', () => {
renderWithRouterWrapper({ route: `${challenges}/super-block/block` });
render(<ChallengesRedirect params={{ '*': 'super-block/block' }} />);
expect(navigate).toHaveBeenLastCalledWith(`${learn}/super-block/block`);
});
it('should handle redirect to /learn/:super-block/:block/:challenge', () => {
renderWithRouterWrapper({
route: `${challenges}/super-block/block/challenge`
});
render(
<ChallengesRedirect params={{ '*': 'super-block/block/challenge' }} />
);
expect(navigate).toHaveBeenLastCalledWith(
`${learn}/super-block/block/challenge`

View File

@@ -1,37 +0,0 @@
// This exists purely to redirect legacy challenge paths to /learn that could
// exist in the web (posts, url shares, etc).
import { Router, RouteComponentProps } from '@gatsbyjs/reach-router';
import { navigate, withPrefix } from 'gatsby';
import React from 'react';
import toLearnPath from '../utils/to-learn-path';
type RouteComponentPropsExtended = RouteComponentProps & {
block?: string;
challenge?: string;
superBlock?: string;
};
function Redirect(props: RouteComponentPropsExtended): null {
if (typeof window !== 'undefined') {
void navigate(toLearnPath(props));
}
return null;
}
function Challenges(): JSX.Element {
return (
<Router basepath={withPrefix('/challenges')}>
<Redirect path='/:superBlock/' />
<Redirect path='/:superBlock/:block/' />
<Redirect path='/:superBlock/:block/:challenge' />
<Redirect default={true} />
</Router>
);
}
Challenges.displayName = 'Challenges';
export default Challenges;

View File

@@ -0,0 +1,26 @@
/* eslint-disable filenames-simple/naming-convention */
// This exists purely to redirect legacy challenge paths to /learn
import { navigate } from 'gatsby';
import { useEffect } from 'react';
import toLearnPath from '../../utils/to-learn-path';
interface ChallengesRedirectProps {
params: {
'*': string;
};
}
const ChallengesRedirect = ({ params }: ChallengesRedirectProps) => {
useEffect(() => {
const pathSegments = params['*'].split('/').filter(Boolean);
const [superBlock, block, challenge] = pathSegments;
void navigate(toLearnPath({ superBlock, block, challenge }));
}, [params]);
return null;
};
export default ChallengesRedirect;

View File

@@ -0,0 +1,15 @@
// This exists purely to redirect legacy challenge paths to /learn that could
// exist in the web (posts, url shares, etc).
import { navigate } from 'gatsby';
import { useEffect } from 'react';
const Challenges = () => {
useEffect(() => {
void navigate('/learn');
}, []);
return null;
};
export default Challenges;

View File

@@ -1,20 +0,0 @@
import { Router } from '@gatsbyjs/reach-router';
import { withPrefix } from 'gatsby';
import React from 'react';
import ShowSettings from '../client-only-routes/show-settings';
import RedirectHome from '../components/redirect-home';
function Settings(): JSX.Element {
return (
<Router>
<ShowSettings path={withPrefix('/settings')} />
<RedirectHome default />
</Router>
);
}
Settings.displayName = 'Settings';
export default Settings;

View File

@@ -0,0 +1,7 @@
/* eslint-disable filenames-simple/naming-convention */
import React from 'react';
import ShowSettings from '../../client-only-routes/show-settings';
const Settings = () => <ShowSettings />;
export default Settings;

View File

@@ -0,0 +1,6 @@
import React from 'react';
import ShowSettings from '../../client-only-routes/show-settings';
const Settings = () => <ShowSettings />;
export default Settings;

View File

@@ -1,22 +0,0 @@
import { Router } from '@gatsbyjs/reach-router';
import { withPrefix } from 'gatsby';
import React from 'react';
import ShowUnsubscribed from '../client-only-routes/show-unsubscribed';
import RedirectHome from '../components/redirect-home';
function Unsubscribed(): JSX.Element {
return (
<Router>
<ShowUnsubscribed path={withPrefix('/unsubscribed/:unsubscribeId')} />
<ShowUnsubscribed path={withPrefix('/unsubscribed')} />
<RedirectHome default />
</Router>
);
}
Unsubscribed.displayName = 'Unsubscribed';
export default Unsubscribed;

View File

@@ -0,0 +1,17 @@
/* eslint-disable filenames-simple/naming-convention */
import React from 'react';
import ShowUnsubscribed from '../../client-only-routes/show-unsubscribed';
interface UnsubscribedWithIdProps {
params: {
unsubscribeId: string;
};
}
const UnsubscribedWithId = ({
params: { unsubscribeId }
}: UnsubscribedWithIdProps) => (
<ShowUnsubscribed unsubscribeId={unsubscribeId} />
);
export default UnsubscribedWithId;

View File

@@ -0,0 +1,6 @@
import React from 'react';
import ShowUnsubscribed from '../../client-only-routes/show-unsubscribed';
const Unsubscribed = () => <ShowUnsubscribed />;
export default Unsubscribed;

View File

@@ -1,21 +0,0 @@
import { Router } from '@gatsbyjs/reach-router';
import { withPrefix } from 'gatsby';
import React from 'react';
import ShowUser from '../client-only-routes/show-user';
import RedirectHome from '../components/redirect-home';
function User(): JSX.Element {
return (
<Router>
{/* @ts-expect-error Adding path property breaks username typing */}
<ShowUser path={withPrefix('/user/:username/report-user')} />
<RedirectHome default />
</Router>
);
}
User.displayName = 'User';
export default User;

View File

@@ -0,0 +1,14 @@
import React from 'react';
import ShowUser from '../../../../client-only-routes/show-user';
interface ReportUserPageProps {
params: {
username: string;
};
}
const ReportUserPage = ({ params: { username } }: ReportUserPageProps) => (
<ShowUser username={username} />
);
export default ReportUserPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import RedirectHome from '../../components/redirect-home';
// Redirect to home if no username provided
const User = () => <RedirectHome />;
export default User;

View File

@@ -20,6 +20,7 @@ export const actionTypes = createTypes(
'onlineStatusChange',
'serverStatusChange',
'resetUserData',
'routeUpdated',
'tryToShowDonationModal',
'startExam',
'stopExam',

View File

@@ -84,6 +84,7 @@ export const reportUserComplete = createAction(actionTypes.reportUserComplete);
export const reportUserError = createAction(actionTypes.reportUserError);
export const resetUserData = createAction(actionTypes.resetUserData);
export const routeUpdated = createAction(actionTypes.routeUpdated);
export const showCert = createAction(actionTypes.showCert);
export const showCertComplete = createAction(actionTypes.showCertComplete);

View File

@@ -1,5 +1,4 @@
import { withPrefix } from 'gatsby';
import { navigate } from '@gatsbyjs/reach-router';
import { navigate } from 'gatsby';
import { call, put, take, takeEvery } from 'redux-saga/effects';
import { createFlashMessage } from '../../components/Flash/redux';
@@ -18,12 +17,12 @@ function* deleteAccountSaga() {
message: FlashMessages.AccountDeleted
})
);
// navigate before signing out, since /settings will attempt to sign users
// back in. Using reach-router's navigate because gatsby's resolves after
// the call. This would allow resetUserData to take place while the user is
// still on /settings.
yield call(navigate, withPrefix('/learn'));
// remove current user information from application state
// Navigate before signing out, since /settings will attempt to sign users
// back in if resetUserData fires while still on /settings.
void navigate('/learn');
// Wait for Gatsby to complete the route transition before clearing user
// data, ensuring /settings is unmounted and won't re-authenticate.
yield take(appTypes.routeUpdated);
yield put(resetUserData());
} catch (e) {
yield put(deleteAccountError(e));

View File

@@ -177,7 +177,9 @@ function ShowCodeAlly({
...challengePaths
});
challengeMounted(challengeMeta.id);
container.current?.focus();
// hack to ensure the container is focused after the component mounts
// and Gatsby doesn't interfere with the focus.
requestAnimationFrame(() => container.current?.focus());
// This effect should be run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -205,7 +205,9 @@ function ShowExam(props: ShowExamProps) {
});
challengeMounted(challengeMeta.id);
container.current?.focus();
// hack to ensure the container is focused after the component mounts
// and Gatsby doesn't interfere with the focus.
requestAnimationFrame(() => container.current?.focus());
return () => {
cleanUp();

View File

@@ -131,7 +131,9 @@ const ShowFillInTheBlank = ({
...challengePaths
});
challengeMounted(challengeMeta.id);
container.current?.focus();
// hack to ensure the container is focused after the component mounts
// and Gatsby doesn't interfere with the focus.
requestAnimationFrame(() => container.current?.focus());
// This effect should be run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -145,7 +145,9 @@ const ShowGeneric = ({
...challengePaths
});
challengeMounted(challengeMeta.id);
container.current?.focus();
// hack to ensure the container is focused after the component mounts
// and Gatsby doesn't interfere with the focus.
requestAnimationFrame(() => container.current?.focus());
// This effect should be run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -1,9 +1,9 @@
import { useEffect } from 'react';
import { useLocation, globalHistory } from '@gatsbyjs/reach-router';
import { useLocation } from '@gatsbyjs/reach-router';
interface Props {
onWindowClose: (event: BeforeUnloadEvent) => void;
onHistoryChange: (targetPathname: string) => void;
onHistoryChange: (targetPathname: string) => boolean;
}
export const usePageLeave = ({ onWindowClose, onHistoryChange }: Props) => {
@@ -11,22 +11,39 @@ export const usePageLeave = ({ onWindowClose, onHistoryChange }: Props) => {
useEffect(() => {
window.addEventListener('beforeunload', onWindowClose);
// Push a dummy state so that navigating back will restore the current page,
// allowing us to manually handle navigation.
window.history.pushState({}, curLocation.pathname);
// This is a workaround as @gatsbyjs/reach-router doesn't support blocking history change.
// https://github.com/reach/router/issues/464
const unlistenHistory = globalHistory.listen(({ action, location }) => {
const isBack = action === 'POP';
const isRouteChanged =
action === 'PUSH' && location.pathname !== curLocation.pathname;
const handlePopState = () => {
// The argument should be an empty string, so that onHistoryChange knows
// to use the default navigation target
onHistoryChange('');
};
if (isBack || isRouteChanged) {
onHistoryChange(location.pathname);
window.addEventListener('popstate', handlePopState);
const handleLinkClick = (event: MouseEvent) => {
const anchor = (event.target as HTMLElement).closest('a');
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!href || !href.startsWith('/')) return;
if (href === curLocation.pathname) return;
const blocked = onHistoryChange(href);
if (blocked) {
event.preventDefault();
event.stopPropagation();
}
});
};
document.addEventListener('click', handleLinkClick, true);
return () => {
window.removeEventListener('beforeunload', onWindowClose);
unlistenHistory();
window.removeEventListener('popstate', handlePopState);
document.removeEventListener('click', handleLinkClick, true);
};
}, [onWindowClose, onHistoryChange, curLocation]);
};

View File

@@ -116,7 +116,9 @@ function MsTrophy(props: MsTrophyProps) {
...challengePaths
});
challengeMounted(challengeMeta.id);
container.current?.focus();
// hack to ensure the container is focused after the component mounts
// and Gatsby doesn't interfere with the focus.
requestAnimationFrame(() => container.current?.focus());
// This effect should be run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -130,7 +130,9 @@ const ShowBackEnd = (props: BackEndProps) => {
...challengePaths
});
challengeMounted(challengeMeta.id);
container.current?.focus();
// hack to ensure the container is focused after the component mounts
// and Gatsby doesn't interfere with the focus.
requestAnimationFrame(() => container.current?.focus());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -105,7 +105,9 @@ const ShowFrontEndProject = (props: ProjectProps) => {
...challengePaths
});
challengeMounted(challengeMeta.id);
container.current?.focus();
// hack to ensure the container is focused after the component mounts
// and Gatsby doesn't interfere with the focus.
requestAnimationFrame(() => container.current?.focus());
// This effect should be run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -1,4 +1,4 @@
import { graphql } from 'gatsby';
import { graphql, navigate } from 'gatsby';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Helmet from 'react-helmet';
import { ObserveKeys } from 'react-hotkeys';
@@ -7,7 +7,6 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import type { Dispatch } from 'redux';
import { createSelector } from 'reselect';
import { useLocation, navigate as reachNavigate } from '@gatsbyjs/reach-router';
import {
Container,
Col,
@@ -116,8 +115,6 @@ const ShowQuiz = ({
closeFinishQuizModal
}: ShowQuizProps) => {
const { t } = useTranslation();
const curLocation = useLocation();
const container = useRef<HTMLElement | null>(null);
// Campers are not allowed to change their answers once the quiz is submitted.
@@ -220,7 +217,9 @@ const ShowQuiz = ({
...challengePaths
});
challengeMounted(challengeMeta.id);
container.current?.focus();
// hack to ensure the container is focused after the component mounts
// and Gatsby doesn't interfere with the focus.
requestAnimationFrame(() => container.current?.focus());
// This effect should be run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -249,7 +248,7 @@ const ShowQuiz = ({
const handleExitQuizModalBtnClick = () => {
exitConfirmed.current = true;
void reachNavigate(exitPathname || '/learn');
void navigate(exitPathname || '/learn', { replace: true });
closeExitQuizModal();
};
@@ -262,29 +261,26 @@ const ShowQuiz = ({
);
const onHistoryChange = useCallback(
(targetPathname: string) => {
(targetPathname: string): boolean => {
// We don't block navigation in the following cases.
// - When campers have submitted the quiz:
// - If they don't pass, the Finish Quiz button is disabled, there isn't anything for them to do other than leaving the page
// - If they pass, the Submit-and-go button shows up, and campers should be allowed to leave the page
// - When they have clicked the exit button on the exit modal
if (hasSubmitted || exitConfirmed.current) {
return;
return false;
}
const newPathname = targetPathname.startsWith('/learn')
? blockHashSlug
: targetPathname;
// For link clicks, save the target pathname. For back button
// (empty targetPathname), keep the default (i.e. blockHashSlug).
if (targetPathname) {
setExitPathname(targetPathname);
}
// Save the pathname of the page the user wants to navigate to before we block the navigation.
setExitPathname(newPathname);
// We need to use Reach Router, because the pathname is already prefixed
// with the language and Gatsby's navigate will prefix it again.
void reachNavigate(`${curLocation.pathname}`);
openExitQuizModal();
return true;
},
[curLocation.pathname, hasSubmitted, openExitQuizModal, blockHashSlug]
[hasSubmitted, openExitQuizModal]
);
usePageLeave({

View File

@@ -73,7 +73,7 @@ export const query = graphql`
html
}
allChallengeNode(
sort: { fields: [challenge___challengeOrder] }
sort: { challenge: { challengeOrder: ASC } }
filter: { challenge: { block: { eq: $block } } }
limit: 1
) {

View File

@@ -358,13 +358,11 @@ export default connect(
export const query = graphql`
query SuperBlockIntroPageQuery {
allChallengeNode(
sort: {
fields: [
challenge___superOrder
challenge___order
challenge___challengeOrder
]
}
sort: [
{ challenge: { superOrder: ASC } }
{ challenge: { order: ASC } }
{ challenge: { challengeOrder: ASC } }
]
) {
nodes {
challenge {

View File

@@ -1,10 +1,10 @@
import React, { type ReactElement } from 'react';
import React, { type ReactElement, type ReactNode } from 'react';
import { render as rtlRender } from '@testing-library/react';
import { Provider } from 'react-redux';
import type { Store } from 'redux';
function render(ui: ReactElement, store: Store) {
function Wrapper({ children }: { children: ReactElement }) {
function Wrapper({ children }: { children: ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
return rtlRender(ui, { wrapper: Wrapper });

View File

@@ -7,13 +7,6 @@ test.describe('Public profile certifications', () => {
}) => {
await page.goto('/certifieduser');
// If you build the client locally, delete the button click below.
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
await expect(
page.getByRole('link', { name: /View.+Certification/ })
).toHaveCount(25);
@@ -24,11 +17,6 @@ test.describe('Public profile certifications', () => {
}) => {
await page.goto('/certifieduser');
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
await page.getByLabel('Username').fill('CertifiedBoozer');
@@ -38,13 +26,6 @@ test.describe('Public profile certifications', () => {
);
await page.goto('/certifiedboozer');
// If you build the client locally, delete the button click below.
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
await page.waitForURL('/certifiedboozer');
await expect(
page.getByRole('link', { name: /View.+Certification/ })

View File

@@ -66,12 +66,6 @@ test.describe('Completed project preview', () => {
test('it should be viewable on the timeline', async ({ page }) => {
await page.goto('/developmentuser');
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
await expect(
page.getByRole('heading', { name: '@developmentuser' })
).toBeVisible();

View File

@@ -4,10 +4,6 @@ import translations from '../client/i18n/locales/english/translations.json';
test.beforeEach(async ({ page }) => {
await page.goto('/certifieduser');
if (!process.env.CI) {
await page.getByRole('button', { name: 'Preview custom 404 page' }).click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
});

View File

@@ -17,12 +17,6 @@ test.describe('Add Experience Item', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/developmentuser');
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
await expect(async () => {

View File

@@ -4,12 +4,6 @@ test.describe('Picture input field', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/certifieduser');
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
});

View File

@@ -19,10 +19,6 @@ test.beforeEach(async ({ page }) => {
await page.goto('/certifieduser');
if (!process.env.CI) {
await page.getByRole('button', { name: 'Preview custom 404 page' }).click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
});

View File

@@ -16,12 +16,6 @@ test.describe('Add Portfolio Item', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/certifieduser');
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
// Will check if the portfolio button is hydrated correctly with different intervals.

View File

@@ -88,13 +88,6 @@ test.describe('Profile component', () => {
test.describe('when viewing my own profile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/certifieduser');
// If you build the client locally, delete the button click below.
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
});
test('renders the camper profile correctly', async ({ page }) => {
@@ -173,13 +166,6 @@ test.describe('Profile component', () => {
test.describe("when viewing someone else's profile", () => {
test.beforeEach(async ({ page }) => {
await page.goto('/publicUser');
// If you build the client locally, delete the button click below.
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
});
test.describe('while logged in', () => {

View File

@@ -236,14 +236,40 @@ test.describe('Quiz challenge', () => {
// Wait for the page content to render
await expect(page.getByRole('radiogroup')).toHaveCount(20);
await page.getByRole('link', { name: 'Basic HTML Quiz' }).click();
// navigate to /learn
await page.getByTestId('header-universal-nav-logo').click();
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeVisible();
await page
.getByRole('button', { name: 'Yes, I want to leave the quiz' })
.click();
await page.waitForURL('/learn/responsive-web-design-v9/#quiz-basic-html');
await expect(page).toHaveURL(allowTrailingSlash('/learn'));
await expect(
page.getByRole('heading', { name: 'Welcome back, Full Stack User.' })
).toBeVisible();
});
test('should show a confirm exit modal when user presses the back button', async ({
page
}) => {
const blockPath = '/learn/responsive-web-design-v9/#quiz-basic-html';
await page.goto(blockPath);
await page.goto(quizPath);
await expect(page.getByRole('radiogroup')).toHaveCount(20);
await page.goBack();
await expect(page).toHaveURL(allowTrailingSlash(quizPath));
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeVisible();
await page
.getByRole('button', { name: 'Yes, I want to leave the quiz' })
.click();
await page.waitForURL(blockPath);
await expect(
page.getByRole('heading', { level: 3, name: 'Basic HTML Quiz' })
).toBeVisible();

View File

@@ -15,11 +15,6 @@ test('should be possible to report a user from their profile page', async ({
}) => {
await page.goto('/twaha');
// If you build the client locally, delete the button click below.
if (!process.env.CI) {
await page.getByRole('button', { name: 'Preview custom 404 page' }).click();
}
await page.getByText("Flag This User's Account for Abuse").click();
await expect(

View File

@@ -9,10 +9,6 @@ test.beforeEach(async ({ page }) => {
await page.goto('/certifieduser');
if (!process.env.CI) {
await page.getByRole('button', { name: 'Preview custom 404 page' }).click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
});
@@ -64,10 +60,6 @@ test('Should allow empty string in any field in about settings', async ({
await page.reload();
if (!process.env.CI) {
await page.getByRole('button', { name: 'Preview custom 404 page' }).click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
await expect(nameInput).toHaveValue('');
await expect(locationInput).toHaveValue('');

View File

@@ -26,12 +26,6 @@ test.describe('Username Settings Validation', () => {
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
await page.goto(`/certifieduser`);
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
});

9076
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,9 @@
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "gatsby-node.js",
"peerDependencies": {
"gatsby": "^5.0.0"
},
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"eslint": "^9.39.1"

View File

@@ -21,6 +21,9 @@
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "gatsby-node.js",
"peerDependencies": {
"gatsby": "^5.0.0"
},
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"@freecodecamp/curriculum": "workspace:*",