From 86a7c386da6aad3d6ae491436fddc4295747085b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 5 May 2026 20:05:57 -0700 Subject: [PATCH] Use stable UI boundaries in React plugins --- .../src/react/AddGoogleDiscoverySource.tsx | 116 +++++++++--------- .../src/react/OnePasswordSettings.tsx | 50 ++++---- packages/react/src/api/scope-context.tsx | 3 + packages/react/src/plugins/secret-form.tsx | 41 ++++--- 4 files changed, 108 insertions(+), 102 deletions(-) diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx index bca1c86aa..f6561c5bd 100644 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; import { usePendingSources } from "@executor-js/react/api/optimistic"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; @@ -97,22 +98,22 @@ type GoogleDiscoveryTemplate = GoogleDiscoveryPreset & { const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate { - try { - const url = new URL(preset.url); - const parts = url.pathname.split("/").filter(Boolean); - const apisIndex = parts.indexOf("apis"); - const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; - const version = - apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); - return { - ...preset, - discoveryUrl: preset.url, - service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), - version: version ?? "", - }; - } catch { + if (!URL.canParse(preset.url)) { return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" }; } + + const url = new URL(preset.url); + const parts = url.pathname.split("/").filter(Boolean); + const apisIndex = parts.indexOf("apis"); + const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; + const version = + apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); + return { + ...preset, + discoveryUrl: preset.url, + service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), + version: version ?? "", + }; } const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset); @@ -202,8 +203,8 @@ export default function AddGoogleDiscoverySource(props: { "google"; const scopeId = useScope(); - const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" }); - const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" }); + const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" }); + const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promiseExit" }); const { beginAdd } = usePendingSources(); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ @@ -235,25 +236,27 @@ export default function AddGoogleDiscoverySource(props: { setError(null); setOauthAuth(null); setShowScopes(false); - try { - const result = await doProbe({ - params: { scopeId }, - payload: { discoveryUrl: discoveryUrl.trim() }, - }); - setProbe({ - ...result, - scopes: [...result.scopes], - operations: [...result.operations], - }); - if (result.scopes.length === 0) { - setAuthKind("none"); - } - } catch (e) { + const exit = await doProbe({ + params: { scopeId }, + payload: { discoveryUrl: discoveryUrl.trim() }, + }); + if (Exit.isFailure(exit)) { setProbe(null); - setError(e instanceof Error ? e.message : "Failed to inspect discovery document"); - } finally { + setError("Failed to inspect discovery document"); setLoadingProbe(false); + return; + } + + const result = exit.value; + setProbe({ + ...result, + scopes: [...result.scopes], + operations: [...result.operations], + }); + if (result.scopes.length === 0) { + setAuthKind("none"); } + setLoadingProbe(false); }, [discoveryUrl, doProbe, scopeId]); // Keep the latest handleProbe in a ref so the debounced effect can call it @@ -331,33 +334,32 @@ export default function AddGoogleDiscoverySource(props: { name: displayName, kind: "google-discovery", }); - try { - await doAdd({ - params: { scopeId }, - payload: { - name: displayName, - discoveryUrl: discoveryUrl.trim(), - namespace, - auth: - authKind === "oauth2" && oauthAuth - ? { - kind: "oauth2" as const, - connectionId: oauthAuth.connectionId, - clientIdSecretId: oauthAuth.clientIdSecretId, - clientSecretSecretId: oauthAuth.clientSecretSecretId, - scopes: oauthAuth.scopes, - } - : { kind: "none" as const }, - }, - reactivityKeys: [...sourceWriteKeys], - }); - props.onComplete(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to add source"); + const exit = await doAdd({ + params: { scopeId }, + payload: { + name: displayName, + discoveryUrl: discoveryUrl.trim(), + namespace, + auth: + authKind === "oauth2" && oauthAuth + ? { + kind: "oauth2" as const, + connectionId: oauthAuth.connectionId, + clientIdSecretId: oauthAuth.clientIdSecretId, + clientSecretSecretId: oauthAuth.clientSecretSecretId, + scopes: oauthAuth.scopes, + } + : { kind: "none" as const }, + }, + reactivityKeys: [...sourceWriteKeys], + }); + placeholder.done(); + if (Exit.isFailure(exit)) { + setError("Failed to add source"); setAdding(false); - } finally { - placeholder.done(); + return; } + props.onComplete(); }, [ probe, doAdd, diff --git a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx index 8ce73852d..85a07371a 100644 --- a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +++ b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { useScope } from "@executor-js/react/api/scope-context"; @@ -62,15 +63,15 @@ function VaultPicker(props: { isLoading: true, error: null, }), - onError: (error) => ({ + onError: () => ({ vaults: [] as { id: string; name: string }[], isLoading: false, - error: error.message, + error: "Failed to list vaults", }), - onDefect: (defect) => ({ + onDefect: () => ({ vaults: [] as { id: string; name: string }[], isLoading: false, - error: defect instanceof Error ? defect.message : "Failed to list vaults", + error: "Failed to list vaults", }), onSuccess: ({ value }) => { const v = value.vaults; @@ -142,7 +143,7 @@ function ConfigDialog(props: { const [error, setError] = useState(null); const scopeId = useScope(); - const doConfigure = useAtomSet(configureOnePassword, { mode: "promise" }); + const doConfigure = useAtomSet(configureOnePassword, { mode: "promiseExit" }); const reset = () => { if (!isEdit) { @@ -159,23 +160,26 @@ function ConfigDialog(props: { if (!accountName.trim() || !vaultId.trim()) return; setSaving(true); setError(null); - try { - const auth = - authKind === "desktop-app" - ? { kind: "desktop-app" as const, accountName: accountName.trim() } - : { kind: "service-account" as const, tokenSecretId: accountName.trim() }; - await doConfigure({ - params: { scopeId }, - payload: { auth, vaultId: vaultId.trim(), name: vaultName.trim() || "1Password" }, - reactivityKeys: [ReactivityKey.secrets], - }); - props.onOpenChange(false); - reset(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save configuration"); + const auth = + authKind === "desktop-app" + ? { kind: "desktop-app" as const, accountName: accountName.trim() } + : { kind: "service-account" as const, tokenSecretId: accountName.trim() }; + + const exit = await doConfigure({ + params: { scopeId }, + payload: { auth, vaultId: vaultId.trim(), name: vaultName.trim() || "1Password" }, + reactivityKeys: [ReactivityKey.secrets], + }); + + if (Exit.isFailure(exit)) { + setError("Failed to save configuration"); setSaving(false); + return; } + + props.onOpenChange(false); + reset(); }; return ( @@ -298,14 +302,10 @@ export default function OnePasswordSettings() { const [configOpen, setConfigOpen] = useState(false); const scopeId = useScope(); const configResult = useAtomValue(onepasswordConfigAtom(scopeId)); - const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promise" }); + const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" }); const handleRemove = async () => { - try { - await doRemove({ params: { scopeId }, reactivityKeys: [ReactivityKey.secrets] }); - } catch { - /* TODO: toast */ - } + await doRemove({ params: { scopeId }, reactivityKeys: [ReactivityKey.secrets] }); }; const config: OnePasswordConfig | null = AsyncResult.match( diff --git a/packages/react/src/api/scope-context.tsx b/packages/react/src/api/scope-context.tsx index b44681287..d3b6b9edc 100644 --- a/packages/react/src/api/scope-context.tsx +++ b/packages/react/src/api/scope-context.tsx @@ -41,6 +41,7 @@ export function ScopeProvider(props: React.PropsWithChildren<{ fallback?: React. export function useScope(): ScopeId { const scope = React.useContext(ScopeContext); if (scope === null) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React hook invariant throw new Error("useScope must be used inside a ScopeProvider"); } return scope.id; @@ -53,6 +54,7 @@ export function useScope(): ScopeId { export function useScopeInfo(): ScopeInfo { const scope = React.useContext(ScopeContext); if (scope === null) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React hook invariant throw new Error("useScopeInfo must be used inside a ScopeProvider"); } return scope; @@ -66,6 +68,7 @@ export function useUserScope(): ScopeId { const stack = useScopeStack(); const innermost = stack[0]; if (!innermost) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React hook invariant throw new Error("useUserScope requires a non-empty scope stack"); } return innermost.id; diff --git a/packages/react/src/plugins/secret-form.tsx b/packages/react/src/plugins/secret-form.tsx index 8fb835966..130fe751e 100644 --- a/packages/react/src/plugins/secret-form.tsx +++ b/packages/react/src/plugins/secret-form.tsx @@ -25,10 +25,8 @@ import { } from "../components/select"; import type { VariantProps } from "class-variance-authority"; -import { - getUniqueSecretId, - isSecretIdTaken, -} from "./secret-id"; +import { getUniqueSecretId, isSecretIdTaken } from "./secret-id"; +import * as Exit from "effect/Exit"; // --------------------------------------------------------------------------- // Context @@ -78,6 +76,7 @@ const SecretFormContext = createContext(null); function useSecretForm(): SecretFormContextValue { const ctx = use(SecretFormContext); if (!ctx) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React context invariant throw new Error("SecretForm parts must be rendered inside "); } return ctx; @@ -110,7 +109,7 @@ function SecretFormProvider(props: SecretFormProviderProps) { const defaultScope = useScope(); const scopeId = scopeIdProp ?? defaultScope; - const doSet = useAtomSet(setSecret, { mode: "promise" }); + const doSet = useAtomSet(setSecret, { mode: "promiseExit" }); const [state, setState] = useState(() => ({ name: "", @@ -142,27 +141,27 @@ function SecretFormProvider(props: SecretFormProviderProps) { const submit = async () => { if (!canSubmit) return; setState((s) => ({ ...s, status: { kind: "submitting" } })); - try { - await doSet({ - params: { scopeId }, - payload: { - id: SecretId.make(id.trim()), - name: displayName || id.trim(), - value: state.value.trim(), - provider: state.provider === "auto" ? undefined : state.provider, - }, - reactivityKeys: secretWriteKeys, - }); - onCreated(id.trim()); - } catch (e) { + const exit = await doSet({ + params: { scopeId }, + payload: { + id: SecretId.make(id.trim()), + name: displayName || id.trim(), + value: state.value.trim(), + provider: state.provider === "auto" ? undefined : state.provider, + }, + reactivityKeys: secretWriteKeys, + }); + if (Exit.isFailure(exit)) { setState((s) => ({ ...s, status: { kind: "error", - message: e instanceof Error ? e.message : "Failed to save secret", + message: "Failed to save secret", }, })); + return; } + onCreated(id.trim()); }; const value: SecretFormContextValue = { @@ -255,7 +254,9 @@ function ValueField(props: { revealable?: boolean; placeholder?: string }) { )} - {errored && {state.status.kind === "error" ? state.status.message : ""}} + {errored && ( + {state.status.kind === "error" ? state.status.message : ""} + )} ); }