on master help sign in
Sort and name tabs the browser discarded to save memory

Discarded tabs report status "unloaded" and never finish a load, so auto-organize, naming and the fit check waited on them forever. They keep their title and address, which is all those steps need. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SsrrFmPFG4NfNt7omzSis

commitfae43a2f81cf4333279244616d03a933cd17b970 authorClaude <noreply@anthropic.com> dateSat 26 Sep 2026, 11:37 (2 weeks ago) parent8a02329
5 files changed+29−8
src/background/fit.ts+2−2 src/background/naming.ts+9−3 src/background/organize.ts+4−3 test/auto.test.ts+6 test/naming.test.ts+8
src/background/fit.ts+2−2
@@ -1,6 +1,6 @@
11import { isInternalUrl } from "../shared/url";
22import { fitCheckable } from "./engine";
3−import { toItems, type Member } from "./naming";
3+import { settled, toItems, type Member } from "./naming";
44import type { OrganizePayload, OrganizeResult } from "./organize";
55import { ungroup, type Runtime } from "./runtime";
66
@@ -75,7 +75,7 @@ export class FitChecker {
7575 if (pausedUntil && pausedUntil > rt.now()) return;
7676
7777 const others = (await chrome.tabs.query({ groupId: rec.groupId }).catch(() => []))
78− .filter((t) => t.id !== tabId && t.status === "complete" && !isInternalUrl(t.url))
78+ .filter((t) => t.id !== tabId && settled(t) && !isInternalUrl(t.url))
7979 .sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0))
8080 .slice(0, FIT_SAMPLE);
8181 if (others.length < MIN_OTHERS) return done();
src/background/naming.ts+9−3
@@ -49,9 +49,15 @@ export interface NameResult {
4949 emoji: string;
5050}
5151
52−/** Tabs that can describe a group: loaded web pages, not the new-tab page or internal pages. */
52+/** Tabs that can describe a group: web pages, not the new-tab page or internal pages. */
5353export const nameable = (m: Member): boolean => !isInternalUrl(m.url);
5454
55+/**
56+ * Done loading, or not loaded at all: a tab the browser discarded to save memory (status "unloaded") keeps
57+ * its title and address, so it can describe its group without being loaded again.
58+ */
59+export const settled = (t: { status?: string }): boolean => t.status !== "loading";
60+
5561/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
5662export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
5763 sha1(members.map((m) => `${m.url}|${m.title}`).sort().join("\n"));
@@ -214,7 +220,7 @@ export class Naming {
214220 }
215221 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
216222 if (!members) return;
217− if (members.length < 2 || members.some((m) => m.status !== "complete")) return; // stay dirty; next event or alarm
223+ if (members.length < 2 || !members.every(settled)) return; // stay dirty; the load finishing touches it again
218224 if (!force && membersHash(members) === g.membersHash) {
219225 g.dirty = false;
220226 this.d.commit();
@@ -252,7 +258,7 @@ export class Naming {
252258 if (!g || !eligible(g, settings)) return;
253259 // Built lazily: a group that changed three times while queued is described once.
254260 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
255− if (!members || members.length < 2 || members.some((m) => m.status !== "complete")) return;
261+ if (!members || members.length < 2 || !members.every(settled)) return;
256262 const hash = membersHash(members);
257263 if (!force && hash === g.membersHash) {
258264 g.dirty = false;
src/background/organize.ts+4−3
@@ -2,7 +2,7 @@ import { COLORS, isColor, type GroupColor } from "../shared/colors";
22import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
33import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
44import { pathKey } from "./engine";
5−import { membersHash, memberUrls, toItems, type Member, type NameItem } from "./naming";
5+import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
66import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
77import { markDirty, type State } from "./state";
88
@@ -377,8 +377,9 @@ export class AutoOrganizer {
377377 if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
378378 const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
379379 const s = rt.state();
380− // Still-loading tabs are skipped here; finishing the load touches the window again.
381− const loose = tabs.filter((t) => organizable(t) && t.status === "complete" && !s.tabs[t.id!]?.keepLoose);
380+ // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
381+ // their title and address, so they're sorted like loaded ones.
382+ const loose = tabs.filter((t) => organizable(t) && settled(t) && !s.tabs[t.id!]?.keepLoose);
382383 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
383384 if (!fresh.length) return;
384385 const { ids: existingIds, list: existingGroups } = existingGroupsFor(s, windowId);
test/auto.test.ts+6
@@ -109,6 +109,12 @@ describe("auto-organize", () => {
109109 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
110110 });
111111
112+ it("sorts tabs the browser discarded, which keep their title and address", async () => {
113+ const auto = setup([liveTab(1, { status: "unloaded" }), liveTab(2)]);
114+ await auto.run(1);
115+ expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 1", "Tab 2"]);
116+ });
117+
112118 it("one loose tab with nothing to join waits for company", async () => {
113119 const auto = setup([liveTab(1)]);
114120 await auto.run(1);
test/naming.test.ts+8
@@ -162,6 +162,14 @@ describe("naming queue", () => {
162162 expect(state.groups[7].dirty).toBe(true);
163163 });
164164
165+ it("names a group whose member the browser discarded to save memory", async () => {
166+ members = [member(1), member(2, { status: "unloaded" })];
167+ naming.touch(7);
168+ await settle();
169+ expect(calls).toHaveLength(1);
170+ expect(state.groups[7].dirty).toBe(false);
171+ });
172+
165173 it("new-tab pages do not count as members", async () => {
166174 members = [member(1), member(2, { url: "brave://newtab/" })];
167175 naming.touch(7);