From 2368974bf10820db026ff3fb6a261b7077df87a3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 01:09:15 -0700 Subject: [PATCH] fix(web): single React root and stable shell providers across navigation (CL-8505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry module declared the Root component, which makes it a React Refresh boundary: a hot update re-executed it in place and called createRoot() on #root a second time, leaving two reconcilers committing into one container. Root moves to its own module so the entry declares no component, and the root is kept on the HMR data slot. Each createContext() call also shared a module with a component, so a hot update minted a fresh context object for the provider while importers that were not re-executed kept reading the previous one — the shape behind 'useBench used outside BenchProvider'. The context objects move to component-free modules. --- apps/web/src/bench-context-value.ts | 25 ++++ apps/web/src/bench-context.tsx | 24 ++-- apps/web/src/main.tsx | 119 +++--------------- apps/web/src/navigation-context.ts | 25 ++++ apps/web/src/navigation.tsx | 22 +--- apps/web/src/root.tsx | 106 ++++++++++++++++ apps/web/src/shell/canvas-availability.tsx | 115 ++--------------- apps/web/src/shell/canvas-host-context.ts | 115 +++++++++++++++++ .../src/shell/composer-insertion-context.ts | 16 +++ apps/web/src/shell/composer-insertion.tsx | 14 +-- 10 files changed, 327 insertions(+), 254 deletions(-) create mode 100644 apps/web/src/bench-context-value.ts create mode 100644 apps/web/src/navigation-context.ts create mode 100644 apps/web/src/root.tsx create mode 100644 apps/web/src/shell/canvas-host-context.ts create mode 100644 apps/web/src/shell/composer-insertion-context.ts diff --git a/apps/web/src/bench-context-value.ts b/apps/web/src/bench-context-value.ts new file mode 100644 index 000000000..10f16b43e --- /dev/null +++ b/apps/web/src/bench-context-value.ts @@ -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; + 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(null); diff --git a/apps/web/src/bench-context.tsx b/apps/web/src/bench-context.tsx index b0be365a3..aaff5c17f 100644 --- a/apps/web/src/bench-context.tsx +++ b/apps/web/src/bench-context.tsx @@ -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 { @@ -34,19 +37,6 @@ function writeStoredTenantId(tenantId: string): void { } } -export type BenchState = { - readonly memberships: APIQuery; - 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(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 diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index d02525e6d..3a10ee147 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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({ 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 ( - - - - - ); -} +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( diff --git a/apps/web/src/navigation-context.ts b/apps/web/src/navigation-context.ts new file mode 100644 index 000000000..7849d6ef3 --- /dev/null +++ b/apps/web/src/navigation-context.ts @@ -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(() => { + 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(undefined); diff --git a/apps/web/src/navigation.tsx b/apps/web/src/navigation.tsx index c94f43bf6..bebdafff0 100644 --- a/apps/web/src/navigation.tsx +++ b/apps/web/src/navigation.tsx @@ -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(() => { - 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(undefined); +export type { Navigate }; export function NavigationProvider({ navigate, diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx new file mode 100644 index 000000000..86da1e014 --- /dev/null +++ b/apps/web/src/root.tsx @@ -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({ 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 ( + + + + + ); +} diff --git a/apps/web/src/shell/canvas-availability.tsx b/apps/web/src/shell/canvas-availability.tsx index 98878c0f6..a6150254e 100644 --- a/apps/web/src/shell/canvas-availability.tsx +++ b/apps/web/src/shell/canvas-availability.tsx @@ -4,116 +4,19 @@ // AppShell's own render, which no longer owns this state — what the canvas // column is actually showing right now. -import { createContext, useContext, type ReactNode } from "react"; +import { useContext, type ReactNode } from "react"; import type { ProfileSubject } from "@/chat"; -import type { ArtifactRendererKind } from "@/library"; -import type { CanvasColumnState } from "@/shell/layout"; -/** The canvas's typed-artifact pane: a title, the already-resolved - * renderer selection (see `@/library`'s `resolveArtifactRendererKind` - * / `resolveRendererKindFromMediaType`), and the content string those - * renderers read. - * - * `canEdit` (phase 2) opts a text-kind ("doc") artifact into - * `ArtifactTextEditor` instead of the read-only `ArtifactRenderer` — - * defaults to `false`/absent so every existing caller keeps rendering - * read-only with zero behavior change. The presence `/update` route's own - * `asset:*`/"write" grant check is the real security boundary regardless - * of this flag; `canEdit` only decides which component a capable viewer - * sees, never whether a write actually lands. */ -export type CanvasArtifactContent = { - readonly id: string; - readonly title: string; - readonly rendererKind: ArtifactRendererKind; - readonly content: string; - readonly unavailableReason?: string; - readonly canEdit?: boolean; - /** The sandboxed preview route for an `"html"`-kind artifact — - * see `ArtifactRenderProps.previewSrc`. Absent for every other renderer - * kind, and for an HTML blob with no Library artifact id to preview. */ - readonly previewSrc?: string; -}; - -/** The canvas's routine pane subject: which routine to show, or `null` to - * start a brand-new one. Distinct from `CanvasArtifactContent` — the panel - * fetches and owns its own routine data (name, instruction, trigger, run - * history) from `routineId`, the same way `ProfileCanvasPane` fetches - * shared workbenches from a `ProfileSubject`'s address rather than being - * handed pre-resolved content. */ -export type RoutinePanelSubject = { - /** Always opens the editor: a specific routine (`routineId` set) or a - * brand-new one (`routineId` omitted or `null`) — routines-page's own - * "New routine"/"Edit" actions, "Make this a routine", the composer's - * `/routine` command, and "New routine in this space" (: - * browsing/running existing routines moved to the global `/workflows` - * page, so this pane no longer has a list mode). */ - readonly routineId?: string | null; - /** Seeds the Name/Instruction fields the instant a brand-new panel opens - * (`routineId: null` only) — "Make this a routine" (a completed task - * result) and similar callers with something worth pre-filling. The - * panel still autosaves on the person's own edits; this only seeds the - * initial draft. */ - readonly initialName?: string; - readonly initialInstruction?: string; - /** The conversation this routine belongs to — its own agent (the - * workbench's host participant; every workbench's host is Myra) backs the - * routine, and its own id is where the routine delivers. Carried through - * list mode too, so "New routine" picked from the list still binds to - * the workbench the panel was opened beside. Omitted only when there is no - * open conversation to bind to (e.g. a deliberate `/workflows` visit), - * in which case the panel falls back to this workbench's own default - * (Myra) workbench — never mints a new one. */ - readonly workbenchId?: string; - /** Seeds the target picker's initial selection — the - * conversation's own single agent participant's definition asset id, - * when the opener could resolve exactly one. Shown visibly in - * `DefinitionTargetPicker` and freely replaceable/clearable by the - * person; only their final explicit pick is ever sent to the backend. - * Omitted whenever the opener found zero or several candidates, or has - * no conversation to derive one from at all. */ - readonly preselectedAssetId?: string; -}; - -/** Workbench's concrete instantiation of `@/shell/layout`'s generic - * canvas state — a `ProfileSubject` for the profile pane, this app's own - * `CanvasArtifactContent` for the artifact pane, `RoutinePanelSubject` for - * the routine pane. */ -export type AppCanvasColumnState = CanvasColumnState< - ProfileSubject, +import { CanvasHostContext } from "./canvas-host-context"; +import type { + AppCanvasColumnState, CanvasArtifactContent, - RoutinePanelSubject ->; - -export type CanvasHost = { - readonly allowed: boolean; - readonly open: boolean; - readonly profile: ProfileSubject | null; - readonly artifact: CanvasArtifactContent | null; - readonly routine: RoutinePanelSubject | null; - readonly focus: boolean; - readonly openProfile: (subject: ProfileSubject) => void; - readonly openArtifact: (artifact: CanvasArtifactContent) => void; - readonly openRoutine: (subject: RoutinePanelSubject) => void; - readonly toggleFocus: () => void; - /** Closes whichever content the canvas currently shows (profile, - * artifact, or routine) and drops focus — one seam regardless of what's - * open. */ - readonly close: () => void; -}; + CanvasHost, + RoutinePanelSubject, +} from "./canvas-host-context"; -const CanvasHostContext = createContext({ - allowed: false, - open: false, - profile: null, - artifact: null, - routine: null, - focus: false, - openProfile: () => undefined, - openArtifact: () => undefined, - openRoutine: () => undefined, - toggleFocus: () => undefined, - close: () => undefined, -}); +export { CanvasHostContext }; +export type { AppCanvasColumnState, CanvasArtifactContent, CanvasHost, RoutinePanelSubject }; export function CanvasAvailabilityProvider({ allowed, diff --git a/apps/web/src/shell/canvas-host-context.ts b/apps/web/src/shell/canvas-host-context.ts new file mode 100644 index 000000000..2985d1bee --- /dev/null +++ b/apps/web/src/shell/canvas-host-context.ts @@ -0,0 +1,115 @@ +// The canvas host context object and the types that shape it, apart from +// the provider and hooks around it — see `../bench-context-value.ts` for +// why a `createContext` call never shares a module with a component. + +import { createContext } from "react"; + +import type { ProfileSubject } from "@/chat"; +import type { ArtifactRendererKind } from "@/library"; +import type { CanvasColumnState } from "@/shell/layout"; + +/** The canvas's typed-artifact pane: a title, the already-resolved + * renderer selection (see `@/library`'s `resolveArtifactRendererKind` + * / `resolveRendererKindFromMediaType`), and the content string those + * renderers read. + * + * `canEdit` (phase 2) opts a text-kind ("doc") artifact into + * `ArtifactTextEditor` instead of the read-only `ArtifactRenderer` — + * defaults to `false`/absent so every existing caller keeps rendering + * read-only with zero behavior change. The presence `/update` route's own + * `asset:*`/"write" grant check is the real security boundary regardless + * of this flag; `canEdit` only decides which component a capable viewer + * sees, never whether a write actually lands. */ +export type CanvasArtifactContent = { + readonly id: string; + readonly title: string; + readonly rendererKind: ArtifactRendererKind; + readonly content: string; + readonly unavailableReason?: string; + readonly canEdit?: boolean; + /** The sandboxed preview route for an `"html"`-kind artifact — + * see `ArtifactRenderProps.previewSrc`. Absent for every other renderer + * kind, and for an HTML blob with no Library artifact id to preview. */ + readonly previewSrc?: string; +}; + +/** The canvas's routine pane subject: which routine to show, or `null` to + * start a brand-new one. Distinct from `CanvasArtifactContent` — the panel + * fetches and owns its own routine data (name, instruction, trigger, run + * history) from `routineId`, the same way `ProfileCanvasPane` fetches + * shared workbenches from a `ProfileSubject`'s address rather than being + * handed pre-resolved content. */ +export type RoutinePanelSubject = { + /** Always opens the editor: a specific routine (`routineId` set) or a + * brand-new one (`routineId` omitted or `null`) — routines-page's own + * "New routine"/"Edit" actions, "Make this a routine", the composer's + * `/routine` command, and "New routine in this space" (: + * browsing/running existing routines moved to the global `/workflows` + * page, so this pane no longer has a list mode). */ + readonly routineId?: string | null; + /** Seeds the Name/Instruction fields the instant a brand-new panel opens + * (`routineId: null` only) — "Make this a routine" (a completed task + * result) and similar callers with something worth pre-filling. The + * panel still autosaves on the person's own edits; this only seeds the + * initial draft. */ + readonly initialName?: string; + readonly initialInstruction?: string; + /** The conversation this routine belongs to — its own agent (the + * workbench's host participant; every workbench's host is Myra) backs the + * routine, and its own id is where the routine delivers. Carried through + * list mode too, so "New routine" picked from the list still binds to + * the workbench the panel was opened beside. Omitted only when there is no + * open conversation to bind to (e.g. a deliberate `/workflows` visit), + * in which case the panel falls back to this workbench's own default + * (Myra) workbench — never mints a new one. */ + readonly workbenchId?: string; + /** Seeds the target picker's initial selection — the + * conversation's own single agent participant's definition asset id, + * when the opener could resolve exactly one. Shown visibly in + * `DefinitionTargetPicker` and freely replaceable/clearable by the + * person; only their final explicit pick is ever sent to the backend. + * Omitted whenever the opener found zero or several candidates, or has + * no conversation to derive one from at all. */ + readonly preselectedAssetId?: string; +}; + +/** Workbench's concrete instantiation of `@/shell/layout`'s generic + * canvas state — a `ProfileSubject` for the profile pane, this app's own + * `CanvasArtifactContent` for the artifact pane, `RoutinePanelSubject` for + * the routine pane. */ +export type AppCanvasColumnState = CanvasColumnState< + ProfileSubject, + CanvasArtifactContent, + RoutinePanelSubject +>; + +export type CanvasHost = { + readonly allowed: boolean; + readonly open: boolean; + readonly profile: ProfileSubject | null; + readonly artifact: CanvasArtifactContent | null; + readonly routine: RoutinePanelSubject | null; + readonly focus: boolean; + readonly openProfile: (subject: ProfileSubject) => void; + readonly openArtifact: (artifact: CanvasArtifactContent) => void; + readonly openRoutine: (subject: RoutinePanelSubject) => void; + readonly toggleFocus: () => void; + /** Closes whichever content the canvas currently shows (profile, + * artifact, or routine) and drops focus — one seam regardless of what's + * open. */ + readonly close: () => void; +}; + +export const CanvasHostContext = createContext({ + allowed: false, + open: false, + profile: null, + artifact: null, + routine: null, + focus: false, + openProfile: () => undefined, + openArtifact: () => undefined, + openRoutine: () => undefined, + toggleFocus: () => undefined, + close: () => undefined, +}); diff --git a/apps/web/src/shell/composer-insertion-context.ts b/apps/web/src/shell/composer-insertion-context.ts new file mode 100644 index 000000000..23eaaefac --- /dev/null +++ b/apps/web/src/shell/composer-insertion-context.ts @@ -0,0 +1,16 @@ +// The composer-insertion context object, apart from the provider and hooks +// around it — see `../bench-context-value.ts` for why a `createContext` +// call never shares a module with a component. + +import { createContext } from "react"; + +export type ComposerInsertionHost = { + readonly registerInsert: (insert: ((text: string) => void) | null) => void; + /** Returns whether a composer was actually mounted to receive the text. */ + readonly insertText: (text: string) => boolean; +}; + +export const ComposerInsertionContext = createContext({ + registerInsert: () => undefined, + insertText: () => false, +}); diff --git a/apps/web/src/shell/composer-insertion.tsx b/apps/web/src/shell/composer-insertion.tsx index a50b3cfbd..202a422ee 100644 --- a/apps/web/src/shell/composer-insertion.tsx +++ b/apps/web/src/shell/composer-insertion.tsx @@ -6,19 +6,13 @@ // "shell context exposing a callback hook" shape `canvas-availability.tsx` // already uses for opening a profile. -import { createContext, useContext, useMemo, useRef } from "react"; +import { useContext, useMemo, useRef } from "react"; import type { ReactNode } from "react"; -export type ComposerInsertionHost = { - readonly registerInsert: (insert: ((text: string) => void) | null) => void; - /** Returns whether a composer was actually mounted to receive the text. */ - readonly insertText: (text: string) => boolean; -}; +import { ComposerInsertionContext } from "./composer-insertion-context"; +import type { ComposerInsertionHost } from "./composer-insertion-context"; -const ComposerInsertionContext = createContext({ - registerInsert: () => undefined, - insertText: () => false, -}); +export type { ComposerInsertionHost }; export function ComposerInsertionProvider({ children }: { readonly children: ReactNode }) { const insertRef = useRef<((text: string) => void) | null>(null);