History

Fix onboarding UI jank: no Loading flash, no canonicalize double-hop (#1007)

Two flashes visible in the onboarding flow: 1. create-org flickered 'Create your organization' → 'Loading' → back. The header was gated on the pending-invitations atom being initial OR waiting, and that atom is keyed on auth — so the create-org submit (which invalidates the auth keys) put it into 'waiting' and flipped the whole page to 'Loading'. Almost no new user has a pending invitation, so we now show NO loading placeholder at all: the create form renders immediately and stays put, and invitations (when they exist) just appear above it once the fetch resolves. Removed the InvitationsSkeleton entirely. 2. The connect-MCP step flashed the app sidebar. 'Continue to app' / 'Skip' navigated to the BARE /{-$orgSlug}, which mounts the shell at / and then OrgSlugGate fires a SECOND navigation to canonicalize / → /<slug>. That double hop is the window where the shell paints over the still-mounted onboarding page. Now it navigates straight to /<slug> (the page already has the org slug), so the shell mounts once. Both pre-existing; the new auth-routing flow test (#1006) made them visible.

Rhys Sullivan committed 5 months ago

Commit 84ad4011 parent 1f9bfe02 files · +18 −37Browse files

Unified · Split

2 files
  1. apps/cloud/src/web/pages/create-org.tsx +10 −35
  2. apps/cloud/src/web/pages/setup-mcp.tsx +8 −2
apps/cloud/src/web/pages/create-org.tsx+10−35
@@ -6,7 +6,6 @@ import * as Exit from "effect/Exit"; · 5 unmodified lines
6import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";6import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
7import { trackEvent } from "@executor-js/react/api/analytics";7import { trackEvent } from "@executor-js/react/api/analytics";
8import { Button } from "@executor-js/react/components/button";8import { Button } from "@executor-js/react/components/button";
9import { Skeleton } from "@executor-js/react/components/skeleton";
10 9
11import { AUTH_PATHS } from "../../auth/api";10import { AUTH_PATHS } from "../../auth/api";
12import { acceptInvitation, pendingInvitationsAtom, useAuth } from "../auth";11import { acceptInvitation, pendingInvitationsAtom, useAuth } from "../auth";
@@ -84,8 +83,11 @@ export const CreateOrgPage = () => { · 71 unmodified lines
84 },83 },
85 });84 });
86 85
87 const isLoading =86 // Almost no new user has a pending invitation, so we never show a loading
88 AsyncResult.isInitial(invitationsResult) || AsyncResult.isWaiting(invitationsResult);87 // placeholder for them — the create form renders immediately and stays put.
88 // Invitations (when they exist) just appear above it once the fetch resolves;
89 // a refetch (the create-org submit invalidates the auth-keyed atom) keeps the
90 // last value, so nothing flickers.
89 const invitations = AsyncResult.match(invitationsResult, {91 const invitations = AsyncResult.match(invitationsResult, {
90 onInitial: () => [] as readonly PendingInvitation[],92 onInitial: () => [] as readonly PendingInvitation[],
91 onFailure: () => [] as readonly PendingInvitation[],93 onFailure: () => [] as readonly PendingInvitation[],
@@ -103,13 +105,9 @@ export const CreateOrgPage = () => { · 11 unmodified lines
103 Step 1 of 2105 Step 1 of 2
104 </p>106 </p>
105 <h1 className="font-serif text-3xl">107 <h1 className="font-serif text-3xl">
106 {isLoading108 {count > 0 ? "You've been invited" : "Create your organization"}
107 ? "Loading"
108 : count === 0
109 ? "Create your organization"
110 : "You've been invited"}
111 </h1>109 </h1>
112 {!isLoading && count === 0 && (110 {count === 0 && (
113 <p className="text-sm text-muted-foreground">111 <p className="text-sm text-muted-foreground">
114 Organizations group your sources, secrets, and teammates. You can invite others once112 Organizations group your sources, secrets, and teammates. You can invite others once
115 it's set up.113 it's set up.
@@ -117,9 +115,7 @@ export const CreateOrgPage = () => { · 1 unmodified line
117 )}115 )}
118 </header>116 </header>
119 117
120 {isLoading && <InvitationsSkeleton />}118 {sole && (
121
122 {!isLoading && sole && (
123 <SingleInvitationView119 <SingleInvitationView
124 invitation={sole}120 invitation={sole}
125 accepting={acceptingId === sole.id}121 accepting={acceptingId === sole.id}
@@ -128,7 +124,7 @@ export const CreateOrgPage = () => { · 2 unmodified lines
128 />124 />
129 )}125 )}
130 126
131 {!isLoading && count > 1 && (127 {count > 1 && (
132 <MultiInvitationsView128 <MultiInvitationsView
133 invitations={invitations}129 invitations={invitations}
134 acceptingId={acceptingId}130 acceptingId={acceptingId}
@@ -137,9 +133,7 @@ export const CreateOrgPage = () => { · 2 unmodified lines
137 />133 />
138 )}134 )}
139 135
140 {!isLoading && (count === 0 || sole || count > 1) && (136 <CreateOrgSection isPrimary={count === 0} form={form} />
141 <CreateOrgSection isPrimary={count === 0} form={form} />
142 )}
143 137
144 <footer className="flex items-center justify-center">138 <footer className="flex items-center justify-center">
145 {/* oxlint-disable-next-line react/forbid-elements */}139 {/* oxlint-disable-next-line react/forbid-elements */}
@@ -161,25 +155,6 @@ export const CreateOrgPage = () => { · 15 unmodified lines
161 155
162// ---------------------------------------------------------------------------156// ---------------------------------------------------------------------------
163 157
164const InvitationsSkeleton = () => (
165 <div className="flex flex-col gap-2.5">
166 <InvitationRowSkeleton />
167 <InvitationRowSkeleton />
168 </div>
169);
170
171const InvitationRowSkeleton = () => (
172 <div className="flex items-center gap-3 rounded-md border border-border px-3 py-2.5">
173 <div className="flex flex-1 flex-col gap-1.5">
174 <Skeleton className="h-3.5 w-2/5" />
175 <Skeleton className="h-3 w-3/5" />
176 </div>
177 <Skeleton className="h-8 w-16 rounded-md" />
178 </div>
179);
180
181// ---------------------------------------------------------------------------
182
183const InviterAttribution = ({ invitation }: { invitation: PendingInvitation }) => {158const InviterAttribution = ({ invitation }: { invitation: PendingInvitation }) => {
184 const inviterLabel = invitation.inviter159 const inviterLabel = invitation.inviter
185 ? invitation.inviter.name && invitation.inviter.name.length > 0160 ? invitation.inviter.name && invitation.inviter.name.length > 0
apps/cloud/src/web/pages/setup-mcp.tsx+8−2
@@ -23,6 +23,12 @@ export const SetupMcpPage = () => { · 22 unmodified lines
23 const auth = useAuth();23 const auth = useAuth();
24 const organizationSlug =24 const organizationSlug =
25 auth.status === "authenticated" ? (auth.organization?.slug ?? null) : null;25 auth.status === "authenticated" ? (auth.organization?.slug ?? null) : null;
26 // Land DIRECTLY on the org's canonical URL. Navigating to the bare
27 // `/{-$orgSlug}` would mount the shell at `/`, then OrgSlugGate would fire a
28 // SECOND navigation to canonicalize `/` → `/<slug>` — that double hop is the
29 // window where the shell paints over this still-mounted onboarding page.
30 const goToApp = () =>
31 navigate({ to: "/{-$orgSlug}", params: { orgSlug: organizationSlug ?? undefined } });
26 const [origin, setOrigin] = useState<string | null>(null);32 const [origin, setOrigin] = useState<string | null>(null);
27 const [advancedOpen, setAdvancedOpen] = useState(false);33 const [advancedOpen, setAdvancedOpen] = useState(false);
28 const [elicitationMode, setElicitationMode] = useState<McpElicitationMode>("model");34 const [elicitationMode, setElicitationMode] = useState<McpElicitationMode>("model");
@@ -150,7 +156,7 @@ export const SetupMcpPage = () => { · 121 unmodified lines
150 type="button"156 type="button"
151 onClick={() => {157 onClick={() => {
152 trackEvent("setup_mcp_skipped");158 trackEvent("setup_mcp_skipped");
153 void navigate({ to: "/{-$orgSlug}" });159 void goToApp();
154 }}160 }}
155 className="text-xs text-muted-foreground transition-colors hover:text-foreground"161 className="text-xs text-muted-foreground transition-colors hover:text-foreground"
156 >162 >
@@ -160,7 +166,7 @@ export const SetupMcpPage = () => { · 3 unmodified lines
160 size="sm"166 size="sm"
161 onClick={() => {167 onClick={() => {
162 trackEvent("setup_mcp_completed");168 trackEvent("setup_mcp_completed");
163 void navigate({ to: "/{-$orgSlug}" });169 void goToApp();
164 }}170 }}
165 >171 >
166 Continue to app172 Continue to app

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t