From 65e70a80886ed2b78063fa231b92ce9a0caa95cf Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 20 Aug 2026 15:35:41 +0200
Subject: [PATCH] feat: address bar reflects the focused Pro tab + live
deep-link delivery to mounted surfaces
---
app/(main)/[locale]/pro/page.tsx | 49 +++++++++++++++-
components/calendar/calendar-app.tsx | 70 +++++++++++++++--------
components/contacts/contacts-app.tsx | 62 ++++++++++++++------
components/files/files-app.tsx | 34 +++++++++--
components/mail/mail-app.tsx | 26 ++++++++-
components/pro/pro-interface-redirect.tsx | 5 +-
components/settings/settings-app.tsx | 21 ++++++-
hooks/use-deep-link-url.ts | 13 ++---
hooks/use-pane-context.ts | 13 +++++
lib/__tests__/deep-link-handoff.test.ts | 60 +++++++++++++++++++
lib/deep-link-handoff.ts | 42 +++++++++++++-
11 files changed, 332 insertions(+), 63 deletions(-)
create mode 100644 lib/__tests__/deep-link-handoff.test.ts
diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx
index d663b7d10..6df1960c8 100644
--- a/app/(main)/[locale]/pro/page.tsx
+++ b/app/(main)/[locale]/pro/page.tsx
@@ -13,7 +13,9 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { PaneSizeContext } from "@/hooks/use-pane-size";
-import { PaneIdContext } from "@/hooks/use-pane-context";
+import { PaneIdContext, ProTabFocusContext } from "@/hooks/use-pane-context";
+import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
+import { appPath, buildMailPath } from "@/lib/deep-links";
import { ProTabBar } from "@/components/pro/pro-tab-bar";
import {
PRO_TAB_DRAG_MIME,
@@ -163,7 +165,11 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
- {renderTabBody(tab)}
+ {/* Exactly one tab across both panes is "focused" - it owns
+ shared single-instance concerns like the address bar. */}
+
+ {renderTabBody(tab)}
+
);
})}
@@ -262,6 +268,45 @@ export default function ProHome() {
return tabs.find((t) => t.id === id) ?? null;
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
+ // ---- Address bar: reflect the focused tab's permalink ----
+ // Mail-family tabs are reflected here (MailApp's own URL machinery is
+ // suppressed when embedded); calendar/contacts/files/settings write their
+ // own, more precise paths via useDeepLinkUrl gated on ProTabFocusContext.
+ // replaceState only, and ProInterfaceRedirect doesn't observe it - a reload
+ // of the shown URL goes through the redirect's handoff and restores the view.
+ const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
+ const selectedEmailId = useEmailStore((s) => s.selectedEmail?.id ?? null);
+ const mailboxes = useEmailStore((s) => s.mailboxes);
+ const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
+ const reflectedPath = useMemo(() => {
+ if (!isAuthenticated || !client || !focusedActiveTab) return null;
+ switch (focusedActiveTab.kind) {
+ case 'mail':
+ return appPath(buildMailPath(
+ { mailboxId: selectedMailbox || null, emailId: selectedEmailId, threadId: null },
+ mailboxes,
+ ));
+ case 'email':
+ return focusedActiveTab.emailData
+ ? appPath(buildMailPath({ mailboxId: null, emailId: focusedActiveTab.emailData.emailId, threadId: null }))
+ : null;
+ case 'folder': {
+ const data = focusedActiveTab.folderData;
+ if (!data) return null;
+ const list = data.accountId ? accountMailboxes[data.accountId] ?? [] : mailboxes;
+ const path = appPath(buildMailPath({ mailboxId: data.mailboxId, emailId: null, threadId: null }, list));
+ return data.accountId ? `${path}?account=${encodeURIComponent(data.accountId)}` : path;
+ }
+ case 'compose':
+ // A draft has no permalink - fall back to the shell's own route so
+ // the bar never shows a message the user is no longer looking at.
+ return appPath('/pro');
+ default:
+ return null;
+ }
+ }, [isAuthenticated, client, focusedActiveTab, selectedMailbox, selectedEmailId, mailboxes, accountMailboxes]);
+ useDeepLinkUrl(reflectedPath);
+
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
openTab(itemId);
return true;
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index b089b2004..88bbab1c7 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -48,6 +48,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
+import { useIsFocusedProTab } from "@/hooks/use-pane-context";
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
@@ -67,8 +68,8 @@ import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-ca
import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
-import { appPath, buildCalendarPath, parseCalendarPath } from "@/lib/deep-links";
-import { consumePendingDeepLink } from "@/lib/deep-link-handoff";
+import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links";
+import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
@@ -591,22 +592,16 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
// `/calendar//` moves the grid; `/calendar/event/` opens the
// event's sidebar and parks the grid on the day it starts. Applied once the
// JMAP session is up, then the URL becomes an output of the view (below).
- const deepLinkHandledRef = useRef(false);
- useEffect(() => {
- if (deepLinkHandledRef.current) return;
- if (!isAuthenticated || !client) return;
-
- const segments = linkSegments ?? consumePendingDeepLink('calendar') ?? [];
- const link = parseCalendarPath(segments, new URLSearchParams(window.location.search));
- deepLinkHandledRef.current = true;
- if (!link) return;
-
+ // Held in a ref so the live-delivery subscription (Pro shell) always calls
+ // the copy that closes over the current client and handlers.
+ const applyCalendarDeepLink = (link: CalendarDeepLink) => {
if (link.kind === 'view') {
setViewMode(link.view);
if (link.date) setSelectedDate(link.date);
return;
}
+ if (!client) return;
void (async () => {
try {
const event = await client.getCalendarEvent(link.id, link.accountId);
@@ -622,21 +617,50 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
toast.error(tDeepLink('event_not_found'));
}
})();
- }, [isAuthenticated, client, linkSegments, setViewMode, setSelectedDate, openEditModal, tDeepLink]);
+ };
+ const applyCalendarDeepLinkRef = useRef(applyCalendarDeepLink);
+ applyCalendarDeepLinkRef.current = applyCalendarDeepLink;
- // The permalink for what the calendar is currently showing. Suppressed in
- // the Pro shell, where /pro owns the address bar.
+ const deepLinkHandledRef = useRef(false);
+ useEffect(() => {
+ if (deepLinkHandledRef.current) return;
+ if (!isAuthenticated || !client) return;
+
+ const segments = linkSegments ?? consumePendingDeepLink('calendar') ?? [];
+ const link = parseCalendarPath(segments, new URLSearchParams(window.location.search));
+ deepLinkHandledRef.current = true;
+ if (!link) return;
+ applyCalendarDeepLinkRef.current(link);
+ }, [isAuthenticated, client, linkSegments]);
+
+ // Pro shell only: this surface stays mounted for the whole session, so links
+ // arriving after mount are delivered live instead of being parked forever.
+ // Embedded-only: during a cold-load redirect the standard instance renders
+ // briefly and must not steal the link parked for the Pro one.
+ useEffect(() => {
+ if (!isEmbedded) return;
+ return subscribePendingDeepLink('calendar', (segments) => {
+ const link = parseCalendarPath(segments, new URLSearchParams(window.location.search));
+ if (link) applyCalendarDeepLinkRef.current(link);
+ });
+ }, [isEmbedded]);
+
+ // The permalink for what the calendar is currently showing. In the Pro
+ // shell only the focused tab writes the address bar; the standard instance
+ // that renders while Pro takes over a route stays silent.
const proInterfaceActive = useProInterfaceActive();
+ const isFocusedProTab = useIsFocusedProTab();
const eventLinkId = showEventModal && editEvent ? editEvent.id : null;
+ const calendarLinkPath = appPath(buildCalendarPath({
+ view: normalizedViewMode,
+ date: selectedDate,
+ eventId: eventLinkId,
+ accountId: eventLinkId ? editEvent?.accountId : null,
+ }));
useDeepLinkUrl(
- isEmbedded || proInterfaceActive
- ? null
- : appPath(buildCalendarPath({
- view: normalizedViewMode,
- date: selectedDate,
- eventId: eventLinkId,
- accountId: eventLinkId ? editEvent?.accountId : null,
- })),
+ isEmbedded
+ ? (isFocusedProTab ? calendarLinkPath : null)
+ : proInterfaceActive ? null : calendarLinkPath,
);
const handleEditFromDetail = useCallback(() => {
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx
index 5699aa47d..a9def7097 100644
--- a/components/contacts/contacts-app.tsx
+++ b/components/contacts/contacts-app.tsx
@@ -31,14 +31,15 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
+import { useIsFocusedProTab } from "@/hooks/use-pane-context";
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
-import { appPath, buildContactsPath, parseContactsPath } from "@/lib/deep-links";
-import { consumePendingDeepLink } from "@/lib/deep-link-handoff";
+import { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } from "@/lib/deep-links";
+import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
@@ -181,15 +182,9 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) {
// plus the older one-shot query form the mobile recipient popover emits
// (`?contactId=`, `?addEmail=`). `from=email` flips the mobile back button to
// `router.back()`. After this the URL is an output of the view, not an input.
- useEffect(() => {
- if (intentAppliedRef.current) return;
- const segments = linkSegments ?? consumePendingDeepLink('contacts') ?? [];
- const link = parseContactsPath(
- segments,
- new URLSearchParams(searchParams.toString()),
- );
- intentAppliedRef.current = true;
- if (!link) return;
+ // The applier is held in a ref so the live-delivery subscription below (Pro
+ // shell) always calls the copy closing over the current setters.
+ const applyContactsDeepLink = (link: ContactDeepLink) => {
if (link.fromEmail) setReturnToEmail(true);
if (link.kind === 'contact') {
setSelectedContact(link.id);
@@ -199,18 +194,47 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) {
setSelectedContact(null);
setView('create');
}
+ };
+ const applyContactsDeepLinkRef = useRef(applyContactsDeepLink);
+ applyContactsDeepLinkRef.current = applyContactsDeepLink;
+
+ useEffect(() => {
+ if (intentAppliedRef.current) return;
+ const segments = linkSegments ?? consumePendingDeepLink('contacts') ?? [];
+ const link = parseContactsPath(
+ segments,
+ new URLSearchParams(searchParams.toString()),
+ );
+ intentAppliedRef.current = true;
+ if (!link) return;
+ applyContactsDeepLinkRef.current(link);
}, [searchParams, linkSegments, setSelectedContact]);
- // The permalink for the contact on screen. Suppressed inside the Pro shell,
- // where /pro owns the address bar.
+ // Pro shell only: this surface stays mounted for the whole session, so links
+ // arriving after mount are delivered live instead of being parked forever.
+ // Embedded-only: during a cold-load redirect the standard instance renders
+ // briefly and must not steal the link parked for the Pro one.
+ useEffect(() => {
+ if (!isEmbedded) return;
+ return subscribePendingDeepLink('contacts', (segments) => {
+ const link = parseContactsPath(segments, new URLSearchParams(window.location.search));
+ if (link) applyContactsDeepLinkRef.current(link);
+ });
+ }, [isEmbedded]);
+
+ // The permalink for the contact on screen. In the Pro shell only the
+ // focused tab writes the address bar; the standard instance that renders
+ // while Pro takes over a route stays silent.
const proInterfaceActive = useProInterfaceActive();
+ const isFocusedProTab = useIsFocusedProTab();
+ const contactsLinkPath = appPath(buildContactsPath({
+ contactId: view === 'detail' || view === 'edit' ? selectedContactId : null,
+ editing: view === 'edit',
+ }));
useDeepLinkUrl(
- isEmbedded || proInterfaceActive
- ? null
- : appPath(buildContactsPath({
- contactId: view === 'detail' || view === 'edit' ? selectedContactId : null,
- editing: view === 'edit',
- })),
+ isEmbedded
+ ? (isFocusedProTab ? contactsLinkPath : null)
+ : proInterfaceActive ? null : contactsLinkPath,
);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx
index 49ed5167a..979127dc9 100644
--- a/components/files/files-app.tsx
+++ b/components/files/files-app.tsx
@@ -18,6 +18,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
+import { useIsFocusedProTab } from "@/hooks/use-pane-context";
import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store";
@@ -31,7 +32,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle, Loader2 } from "lucide-react";
import { isFilePreviewable } from "@/lib/file-preview";
import { appPath, buildFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links";
-import { consumePendingDeepLink } from "@/lib/deep-link-handoff";
+import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
@@ -212,6 +213,24 @@ export function FilesApp({ linkSegments }: FilesAppProps = {}) {
return link;
}, []);
+ // Pro shell only: this surface stays mounted for the whole session, so links
+ // arriving after mount are delivered live instead of being parked forever.
+ // The bootstrap has already run by then - walk the drive directly and let
+ // the preview effect below claim `pendingPreviewRef` once the listing lands.
+ // Embedded-only: during a cold-load redirect the standard instance renders
+ // briefly and must not steal the link parked for the Pro one.
+ const navigateByPathRef = useRef(navigateByPath);
+ navigateByPathRef.current = navigateByPath;
+ useEffect(() => {
+ if (!isEmbedded) return;
+ return subscribePendingDeepLink('files', (segments) => {
+ const link = parseFilesPath(segments, new URLSearchParams(window.location.search));
+ if (!link) return;
+ if (link.preview) pendingPreviewRef.current = link.preview;
+ void navigateByPathRef.current(link.path);
+ });
+ }, [isEmbedded]);
+
// Check support and load the first listing after the client is initialized.
// One effect, run once: `checkSupport` publishes `supportsFiles` before it
// resolves, so splitting the cold path from the warm one would have them
@@ -480,13 +499,16 @@ export function FilesApp({ linkSegments }: FilesAppProps = {}) {
setShowDetails(true);
}, []);
- // The permalink for the folder on screen. Suppressed inside the Pro shell,
- // where /pro owns the address bar, and at the account picker (no path yet).
+ // The permalink for the folder on screen. In the Pro shell only the focused
+ // tab writes the address bar; the standard instance that renders while Pro
+ // takes over a route stays silent.
const proInterfaceActive = useProInterfaceActive();
+ const isFocusedProTab = useIsFocusedProTab();
+ const filesLinkPath = appPath(buildFilesPath(currentPath, previewFile));
useDeepLinkUrl(
- isEmbedded || proInterfaceActive
- ? null
- : appPath(buildFilesPath(currentPath, previewFile)),
+ isEmbedded
+ ? (isFocusedProTab ? filesLinkPath : null)
+ : proInterfaceActive ? null : filesLinkPath,
);
const handleToggleDetails = useCallback(() => {
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 250f19e15..882fe3586 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -105,7 +105,7 @@ import {
resolveFolderRef,
type MailDeepLink,
} from "@/lib/deep-links";
-import { consumePendingDeepLink } from "@/lib/deep-link-handoff";
+import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import type { QuoteHeader } from "@/lib/plugin-types";
@@ -1277,6 +1277,17 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
}
if (link.kind === 'message') {
+ // In the Pro shell a message permalink opens as its own fullscreen
+ // email tab - the tab body fetches the message and retitles itself.
+ if (isEmbedded) {
+ useProTabStore.getState().openEmailTab({
+ accountId: '',
+ emailId: link.id,
+ mailboxId: null,
+ title: t('common.loading'),
+ });
+ return;
+ }
setLoadingEmail(true);
try {
const email = await fetchEmailContent(activeClient, link.id);
@@ -1337,6 +1348,19 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
const applyMailDeepLinkRef = useRef(applyMailDeepLink);
applyMailDeepLinkRef.current = applyMailDeepLink;
+ // Pro shell only: this MailApp stays mounted for the whole session, so the
+ // mount-time consume below never sees a link that arrives later (an in-app
+ // navigation ProInterfaceRedirect intercepts). Subscribe for live delivery.
+ // Embedded-only: during a cold-load redirect the standard instance renders
+ // briefly and must not steal the link parked for the Pro one.
+ useEffect(() => {
+ if (!isEmbedded) return;
+ return subscribePendingDeepLink('mail', (segments) => {
+ const link = parseMailPath(segments, new URLSearchParams(window.location.search));
+ if (link) void applyMailDeepLinkRef.current(link);
+ });
+ }, [isEmbedded]);
+
useEffect(() => {
if (deepLinkHandledRef.current) return;
if (!isAuthenticated || !client) return;
diff --git a/components/pro/pro-interface-redirect.tsx b/components/pro/pro-interface-redirect.tsx
index 5152656e4..faf802474 100644
--- a/components/pro/pro-interface-redirect.tsx
+++ b/components/pro/pro-interface-redirect.tsx
@@ -48,10 +48,13 @@ export function ProInterfaceRedirect() {
if (!proInterface || !isDesktop) return;
const match = matchSurface(pathname);
if (!match) return;
+ // Focus the tab before handing over the link: when the surface is already
+ // mounted the handoff delivers live, and its handler may itself open a
+ // tab (a message permalink opens an email tab) that must keep focus.
+ useProTabStore.getState().openTab(SURFACE_TO_TAB[match.surface]);
if (match.segments.length > 0) {
setPendingDeepLink(match.surface, match.segments);
}
- useProTabStore.getState().openTab(SURFACE_TO_TAB[match.surface]);
router.replace('/pro');
}, [proInterface, isDesktop, pathname, router]);
diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx
index 2194a3776..f33b28ca7 100644
--- a/components/settings/settings-app.tsx
+++ b/components/settings/settings-app.tsx
@@ -70,7 +70,7 @@ import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { appPath, buildSettingsPath, parseSettingsPath } from '@/lib/deep-links';
-import { consumePendingDeepLink } from '@/lib/deep-link-handoff';
+import { consumePendingDeepLink, subscribePendingDeepLink } from '@/lib/deep-link-handoff';
import { useDeepLinkUrl } from '@/hooks/use-deep-link-url';
import { useProInterfaceActive } from '@/components/pro/pro-interface-redirect';
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
@@ -85,6 +85,7 @@ import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
+import { useIsFocusedProTab } from '@/hooks/use-pane-context';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
@@ -434,6 +435,16 @@ export function SettingsApp({ linkSegments }: SettingsAppProps = {}) {
const tab = tabFromDeepLink(consumePendingDeepLink('settings'));
if (tab) setActiveTab(tab);
}, [linkSegments]);
+ // Pro shell only: this surface stays mounted for the whole session, so links
+ // arriving later (e.g. the sidebar's folder-settings gear) are delivered
+ // live instead of being parked for a mount that already happened.
+ useEffect(() => {
+ if (!isEmbedded) return;
+ return subscribePendingDeepLink('settings', (segments) => {
+ const tab = tabFromDeepLink(segments);
+ if (tab) setActiveTab(tab);
+ });
+ }, [isEmbedded]);
const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
@@ -652,10 +663,14 @@ export function SettingsApp({ linkSegments }: SettingsAppProps = {}) {
// so the tab is only in the URL once the user is looking at its content.
// Must sit above the early return below - it is a hook.
const proInterfaceActive = useProInterfaceActive();
+ const isFocusedProTab = useIsFocusedProTab();
+ const settingsLinkPath = appPath(buildSettingsPath(!isDesktop && !mobileShowContent ? null : activeTab));
useDeepLinkUrl(
- !isAuthenticated || isEmbedded || proInterfaceActive
+ !isAuthenticated
? null
- : appPath(buildSettingsPath(!isDesktop && !mobileShowContent ? null : activeTab)),
+ : isEmbedded
+ ? (isFocusedProTab ? settingsLinkPath : null)
+ : proInterfaceActive ? null : settingsLinkPath,
);
// Park the content-pane scroll offset when the Pro toggle flips (the flip
diff --git a/hooks/use-deep-link-url.ts b/hooks/use-deep-link-url.ts
index a5c829149..7cc6a84b3 100644
--- a/hooks/use-deep-link-url.ts
+++ b/hooks/use-deep-link-url.ts
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef } from "react";
+import { useEffect } from "react";
/**
* Keeps the address bar in step with what a surface is showing (#733), so the
@@ -11,20 +11,19 @@ import { useEffect, useRef } from "react";
* than not pushing at all - the URL would change while the view sat still. Mail
* is the exception and drives its own history through `useBrowserNavigation`.
*
- * Pass `null` to leave the URL alone (inside the Pro shell, where the route is
- * /pro and the tab strip owns the address bar).
+ * Pass `null` to leave the URL alone (an unfocused Pro tab, whose focused
+ * sibling owns the address bar). Dedupe is against the *actual* location, not
+ * a remembered last write: in the Pro shell several surfaces take turns
+ * writing the URL, so what this hook wrote last says nothing about what the
+ * bar currently shows.
*
* The existing history state is preserved so Next's router bookkeeping - which
* lives in `window.history.state` - survives the rewrite.
*/
export function useDeepLinkUrl(path: string | null): void {
- const lastRef = useRef(null);
-
useEffect(() => {
if (typeof window === "undefined") return;
if (!path) return;
- if (lastRef.current === path) return;
- lastRef.current = path;
if (`${window.location.pathname}${window.location.search}` === path) return;
window.history.replaceState(window.history.state, "", path);
}, [path]);
diff --git a/hooks/use-pane-context.ts b/hooks/use-pane-context.ts
index d1838f4ec..cda7550b8 100644
--- a/hooks/use-pane-context.ts
+++ b/hooks/use-pane-context.ts
@@ -16,6 +16,19 @@ export function usePaneId(): ProPaneId | null {
return useContext(PaneIdContext);
}
+/**
+ * True when the current subtree is the *focused* Pro tab: the active tab of
+ * the focused pane. Exactly one tab body sees `true` at any time, which is
+ * what lets that tab own shared, single-instance concerns - currently the
+ * address bar, where the focused tab's permalink is reflected. Always false
+ * outside the Pro shell.
+ */
+export const ProTabFocusContext = createContext(false);
+
+export function useIsFocusedProTab(): boolean {
+ return useContext(ProTabFocusContext);
+}
+
/**
* True when the current subtree renders inside a Pro pane.
*
diff --git a/lib/__tests__/deep-link-handoff.test.ts b/lib/__tests__/deep-link-handoff.test.ts
new file mode 100644
index 000000000..836c47847
--- /dev/null
+++ b/lib/__tests__/deep-link-handoff.test.ts
@@ -0,0 +1,60 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ setPendingDeepLink,
+ consumePendingDeepLink,
+ subscribePendingDeepLink,
+ clearPendingDeepLinks,
+} from '@/lib/deep-link-handoff';
+
+describe('deep-link handoff', () => {
+ beforeEach(() => {
+ clearPendingDeepLinks();
+ });
+
+ it('parks segments and yields them exactly once', () => {
+ setPendingDeepLink('mail', ['message', 'abc']);
+ expect(consumePendingDeepLink('mail')).toEqual(['message', 'abc']);
+ expect(consumePendingDeepLink('mail')).toBeNull();
+ });
+
+ it('keeps surfaces separate', () => {
+ setPendingDeepLink('mail', ['message', 'abc']);
+ setPendingDeepLink('settings', ['folders']);
+ expect(consumePendingDeepLink('settings')).toEqual(['folders']);
+ expect(consumePendingDeepLink('mail')).toEqual(['message', 'abc']);
+ });
+
+ it('delivers live to a subscribed surface instead of parking', () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribePendingDeepLink('mail', listener);
+
+ setPendingDeepLink('mail', ['message', 'abc']);
+
+ expect(listener).toHaveBeenCalledWith(['message', 'abc']);
+ // Delivered, not parked: a later mount finds nothing.
+ expect(consumePendingDeepLink('mail')).toBeNull();
+ unsubscribe();
+ });
+
+ it('a listener only receives its own surface', () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribePendingDeepLink('mail', listener);
+
+ setPendingDeepLink('settings', ['folders']);
+
+ expect(listener).not.toHaveBeenCalled();
+ expect(consumePendingDeepLink('settings')).toEqual(['folders']);
+ unsubscribe();
+ });
+
+ it('unsubscribing restores parking', () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribePendingDeepLink('mail', listener);
+ unsubscribe();
+
+ setPendingDeepLink('mail', ['folder', 'inbox']);
+
+ expect(listener).not.toHaveBeenCalled();
+ expect(consumePendingDeepLink('mail')).toEqual(['folder', 'inbox']);
+ });
+});
diff --git a/lib/deep-link-handoff.ts b/lib/deep-link-handoff.ts
index e1d1b3e19..630631645 100644
--- a/lib/deep-link-handoff.ts
+++ b/lib/deep-link-handoff.ts
@@ -1,7 +1,7 @@
import type { AppSurface } from '@/lib/deep-links';
/**
- * One-shot handoff for a deep link that has to survive a route change (#733).
+ * Handoff for a deep link that has to survive a route change (#733).
*
* The Pro shell owns a single route (`/pro`) and renders each surface inside a
* tab, so a permalink like `/mail/message/` can't reach the surface as a
@@ -9,12 +9,30 @@ import type { AppSurface } from '@/lib/deep-links';
* parks the segments here, opens the right tab, and the surface picks them up
* on mount - exactly like `protocol-handlers/session` does for `mailto:`.
*
+ * Surfaces in the Pro shell stay mounted for the whole session, so a link that
+ * arrives *while the shell is already running* (an in-app link to
+ * `/settings/folders`, a `mailto:`-style handoff, ...) would be parked and
+ * never seen - the mount-time consume has long since run. For that case a
+ * mounted surface subscribes here: when a listener exists, `setPendingDeepLink`
+ * delivers the segments to it directly instead of parking them.
+ *
* Module state, deliberately: it must not survive a reload (the URL is gone by
* then, so re-applying it would reopen something the user already closed).
*/
const pending = new Map();
+type PendingDeepLinkListener = (segments: string[]) => void;
+
+const listeners = new Map>();
+
export function setPendingDeepLink(surface: AppSurface, segments: string[]): void {
+ const live = listeners.get(surface);
+ if (live && live.size > 0) {
+ // A mounted surface is listening - deliver instead of parking, so links
+ // arriving while the Pro shell is already running still land.
+ for (const listener of [...live]) listener(segments);
+ return;
+ }
pending.set(surface, segments);
}
@@ -26,6 +44,28 @@ export function consumePendingDeepLink(surface: AppSurface): string[] | null {
return segments;
}
+/**
+ * Live delivery for an already-mounted surface (the Pro shell keeps surfaces
+ * mounted for the whole session). Returns the unsubscribe function. Only the
+ * embedded (Pro) instance of a surface should subscribe - during a cold-load
+ * redirect the standard instance briefly renders too and must not steal the
+ * link parked for the Pro instance that mounts right after.
+ */
+export function subscribePendingDeepLink(
+ surface: AppSurface,
+ listener: PendingDeepLinkListener,
+): () => void {
+ let set = listeners.get(surface);
+ if (!set) {
+ set = new Set();
+ listeners.set(surface, set);
+ }
+ set.add(listener);
+ return () => {
+ set.delete(listener);
+ };
+}
+
export function clearPendingDeepLinks(): void {
pending.clear();
}