Skip to content
Merged
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
25 changes: 25 additions & 0 deletions apps/web/src/bench-context-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// The context object itself, apart from the provider that fills it.
// `createContext` mints a fresh object every time its module runs, and a
// module that also exports a component is a React Refresh boundary that a
// hot update re-executes without re-executing its importers — the provider
// would then publish one context while `useBench` still reads the previous
// one. Holding it in a component-free module keeps a single identity for
// every reader.

import type { APIQuery } from "@/lib/api-query";
import { createContext } from "react";

import type { PrincipalsPage } from "./api";

export type BenchState = {
readonly memberships: APIQuery<PrincipalsPage>;
readonly selectedTenantId: string | null;
readonly selectedPrincipalId: string | null;
readonly selectTenant: (tenantId: string) => void;
readonly onBenchCreated: (tenantId: string) => void;
};

/** Exported only so a render test can inject a fixed `BenchState` without
* standing up `BenchProvider`'s own `/api/me/principals` fetch — every
* real caller still goes through `useBench`/`BenchProvider`. */
export const BenchContext = createContext<BenchState | null>(null);
24 changes: 7 additions & 17 deletions apps/web/src/bench-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@

import { isRawIdentifier } from "@/bench";
import { useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useMemo, useState } from "react";
import { useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";

import type { APIQuery } from "@/lib/api-query";

import { PrincipalsSchema, useAPIQuery } from "./api";
import type { Principal, PrincipalsPage } from "./api";
import type { Principal } from "./api";
import { BenchContext } from "./bench-context-value";
import type { BenchState } from "./bench-context-value";
import { meKeys, tenantKeys } from "./query-client";

export { BenchContext };
export type { BenchState };

const STORAGE_KEY = "workbench.selectedTenantId";

function readStoredTenantId(): string | null {
Expand All @@ -34,19 +37,6 @@ function writeStoredTenantId(tenantId: string): void {
}
}

export type BenchState = {
readonly memberships: APIQuery<PrincipalsPage>;
readonly selectedTenantId: string | null;
readonly selectedPrincipalId: string | null;
readonly selectTenant: (tenantId: string) => void;
readonly onBenchCreated: (tenantId: string) => void;
};

/** Exported only so a render test can inject a fixed `BenchState` without
* standing up `BenchProvider`'s own `/api/me/principals` fetch — every
* real caller still goes through `useBench`/`BenchProvider`. */
export const BenchContext = createContext<BenchState | null>(null);

/** The membership this context currently treats as selected: the stored
* choice if it still names a bench the account belongs to, otherwise the
* first named membership — the same personal-bench convention
Expand Down
119 changes: 16 additions & 103 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,115 +1,28 @@
// The mount, and nothing else. This module declares no component on
// purpose: a module that declares one becomes a React Refresh boundary,
// and a hot update then re-executes it in place — calling `createRoot` on
// `#root` a second time and leaving two reconcilers committing into one
// container. The root itself is kept on the HMR data slot so even a
// re-execution reuses the single root it already created.

import "@corbits/react-ui/styles.css";
import "./app.css";
import "./tailwind.css";

import { ThemeProvider, Toaster, toast } from "@corbits/react-ui";
import { StrictMode, useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";
import { StrictMode } from "react";
import { createRoot, type Root as ReactRoot } from "react-dom/client";

import { getLogger } from "@/lib/client-log";
import { AppErrorBoundary } from "./app-error-boundary";
import { App } from "./app";
import { validatedNextPath } from "./login-next";
import { triggerFirstLoginProvisioning } from "./onboarding";
import { getPath, navigateTo, subscribeToPath } from "./router-store";
import { ONBOARDING_PATH } from "./routes";
import { fetchSession, signOut } from "./session";
import type { SessionState, SessionUser } from "./session";

const log = getLogger("web.session");

function Root() {
// History lives outside React (`./router-store`), so the path is a
// subscription, not an effect that starts listening after first paint.
const path = useSyncExternalStore(subscribeToPath, getPath);
const navigate = navigateTo;

const [session, setSession] = useState<SessionState>({ kind: "loading" });
const probe = useCallback(() => {
setSession({ kind: "loading" });
void fetchSession().then(setSession);
}, []);
useEffect(probe, [probe]);

const handleSignedIn = useCallback(
(user: SessionUser) => {
setSession({ kind: "signed-in", user });
navigate(validatedNextPath(window.location.search));
},
[navigate],
);

// The first-login hook: once per session that reaches signed-in, ask
// the hub's native setup-status route whether any bench exists yet. An
// empty hub reports setup-required so we route into the setup screen;
// a hub with tenants loads the shell normally. Read-only on purpose
// — this never mints anything. A failure blocks the shell
// entirely rather than leaving the user silently benchless.
const [provisioningError, setProvisioningError] = useState<{
message: string;
refId?: string | undefined;
} | null>(null);
const provisionedUserId = session.kind === "signed-in" ? session.user.id : null;
const runProvisioning = useCallback(() => {
if (provisionedUserId === null) return () => undefined;
let cancelled = false;
setProvisioningError(null);
void triggerFirstLoginProvisioning().then((result) => {
if (cancelled) return;
if (result.kind === "needs-onboarding") {
navigate(ONBOARDING_PATH);
} else if (result.kind === "error") {
setProvisioningError({ message: result.message, refId: result.refId });
}
});
return () => {
cancelled = true;
};
}, [provisionedUserId, navigate]);
useEffect(runProvisioning, [runProvisioning]);
const handleRetryProvisioning = useCallback(() => {
runProvisioning();
}, [runProvisioning]);

const handleSignOut = useCallback(() => {
setSession({ kind: "signed-out" });
toast("Signed out. If you were on a shared computer, close the browser to be sure.");
void signOut().then((ok) => {
if (ok) return;
log.error("Sign-out request to the server failed");
});
}, []);

// Per-user storage when signed in so theme preference follows the account;
// signed-out / loading share the anonymous host key. Not synced to the
// preferences store: @corbits/react-ui's ThemeProvider owns mode
// entirely internally (localStorage read/write on setMode/cycleMode) and
// exposes no onChange hook or externally-supplied initial value a host
// could observe or override without forking the component.
const themeStorageKey =
session.kind === "signed-in" ? `corbits-theme:${session.user.id}` : "corbits-theme";

return (
<ThemeProvider storageKey={themeStorageKey} defaultMode="light">
<App
path={path}
navigate={navigate}
session={session}
onSignedIn={handleSignedIn}
onSignOut={handleSignOut}
onRetry={probe}
provisioningError={provisioningError?.message ?? null}
provisioningErrorRefId={provisioningError?.refId}
onRetryProvisioning={handleRetryProvisioning}
/>
<Toaster position="bottom-right" />
</ThemeProvider>
);
}
import { Root } from "./root";

const container = document.getElementById("root");
if (container === null) throw new Error("index.html is missing #root");
createRoot(container).render(

const hotData = import.meta.hot?.data as { root?: ReactRoot } | undefined;
const root = hotData?.root ?? createRoot(container);
if (hotData !== undefined) hotData.root = root;

root.render(
<StrictMode>
<AppErrorBoundary>
<Root />
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/navigation-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// The navigation context objects, apart from the provider and hooks that
// use them — see `bench-context-value.ts` for why a `createContext` call
// never shares a module with a component.

import { createContext } from "react";

import type { SessionUser } from "./session";

export type Navigate = (to: string) => void;

export const NavigateContext = createContext<Navigate>(() => {
throw new Error("navigation used outside NavigationProvider");
});

/** Absent outside a signed-in shell (the onboarding wizard has no account
* menu, no settings surface) — `undefined` rather than a throwing default,
* so a reader like `AccountSection` (mounted in package tests with no
* provider at all) can simply omit the Sign out action instead of
* crashing. */
export const SignOutContext = createContext<(() => void) | undefined>(undefined);

/** Same availability rule as `SignOutContext`: present in the signed-in
* shell so surfaces like `ChatPage` can label the reader's own avatar from
* the auth account, undefined outside that shell. */
export const SessionUserContext = createContext<SessionUser | undefined>(undefined);
22 changes: 4 additions & 18 deletions apps/web/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,14 @@
// browser — the hub serves index.html for every non-/api path, so a full page
// load lands on the same route.

import { createContext, useContext } from "react";
import { useContext } from "react";
import type { ComponentProps, MouseEvent, ReactNode } from "react";

import { NavigateContext, SessionUserContext, SignOutContext } from "./navigation-context";
import type { Navigate } from "./navigation-context";
import type { SessionUser } from "./session";

export type Navigate = (to: string) => void;

const NavigateContext = createContext<Navigate>(() => {
throw new Error("navigation used outside NavigationProvider");
});

/** Absent outside a signed-in shell (the onboarding wizard has no account
* menu, no settings surface) — `undefined` rather than a throwing default,
* so a reader like `AccountSection` (mounted in package tests with no
* provider at all) can simply omit the Sign out action instead of
* crashing. */
const SignOutContext = createContext<(() => void) | undefined>(undefined);

/** Same availability rule as `SignOutContext`: present in the signed-in
* shell so surfaces like `ChatPage` can label the reader's own avatar from
* the auth account, undefined outside that shell. */
const SessionUserContext = createContext<SessionUser | undefined>(undefined);
export type { Navigate };

export function NavigationProvider({
navigate,
Expand Down
106 changes: 106 additions & 0 deletions apps/web/src/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// The top of the React tree: browser history, the one session probe, the
// first-login hook, and the theme shell everything else renders inside.
// Kept out of `main.tsx` so the entry module owns nothing but the mount.

import { ThemeProvider, Toaster, toast } from "@corbits/react-ui";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";

import { getLogger } from "@/lib/client-log";
import { App } from "./app";
import { validatedNextPath } from "./login-next";
import { triggerFirstLoginProvisioning } from "./onboarding";
import { getPath, navigateTo, subscribeToPath } from "./router-store";
import { ONBOARDING_PATH } from "./routes";
import { fetchSession, signOut } from "./session";
import type { SessionState, SessionUser } from "./session";

const log = getLogger("web.session");

export function Root() {
// History lives outside React (`./router-store`), so the path is a
// subscription, not an effect that starts listening after first paint.
const path = useSyncExternalStore(subscribeToPath, getPath);
const navigate = navigateTo;

const [session, setSession] = useState<SessionState>({ kind: "loading" });
const probe = useCallback(() => {
setSession({ kind: "loading" });
void fetchSession().then(setSession);
}, []);
useEffect(probe, [probe]);

const handleSignedIn = useCallback(
(user: SessionUser) => {
setSession({ kind: "signed-in", user });
navigate(validatedNextPath(window.location.search));
},
[navigate],
);

// The first-login hook: once per session that reaches signed-in, ask
// the hub's native setup-status route whether any bench exists yet. An
// empty hub reports setup-required so we route into the setup screen;
// a hub with tenants loads the shell normally. Read-only on purpose
// — this never mints anything. A failure blocks the shell
// entirely rather than leaving the user silently benchless.
const [provisioningError, setProvisioningError] = useState<{
message: string;
refId?: string | undefined;
} | null>(null);
const provisionedUserId = session.kind === "signed-in" ? session.user.id : null;
const runProvisioning = useCallback(() => {
if (provisionedUserId === null) return () => undefined;
let cancelled = false;
setProvisioningError(null);
void triggerFirstLoginProvisioning().then((result) => {
if (cancelled) return;
if (result.kind === "needs-onboarding") {
navigate(ONBOARDING_PATH);
} else if (result.kind === "error") {
setProvisioningError({ message: result.message, refId: result.refId });
}
});
return () => {
cancelled = true;
};
}, [provisionedUserId, navigate]);
useEffect(runProvisioning, [runProvisioning]);
const handleRetryProvisioning = useCallback(() => {
runProvisioning();
}, [runProvisioning]);

const handleSignOut = useCallback(() => {
setSession({ kind: "signed-out" });
toast("Signed out. If you were on a shared computer, close the browser to be sure.");
void signOut().then((ok) => {
if (ok) return;
log.error("Sign-out request to the server failed");
});
}, []);

// Per-user storage when signed in so theme preference follows the account;
// signed-out / loading share the anonymous host key. Not synced to the
// preferences store: @corbits/react-ui's ThemeProvider owns mode
// entirely internally (localStorage read/write on setMode/cycleMode) and
// exposes no onChange hook or externally-supplied initial value a host
// could observe or override without forking the component.
const themeStorageKey =
session.kind === "signed-in" ? `corbits-theme:${session.user.id}` : "corbits-theme";

return (
<ThemeProvider storageKey={themeStorageKey} defaultMode="light">
<App
path={path}
navigate={navigate}
session={session}
onSignedIn={handleSignedIn}
onSignOut={handleSignOut}
onRetry={probe}
provisioningError={provisioningError?.message ?? null}
provisioningErrorRefId={provisioningError?.refId}
onRetryProvisioning={handleRetryProvisioning}
/>
<Toaster position="bottom-right" />
</ThemeProvider>
);
}
Loading
Loading