feat: address bar reflects the focused Pro tab + live deep-link delivery to mounted surfaces

This commit is contained in:
Linus Rath
2026-08-20 15:35:41 +02:00
parent d83c83b0a9
commit 65e70a8088
11 changed files with 332 additions and 63 deletions
+47 -2
View File
@@ -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. */}
<ProTabFocusContext.Provider value={isActive && isFocused}>
{renderTabBody(tab)}
</ProTabFocusContext.Provider>
</div>
);
})}
@@ -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;
+47 -23
View File
@@ -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/<view>/<date>` moves the grid; `/calendar/event/<id>` 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(() => {
+43 -19
View File
@@ -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)
+28 -6
View File
@@ -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(() => {
+25 -1
View File
@@ -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;
+4 -1
View File
@@ -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]);
+18 -3
View File
@@ -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
+6 -7
View File
@@ -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<string | null>(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]);
+13
View File
@@ -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<boolean>(false);
export function useIsFocusedProTab(): boolean {
return useContext(ProTabFocusContext);
}
/**
* True when the current subtree renders inside a Pro pane.
*
+60
View File
@@ -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']);
});
});
+41 -1
View File
@@ -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/<id>` 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<AppSurface, string[]>();
type PendingDeepLinkListener = (segments: string[]) => void;
const listeners = new Map<AppSurface, Set<PendingDeepLinkListener>>();
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();
}