fix(webapp): landing on / opens the project you last used (BEA-75) (#115)

Hitting the hub with no project in the URL always resolved to projects[0]
— alphabetically first, unrelated to what you were doing — and HubApp then
rewrote the address bar to it, so the wrong choice was the one that got
bookmarked.

The browser now remembers the project it last opened and prefers it. The
precedence chain gains one clause between the just-joined org and the
fallback; a remembered id is looked up in the project list, so one that was
deleted, or that this account can no longer see, simply doesn't match and
projects[0] takes over with nothing on screen to say so.

localStorage, per browser and origin-scoped, so two hubs never share an
answer. Both helpers swallow — storage throws in Safari private mode, and a
preference is never worth a broken page.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-04 18:16:54 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent e02bd5330d
commit 5330532f7f
7 changed files with 132 additions and 15 deletions
+1
View File
@@ -23,6 +23,7 @@ classDiagram
class HubApp {
project list, org walls
admin panels, invites
remembers last opened project (localStorage)
}
class VolumeApp {
thin wrapper: one volume
+31
View File
@@ -199,3 +199,34 @@ test("new-project modal cancels on Escape", async ({ page }) => {
await page.keyboard.press("Escape");
await expect(page.locator(".modal-input")).toHaveCount(0);
});
// BEA-75. Landing on "/" used to open whatever project sorts first, so a
// bookmark or a new tab threw away wherever you actually were. Both specs
// create or read state, so they sit at the end of the file with the other
// mutating ones.
test("landing returns to the last project opened, not the first one", async ({ page }) => {
await login(page);
// Named to sort last, so it can never be projects[0] — that is what makes
// the assertion mean anything.
const made = (await (await page.request.post("/api/projects", { data: { name: "zz-last" } })).json())
.project;
await page.goto("/" + made.id);
await expect(page.locator("#project-select")).toContainText("zz-last");
await page.goto("/"); // no project in the URL, the way a bookmark arrives
await page.waitForURL("/" + made.id);
await expect(page.locator("#project-select")).toContainText("zz-last");
});
test("a remembered project that is gone falls back silently", async ({ page }) => {
await login(page);
const errors: Error[] = [];
page.on("pageerror", (e) => errors.push(e));
await page.addInitScript(() =>
localStorage.setItem("bdrive.lastProject", "00000000-0000-0000-0000-000000000000"),
);
await page.goto("/");
await page.waitForURL(/\/[0-9a-f-]{36}$/);
await expect(page.locator("#project-select")).toContainText(/.+/);
await expect(page.locator("#toast.show, [data-sonner-toast]")).toHaveCount(0);
expect(errors).toEqual([]);
});
@@ -15,6 +15,7 @@ import { ConnectGuide } from "../components/ConnectGuide";
import { EmptyState } from "../components/EmptyState";
import { EXISTING, NewProjectDialog } from "../components/NewProjectDialog";
import { toast } from "../toast";
import { lastProject, rememberProject } from "../util";
import Browser from "./Browser";
export default function HubApp({ config }: { config: ServerConfig }) {
@@ -87,11 +88,17 @@ export default function HubApp({ config }: { config: ServerConfig }) {
/>
) : null;
// Precedence: the URL wins, then an org joined this page-load, then the
// project this browser last opened, then whatever sorts first. Looking the
// remembered id up in `projects` is the whole "deleted / access revoked /
// different account signed in" story — it simply doesn't match and the
// fallback takes over, with nothing on screen to say so.
const current: Project | null = useMemo(() => {
if (!projects) return null;
return (
projects.find((p) => p.id === route.project) ||
(joinedOrgId && projects.find((p) => p.org === joinedOrgId)) ||
projects.find((p) => p.id === lastProject()) ||
projects[0] ||
null
);
@@ -101,6 +108,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
document.title = current
? current.name + " — BearDrive"
: config.brand || "BearDrive";
// Fires on exactly the events that matter — sidebar, deep link, palette,
// post-create navigate — so the memory needs no subscription of its own.
if (current) rememberProject(current.id);
}, [current, config]);
if (joinToken) {
+53
View File
@@ -0,0 +1,53 @@
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
// Node has no localStorage, which is what makes the hostile-browser branches
// cheap to pin: the bare run IS the "storage missing" case.
import { test } from "node:test";
import assert from "node:assert/strict";
import { lastProject, rememberProject } from "./util.ts";
// Swap globalThis.localStorage for the length of one call, always putting the
// original back — later tests in the same process must not inherit a stub.
function withStorage(stub: unknown, fn: () => void) {
const g = globalThis as { localStorage?: unknown };
const had = "localStorage" in g;
const prev = g.localStorage;
g.localStorage = stub;
try {
fn();
} finally {
if (had) g.localStorage = prev;
else delete g.localStorage;
}
}
test("no storage at all: reads empty, writes stay silent", () => {
assert.equal(lastProject(), "");
assert.doesNotThrow(() => rememberProject("p1"));
});
test("round-trips the last project through storage", () => {
const m = new Map<string, string>();
withStorage(
{
getItem: (k: string) => m.get(k) ?? null,
setItem: (k: string, v: string) => void m.set(k, v),
},
() => {
assert.equal(lastProject(), "");
rememberProject("p1");
assert.equal(lastProject(), "p1");
rememberProject("p2");
assert.equal(lastProject(), "p2");
},
);
});
test("storage that throws is swallowed on both sides", () => {
const boom = () => {
throw new Error("SecurityError");
};
withStorage({ getItem: boom, setItem: boom }, () => {
assert.equal(lastProject(), "");
assert.doesNotThrow(() => rememberProject("p1"));
});
});
+22
View File
@@ -42,6 +42,28 @@ export async function copyText(text: string): Promise<boolean> {
return false;
}
/* Last project opened on this browser. localStorage throws in Safari's
private mode and wherever storage is disabled, so both sides swallow — a
preference is never worth a broken page. Origin-scoped already, so two
hubs never share an answer. */
const LAST_PROJECT = "bdrive.lastProject";
export function lastProject(): string {
try {
return localStorage.getItem(LAST_PROJECT) || "";
} catch {
return "";
}
}
export function rememberProject(id: string) {
try {
localStorage.setItem(LAST_PROJECT, id);
} catch {
/* preference only */
}
}
/* Who made a change, as history renders it everywhere: the account, with
the display name in front when the server knows one, falling back to the
git/OS identity of an offline device. */
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-DrjjYyEZ.js"></script>
<script type="module" crossorigin src="/assets/index-w2PDXp5a.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DKdbhP6i.css">
</head>
<body>