fix(webapp): Public links says "Loading…", not a premature "no" (BEA-78) (#127)

The public-links panel is where a member answers "is anything of ours
public right now?". SharesTable read an empty array as a settled answer,
and both callers hand it `shares || []` while the request is still in
flight — so the panel printed "No public links." as a confident NO, then
swapped to a table listing an active, never-expiring link.

Fix at the choke point: an optional `loading` prop honored above the
empty branch, in the same .admin-list/.admin-empty shell so the section
is one row tall either way and doesn't jump when the data lands. Wired at
both call sites — project Settings and the org-wide cross-project audit,
which had the identical bug.

`isLoading`, not `isPending`: OrgAdmin's shares query is `enabled: owner`,
and TanStack reports isPending true forever for a disabled query.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-11 01:21:19 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 7dd5d097fe
commit 7a20849631
6 changed files with 66 additions and 18 deletions
@@ -363,6 +363,35 @@ test("project settings lists this project's public links and revokes them", asyn
await expect(page.locator(".admin-empty", { hasText: "No public links." })).toBeVisible();
});
// "No public links." before the request lands is a confident no to "is anything
// of ours public right now?" — the one wrong answer this panel must never give.
test("public links show a loading row, never a premature 'no', while shares load", async ({
page,
}) => {
await login(page);
const pid = await wikiId(page);
const made = await (
await page.request.post(`/api/p/${pid}/shares`, { data: { path: "notes/readme.md" } })
).json();
await page.route("**/api/p/*/shares", async (route) => {
await new Promise((r) => setTimeout(r, 2000));
await route.continue();
});
await page.goto(`/${pid}/settings`);
await expect(page.locator(".admin-empty", { hasText: "Loading…" })).toBeVisible();
await expect(page.locator(".admin-empty", { hasText: "No public links." })).toHaveCount(0);
await expect(page.locator(".admin-item", { hasText: "notes/readme.md" })).toBeVisible({
timeout: 10_000,
});
await expect(page.locator(".admin-empty", { hasText: "Loading…" })).toHaveCount(0);
await page.unroute("**/api/p/*/shares");
await page.request.delete(`/api/shares/${made.token}`);
});
test("a read-only member sees the public-link banner but cannot revoke", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
@@ -58,7 +58,7 @@ export function OrgAdmin({
enabled: owner,
select: (d) => d.invites || [],
});
const { data: shares } = useQuery({
const { data: shares, isLoading: sharesLoading } = useQuery({
queryKey: ["orgShares", org.id],
queryFn: () => getJSON<{ shares: ShareInfo[] }>(`/api/orgs/${org.id}/shares`),
enabled: owner,
@@ -207,7 +207,12 @@ export function OrgAdmin({
Every live link across this organization's projects. A project's own links are on its
Settings page, and on the file itself.
</p>
<SharesTable shares={shares || []} onChanged={refreshShares} showProject />
<SharesTable
shares={shares || []}
loading={sharesLoading}
onChanged={refreshShares}
showProject
/>
</>
)}
</div>
@@ -296,7 +296,7 @@ export function ProjectSettings({
// there to find one project's links.
function PublicLinks({ project }: { project: Project }) {
const qc = useQueryClient();
const { data: shares, error } = useShares(project.id);
const { data: shares, error, isLoading } = useShares(project.id);
if (error) return null; // sharing is off on this server, or single-volume mode
return (
<Card>
@@ -313,6 +313,7 @@ function PublicLinks({ project }: { project: Project }) {
<CardContent>
<SharesTable
shares={shares || []}
loading={isLoading}
canRevoke={atLeast(project.perm, "write")}
onChanged={() => qc.invalidateQueries({ queryKey: ["shares", project.id] })}
empty="No public links."
@@ -63,12 +63,16 @@ export function SharesTable({
showProject = false,
canRevoke = true,
empty = "No public shares.",
loading = false,
}: {
shares: ShareInfo[];
onChanged: () => void;
showProject?: boolean;
canRevoke?: boolean;
empty?: string;
// An empty array is not a settled answer while the request is in flight, and
// "nothing is public" is the one wrong answer nobody should read for a frame.
loading?: boolean;
}) {
const [sorting, setSorting] = useState<SortingState>([]);
const col = useMemo(() => createColumnHelper<ShareInfo>(), []);
@@ -121,6 +125,15 @@ export function SharesTable({
getSortedRowModel: getSortedRowModel(),
});
// Same shell and metrics as the empty state, so the section is one row tall
// either way and doesn't jump when the data lands.
if (loading)
return (
<div className="admin-list">
<div className="admin-empty">Loading</div>
</div>
);
if (shares.length === 0)
return (
<div className="admin-list">
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-xRtrzl4p.js"></script>
<script type="module" crossorigin src="/assets/index-BeL6RCAd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C62PcDae.css">
</head>
<body>