From ff5095e71f33148c1517883b732ff382cbbe7b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 02:26:37 +0000 Subject: [PATCH] a tab name should survive coming back to the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/apps/officer-web/state/usePageTitle.ts | 89 +++++++++++++++++----- 1 file changed, 70 insertions(+), 19 deletions(-) diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index be4a2cd8..7db4ce2c 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useLocation } from 'react-router'; 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. 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. */ -function readTabLabel(): string | null { +function readStored(key: string): string | null { try { - return sessionStorage.getItem(TAB_LABEL_KEY); + return sessionStorage.getItem(key); } catch { return null; } } -function writeTabLabel(label: string | null): void { +function writeStored(key: string, value: string | null): void { try { - if (label) sessionStorage.setItem(TAB_LABEL_KEY, label); - else sessionStorage.removeItem(TAB_LABEL_KEY); + if (value) sessionStorage.setItem(key, value); + else sessionStorage.removeItem(key); } 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 — * 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. * - * A duplicate is a fresh navigation and a refresh is not, so `navigation.type` separates them: only a - * `navigate` arriving with a name it did not earn is a clone. A brand-new tab is `navigate` too, but has - * nothing stored, so it is untouched. + * This used to key off `navigation.type`, on the theory that a duplicate is a fresh navigation and a + * refresh is not. It is not that simple: **only F5/Ctrl-R reports `reload`.** Pressing Enter in the + * 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 - * way, the copy simply keeps the name — redundant, not wrong. + * So ask instead of guess. Each tab holds an id beside the name, and a copy is a tab whose id is *still + * 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 { - try { - const [nav] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[]; - if (nav?.type === 'navigate' && readTabLabel()) writeTabLabel(null); - } catch { - /* leave it alone */ +function claimTabIdentity(): void { + let tabId = readStored(TAB_ID_KEY); + if (!tabId) { + // A tab with no id has nothing inherited to disown — it cannot be a copy of anything. + writeStored(TAB_ID_KEY, newTabId()); + 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. -dropInheritedTabLabel(); +claimTabIdentity(); /** * 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 [label, setLabel] = useGlobal('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. const rename = useCallback( (next: string) => {