Skip to content
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# OpenMined Web Monorepo
# OpenMined Courses

Welcome to the OpenMined web monorepo, the home of all of OpenMined's many websites. Below are some basic instructions for getting this repository running on your machine.
This repository powers [OpenMined Courses](https://courses.openmined.org), our platform for free privacy-preserving AI courses. It's an [Nx](https://nx.dev) monorepo containing the Courses web app along with its Firebase backend and Sanity CMS. Below are basic instructions for getting it running on your machine.

## Support

If you're looking for support about the courses, please go the [Courses Discussion Board](https://github.com/OpenMined/courses/discussions). If you've found a bug, or have a suggestion for an improvement to the Courses site, or any of our websites, [please file an issue here](https://github.com/OpenMined/openmined/issues).
If you're looking for support about the courses, please go the [Courses Discussion Board](https://github.com/OpenMined/courses/discussions). If you've found a bug, or have a suggestion for an improvement to the Courses site, or any of our websites, [please file an issue here](https://github.com/OpenMined/courses-app/issues).

## Contributing

Expand Down
70 changes: 22 additions & 48 deletions apps/courses/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,28 @@
import React, { useState, useLayoutEffect, useEffect } from 'react';
import React, { useState, useLayoutEffect } from 'react';
import { Router } from 'react-router-dom';
import { createBrowserHistory } from 'history';
import {
preloadAuth,
preloadFirestore,
preloadFunctions,
useFirebaseApp,
useFirestore,
} from 'reactfire';

import Routes from './routes';

import Loading from './components/Loading';

import { SuspenseWithPerf } from 'reactfire';
import useToast, { toastConfig } from './components/Toast';

const history = createBrowserHistory();

const Firebase = () => {
const firestore = useFirestore();
const toast = useToast();

useEffect(() => {
try {
firestore.enablePersistence({ synchronizeTabs: true }).catch((error) => {
if (error.code === 'failed-precondition') {
toast({
...toastConfig,
title: 'Error',
description: error.message,
status: 'error',
});
} else if (error.code === 'unimplemented') {
toast({
...toastConfig,
title: 'Error',
description:
'This browser is not fully compatible with offline mode. While you do not have to, we suggest you use a different browser.',
status: 'error',
});
} else {
toast({
...toastConfig,
title: 'Error',
description: error.message,
status: 'error',
});
}
});
} catch (error) {
toast({
...toastConfig,
title: 'Error',
description: error.message,
status: 'error',
});
}
}, []);

return null;
};
// NOTE: Firestore offline persistence (`enablePersistence`) is intentionally NOT enabled.
// In Safari/WebKit its IndexedDB initialization intermittently never settles, and because
// Firestore queues every read behind persistence init, the app hung forever on the
// app-wide Suspense loader (the long-standing "infinite loading after login" bug — verified
// 2026-06-19: `enablePersistence` START with no resolve/reject, zero Listen/channel requests
// on the wire; disabling it makes reads resolve in ~270ms). `experimentalForceOwningTab` did
// not cure it. The app reads live from the network and does not rely on offline caching.

const preloadSDKs = (firebaseApp) => {
Promise.all([
Expand All @@ -85,7 +47,6 @@ const preloadSDKs = (firebaseApp) => {
ssl: false,
experimentalForceLongPolling: true,
});
firestore().enablePersistence({ experimentalForceOwningTab: true });
},
}),
// TODO: Create a bucket for dev purposes only
Expand All @@ -99,6 +60,18 @@ const preloadSDKs = (firebaseApp) => {
]);
};

// Real browsers (non-Cypress): force Firestore to use long-polling instead of the default
// streaming WebChannel. This is a Safari/WebChannel compatibility safety measure and is the
// transport configuration validated working in Safari (2026-06-19). It is NOT the fix for
// the infinite-loading bug — that was offline persistence (see note above).
const preloadProdFirestore = (firebaseApp) =>
preloadFirestore({
firebaseApp,
setup: (firestore) => {
firestore().settings({ experimentalForceLongPolling: true });
},
});

const App = () => {
const [action, setAction] = useState(history.action);
const [location, setLocation] = useState(history.location);
Expand All @@ -115,12 +88,13 @@ const App = () => {
// @ts-ignore
if (window.Cypress) {
preloadSDKs(firebaseApp);
} else {
preloadProdFirestore(firebaseApp);
}

return (
<Router action={action} location={location} navigator={history}>
<SuspenseWithPerf fallback={<Loading />} traceId={location.pathname}>
<Firebase />
<Routes />
</SuspenseWithPerf>
</Router>
Expand Down
59 changes: 59 additions & 0 deletions apps/courses/src/components/AuthReady.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { useEffect, useState } from 'react';
import { useUser } from 'reactfire';

import Loading from './Loading';

// After sign-in, Firebase can attach a Firestore listener before the freshly-issued ID
// token has been wired into Firestore's connection. The first authed read then races the
// token and the server returns `permission-denied` (e.g. the dashboard's
// users/{uid}/courses listener). reactfire caches that error for the query, so the app
// crashes through the Suspense/error boundary and only a full page reload recovers. This
// gate waits for the ID token to be available before rendering authed content, so the
// listener attaches with auth.
//
// Self-gating: with no logged-in user it renders children immediately (public pages need
// no token). We prime once per uid (module-scoped) so this only blocks the first authed
// render after a fresh load/login, not every in-app navigation. A full reload re-primes.
let primedUid = null;

const AuthReady = ({ children }) => {
const user: firebase.User = useUser();
const uid = user ? user.uid : null;
const [ready, setReady] = useState(!uid || primedUid === uid);

useEffect(() => {
if (!uid) {
// Logged out: forget the prime so a re-login (even same tab, same user,
// no page reload) re-runs the gate and waits for the new token.
primedUid = null;
setReady(true);
return;
}
if (primedUid === uid) {
setReady(true);
return;
}
let active = true;
setReady(false);
user
.getIdToken()
.then(() => {
primedUid = uid;
if (active) setReady(true);
})
.catch(() => {
// Don't block the UI if the token fetch itself fails; let the normal flow run.
primedUid = uid;
if (active) setReady(true);
});
return () => {
active = false;
};
}, [uid, user]);

if (!ready) return <Loading />;

return <>{children}</>;
};

export default AuthReady;
15 changes: 7 additions & 8 deletions apps/courses/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,13 @@ export default ({ noScrolling }) => {
onClick={() =>
auth
.signOut()
.then(() =>
toast({
...toastConfig,
title: 'Sign out successful',
description: 'Come back soon!',
status: 'success',
})
)
// Full reload on logout: clears reactfire's global query-observable
// cache (keyed by path, never evicted) and the auth-ready prime, so a
// re-login is a clean cold load instead of reusing a stale
// users/{uid}/courses observable that crashes on same-user re-login.
.then(() => {
window.location.assign('/');
})
.catch((error) => handleErrors(toast, error))
}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/courses/src/content/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const placementsLink = 'https://placements.openmined.org';
export const slackLink = 'https://slack.openmined.org';
export const blogLink = 'https://blog.openmined.org';
export const githubLink = 'https://github.com/OpenMined';
export const issuesLink = 'https://github.com/OpenMined/openmined/issues';
export const issuesLink = 'https://github.com/OpenMined/courses-app/issues';
export const opencollectiveLink = 'https://opencollective.com/openmined';
export const mentorratesLink =
'https://www.notion.so/openmined/822b6f0510a644bab826eccb1ac3a477?v=69f88b18cdbd4410af89615043c1b983';
Expand Down
2 changes: 1 addition & 1 deletion apps/courses/src/routes/courses/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const DrawerItem = ({

linkProps._hover = { color: linkProps.color };

// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
if (linkProps.to) {
linkProps.as = 'a';
linkProps.to = null;
Expand Down
15 changes: 7 additions & 8 deletions apps/courses/src/routes/courses/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,13 @@ export default ({
onClick: () =>
auth
.signOut()
.then(() =>
toast({
...toastConfig,
title: 'Sign out successful',
description: 'Come back soon!',
status: 'success',
})
)
// Full reload on logout: clears reactfire's global query-observable cache
// (keyed by path, never evicted) and the auth-ready prime, so a re-login is a
// clean cold load instead of reusing a stale users/{uid}/courses observable
// that crashes on same-user re-login.
.then(() => {
window.location.assign('/');
})
.catch((error) => handleErrors(toast, error)),
},
];
Expand Down
4 changes: 2 additions & 2 deletions apps/courses/src/routes/courses/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ export const useIsAllowedToAccessPage = (
let url = `/courses/${course}/${suggestedPage.lesson}`;
if (suggestedPage.concept) url = `${url}/${suggestedPage.concept}`;

// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// navigate(url);
window.location.href = url;
} else {
Expand All @@ -370,7 +370,7 @@ export const useIsAllowedToAccessPage = (
return isAllowed;
};

// TODO: https://github.com/OpenMined/openmined/issues/54
// TODO: https://github.com/OpenMined/courses-app/issues/54
export const useCoursePermissionGate = (user, lessons, page, params) => {
// For pages that don't require any permissions or redirection...
const permissionless = ['search', 'overview'];
Expand Down
6 changes: 3 additions & 3 deletions apps/courses/src/routes/courses/concept/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ const Controls = ({
total,
onCompleteConcept,
}) => {
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// const navigate = useNavigate();

return (
Expand All @@ -189,7 +189,7 @@ const Controls = ({
window.scrollTo({ top: 0, behavior: 'smooth' });

setTimeout(() => {
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// navigate(backLink);
window.location.href = backLink;
}, 500);
Expand All @@ -208,7 +208,7 @@ const Controls = ({
window.scrollTo({ top: 0, behavior: 'smooth' });

setTimeout(() => {
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// navigate(nextLink);
window.location.href = nextLink;
}, 500);
Expand Down
2 changes: 1 addition & 1 deletion apps/courses/src/routes/courses/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export default ({ which }: PropType) => {
// Get our data from the CMS
const { data, loading } = useFirebaseSanity(which, params);

// TODO: https://github.com/OpenMined/openmined/issues/54
// TODO: https://github.com/OpenMined/courses-app/issues/54
// const newPermissionGate = useCoursePermissionGate(
// dbCourse,
// data,
Expand Down
2 changes: 1 addition & 1 deletion apps/courses/src/routes/courses/lesson-complete/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ export default ({
size="lg"
onClick={() =>
onCompleteLesson().then(() => {
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// navigate(
// `/courses/${course}/${
// typeof nextLesson === 'string'
Expand Down
4 changes: 2 additions & 2 deletions apps/courses/src/routes/courses/lesson/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default ({
}: CoursePagesProp) => {
const db = useFirestore();
const toast = useToast();
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// const navigate = useNavigate();

const {
Expand All @@ -74,7 +74,7 @@ export default ({
const onLessonStart = () => {
handleLessonStart(db, user.uid, course, ts, progress, lesson)
.then(() => {
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// navigate(`/courses/${course}/${lesson}/${firstConcept}`);
window.location.href = `/courses/${course}/${lesson}/${firstConcept}`;
})
Expand Down
10 changes: 5 additions & 5 deletions apps/courses/src/routes/courses/overview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export default ({ course, page, progress, user }: CoursePagesProp) => {
)}
{isCurrent && isTakingCourse && parts && (
<Flex justify="flex-end" mt={4}>
{/* TODO: https://github.com/OpenMined/openmined/issues/53 */}
{/* TODO: https://github.com/OpenMined/courses-app/issues/53 */}
{/* <Link to={resumeLink}> */}
<a href={resumeLink} target="_self">
<Flex align="center">
Expand Down Expand Up @@ -382,7 +382,7 @@ export default ({ course, page, progress, user }: CoursePagesProp) => {
<Button
colorScheme="blue"
size="lg"
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// as={courseStartLink ? Link : null}
// to={courseStartLink}
as={courseStartLink ? 'a' : null}
Expand All @@ -408,7 +408,7 @@ export default ({ course, page, progress, user }: CoursePagesProp) => {
<Button
colorScheme="black"
size="lg"
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// as={Link}
// to={resumeLink}
as="a"
Expand Down Expand Up @@ -496,7 +496,7 @@ export default ({ course, page, progress, user }: CoursePagesProp) => {
<Button
colorScheme="black"
size="lg"
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// as={courseStartLink ? Link : null}
// to={courseStartLink}
as={courseStartLink ? 'a' : null}
Expand All @@ -511,7 +511,7 @@ export default ({ course, page, progress, user }: CoursePagesProp) => {
<Button
colorScheme="black"
size="lg"
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// as={Link}
// to={resumeLink}
as="a"
Expand Down
4 changes: 2 additions & 2 deletions apps/courses/src/routes/courses/project-complete/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const DetailLink = ({ icon, children, ...props }) => (

export default ({ progress, page, user, course }: CoursePagesProp) => {
const db = useFirestore();
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// const navigate = useNavigate();

const {
Expand Down Expand Up @@ -88,7 +88,7 @@ export default ({ progress, page, user, course }: CoursePagesProp) => {
setClickedContinue(false);

if (data && !data.error) {
// TODO: https://github.com/OpenMined/openmined/issues/53
// TODO: https://github.com/OpenMined/courses-app/issues/53
// navigate(`/courses/${course}/complete`);
window.location.href = `/courses/${course}/complete`;
} else {
Expand Down
Loading
Loading