diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index a31d77984c1..d849418ebb8 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -127,6 +127,17 @@ const App: FunctionComponent = ({ const awaitingEmailVerificationStatus = !!authenticatedUser && !emailVerificationStatusKnown && !aal2Required; + /** + * A `redirectTo` that points at the page we're already on is a no-op we must + * ignore. `getInitialProps` re-runs on every navigation — including the + * same-URL `router.replace`s this effect performs — and `useRouter()` returns + * a fresh object each time, so honouring such a redirect spins forever in a + * `replace` -> `getInitialProps` -> `replace` loop (e.g. landing on `/` after + * accepting an org invite, where a stale `redirectTo: "/"` kept re-firing). + */ + const pendingRedirect = + !!redirectTo && redirectTo !== router.asPath ? redirectTo : undefined; + /** * Handle client-side redirects that were determined in getInitialProps. * On the server these are HTTP 307s; on the client getInitialProps returns @@ -134,10 +145,10 @@ const App: FunctionComponent = ({ * the current route transition completes (avoiding NProgress stalls). */ useEffect(() => { - if (redirectTo) { - void router.replace(redirectTo); + if (pendingRedirect) { + void router.replace(pendingRedirect); } - }, [redirectTo, router]); + }, [pendingRedirect, router]); useEffect(() => { setSentryUser({ authenticatedUser }); @@ -167,8 +178,15 @@ const App: FunctionComponent = ({ // router.query is empty during server-side rendering for pages that don’t use // getServerSideProps. By showing app skeleton on the server, we avoid UI // mismatches during rehydration and improve type-safety of param extraction. - // We also gate on `redirectTo` so the page doesn't flash before navigating. - if (ssr || !router.isReady || awaitingEmailVerificationStatus || redirectTo) { + // We also gate on a pending redirect so the page doesn't flash before + // navigating. A `redirectTo` matching the current path isn't pending (see + // `pendingRedirect`), so we render rather than stall on the loading state. + if ( + ssr || + !router.isReady || + awaitingEmailVerificationStatus || + pendingRedirect + ) { return ; // Replace with app skeleton } diff --git a/apps/hash-frontend/src/pages/index.page.tsx b/apps/hash-frontend/src/pages/index.page.tsx index 6fe202a5df8..55cd5ff8954 100644 --- a/apps/hash-frontend/src/pages/index.page.tsx +++ b/apps/hash-frontend/src/pages/index.page.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@apollo/client"; import { Stack } from "@mui/material"; import { useRouter } from "next/router"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { hasAccessToHashQuery } from "../graphql/queries/user.queries"; import { getLayoutWithSidebar } from "../shared/layout"; @@ -30,9 +30,20 @@ const Page: NextPageWithLayout = () => { } }, [authenticatedUser, hasAccessToHashResponse]); + const shouldCompleteSignup = + !authenticatedUser?.accountSignupComplete && !!hasAccessToHash; + + /** + * Send users who have access but haven't finished signup to `/signup`. + */ + useEffect(() => { + if (shouldCompleteSignup) { + void push("/signup"); + } + }, [shouldCompleteSignup, push]); + if (!authenticatedUser?.accountSignupComplete) { if (hasAccessToHash) { - void push("/signup"); return null; } diff --git a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx index ccec2d5d023..6ab43173e19 100644 --- a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx +++ b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx @@ -12,6 +12,7 @@ import { import { getOutgoingLinksForEntity, getRoots, + intervalCompareWithInterval, intervalForTimestamp, } from "@blockprotocol/graph/stdlib"; import { @@ -53,6 +54,29 @@ export const AuthInfoContext = createContext( undefined, ); +/** + * Returns `true` if `candidate` contains a newer edition of the user (by + * transaction time) than `existing`. + */ +const subgraphHasNewerUser = ( + candidate: Subgraph>, + existing: Subgraph>, +): boolean => { + const candidateUser = getRoots(candidate)[0]; + const existingUser = getRoots(existing)[0]; + + if (!candidateUser || !existingUser) { + return true; + } + + return ( + intervalCompareWithInterval( + existingUser.metadata.temporalVersioning.transactionTime, + candidateUser.metadata.temporalVersioning.transactionTime, + ) < 0 + ); +}; + type AuthInfoProviderProps = { initialAuthenticatedUserSubgraph?: Subgraph>; children: ReactElement; @@ -72,6 +96,24 @@ export const AuthInfoProvider: FunctionComponent = ({ const [emailVerificationStatusKnown, setEmailVerificationStatusKnown] = useState(false); + /** + * `getInitialProps` re-fetches the authenticated user on every navigation and + * passes it as `initialAuthenticatedUserSubgraph`. Adopt that server-provided + * data whenever it is newer than what the client currently holds. + */ + useEffect(() => { + if (!initialAuthenticatedUserSubgraph) { + return; + } + + setAuthenticatedUserSubgraph((current) => + !current || + subgraphHasNewerUser(initialAuthenticatedUserSubgraph, current) + ? initialAuthenticatedUserSubgraph + : current, + ); + }, [initialAuthenticatedUserSubgraph]); + const userMemberOfLinks = useMemo(() => { if (!authenticatedUserSubgraph) { return undefined; diff --git a/apps/hash-frontend/src/pages/shared/workspace-context.tsx b/apps/hash-frontend/src/pages/shared/workspace-context.tsx index 922db32b6ad..e47fdc43dc8 100644 --- a/apps/hash-frontend/src/pages/shared/workspace-context.tsx +++ b/apps/hash-frontend/src/pages/shared/workspace-context.tsx @@ -79,14 +79,17 @@ export const WorkspaceContextProvider: FunctionComponent<{ } }, [activeWorkspaceWebId, updateActiveWorkspaceWebId, authenticatedUser]); - const workspaceContextValue = useMemo(() => { - const activeWorkspace = + const activeWorkspace = useMemo( + () => authenticatedUser && authenticatedUser.accountId === activeWorkspaceWebId ? authenticatedUser : authenticatedUser?.memberOf.find( ({ org: { webId } }) => webId === activeWorkspaceWebId, - )?.org; + )?.org, + [authenticatedUser, activeWorkspaceWebId], + ); + useEffect(() => { /** * If there is an `activeWorkspaceWebId` and an `authenticatedUser`, but * `activeWorkspace` is not defined, reset `activeWorkspaceWebId` to the @@ -98,7 +101,14 @@ export const WorkspaceContextProvider: FunctionComponent<{ (authenticatedUser.accountId as WebId), ); } + }, [ + activeWorkspace, + activeWorkspaceWebId, + authenticatedUser, + updateActiveWorkspaceWebId, + ]); + const workspaceContextValue = useMemo(() => { return { activeWorkspace, activeWorkspaceWebId, @@ -106,7 +116,7 @@ export const WorkspaceContextProvider: FunctionComponent<{ refetchActiveWorkspace: () => refetch().then(() => undefined), }; }, [ - authenticatedUser, + activeWorkspace, activeWorkspaceWebId, updateActiveWorkspaceWebId, refetch, diff --git a/apps/hash-frontend/src/pages/signup.page.tsx b/apps/hash-frontend/src/pages/signup.page.tsx index 50092fe64b5..d9733455dc6 100644 --- a/apps/hash-frontend/src/pages/signup.page.tsx +++ b/apps/hash-frontend/src/pages/signup.page.tsx @@ -242,7 +242,16 @@ const SignupPage: NextPageWithLayout = () => { refetchInvites(); await refetchAuthenticatedUser(); updateActiveWorkspaceWebId(invitation.org.webId); - void router.replace("/"); + /** + * Hard navigation rather than `router.replace`: a client-side + * transition here can leave this component mounted on `/` (Next + * doesn't reliably re-resolve the route component on these + * `getInitialProps`-driven transitions). A full load guarantees a + * clean home render with fresh auth state. + * + * @todo sort out signup redirecting logic generally + */ + window.location.assign("/"); return; } @@ -368,7 +377,16 @@ const SignupPage: NextPageWithLayout = () => { updateActiveWorkspaceWebId(invitation.org.webId); } - void router.push("/"); + /** + * Hard navigation rather than `router.push`: a client-side transition + * here can leave this component mounted on `/` (Next doesn't reliably + * re-resolve the route component on these `getInitialProps`-driven + * transitions), stranding the user on a loading state. A full load + * guarantees a clean home render with fresh auth state. + * + * @todo sort out signup redirecting logic generally + */ + window.location.assign("/"); }, [ acceptInvitationOnce, @@ -378,7 +396,6 @@ const SignupPage: NextPageWithLayout = () => { refetchAuthenticatedUser, updateAuthenticatedUser, updateActiveWorkspaceWebId, - router, ], );