a tab name should survive coming back to the page
The clone check keyed off `navigation.type`, which only reports `reload` for F5/Ctrl-R. Every other way back into the app — Enter in the address bar, a link, re-opening the URL after the server was down — is `navigate`, and threw away the name you typed. Ask instead of guess: each tab holds an id beside its name, and a copy is a tab whose id is still held by a live tab, which the original says over a BroadcastChannel. A refresh has nobody to answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
|
||||||
@@ -53,48 +53,93 @@ export function titleForPath(pathname: string): string {
|
|||||||
// name. localStorage would be wrong in the obvious way: every tab would share one name.
|
// name. localStorage would be wrong in the obvious way: every tab would share one name.
|
||||||
|
|
||||||
const TAB_LABEL_KEY = 'OFFICER_TAB_LABEL';
|
const TAB_LABEL_KEY = 'OFFICER_TAB_LABEL';
|
||||||
|
const TAB_ID_KEY = 'OFFICER_TAB_ID';
|
||||||
|
const IDENTITY_CHANNEL = 'officer-tab-identity';
|
||||||
|
|
||||||
/** Storage can throw outright (private mode, storage disabled). A tab name is not worth a crash. */
|
/** Storage can throw outright (private mode, storage disabled). A tab name is not worth a crash. */
|
||||||
function readTabLabel(): string | null {
|
function readStored(key: string): string | null {
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(TAB_LABEL_KEY);
|
return sessionStorage.getItem(key);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeTabLabel(label: string | null): void {
|
function writeStored(key: string, value: string | null): void {
|
||||||
try {
|
try {
|
||||||
if (label) sessionStorage.setItem(TAB_LABEL_KEY, label);
|
if (value) sessionStorage.setItem(key, value);
|
||||||
else sessionStorage.removeItem(TAB_LABEL_KEY);
|
else sessionStorage.removeItem(key);
|
||||||
} catch {
|
} catch {
|
||||||
/* the name just doesn't persist */
|
/* the value just doesn't persist */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readTabLabel = (): string | null => readStored(TAB_LABEL_KEY);
|
||||||
|
const writeTabLabel = (label: string | null): void => writeStored(TAB_LABEL_KEY, label);
|
||||||
|
|
||||||
|
const labelDroppedHandlers = new Set<() => void>();
|
||||||
|
|
||||||
|
/** The clone check answers late, so React may already be showing the inherited name when it lands. */
|
||||||
|
function onTabLabelDropped(handler: () => void): () => void {
|
||||||
|
labelDroppedHandlers.add(handler);
|
||||||
|
return () => labelDroppedHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duplicating a tab **clones its sessionStorage**, so the copy would open wearing the original's name —
|
* Duplicating a tab **clones its sessionStorage**, so the copy would open wearing the original's name —
|
||||||
* two tabs called "Platform Arch", which is precisely what naming one was meant to prevent. Same for a
|
* two tabs called "Platform Arch", which is precisely what naming one was meant to prevent. Same for a
|
||||||
* tab opened by `window.open` from inside the app.
|
* tab opened by `window.open` from inside the app.
|
||||||
*
|
*
|
||||||
* A duplicate is a fresh navigation and a refresh is not, so `navigation.type` separates them: only a
|
* This used to key off `navigation.type`, on the theory that a duplicate is a fresh navigation and a
|
||||||
* `navigate` arriving with a name it did not earn is a clone. A brand-new tab is `navigate` too, but has
|
* refresh is not. It is not that simple: **only F5/Ctrl-R reports `reload`.** Pressing Enter in the
|
||||||
* nothing stored, so it is untouched.
|
* address bar, following a link back into the app, and re-opening the URL after the server was down are
|
||||||
|
* all `navigate`, so every one of them threw the name away. Losing a name you typed is far worse than a
|
||||||
|
* copy keeping one.
|
||||||
*
|
*
|
||||||
* It fails soft on purpose. If the timing entry is missing, or a browser labels duplication some other
|
* So ask instead of guess. Each tab holds an id beside the name, and a copy is a tab whose id is *still
|
||||||
* way, the copy simply keeps the name — redundant, not wrong.
|
* held by a tab that is alive* — which the original can simply say, over a BroadcastChannel. A refresh
|
||||||
|
* has nobody to answer: the old document is gone before the new one's scripts run.
|
||||||
|
*
|
||||||
|
* Fails soft, in the direction of keeping the name: no channel, or nobody answering, means no clone.
|
||||||
*/
|
*/
|
||||||
function dropInheritedTabLabel(): void {
|
function claimTabIdentity(): void {
|
||||||
try {
|
let tabId = readStored(TAB_ID_KEY);
|
||||||
const [nav] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
|
if (!tabId) {
|
||||||
if (nav?.type === 'navigate' && readTabLabel()) writeTabLabel(null);
|
// A tab with no id has nothing inherited to disown — it cannot be a copy of anything.
|
||||||
} catch {
|
writeStored(TAB_ID_KEY, newTabId());
|
||||||
/* leave it alone */
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let channel: BroadcastChannel;
|
||||||
|
try {
|
||||||
|
channel = new BroadcastChannel(IDENTITY_CHANNEL);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept open for the life of the document: this tab is also the one that answers for its own id later.
|
||||||
|
channel.onmessage = (ev: MessageEvent<{ kind: 'claim' | 'taken'; tabId: string }>) => {
|
||||||
|
if (ev.data?.tabId !== tabId) return;
|
||||||
|
if (ev.data.kind === 'claim') {
|
||||||
|
channel.postMessage({ kind: 'taken', tabId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Someone alive is already this tab, so we are the copy. Take a new id and give up the name.
|
||||||
|
tabId = newTabId();
|
||||||
|
writeStored(TAB_ID_KEY, tabId);
|
||||||
|
writeTabLabel(null);
|
||||||
|
for (const handler of labelDroppedHandlers) handler();
|
||||||
|
};
|
||||||
|
channel.postMessage({ kind: 'claim', tabId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */
|
||||||
|
function newTabId(): string {
|
||||||
|
return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once per document, before React reads the stored name.
|
// Once per document, before React reads the stored name.
|
||||||
dropInheritedTabLabel();
|
claimTabIdentity();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The title shown in the header and in the browser tab, plus the setter behind the header's edit.
|
* The title shown in the header and in the browser tab, plus the setter behind the header's edit.
|
||||||
@@ -108,6 +153,12 @@ export function usePageTitle() {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [label, setLabel] = useGlobal<string | null>('TAB_LABEL', readTabLabel);
|
const [label, setLabel] = useGlobal<string | null>('TAB_LABEL', readTabLabel);
|
||||||
|
|
||||||
|
// `setLabel` is rebuilt every render, so it is read through a ref rather than listed as a dependency —
|
||||||
|
// as a dependency it would tear the subscription down and rebuild it on every render.
|
||||||
|
const setLabelRef = useRef(setLabel);
|
||||||
|
setLabelRef.current = setLabel;
|
||||||
|
useEffect(() => onTabLabelDropped(() => setLabelRef.current(null)), []);
|
||||||
|
|
||||||
// The React Query copy is what re-renders every consumer; sessionStorage is what survives the reload.
|
// The React Query copy is what re-renders every consumer; sessionStorage is what survives the reload.
|
||||||
const rename = useCallback(
|
const rename = useCallback(
|
||||||
(next: string) => {
|
(next: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user