Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
getCollections,
preloadCollections,
} from "./collections";
import { OrgResolutionScreen } from "./components/OrgResolutionScreen";
import { resolveWindowOrg } from "./resolveWindowOrg";

// Cloud query procedures take no organizationId input (the server scopes by
// active org), so their React Query keys don't encode the org — on org switch
Expand Down Expand Up @@ -86,8 +88,32 @@ export function CollectionsProvider({ children }: { children: ReactNode }) {

// Account-wide ("the orgs I belong to"), so it is not affected by — and does
// not depend on — the org header this provider sets.
const { data: organizations } =
cloudTrpc.organization.list.useQuery(undefined);
const {
data: organizations,
isError: organizationsErrored,
refetch: refetchOrganizations,
} = cloudTrpc.organization.list.useQuery(undefined);

// `unresolvable` is rendered below, never adopted: initialization is
// one-shot, so seeding from an unverified registry id on a transient failure
// would pin the window permanently. Waiting keeps the blank recoverable.
const resolution = useMemo(
() =>
resolveWindowOrg({
windowOrgPending,
windowOrgId,
organizations,
organizationsErrored,
sessionOrgId,
}),
[
windowOrgPending,
windowOrgId,
organizations,
organizationsErrored,
sessionOrgId,
],
);

// Initialize the window's org exactly once. After this, the window's org is
// owned by local state (and switchOrganization); later — possibly transient —
Expand All @@ -98,24 +124,10 @@ export function CollectionsProvider({ children }: { children: ReactNode }) {
const initializedRef = useRef(false);
useEffect(() => {
if (initializedRef.current) return;
if (windowOrgPending) return;
// The registry's org is only preferred while it is still one the user
// belongs to. Leaving an organization (or having membership revoked
// elsewhere) leaves a dead id in the registry, and adopting it would pin
// the window to an org whose every read now fails. Until the membership
// list has loaded we cannot tell stale from valid, so wait rather than
// guess — the window is showing nothing yet either way.
const registryOrgIsStillMine =
windowOrgId != null &&
organizations != null &&
organizations.some((organization) => organization.id === windowOrgId);
if (windowOrgId != null && organizations == null) return;
const resolved =
(registryOrgIsStillMine ? windowOrgId : sessionOrgId) ?? null;
if (!resolved) return;
if (resolution.kind !== "resolved") return;
initializedRef.current = true;
setActiveOrganizationId(resolved);
}, [windowOrgPending, windowOrgId, sessionOrgId, organizations]);
setActiveOrganizationId(resolution.organizationId);
}, [resolution]);

// Scope this window's cloud reads to its own org, during render rather than
// in an effect: children below issue their first queries while this render
Expand Down Expand Up @@ -197,14 +209,19 @@ export function CollectionsProvider({ children }: { children: ReactNode }) {
[collections, activeOrganizationId, switchOrganization],
);

// Only a window with no org at all renders nothing. Switching used to
// return null too, which unmounted the whole authenticated tree for as
// long as the destination org's collections took to preload — a blank
// Only a window with no org at all shows the resolution screen. Switching
// used to return null too, which unmounted the whole authenticated tree for
// as long as the destination org's collections took to preload — a blank
// window for minutes on a large org. The context still points at the
// previous org until the switch resolves, so keeping it mounted shows the
// org you're leaving rather than a void.
if (!contextValue) {
return null;
return (
<OrgResolutionScreen
errored={resolution.kind === "unresolvable"}
onRetry={() => void refetchOrganizations()}
/>
);
}

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Button } from "@superset/ui/button";
import { Spinner } from "@superset/ui/spinner";
import { useDelayElapsed } from "renderer/hooks/useDelayElapsed";

const ORG_PENDING_TIMEOUT_MS = 15_000;

interface OrgResolutionScreenProps {
errored: boolean;
onRetry: () => void;
}

// Shown while the window's org is unresolved. A bare spinner while the
// membership list loads; once the list has failed, or has been in flight long
// enough to look like a hang, say so and offer a retry. Mirrors the blocking
// screens in `_authenticated/layout.tsx`, including the drag strip: main
// windows are frameless, so a screen without one can't be moved.
export function OrgResolutionScreen({
errored,
onRetry,
}: OrgResolutionScreenProps) {
const pendingTimedOut = useDelayElapsed(!errored, ORG_PENDING_TIMEOUT_MS);
const showRetry = errored || pendingTimedOut;

return (
<div className="relative flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background">
<div className="drag absolute inset-x-0 top-0 h-12" />
<Spinner className="size-8" />
{showRetry && (
<>
<div className="text-center select-text cursor-text">
<h2 className="text-lg font-medium">
{errored
? "Can't reach the Superset server"
: "Still loading your organizations"}
</h2>
<p className="text-sm text-muted-foreground">
Superset couldn't load your organizations. Check your connection
and try again.
</p>
</div>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { OrgResolutionScreen } from "./OrgResolutionScreen";
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "bun:test";
import { resolveWindowOrg } from "./resolveWindowOrg";

const base = {
windowOrgPending: false,
windowOrgId: "org-registry",
organizations: undefined,
organizationsErrored: false,
sessionOrgId: "org-session",
};

describe("resolveWindowOrg", () => {
it("waits while the registry read is pending", () => {
expect(
resolveWindowOrg({
...base,
windowOrgPending: true,
organizationsErrored: true,
}),
).toEqual({ kind: "wait" });
});

it("waits while the registry org exists and the list is loading", () => {
expect(resolveWindowOrg(base)).toEqual({ kind: "wait" });
});

it("is unresolvable when the registry org exists and the list failed", () => {
expect(resolveWindowOrg({ ...base, organizationsErrored: true })).toEqual({
kind: "unresolvable",
});
});

it("keeps the registry org when it is still a membership", () => {
expect(
resolveWindowOrg({
...base,
organizations: [{ id: "org-other" }, { id: "org-registry" }],
}),
).toEqual({ kind: "resolved", organizationId: "org-registry" });
});

it("falls back to the session org when the registry org is dead", () => {
expect(
resolveWindowOrg({ ...base, organizations: [{ id: "org-other" }] }),
).toEqual({ kind: "resolved", organizationId: "org-session" });
});

it("resolves to the session org with no registry org even if the list failed", () => {
expect(
resolveWindowOrg({
...base,
windowOrgId: null,
organizationsErrored: true,
}),
).toEqual({ kind: "resolved", organizationId: "org-session" });
});

it("waits when nothing can supply an org", () => {
expect(
resolveWindowOrg({ ...base, windowOrgId: null, sessionOrgId: undefined }),
).toEqual({ kind: "wait" });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type WindowOrgResolution =
| { kind: "wait" }
| { kind: "unresolvable" }
| { kind: "resolved"; organizationId: string };

export interface ResolveWindowOrgInput {
windowOrgPending: boolean;
windowOrgId: string | null | undefined;
organizations: { id: string }[] | undefined;
organizationsErrored: boolean;
sessionOrgId: string | null | undefined;
}

// The registry's org is only preferred while it is still one the user belongs
// to. Leaving an organization (or having membership revoked elsewhere) leaves
// a dead id in the registry, and adopting it would pin the window to an org
// whose every read now fails. Until the membership list has loaded we cannot
// tell stale from valid, so wait rather than guess. If the list failed to load
// the window is unresolvable, never "resolved" against an unverified id: a
// transient failure would otherwise pin the window permanently.
export function resolveWindowOrg({
windowOrgPending,
windowOrgId,
organizations,
organizationsErrored,
sessionOrgId,
}: ResolveWindowOrgInput): WindowOrgResolution {
if (windowOrgPending) return { kind: "wait" };
if (windowOrgId != null && organizations == null) {
return organizationsErrored ? { kind: "unresolvable" } : { kind: "wait" };
}
const registryOrgIsStillMine =
windowOrgId != null &&
organizations != null &&
organizations.some((organization) => organization.id === windowOrgId);
const resolved =
(registryOrgIsStillMine ? windowOrgId : sessionOrgId) ?? null;
if (!resolved) return { kind: "wait" };
return { kind: "resolved", organizationId: resolved };
}
Loading