History

refactor(react): declarative reactivity keys + optimistic source add

Replace 30 scattered `useAtomRefresh()` call sites with effect-atom's `reactivityKeys` invalidation. Every query declares its key at the atom definition; every mutation passes the matching key at the call site, and effect-atom's Reactivity service auto-refetches on mutation completion. Adds a client-only optimistic-update layer (`usePendingSources` + `useSourcesWithPending`) so the sidebar shows a placeholder row the instant a user submits an add form, rather than waiting for the network roundtrip. A CI lint script (`lint:reactivity-keys`) fails any build that defines a write mutation call without `reactivityKeys`, locking the convention in. Key files: - packages/react/src/api/reactivity-keys.tsx — canonical key constants - packages/react/src/api/optimistic.tsx — pending-resource atom family - scripts/check-reactivity-keys.ts — CI enforcement

Rhys Sullivan committed 7 months ago

Commit c9a91b91 parent 6e4fc7930 files · +544 −119Browse files

Unified · Split

30 files
  1. apps/cloud/src/routes/org.tsx +23 −18
  2. apps/cloud/src/web/auth.tsx +6 −1
  3. apps/cloud/src/web/components/create-organization-form.tsx +2 −1
  4. apps/cloud/src/web/org-atoms.ts +4 −0
  5. apps/cloud/src/web/pages/onboarding.tsx +5 −10
  6. apps/cloud/src/web/shell.tsx +2 −2
  7. apps/local/src/web/shell.tsx +3 −2
  8. package.json +2 −1
  9. packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +17 −5
  10. packages/plugins/google-discovery/src/react/atoms.ts +2 −0
  11. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +30 −11
  12. packages/plugins/graphql/src/react/EditGraphqlSource.tsx +3 −3
  13. packages/plugins/graphql/src/react/atoms.ts +2 −0
  14. packages/plugins/mcp/src/react/AddMcpSource.tsx +55 −29
  15. packages/plugins/mcp/src/react/EditMcpSource.tsx +3 −3
  16. packages/plugins/mcp/src/react/atoms.ts +2 −0
  17. packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +4 −6
  18. packages/plugins/onepassword/src/react/atoms.ts +6 −0
  19. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +18 −0
  20. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +3 −3
  21. packages/plugins/openapi/src/react/atoms.ts +2 −0
  22. packages/react/src/api/atoms.tsx +11 −1
  23. packages/react/src/api/optimistic.tsx +143 −0
  24. packages/react/src/api/reactivity-keys.tsx +57 −0
  25. packages/react/src/pages/secrets.tsx +4 −5
  26. packages/react/src/pages/source-detail.tsx +3 −5
  27. packages/react/src/pages/sources-add.tsx +0 −6
  28. packages/react/src/pages/sources.tsx +4 −3
  29. packages/react/src/plugins/secret-header-auth.tsx +4 −4
  30. scripts/check-reactivity-keys.ts +124 −0
apps/cloud/src/routes/org.tsx+23−18
@@ -1,9 +1,14 @@
11 import { useReducer, useState } from "react";
22 import { Exit } from "effect";
33 import { createFileRoute, Link } from "@tanstack/react-router";
4−import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
4+import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
55 import { useCustomer } from "autumn-js/react";
66 import { toast } from "sonner";
7+import {
8+ orgMemberWriteKeys,
9+ orgDomainWriteKeys,
10+ orgInfoWriteKeys,
11+} from "@executor/react/api/reactivity-keys";
712 import {
813 Dialog,
914 DialogContent,
@@ -46,7 +51,7 @@ import { · 36 unmodified lines
4651 deleteDomain,
4752 updateOrgName,
4853 } from "../web/org-atoms";
49−import { authAtom, useAuth } from "../web/auth";
54+import { useAuth } from "../web/auth";
5055
5156 export const Route = createFileRoute("/org")({
5257 component: OrgPage,
@@ -109,8 +114,6 @@ function OrgPage() { · 56 unmodified lines
109114 const membersResult = useAtomValue(orgMembersAtom);
110115 const rolesResult = useAtomValue(orgRolesAtom);
111116 const domainsResult = useAtomValue(orgDomainsAtom);
112− const refreshMembers = useAtomRefresh(orgMembersAtom);
113− const refreshDomains = useAtomRefresh(orgDomainsAtom);
114117 const doRemove = useAtomSet(removeMember, { mode: "promiseExit" });
115118 const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" });
116119 const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" });
@@ -130,27 +133,27 @@ function OrgPage() { · 13 unmodified lines
130133 });
131134
132135 const handleRemove = async (membershipId: string, name: string) => {
133− const exit = await doRemove({ path: { membershipId } });
136+ const exit = await doRemove({ path: { membershipId }, reactivityKeys: orgMemberWriteKeys });
134137 if (Exit.isSuccess(exit)) {
135138 toast.success(`Removed ${name}`);
136− refreshMembers();
137139 } else {
138140 toast.error("Failed to remove member");
139141 }
140142 };
141143
142144 const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => {
143− const exit = await doUpdateRole({ path: { membershipId }, payload: { roleSlug } });
145+ const exit = await doUpdateRole({
146+ path: { membershipId },
147+ payload: { roleSlug },
148+ reactivityKeys: orgMemberWriteKeys,
149+ });
144150 if (Exit.isSuccess(exit)) {
145151 toast.success(`Role changed to ${roleName}`);
146− refreshMembers();
147152 } else {
148153 toast.error("Failed to change role");
149154 }
150155 };
151156
152− const refreshAuth = useAtomRefresh(authAtom);
153−
154157 const handleSaveName = async () => {
155158 const trimmed = editName.trim();
156159 if (!trimmed || trimmed === orgName) {
@@ -158,10 +161,12 @@ function OrgPage() { · 1 unmodified line
158161 return;
159162 }
160163 setSavingName(true);
161− const exit = await doUpdateOrgName({ payload: { name: trimmed } });
164+ const exit = await doUpdateOrgName({
165+ payload: { name: trimmed },
166+ reactivityKeys: orgInfoWriteKeys,
167+ });
162168 if (Exit.isSuccess(exit)) {
163169 toast.success("Organization name updated");
164− refreshAuth();
165170 } else {
166171 toast.error("Failed to update organization name");
167172 setEditName(orgName);
@@ -170,17 +175,19 @@ function OrgPage() { · 2 unmodified lines
170175 };
171176
172177 const handleDeleteDomain = async (domainId: string, domain: string) => {
173− const exit = await doDeleteDomain({ path: { domainId } });
178+ const exit = await doDeleteDomain({
179+ path: { domainId },
180+ reactivityKeys: orgDomainWriteKeys,
181+ });
174182 if (Exit.isSuccess(exit)) {
175183 toast.success(`Removed ${domain}`);
176− refreshDomains();
177184 } else {
178185 toast.error("Failed to remove domain");
179186 }
180187 };
181188
182189 const handleAddDomain = async () => {
183− const exit = await doGetVerificationLink({});
190+ const exit = await doGetVerificationLink({ reactivityKeys: orgDomainWriteKeys });
184191 if (Exit.isSuccess(exit)) {
185192 window.open(exit.value.link, "_blank");
186193 } else {
@@ -473,7 +480,6 @@ function OrgPage() { · 286 unmodified lines
473480 <InviteDialog
474481 open={inviteOpen}
475482 onOpenChange={setInviteOpen}
476− onInvited={refreshMembers}
477483 roles={roles}
478484 />
479485 </div>
@@ -580,7 +586,6 @@ function DomainCard({ · 100 unmodified lines
580586 function InviteDialog(props: {
581587 open: boolean;
582588 onOpenChange: (v: boolean) => void;
583− onInvited: () => void;
584589 roles: readonly { slug: string; name: string }[];
585590 }) {
586591 const [state, dispatch] = useReducer(inviteReducer, initialInviteState);
@@ -595,13 +600,13 @@ function InviteDialog(props: { · 8 unmodified lines
595600 email: state.email.trim(),
596601 ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}),
597602 },
603+ reactivityKeys: orgMemberWriteKeys,
598604 });
599605
600606 if (Exit.isSuccess(exit)) {
601607 toast.success(`Invitation sent to ${state.email.trim()}`);
602608 dispatch({ type: "reset" });
603609 props.onOpenChange(false);
604− props.onInvited();
605610 } else {
606611 dispatch({ type: "error", message: "Failed to send invitation" });
607612 }
apps/cloud/src/web/auth.tsx+6−1
@@ -1,6 +1,7 @@
11 import React, { createContext, useContext } from "react";
22 import { Atom } from "@effect-atom/atom";
33 import { useAtomValue, Result } from "@effect-atom/atom-react";
4+import { ReactivityKey } from "@executor/react/api/reactivity-keys";
45
56 import { CloudApiClient } from "./client";
67
@@ -26,10 +27,14 @@ type AuthOrganization = { · 19 unmodified lines
2627
2728 export const authAtom = CloudApiClient.query("cloudAuth", "me", {
2829 timeToLive: "5 minutes",
30+ reactivityKeys: [ReactivityKey.auth],
2931 });
3032
3133 export const organizationsAtom = Atom.refreshOnWindowFocus(
32− CloudApiClient.query("cloudAuth", "organizations", { timeToLive: "1 minute" }),
34+ CloudApiClient.query("cloudAuth", "organizations", {
35+ timeToLive: "1 minute",
36+ reactivityKeys: [ReactivityKey.auth],
37+ }),
3338 );
3439
3540 export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
apps/cloud/src/web/components/create-organization-form.tsx+2−1
@@ -1,5 +1,6 @@
11 import { useState } from "react";
22 import { useAtomSet } from "@effect-atom/atom-react";
3+import { authWriteKeys } from "@executor/react/api/reactivity-keys";
34 import { Input } from "@executor/react/components/input";
45 import { Label } from "@executor/react/components/label";
56
@@ -31,7 +32,7 @@ export function useCreateOrganizationForm(options: { · 25 unmodified lines
3132 }
3233 setCreating(true);
3334 setError(null);
34− const exit = await doCreate({ payload: { name: trimmed } });
35+ const exit = await doCreate({ payload: { name: trimmed }, reactivityKeys: authWriteKeys });
3536 setCreating(false);
3637 if (exit._tag === "Success") {
3738 options.onSuccess(exit.value);
apps/cloud/src/web/org-atoms.ts+4−0
@@ -1,14 +1,17 @@
11 import { Atom } from "@effect-atom/atom";
2+import { ReactivityKey } from "@executor/react/api/reactivity-keys";
23 import { CloudApiClient } from "./client";
34
45 export const orgMembersAtom = Atom.refreshOnWindowFocus(
56 CloudApiClient.query("org", "listMembers", {
67 timeToLive: "30 seconds",
8+ reactivityKeys: [ReactivityKey.orgMembers],
79 }),
810 );
911
1012 export const orgRolesAtom = CloudApiClient.query("org", "listRoles", {
1113 timeToLive: "5 minutes",
14+ reactivityKeys: [ReactivityKey.orgMembers],
1215 });
1316
1417 export const inviteMember = CloudApiClient.mutation("org", "invite");
@@ -20,6 +23,7 @@ export const updateMemberRole = CloudApiClient.mutation("org", "updateMemberRole · 5 unmodified lines
2023 export const orgDomainsAtom = Atom.refreshOnWindowFocus(
2124 CloudApiClient.query("org", "listDomains", {
2225 timeToLive: "30 seconds",
26+ reactivityKeys: [ReactivityKey.orgDomains],
2327 }),
2428 );
2529
apps/cloud/src/web/pages/onboarding.tsx+5−10
@@ -1,8 +1,7 @@
1−import { useAtomRefresh } from "@effect-atom/atom-react";
21 import { Button } from "@executor/react/components/button";
32
43 import { AUTH_PATHS } from "../../auth/api";
5−import { authAtom, useAuth } from "../auth";
4+import { useAuth } from "../auth";
65 import {
76 CreateOrganizationFields,
87 useCreateOrganizationForm,
@@ -10,7 +9,6 @@ import { · 1 unmodified line
109
1110 export const OnboardingPage = () => {
1211 const auth = useAuth();
13− const refreshAuth = useAtomRefresh(authAtom);
1412
1513 const suggestedName =
1614 auth.status === "authenticated" &&
@@ -21,13 +19,10 @@ export const OnboardingPage = () => { · 4 unmodified lines
2119
2220 const form = useCreateOrganizationForm({
2321 defaultName: suggestedName,
24− // On success: the server set a new cookie with the new org; refetch /me
25− // so AuthGate routes into Shell.
26− // On failure: the server may have cleared the cookie because the current
27− // session was too stale to attach the new org. Refetch /me regardless so
28− // AuthGate can route to LoginPage if that's the case.
29− onSuccess: () => refreshAuth(),
30− onFailure: () => refreshAuth(),
22+ // The server sets a cookie for the new (or cleared) org on both success and
23+ // failure. The authWriteKeys passed at the mutation site auto-invalidate
24+ // authAtom so AuthGate re-routes into Shell or LoginPage as appropriate.
25+ onSuccess: () => {},
3126 });
3227
3328 return (
apps/cloud/src/web/shell.tsx+2−2
@@ -1,7 +1,7 @@
11 import { Link, Outlet, useLocation } from "@tanstack/react-router";
22 import { useEffect, useRef, useState } from "react";
33 import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
4−import { sourcesAtom } from "@executor/react/api/atoms";
4+import { useSourcesWithPending } from "@executor/react/api/optimistic";
55 import { useScope } from "@executor/react/api/scope-context";
66 import { Button } from "@executor/react/components/button";
77 import { Skeleton } from "@executor/react/components/skeleton";
@@ -66,7 +66,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 58 unmodified lines
6666
6767 function SourceList(props: { pathname: string; onNavigate?: () => void }) {
6868 const scopeId = useScope();
69− const sources = useAtomValue(sourcesAtom(scopeId));
69+ const sources = useSourcesWithPending(scopeId);
7070
7171 return Result.match(sources, {
7272 onInitial: () => (
apps/local/src/web/shell.tsx+3−2
@@ -1,7 +1,8 @@
11 import { Link, Outlet, useLocation } from "@tanstack/react-router";
22 import { useCallback, useEffect, useRef, useState } from "react";
3−import { useAtomRefresh, useAtomValue, Result } from "@effect-atom/atom-react";
3+import { useAtomRefresh, Result } from "@effect-atom/atom-react";
44 import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms";
5+import { useSourcesWithPending } from "@executor/react/api/optimistic";
56 import { useScope, useScopeInfo } from "@executor/react/api/scope-context";
67 import { Button } from "@executor/react/components/button";
78 import { SourceFavicon } from "@executor/react/components/source-favicon";
@@ -224,7 +225,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 216 unmodified lines
224225
225226 function SourceList(props: { pathname: string; onNavigate?: () => void }) {
226227 const scopeId = useScope();
227− const sources = useAtomValue(sourcesAtom(scopeId));
228+ const sources = useSourcesWithPending(scopeId);
228229
229230 return Result.match(sources, {
230231 onInitial: () => (
package.json+2−1
@@ -37,7 +37,8 @@ · 36 unmodified lines
3737 "typecheck": "turbo run typecheck",
3838 "typecheck:slow": "turbo run typecheck:slow",
3939 "ci": "bun run lint && bun run typecheck && bun run test",
40− "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings",
40+ "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings && bun run lint:reactivity-keys",
41+ "lint:reactivity-keys": "bun run scripts/check-reactivity-keys.ts",
4142 "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .",
4243 "format": "oxfmt .",
4344 "format:check": "oxfmt --check .",
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+17−5
@@ -1,5 +1,5 @@
11 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2−import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
2+import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
33
44 import {
55 openOAuthPopup,
@@ -7,6 +7,8 @@ import { · 1 unmodified line
77 } from "@executor/plugin-oauth2/react";
88
99 import { secretsAtom, setSecret } from "@executor/react/api/atoms";
10+import { usePendingSources } from "@executor/react/api/optimistic";
11+import { secretWriteKeys, sourceWriteKeys } from "@executor/react/api/reactivity-keys";
1012 import { useScope } from "@executor/react/api/scope-context";
1113 import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";
1214 import { SecretId } from "@executor/sdk";
@@ -64,7 +66,6 @@ function InlineCreateSecret(props: { · 51 unmodified lines
6466 const [error, setError] = useState<string | null>(null);
6567 const scopeId = useScope();
6668 const doSet = useAtomSet(setSecret, { mode: "promise" });
67− const refreshSecrets = useAtomRefresh(secretsAtom(scopeId));
6869
6970 const handleSave = async () => {
7071 if (!secretId.trim() || !secretValue.trim()) return;
@@ -78,8 +79,8 @@ function InlineCreateSecret(props: { · 7 unmodified lines
7879 name: secretName.trim() || secretId.trim(),
7980 value: secretValue.trim(),
8081 },
82+ reactivityKeys: [...secretWriteKeys],
8183 });
82− refreshSecrets();
8384 props.onCreated(secretId.trim());
8485 } catch (e) {
8586 setError(e instanceof Error ? e.message : "Failed to save secret");
@@ -417,6 +418,7 @@ export default function AddGoogleDiscoverySource(props: { · 331 unmodified lines
417418 const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" });
418419 const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" });
419420 const secrets = useAtomValue(secretsAtom(scopeId));
421+ const { beginAdd } = usePendingSources();
420422
421423 const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]);
422424 const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
@@ -556,11 +558,18 @@ export default function AddGoogleDiscoverySource(props: { · 133 unmodified lines
556558 if (!probe) return;
557559 setAdding(true);
558560 setError(null);
561+ const displayName = identity.name.trim() || probe.name;
562+ const namespace = slugifyNamespace(identity.namespace) || probe.name;
563+ const placeholder = beginAdd({
564+ id: namespace,
565+ name: displayName,
566+ kind: "google-discovery",
567+ });
559568 try {
560569 await doAdd({
561570 path: { scopeId },
562571 payload: {
563− name: identity.name.trim() || probe.name,
572+ name: displayName,
564573 discoveryUrl: discoveryUrl.trim(),
565574 namespace: slugifyNamespace(identity.namespace) || undefined,
566575 auth:
@@ -568,13 +577,16 @@ export default function AddGoogleDiscoverySource(props: { · 1 unmodified line
568577 ? (oauthAuth ?? { kind: "none" as const })
569578 : { kind: "none" as const },
570579 },
580+ reactivityKeys: [...sourceWriteKeys],
571581 });
572582 props.onComplete();
573583 } catch (e) {
574584 setError(e instanceof Error ? e.message : "Failed to add source");
575585 setAdding(false);
586+ } finally {
587+ placeholder.done();
576588 }
577− }, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId]);
589+ }, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId, beginAdd]);
578590
579591 const addDisabled =
580592 !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null));
packages/plugins/google-discovery/src/react/atoms.ts+2−0
@@ -1,10 +1,12 @@
11 import type { ScopeId } from "@executor/sdk";
2+import { ReactivityKey } from "@executor/react/api/reactivity-keys";
23 import { GoogleDiscoveryClient } from "./client";
34
45 export const googleDiscoverySourceAtom = (scopeId: ScopeId, namespace: string) =>
56 GoogleDiscoveryClient.query("googleDiscovery", "getSource", {
67 path: { scopeId, namespace },
78 timeToLive: "15 seconds",
9+ reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools],
810 });
911
1012 export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation(
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+30−11
@@ -2,6 +2,8 @@ import { useState } from "react"; · 1 unmodified line
22 import { useAtomSet } from "@effect-atom/atom-react";
33
44 import { useScope } from "@executor/react/api/scope-context";
5+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
6+import { usePendingSources } from "@executor/react/api/optimistic";
57 import { HeadersList } from "@executor/react/plugins/headers-list";
68 import { type HeaderState } from "@executor/react/plugins/secret-header-auth";
79 import {
@@ -46,6 +48,7 @@ export default function AddGraphqlSource(props: { · 38 unmodified lines
4648
4749 const scopeId = useScope();
4850 const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });
51+ const { beginAdd } = usePendingSources();
4952 const secretList = useSecretPickerSecrets();
5053
5154 const headersValid = headers.every((header) => header.name.trim() && header.secretId);
@@ -54,31 +57,47 @@ export default function AddGraphqlSource(props: { · 2 unmodified lines
5457 const handleAdd = async () => {
5558 setAdding(true);
5659 setAddError(null);
57− try {
58− const headerMap: Record<string, HeaderValue> = {};
59− for (const header of headers) {
60− const name = header.name.trim();
61− if (name && header.secretId) {
62− headerMap[name] = {
63− secretId: header.secretId,
64− ...(header.prefix ? { prefix: header.prefix } : {}),
65− };
66− }
60+ const headerMap: Record<string, HeaderValue> = {};
61+ for (const header of headers) {
62+ const name = header.name.trim();
63+ if (name && header.secretId) {
64+ headerMap[name] = {
65+ secretId: header.secretId,
66+ ...(header.prefix ? { prefix: header.prefix } : {}),
67+ };
6768 }
69+ }
6870
71+ const trimmedEndpoint = endpoint.trim();
72+ const namespace =
73+ slugifyNamespace(identity.namespace) ||
74+ slugifyNamespace(displayNameFromUrl(trimmedEndpoint) ?? "") ||
75+ "graphql";
76+ const displayName =
77+ identity.name.trim() || displayNameFromUrl(trimmedEndpoint) || namespace;
78+ const placeholder = beginAdd({
79+ id: namespace,
80+ name: displayName,
81+ kind: "graphql",
82+ url: trimmedEndpoint || undefined,
83+ });
84+ try {
6985 await doAdd({
7086 path: { scopeId },
7187 payload: {
72− endpoint: endpoint.trim(),
88+ endpoint: trimmedEndpoint,
7389 name: identity.name.trim() || undefined,
7490 namespace: slugifyNamespace(identity.namespace) || undefined,
7591 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),
7692 },
93+ reactivityKeys: sourceWriteKeys,
7794 });
7895 props.onComplete();
7996 } catch (e) {
8097 setAddError(e instanceof Error ? e.message : "Failed to add source");
8198 setAdding(false);
99+ } finally {
100+ placeholder.done();
82101 }
83102 };
84103
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+3−3
@@ -1,7 +1,8 @@
11 import { useState } from "react";
2−import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
2+import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
33 import { graphqlSourceAtom, updateGraphqlSource } from "./atoms";
44 import { useScope } from "@executor/react/api/scope-context";
5+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
56 import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
67 import {
78 headerValueToState,
@@ -35,7 +36,6 @@ function EditForm(props: { · 27 unmodified lines
3536 }) {
3637 const scopeId = useScope();
3738 const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promise" });
38− const refreshSource = useAtomRefresh(graphqlSourceAtom(scopeId, props.sourceId));
3939 const secretList = useSecretPickerSecrets();
4040
4141 const identity = useSourceIdentity({
@@ -70,8 +70,8 @@ function EditForm(props: { · 28 unmodified lines
7070 endpoint: endpoint.trim() || undefined,
7171 headers: headersFromState(headers),
7272 },
73+ reactivityKeys: sourceWriteKeys,
7374 });
74− refreshSource();
7575 setDirty(false);
7676 props.onSave();
7777 } catch (e) {
packages/plugins/graphql/src/react/atoms.ts+2−0
@@ -1,4 +1,5 @@
11 import type { ScopeId } from "@executor/sdk";
2+import { ReactivityKey } from "@executor/react/api/reactivity-keys";
23 import { GraphqlClient } from "./client";
34
45 // ---------------------------------------------------------------------------
@@ -9,6 +10,7 @@ export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines
910 GraphqlClient.query("graphql", "getSource", {
1011 path: { scopeId, namespace },
1112 timeToLive: "15 seconds",
13+ reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools],
1214 });
1315
1416 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/react/AddMcpSource.tsx+55−29
@@ -35,6 +35,8 @@ import { · 34 unmodified lines
3535 import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
3636
3737 type RemoteAuthMode = "none" | "header" | "oauth2";
38+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
39+import { usePendingSources } from "@executor/react/api/optimistic";
3840 import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms";
3941 import { mcpPresets, type McpPreset } from "../sdk/presets";
4042
@@ -330,6 +332,7 @@ export default function AddMcpSource(props: { · 289 unmodified lines
330332 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });
331333 const doAdd = useAtomSet(addMcpSource, { mode: "promise" });
332334 const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" });
335+ const { beginAdd } = usePendingSources();
333336 const secretList = useSecretPickerSecrets();
334337
335338 const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none");
@@ -462,42 +465,51 @@ export default function AddMcpSource(props: { · 126 unmodified lines
462465 const handleAddRemote = useCallback(async () => {
463466 if (!probe) return;
464467 dispatch({ type: "add-start" });
465− try {
466− const headerAuth = remoteAuthHeaders[0];
467− const auth =
468− remoteAuthMode === "header" && headerAuth?.secretId
468+ const headerAuth = remoteAuthHeaders[0];
469+ const auth =
470+ remoteAuthMode === "header" && headerAuth?.secretId
471+ ? {
472+ kind: "header" as const,
473+ headerName: headerAuth.name.trim(),
474+ secretId: headerAuth.secretId,
475+ ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}),
476+ }
477+ : remoteAuthMode === "oauth2" && tokens
469478 ? {
470− kind: "header" as const,
471− headerName: headerAuth.name.trim(),
472− secretId: headerAuth.secretId,
473− ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}),
479+ kind: "oauth2" as const,
480+ accessTokenSecretId: tokens.accessTokenSecretId,
481+ refreshTokenSecretId: tokens.refreshTokenSecretId,
482+ tokenType: tokens.tokenType,
483+ expiresAt: tokens.expiresAt,
484+ scope: tokens.scope,
474485 }
475− : remoteAuthMode === "oauth2" && tokens
476− ? {
477− kind: "oauth2" as const,
478− accessTokenSecretId: tokens.accessTokenSecretId,
479− refreshTokenSecretId: tokens.refreshTokenSecretId,
480− tokenType: tokens.tokenType,
481− expiresAt: tokens.expiresAt,
482− scope: tokens.scope,
483− }
484− : { kind: "none" as const };
485− const headers = Object.fromEntries(
486− remoteHeaders
487− .map((header) => [header.name.trim(), header.value.trim()] as const)
488− .filter(([name, value]) => name && value),
489− );
490−
486+ : { kind: "none" as const };
487+ const headers = Object.fromEntries(
488+ remoteHeaders
489+ .map((header) => [header.name.trim(), header.value.trim()] as const)
490+ .filter(([name, value]) => name && value),
491+ );
492+ const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name;
493+ const slugNamespace = slugifyNamespace(remoteIdentity.namespace);
494+ const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`;
495+ const placeholder = beginAdd({
496+ id: placeholderId,
497+ name: displayName,
498+ kind: "mcp",
499+ url: state.url.trim(),
500+ });
501+ try {
491502 await doAdd({
492503 path: { scopeId },
493504 payload: {
494505 transport: "remote" as const,
495− name: remoteIdentity.name.trim() || probe.serverName || probe.name,
496− namespace: slugifyNamespace(remoteIdentity.namespace) || undefined,
506+ name: displayName,
507+ namespace: slugNamespace || undefined,
497508 endpoint: state.url.trim(),
498509 auth,
499510 ...(Object.keys(headers).length > 0 ? { headers } : {}),
500511 },
512+ reactivityKeys: sourceWriteKeys,
501513 });
502514 props.onComplete();
503515 } catch (e) {
@@ -505,6 +517,8 @@ export default function AddMcpSource(props: { · 1 unmodified line
505517 type: "add-fail",
506518 error: e instanceof Error ? e.message : "Failed to add source",
507519 });
520+ } finally {
521+ placeholder.done();
508522 }
509523 }, [
510524 probe,
@@ -517,6 +531,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines
517531 doAdd,
518532 props,
519533 scopeId,
534+ beginAdd,
520535 ]);
521536
522537 // ---- Stdio actions ----
@@ -549,24 +564,35 @@ export default function AddMcpSource(props: { · 26 unmodified lines
549564 if (!cmd) return;
550565 setStdioAdding(true);
551566 setStdioError(null);
567+ const displayName = stdioIdentity.name.trim() || cmd;
568+ const slugNamespace = slugifyNamespace(stdioIdentity.namespace);
569+ const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`;
570+ const placeholder = beginAdd({
571+ id: placeholderId,
572+ name: displayName,
573+ kind: "mcp",
574+ });
552575 try {
553576 await doAdd({
554577 path: { scopeId },
555578 payload: {
556579 transport: "stdio" as const,
557− name: stdioIdentity.name.trim() || cmd,
558− namespace: slugifyNamespace(stdioIdentity.namespace) || undefined,
580+ name: displayName,
581+ namespace: slugNamespace || undefined,
559582 command: cmd,
560583 args: parseStdioArgs(stdioArgs),
561584 env: parseStdioEnv(stdioEnv),
562585 },
586+ reactivityKeys: sourceWriteKeys,
563587 });
564588 props.onComplete();
565589 } catch (e) {
566590 setStdioError(e instanceof Error ? e.message : "Failed to add source");
567591 setStdioAdding(false);
592+ } finally {
593+ placeholder.done();
568594 }
569− }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]);
595+ }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]);
570596
571597 // ---- Render ----
572598
packages/plugins/mcp/src/react/EditMcpSource.tsx+3−3
@@ -1,7 +1,8 @@
11 import { useState } from "react";
2−import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
2+import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
33 import { mcpSourceAtom, updateMcpSource } from "./atoms";
44 import { useScope } from "@executor/react/api/scope-context";
5+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
56 import {
67 SourceIdentityFields,
78 useSourceIdentity,
@@ -37,7 +38,6 @@ function RemoteEditForm(props: { · 29 unmodified lines
3738 }) {
3839 const scopeId = useScope();
3940 const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" });
40− const refreshSource = useAtomRefresh(mcpSourceAtom(scopeId, props.sourceId));
4141
4242 const identity = useSourceIdentity({
4343 fallbackName: props.initial.name,
@@ -90,8 +90,8 @@ function RemoteEditForm(props: { · 46 unmodified lines
9090 endpoint: endpoint.trim() || undefined,
9191 headers: headersObj,
9292 },
93+ reactivityKeys: sourceWriteKeys,
9394 });
94− refreshSource();
9595 setDirty(false);
9696 props.onSave();
9797 } catch (e) {
packages/plugins/mcp/src/react/atoms.ts+2−0
@@ -1,4 +1,5 @@
11 import type { ScopeId } from "@executor/sdk";
2+import { ReactivityKey } from "@executor/react/api/reactivity-keys";
23 import { McpClient } from "./client";
34
45 // ---------------------------------------------------------------------------
@@ -9,6 +10,7 @@ export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines
910 McpClient.query("mcp", "getSource", {
1011 path: { scopeId, namespace },
1112 timeToLive: "15 seconds",
13+ reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools],
1214 });
1315
1416 // ---------------------------------------------------------------------------
packages/plugins/onepassword/src/react/OnePasswordSettings.tsx+4−6
@@ -1,6 +1,7 @@
11 import { useState } from "react";
2−import { useAtomSet, useAtomValue, useAtomRefresh } from "@effect-atom/atom-react";
2+import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";
33 import { Result } from "@effect-atom/atom-react";
4+import { ReactivityKey } from "@executor/react/api/reactivity-keys";
45 import { useScope } from "@executor/react/api/scope-context";
56 import { Button } from "@executor/react/components/button";
67 import { Input } from "@executor/react/components/input";
@@ -139,7 +140,6 @@ function ConfigDialog(props: { · 132 unmodified lines
139140
140141 const scopeId = useScope();
141142 const doConfigure = useAtomSet(configureOnePassword, { mode: "promise" });
142− const refreshConfig = useAtomRefresh(onepasswordConfigAtom(scopeId));
143143
144144 const reset = () => {
145145 if (!isEdit) {
@@ -165,9 +165,9 @@ function ConfigDialog(props: { · 19 unmodified lines
165165 await doConfigure({
166166 path: { scopeId },
167167 payload: { auth, vaultId: vaultId.trim(), name: vaultName.trim() || "1Password" },
168+ reactivityKeys: [ReactivityKey.secrets],
168169 });
169170 props.onOpenChange(false);
170− refreshConfig();
171171 reset();
172172 } catch (e) {
173173 setError(e instanceof Error ? e.message : "Failed to save configuration");
@@ -296,12 +296,10 @@ export default function OnePasswordSettings() { · 122 unmodified lines
296296 const scopeId = useScope();
297297 const configResult = useAtomValue(onepasswordConfigAtom(scopeId));
298298 const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promise" });
299− const refreshConfig = useAtomRefresh(onepasswordConfigAtom(scopeId));
300299
301300 const handleRemove = async () => {
302301 try {
303− await doRemove({ path: { scopeId } });
304− refreshConfig();
302+ await doRemove({ path: { scopeId }, reactivityKeys: [ReactivityKey.secrets] });
305303 } catch {
306304 /* TODO: toast */
307305 }
packages/plugins/onepassword/src/react/atoms.ts+6−0
@@ -1,6 +1,9 @@
11 import type { ScopeId } from "@executor/sdk";
2+import { ReactivityKey } from "@executor/react/api/reactivity-keys";
23 import { OnePasswordClient } from "./client";
34
5+export const onepasswordWriteKeys = [ReactivityKey.secrets] as const;
6+
47 // ---------------------------------------------------------------------------
58 // Query atoms
69 // ---------------------------------------------------------------------------
@@ -9,12 +12,14 @@ export const onepasswordConfigAtom = (scopeId: ScopeId) => · 2 unmodified lines
912 OnePasswordClient.query("onepassword", "getConfig", {
1013 path: { scopeId },
1114 timeToLive: "30 seconds",
15+ reactivityKeys: [ReactivityKey.secrets],
1216 });
1317
1418 export const onepasswordStatusAtom = (scopeId: ScopeId) =>
1519 OnePasswordClient.query("onepassword", "status", {
1620 path: { scopeId },
1721 timeToLive: "15 seconds",
22+ reactivityKeys: [ReactivityKey.secrets],
1823 });
1924
2025 // ---------------------------------------------------------------------------
@@ -30,6 +35,7 @@ export const onepasswordVaultsAtom = ( · 9 unmodified lines
3035 path: { scopeId },
3136 urlParams: { authKind, account },
3237 timeToLive: "30 seconds",
38+ reactivityKeys: [ReactivityKey.secrets],
3339 });
3440
3541 // ---------------------------------------------------------------------------
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+18−0
@@ -6,6 +6,8 @@ import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/r · 5 unmodified lines
66
77 import { SecretPicker } from "@executor/react/plugins/secret-picker";
88 import { useScope } from "@executor/react/api/scope-context";
9+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
10+import { usePendingSources } from "@executor/react/api/optimistic";
911 import { HeadersList } from "@executor/react/plugins/headers-list";
1012 import {
1113 matchPresetKey,
@@ -171,6 +173,7 @@ export default function AddOpenApiSource(props: { · 159 unmodified lines
171173 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });
172174 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });
173175 const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" });
176+ const { beginAdd } = usePendingSources();
174177 const secretList = useSecretPickerSecrets();
175178
176179 // Keep the latest handleAnalyze in a ref so the debounced effect doesn't
@@ -456,6 +459,18 @@ export default function AddOpenApiSource(props: { · 279 unmodified lines
456459 const handleAdd = async () => {
457460 setAdding(true);
458461 setAddError(null);
462+ const namespace =
463+ slugifyNamespace(identity.namespace) ||
464+ (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");
465+ const displayName =
466+ identity.name.trim() ||
467+ (preview ? Option.getOrElse(preview.title, () => namespace) : namespace);
468+ const placeholder = beginAdd({
469+ id: namespace,
470+ name: displayName,
471+ kind: "openapi",
472+ url: resolvedBaseUrl || undefined,
473+ });
459474 try {
460475 await doAdd({
461476 path: { scopeId },
@@ -467,11 +482,14 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines
467482 ...(hasHeaders ? { headers: allHeaders } : {}),
468483 ...(oauth2Auth ? { oauth2: oauth2Auth } : {}),
469484 },
485+ reactivityKeys: sourceWriteKeys,
470486 });
471487 props.onComplete();
472488 } catch (e) {
473489 setAddError(e instanceof Error ? e.message : "Failed to add source");
474490 setAdding(false);
491+ } finally {
492+ placeholder.done();
475493 }
476494 };
477495
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+3−3
@@ -1,7 +1,8 @@
11 import { useState } from "react";
2−import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
2+import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
33 import { openApiSourceAtom, updateOpenApiSource } from "./atoms";
44 import { useScope } from "@executor/react/api/scope-context";
5+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
56 import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
67 import {
78 headerValueToState,
@@ -35,7 +36,6 @@ function EditForm(props: { · 27 unmodified lines
3536 }) {
3637 const scopeId = useScope();
3738 const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" });
38− const refreshSource = useAtomRefresh(openApiSourceAtom(scopeId, props.sourceId));
3939 const secretList = useSecretPickerSecrets();
4040
4141 const identity = useSourceIdentity({
@@ -70,8 +70,8 @@ function EditForm(props: { · 28 unmodified lines
7070 baseUrl: baseUrl.trim() || undefined,
7171 headers: headersFromState(headers),
7272 },
73+ reactivityKeys: sourceWriteKeys,
7374 });
74− refreshSource();
7575 setDirty(false);
7676 props.onSave();
7777 } catch (e) {
packages/plugins/openapi/src/react/atoms.ts+2−0
@@ -1,4 +1,5 @@
11 import type { ScopeId } from "@executor/sdk";
2+import { ReactivityKey } from "@executor/react/api/reactivity-keys";
23 import { OpenApiClient } from "./client";
34
45 // ---------------------------------------------------------------------------
@@ -9,6 +10,7 @@ export const openApiSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines
910 OpenApiClient.query("openapi", "getSource", {
1011 path: { scopeId, namespace },
1112 timeToLive: "15 seconds",
13+ reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools],
1214 });
1315
1416 // ---------------------------------------------------------------------------
packages/react/src/api/atoms.tsx+11−1
@@ -2,6 +2,7 @@ import type { ScopeId, ToolId, SecretId } from "@executor/sdk"; · 1 unmodified line
22 import { Atom } from "@effect-atom/atom-react";
33
44 import { ExecutorApiClient } from "./client";
5+import { ReactivityKey } from "./reactivity-keys";
56
67 // ---------------------------------------------------------------------------
78 // Scope — fetched from the server
@@ -9,6 +10,7 @@ import { ExecutorApiClient } from "./client"; · 1 unmodified line
910
1011 export const scopeAtom = ExecutorApiClient.query("scope", "info", {
1112 timeToLive: "5 minutes",
13+ reactivityKeys: [ReactivityKey.scope],
1214 });
1315
1416 // ---------------------------------------------------------------------------
@@ -19,6 +21,7 @@ export const toolsAtom = (scopeId: ScopeId) => · 4 unmodified lines
1921 ExecutorApiClient.query("tools", "list", {
2022 path: { scopeId },
2123 timeToLive: "30 seconds",
24+ reactivityKeys: [ReactivityKey.tools],
2225 });
2326
2427 /** Tools for a specific source */
@@ -26,18 +29,21 @@ export const sourceToolsAtom = (sourceId: string, scopeId: ScopeId) => · 1 unmodified line
2629 ExecutorApiClient.query("sources", "tools", {
2730 path: { scopeId, sourceId },
2831 timeToLive: "30 seconds",
32+ reactivityKeys: [ReactivityKey.tools],
2933 });
3034
3135 export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) =>
3236 ExecutorApiClient.query("tools", "schema", {
3337 path: { scopeId, toolId },
3438 timeToLive: "1 minute",
39+ reactivityKeys: [ReactivityKey.tools],
3540 });
3641
3742 export const sourcesAtom = (scopeId: ScopeId) =>
3843 ExecutorApiClient.query("sources", "list", {
3944 path: { scopeId },
4045 timeToLive: "30 seconds",
46+ reactivityKeys: [ReactivityKey.sources],
4147 });
4248
4349 /** Single source by id — derived from the sources list */
@@ -48,16 +54,20 @@ export const secretsAtom = (scopeId: ScopeId) => · 4 unmodified lines
4854 ExecutorApiClient.query("secrets", "list", {
4955 path: { scopeId },
5056 timeToLive: "30 seconds",
57+ reactivityKeys: [ReactivityKey.secrets],
5158 });
5259
5360 export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) =>
5461 ExecutorApiClient.query("secrets", "status", {
5562 path: { scopeId, secretId },
5663 timeToLive: "15 seconds",
64+ reactivityKeys: [ReactivityKey.secrets],
5765 });
5866
5967 // ---------------------------------------------------------------------------
60−// Mutation atoms
68+// Mutation atoms — reactivityKeys must be passed at call site (effect-atom
69+// does not accept them at definition time). See `reactivity-keys.tsx` for the
70+// canonical key arrays.
6171 // ---------------------------------------------------------------------------
6272
6373 export const setSecret = ExecutorApiClient.mutation("secrets", "set");
packages/react/src/api/optimistic.tsx+143−0
@@ -0,0 +1,143 @@
1+import * as React from "react";
2+import { Atom, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react";
3+import type { ScopeId } from "@executor/sdk";
4+
5+import { sourcesAtom } from "./atoms";
6+
7+/**
8+ * Client-only optimistic-update layer.
9+ *
10+ * Mutations that add a row return only after the server roundtrip. The
11+ * `pendingResourceAtom` family lets a UI insert a placeholder *before* the
12+ * mutation resolves so the sidebar/list updates instantly. Once the server
13+ * returns and reactivity refetches the canonical list, callers should remove
14+ * the placeholder (the dedupe in `mergePending` filters it anyway, but
15+ * explicit removal keeps the pending set bounded on errors).
16+ *
17+ * Resource keys (`"sources"`, `"secrets"`, …) match the strings in
18+ * `ReactivityKey` but are intentionally not coupled — pending state is purely
19+ * client-side.
20+ */
21+
22+export const PendingResource = {
23+ sources: "sources",
24+} as const;
25+
26+export interface PendingEntry<T> {
27+ readonly id: string;
28+ readonly value: T;
29+}
30+
31+const pendingFamily = Atom.family((_resource: string) =>
32+ Atom.make<ReadonlyArray<PendingEntry<unknown>>>([]),
33+);
34+
35+const atomFor = <T,>(resource: string) =>
36+ pendingFamily(resource) as ReturnType<typeof pendingFamily> & {
37+ readonly __t?: T;
38+ };
39+
40+export const usePendingResource = <T,>(resource: string) => {
41+ const atom = atomFor<T>(resource);
42+ const pending = useAtomValue(atom) as ReadonlyArray<PendingEntry<T>>;
43+ const setPending = useAtomSet(atom);
44+
45+ const add = React.useCallback(
46+ (entry: PendingEntry<T>) =>
47+ setPending((prev) => [...(prev as ReadonlyArray<PendingEntry<T>>), entry]),
48+ [setPending],
49+ );
50+
51+ const remove = React.useCallback(
52+ (id: string) =>
53+ setPending((prev) =>
54+ (prev as ReadonlyArray<PendingEntry<T>>).filter((p) => p.id !== id),
55+ ),
56+ [setPending],
57+ );
58+
59+ const clear = React.useCallback(() => setPending([]), [setPending]);
60+
61+ return { pending, add, remove, clear };
62+};
63+
64+/**
65+ * Merges pending entries with a server-loaded list. Pending entries whose id
66+ * already exists on the server are dropped so we never double-render a row
67+ * once the canonical version arrives.
68+ */
69+export const mergePending = <T, R>(
70+ pending: ReadonlyArray<PendingEntry<T>>,
71+ server: ReadonlyArray<R>,
72+ serverId: (row: R) => string,
73+ fromPending: (entry: PendingEntry<T>) => R,
74+): ReadonlyArray<R> => {
75+ if (pending.length === 0) return server;
76+ const seen = new Set(server.map(serverId));
77+ const extras = pending.filter((p) => !seen.has(p.id)).map(fromPending);
78+ if (extras.length === 0) return server;
79+ return [...extras, ...server];
80+};
81+
82+// ---------------------------------------------------------------------------
83+// Sources — convenience wrappers used by sidebar/list views and add forms.
84+// Pending entries are global (not per-scope) because the user only sees one
85+// scope at a time; this keeps the add-form code free of scope plumbing.
86+// ---------------------------------------------------------------------------
87+
88+export interface PendingSource {
89+ readonly name: string;
90+ readonly kind: string;
91+ readonly url?: string;
92+}
93+
94+/**
95+ * Sidebar/list helper. Reads `sourcesAtom(scopeId)` and merges any pending
96+ * placeholder rows on top so the UI updates instantly when an add form pushes
97+ * a placeholder.
98+ */
99+export const useSourcesWithPending = (scopeId: ScopeId) => {
100+ const result = useAtomValue(sourcesAtom(scopeId));
101+ const { pending } = usePendingResource<PendingSource>(PendingResource.sources);
102+ return React.useMemo(
103+ () =>
104+ Result.map(result, (sources) =>
105+ mergePending(
106+ pending,
107+ sources,
108+ (s) => s.id,
109+ (p) => ({
110+ id: p.id,
111+ name: p.value.name,
112+ kind: p.value.kind,
113+ url: p.value.url,
114+ // The placeholder cannot be removed/refreshed/edited until the
115+ // server confirms it, so disable those affordances.
116+ canRemove: false,
117+ canRefresh: false,
118+ canEdit: false,
119+ }),
120+ ),
121+ ),
122+ [result, pending],
123+ );
124+};
125+
126+/**
127+ * Hook for an add form. Returns helpers to push and clear a pending source
128+ * placeholder. On submit:
129+ * const placeholder = beginAdd({ id: namespace, name, kind });
130+ * try { await doAdd(...); } finally { placeholder.done(); }
131+ */
132+export const usePendingSources = () => {
133+ const { add, remove } = usePendingResource<PendingSource>(PendingResource.sources);
134+ return React.useMemo(
135+ () => ({
136+ beginAdd: (entry: { id: string } & PendingSource) => {
137+ add({ id: entry.id, value: { name: entry.name, kind: entry.kind, url: entry.url } });
138+ return { done: () => remove(entry.id) };
139+ },
140+ }),
141+ [add, remove],
142+ );
143+};
packages/react/src/api/reactivity-keys.tsx+57−0
@@ -0,0 +1,57 @@
1+/**
2+ * Canonical reactivity keys for query/mutation invalidation.
3+ *
4+ * effect-atom's `Reactivity` service refreshes any query whose `reactivityKeys`
5+ * overlap with a completed mutation's `reactivityKeys`. The Reactivity instance
6+ * is shared across the global Atom registry, so keys interop across plugin
7+ * clients (`McpClient`, `OpenApiClient`, `ExecutorApiClient`, …).
8+ *
9+ * Conventions:
10+ * - Every query that reads a server resource sets `reactivityKeys` at the
11+ * query atom's definition site.
12+ * - Every mutation passes `reactivityKeys` at the call site (mutations don't
13+ * accept the option at definition time — see effect-atom AtomHttpApi).
14+ * - Use the constants below; do not invent ad-hoc string keys at call sites.
15+ *
16+ * Per-scope precision is intentionally dropped: a mutation in one scope
17+ * invalidating another scope's queries is harmless (users see one scope at a
18+ * time) and keeps the convention ergonomic.
19+ */
20+export const ReactivityKey = {
21+ sources: "sources",
22+ tools: "tools",
23+ secrets: "secrets",
24+ scope: "scope",
25+ // cloud-only resources
26+ orgMembers: "org:members",
27+ orgDomains: "org:domains",
28+ orgInfo: "org:info",
29+ auth: "auth",
30+} as const;
31+
32+/** Mutations that add/remove/refresh a source also affect tool listings. */
33+export const sourceWriteKeys = [ReactivityKey.sources, ReactivityKey.tools] as const;
34+
35+/** Mutations that mint or revoke secrets. */
36+export const secretWriteKeys = [ReactivityKey.secrets] as const;
37+
38+/** Mutations that change scope membership/info. */
39+export const scopeWriteKeys = [ReactivityKey.scope] as const;
40+
41+/** Cloud-only: org membership mutations. */
42+export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const;
43+
44+/** Cloud-only: org domain mutations. */
45+export const orgDomainWriteKeys = [ReactivityKey.orgDomains] as const;
46+
47+/** Cloud-only: org info mutations (name, etc.) — also touches scope/auth. */
48+export const orgInfoWriteKeys = [ReactivityKey.orgInfo, ReactivityKey.auth] as const;
49+
50+/** Cloud-only: auth mutations (org switch/create) — invalidate everything user-visible. */
51+export const authWriteKeys = [
52+ ReactivityKey.auth,
53+ ReactivityKey.orgInfo,
54+ ReactivityKey.orgMembers,
55+ ReactivityKey.orgDomains,
56+ ReactivityKey.scope,
57+] as const;
packages/react/src/pages/secrets.tsx+4−5
@@ -1,6 +1,7 @@
11 import { useState, Suspense } from "react";
2−import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
2+import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
33 import { secretsAtom, setSecret, removeSecret } from "../api/atoms";
4+import { secretWriteKeys } from "../api/reactivity-keys";
45 import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin";
56 import { SecretId } from "@executor/sdk";
67 import { useScope } from "../hooks/use-scope";
@@ -72,7 +73,6 @@ function AddSecretDialog(props: { · 65 unmodified lines
7273
7374 const scopeId = useScope();
7475 const doSet = useAtomSet(setSecret, { mode: "promise" });
75− const refresh = useAtomRefresh(secretsAtom(scopeId));
7676
7777 const reset = () => {
7878 setId("");
@@ -96,10 +96,10 @@ function AddSecretDialog(props: { · 17 unmodified lines
9696 value: value.trim(),
9797 provider: provider === "auto" ? undefined : provider,
9898 },
99+ reactivityKeys: secretWriteKeys,
99100 });
100101 reset();
101102 props.onOpenChange(false);
102− refresh();
103103 } catch (e) {
104104 setError(e instanceof Error ? e.message : "Failed to save secret");
105105 setSaving(false);
@@ -292,7 +292,6 @@ export function SecretsPage(props: { · 186 unmodified lines
292292 const scopeId = useScope();
293293 const secrets = useAtomValue(secretsAtom(scopeId));
294294 const doRemove = useAtomSet(removeSecret, { mode: "promise" });
295− const refresh = useAtomRefresh(secretsAtom(scopeId));
296295
297296 const handleRemove = async (secretId: string) => {
298297 try {
@@ -301,8 +300,8 @@ export function SecretsPage(props: { · 2 unmodified lines
301300 scopeId,
302301 secretId: SecretId.make(secretId),
303302 },
303+ reactivityKeys: secretWriteKeys,
304304 });
305− refresh();
306305 } catch {
307306 // TODO: toast
308307 }
packages/react/src/pages/source-detail.tsx+3−5
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 removeSource,
99 refreshSource,
1010 } from "../api/atoms";
11+import { sourceWriteKeys } from "../api/reactivity-keys";
1112 import { ToolTree } from "../components/tool-tree";
1213 import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";
1314 import type { ToolSummary } from "../components/tool-tree";
@@ -81,8 +82,8 @@ export function SourceDetailPage(props: { · 67 unmodified lines
8182 try {
8283 await doRemove({
8384 path: { scopeId, sourceId: namespace },
85+ reactivityKeys: sourceWriteKeys,
8486 });
85− refreshSources();
8687 void navigate({ to: "/" });
8788 } catch {
8889 setDeleting(false);
@@ -95,9 +96,8 @@ export function SourceDetailPage(props: { · 6 unmodified lines
9596 try {
9697 await doRefresh({
9798 path: { scopeId, sourceId: namespace },
99+ reactivityKeys: sourceWriteKeys,
98100 });
99− refreshTools();
100− refreshSources();
101101 } finally {
102102 setRefreshing(false);
103103 }
@@ -105,8 +105,6 @@ export function SourceDetailPage(props: { · 1 unmodified line
105105
106106 const handleEditSave = () => {
107107 setEditing(false);
108− refreshSources();
109− refreshTools();
110108 };
111109
112110 return (
packages/react/src/pages/sources-add.tsx+0−6
@@ -1,9 +1,6 @@
11 import { Suspense } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import type { SourcePlugin } from "../plugins/source-plugin";
4−import { useAtomRefresh } from "@effect-atom/atom-react";
5−import { sourcesAtom } from "../api/atoms";
6−import { useScope } from "../hooks/use-scope";
74
85 // ---------------------------------------------------------------------------
96 // Page
@@ -17,8 +14,6 @@ export function SourcesAddPage(props: { · 7 unmodified lines
1714 sourcePlugins: readonly SourcePlugin[];
1815 }) {
1916 const { pluginKey, url, preset, namespace, sourcePlugins } = props;
20− const scopeId = useScope();
21− const refreshSources = useAtomRefresh(sourcesAtom(scopeId));
2217 const navigate = useNavigate();
2318
2419 const plugin = sourcePlugins.find((p) => p.key === pluginKey);
@@ -57,7 +52,6 @@ export function SourcesAddPage(props: { · 32 unmodified lines
5752 initialPreset={preset}
5853 initialNamespace={namespace}
5954 onComplete={() => {
60− refreshSources();
6155 void navigate({ to: "/" });
6256 }}
6357 onCancel={() => {
packages/react/src/pages/sources.tsx+4−3
@@ -1,7 +1,8 @@
11 import { useState, useCallback, useMemo } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
3−import { Result, useAtomValue, useAtomSet } from "@effect-atom/atom-react";
4−import { sourcesAtom, detectSource } from "../api/atoms";
3+import { Result, useAtomSet } from "@effect-atom/atom-react";
4+import { detectSource } from "../api/atoms";
5+import { useSourcesWithPending } from "../api/optimistic";
56 import { useScope } from "../hooks/use-scope";
67 import type { SourcePlugin, SourcePreset } from "../plugins/source-plugin";
78 import { McpInstallCard } from "../components/mcp-install-card";
@@ -41,7 +42,7 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 33 unmodified lines
4142 const [error, setError] = useState<string | null>(null);
4243
4344 const scopeId = useScope();
44− const sources = useAtomValue(sourcesAtom(scopeId));
45+ const sources = useSourcesWithPending(scopeId);
4546 const doDetect = useAtomSet(detectSource, { mode: "promise" });
4647 const navigate = useNavigate();
4748
packages/react/src/plugins/secret-header-auth.tsx+4−4
@@ -1,7 +1,8 @@
11 import { useId, useState } from "react";
2−import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react";
2+import { useAtomSet } from "@effect-atom/atom-react";
33
4−import { secretsAtom, setSecret, resolveSecret } from "../api/atoms";
4+import { setSecret, resolveSecret } from "../api/atoms";
5+import { secretWriteKeys } from "../api/reactivity-keys";
56 import { useScope } from "../api/scope-context";
67 import { Button } from "../components/button";
78 import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field";
@@ -73,7 +74,6 @@ function InlineCreateSecret(props: { · 65 unmodified lines
7374 const [error, setError] = useState<string | null>(null);
7475 const scopeId = useScope();
7576 const doSet = useAtomSet(setSecret, { mode: "promise" });
76− const refreshSecrets = useAtomRefresh(secretsAtom(scopeId));
7777 const secretIdInputId = useId();
7878 const secretNameInputId = useId();
7979 const secretValueInputId = useId();
@@ -90,8 +90,8 @@ function InlineCreateSecret(props: { · 10 unmodified lines
9090 name: `${secretName.trim() || secretId.trim()} (Auth header: ${props.headerName})`,
9191 value: secretValue.trim(),
9292 },
93+ reactivityKeys: secretWriteKeys,
9394 });
94− refreshSecrets();
9595 props.onCreated(secretId.trim());
9696 } catch (e) {
9797 setError(e instanceof Error ? e.message : "Failed to save secret");
scripts/check-reactivity-keys.ts+124−0
@@ -0,0 +1,124 @@
1+#!/usr/bin/env bun
2+// Fail CI when a frontend file calls a mutation via `useAtomSet(<atom>, { mode: "promise" })`
3+// without ever passing `reactivityKeys` in the corresponding call args.
4+//
5+// The check is intentionally coarse: per-file we count `useAtomSet(...)` sites
6+// and `reactivityKeys` mentions. A file with N mutation sites must mention
7+// `reactivityKeys` at least N times. Read-only mutations (probes, previews,
8+// auth-flow starts) are allow-listed so the convention can be locked in
9+// without false positives.
10+//
11+// Run: `bun run scripts/check-reactivity-keys.ts`
12+// Exits 1 with a punch list when violations exist.
13+
14+import { Glob } from "bun";
15+
16+const ROOTS = ["packages", "apps"];
17+
18+// Mutations that intentionally do not invalidate any cached query (probes,
19+// previews, OAuth-flow kickoffs that just open a window).
20+const READ_ONLY_MUTATIONS = new Set<string>([
21+ "probeMcpEndpoint",
22+ "startMcpOAuth",
23+ "probeGoogleDiscovery",
24+ "startGoogleDiscoveryOAuth",
25+ "previewOpenApiSpec",
26+ "startOpenApiOAuth",
27+ "resolveSecret",
28+ "detectSource",
29+ "getDomainVerificationLink",
30+]);
31+
32+interface Violation {
33+ readonly file: string;
34+ readonly line: number;
35+ readonly mutationVar: string;
36+}
37+
38+const violations: Violation[] = [];
39+
40+const useAtomSetRegex = /useAtomSet\(\s*([A-Za-z_][\w]*)\s*,\s*\{\s*mode:\s*"promise"\s*\}\s*\)/g;
41+
42+for (const root of ROOTS) {
43+ const glob = new Glob(`${root}/**/*.{ts,tsx}`);
44+ for await (const path of glob.scan({ cwd: import.meta.dir + "/.." })) {
45+ if (path.includes("node_modules") || path.includes(".test.") || path.endsWith(".d.ts")) continue;
46+ const file = Bun.file(`${import.meta.dir}/../${path}`);
47+ const text = await file.text();
48+ if (!text.includes("useAtomSet")) continue;
49+
50+ const lines = text.split("\n");
51+ let cursor = 0;
52+ for (const line of lines) {
53+ cursor++;
54+ useAtomSetRegex.lastIndex = 0;
55+ const match = useAtomSetRegex.exec(line);
56+ if (!match) continue;
57+ const mutationVar = match[1] ?? "<unknown>";
58+ // The variable being declared from useAtomSet is what the file later awaits.
59+ // Find the binding name to look up the await call.
60+ const bindingMatch = line.match(/const\s+(\w+)\s*=\s*useAtomSet/);
61+ const binding = bindingMatch?.[1];
62+
63+ if (READ_ONLY_MUTATIONS.has(mutationVar)) continue;
64+ if (!binding) continue;
65+
66+ // Look for `await <binding>(...)` calls in the rest of the file and
67+ // check whether the *args object* contains `reactivityKeys`. We scan
68+ // line-by-line and grow a brace-aware window once we see the call.
69+ const callRegex = new RegExp(`await\\s+${binding}\\s*\\(`);
70+ let i = cursor;
71+ while (i < lines.length) {
72+ const l = lines[i] ?? "";
73+ if (callRegex.test(l)) {
74+ // Extract the args block from the opening paren until balanced close.
75+ let depth = 0;
76+ let started = false;
77+ let argText = "";
78+ let j = i;
79+ while (j < lines.length) {
80+ const cur = lines[j] ?? "";
81+ for (const ch of cur) {
82+ if (ch === "(") {
83+ depth++;
84+ started = true;
85+ continue;
86+ }
87+ if (started && ch === ")") {
88+ depth--;
89+ if (depth === 0) break;
90+ }
91+ if (started) argText += ch;
92+ }
93+ if (started && depth === 0) break;
94+ j++;
95+ if (j - i > 60) break; // bail on runaway
96+ }
97+ if (!argText.includes("reactivityKeys")) {
98+ violations.push({ file: path, line: i + 1, mutationVar });
99+ }
100+ break;
101+ }
102+ i++;
103+ if (i - cursor > 80) break; // give up on this site if the await is far away
104+ }
105+ }
106+ }
107+}
108+
109+if (violations.length === 0) {
110+ console.log("✓ reactivityKeys check passed");
111+ process.exit(0);
112+}
113+
114+console.error(`✗ reactivityKeys check failed — ${violations.length} mutation call(s) missing reactivityKeys:\n`);
115+for (const v of violations) {
116+ console.error(` ${v.file}:${v.line} — ${v.mutationVar}`);
117+}
118+console.error(
119+ `\nEvery write mutation must pass reactivityKeys at the call site.\n` +
120+ `See packages/react/src/api/reactivity-keys.tsx for canonical key arrays.\n` +
121+ `If this mutation truly is read-only (probe/preview/OAuth-start), add it to\n` +
122+ `the READ_ONLY_MUTATIONS allowlist in scripts/check-reactivity-keys.ts.`,
123+);
124+process.exit(1);