(feat) chain app nav to browser history state. Closes #250.

Hook browser back/forward to nav state
This commit is contained in:
Jack Kingsman
2026-05-15 22:49:20 -07:00
committed by GitHub
6 changed files with 251 additions and 11 deletions
+40 -5
View File
@@ -3,7 +3,7 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
import type { SettingsSection } from '../components/settings/settingsConstants';
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
import { parseHashSettingsSection, updateSettingsHash, pushSettingsHash } from '../utils/urlHash';
interface UseAppShellResult {
showNewMessage: boolean;
@@ -39,19 +39,31 @@ export function useAppShell(): UseAppShellResult {
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
const previousHashRef = useRef('');
const isOpeningSettingsRef = useRef(false);
const pushedSettingsEntryRef = useRef(false);
useEffect(() => {
if (showSettings) {
updateSettingsHash(settingsSection);
if (isOpeningSettingsRef.current) {
pushSettingsHash(settingsSection);
isOpeningSettingsRef.current = false;
} else {
updateSettingsHash(settingsSection);
}
}
}, [settingsSection, showSettings]);
const handleCloseSettingsView = useCallback(() => {
if (typeof window !== 'undefined' && parseHashSettingsSection() !== null) {
window.history.replaceState(null, '', previousHashRef.current || window.location.pathname);
}
startTransition(() => setShowSettings(false));
setSidebarOpen(false);
if (typeof window !== 'undefined') {
if (pushedSettingsEntryRef.current) {
pushedSettingsEntryRef.current = false;
window.history.back();
} else if (parseHashSettingsSection() !== null) {
window.history.replaceState(null, '', previousHashRef.current || window.location.pathname);
}
}
}, []);
const handleToggleSettingsView = useCallback(() => {
@@ -64,12 +76,35 @@ export function useAppShell(): UseAppShellResult {
previousHashRef.current =
parseHashSettingsSection() === null ? window.location.hash : previousHashRef.current;
}
isOpeningSettingsRef.current = true;
pushedSettingsEntryRef.current = true;
startTransition(() => {
setShowSettings(true);
});
setSidebarOpen(false);
}, [handleCloseSettingsView, showSettings]);
// Respond to browser back/forward navigating into or out of settings
useEffect(() => {
const handlePopstate = () => {
const section = parseHashSettingsSection();
if (section !== null) {
// Don't set pushedSettingsEntryRef here — the user arrived via
// back/forward, not by opening settings. Closing settings should
// replaceState, not history.back(), to avoid popping an unrelated entry.
startTransition(() => {
setShowSettings(true);
setSettingsSection(section);
});
} else {
startTransition(() => setShowSettings(false));
}
};
window.addEventListener('popstate', handlePopstate);
return () => window.removeEventListener('popstate', handlePopstate);
}, []);
const handleOpenNewMessage = useCallback(() => {
setShowNewMessage(true);
setSidebarOpen(false);
+79 -5
View File
@@ -3,6 +3,7 @@ import {
parseHashConversation,
parseHashSettingsSection,
updateUrlHash,
pushUrlHash,
resolveChannelFromHashToken,
resolveContactFromHashToken,
} from '../utils/urlHash';
@@ -16,6 +17,43 @@ import { getContactDisplayName } from '../utils/pubkey';
import { toast } from '../components/ui/sonner';
import type { Channel, Contact, Conversation } from '../types';
function resolveConversationFromHash(
channels: Channel[],
contacts: Contact[]
): Conversation | null {
const hashConv = parseHashConversation();
if (!hashConv) return null;
switch (hashConv.type) {
case 'raw':
return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
case 'map':
return { type: 'map', id: 'map', name: 'Node Map', mapFocusKey: hashConv.mapFocusKey };
case 'visualizer':
return { type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' };
case 'search':
return { type: 'search', id: 'search', name: 'Message Search' };
case 'trace':
return { type: 'trace', id: 'trace', name: 'Trace' };
case 'channel': {
const channel = resolveChannelFromHashToken(hashConv.name, channels);
return channel ? { type: 'channel', id: channel.key, name: channel.name } : null;
}
case 'contact': {
const contact = resolveContactFromHashToken(hashConv.name, contacts);
return contact
? {
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
}
: null;
}
default:
return null;
}
}
interface UseConversationRouterArgs {
channels: Channel[];
contacts: Contact[];
@@ -42,9 +80,21 @@ export function useConversationRouter({
? window.location.hash.length > 0 && parseHashSettingsSection() === null
: false
);
const shouldPushHistoryRef = useRef(false);
const isHandlingPopstateRef = useRef(false);
const channelsRef = useRef(channels);
const contactsRef = useRef(contacts);
useEffect(() => {
channelsRef.current = channels;
}, [channels]);
useEffect(() => {
contactsRef.current = contacts;
}, [contacts]);
const setActiveConversation = useCallback((conv: Conversation | null) => {
hashSyncEnabledRef.current = true;
shouldPushHistoryRef.current = true;
setActiveConversationState(conv);
}, []);
@@ -230,16 +280,40 @@ export function useConversationRouter({
// Keep ref in sync and update URL hash
useEffect(() => {
activeConversationRef.current = activeConversation;
if (activeConversation) {
if (isHandlingPopstateRef.current) {
// URL is already correct from the browser's popstate — no update needed
isHandlingPopstateRef.current = false;
} else if (activeConversation) {
if (hashSyncEnabledRef.current && !suspendHashSync) {
updateUrlHash(activeConversation);
}
if (activeConversation.type !== 'search') {
saveLastViewedConversation(activeConversation);
if (shouldPushHistoryRef.current) {
shouldPushHistoryRef.current = false;
pushUrlHash(activeConversation);
} else {
updateUrlHash(activeConversation);
}
}
}
if (activeConversation && activeConversation.type !== 'search') {
saveLastViewedConversation(activeConversation);
}
}, [activeConversation, suspendHashSync]);
// Respond to browser back/forward by updating the active conversation
useEffect(() => {
const handlePopstate = () => {
// Settings hash transitions are handled by useAppShell
if (parseHashSettingsSection() !== null) return;
const conv = resolveConversationFromHash(channelsRef.current, contactsRef.current);
hashSyncEnabledRef.current = true;
isHandlingPopstateRef.current = true;
setActiveConversationState(conv);
};
window.addEventListener('popstate', handlePopstate);
return () => window.removeEventListener('popstate', handlePopstate);
}, []);
// If a delete action left us without an active conversation, recover to Public
useEffect(() => {
if (!pendingDeleteFallbackRef.current) return;
+2
View File
@@ -192,7 +192,9 @@ vi.mock('../utils/urlHash', () => ({
parseHashConversation: () => null,
parseHashSettingsSection: () => null,
updateUrlHash: vi.fn(),
pushUrlHash: vi.fn(),
updateSettingsHash: vi.fn(),
pushSettingsHash: vi.fn(),
getSettingsHash: (section: string) => `#settings/${section}`,
getMapFocusHash: () => '#map',
}));
+79
View File
@@ -134,6 +134,7 @@ vi.mock('../components/ui/sonner', () => ({
},
}));
import { act } from '@testing-library/react';
import { App } from '../App';
import {
LAST_VIEWED_CONVERSATION_KEY,
@@ -344,6 +345,7 @@ describe('App startup hash resolution', () => {
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -377,6 +379,83 @@ describe('App startup hash resolution', () => {
expect(window.location.hash).toBe('');
});
describe('Browser back/forward navigation', () => {
it('navigates to a channel conversation when popstate fires', async () => {
const opsChannel = {
key: 'CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
name: 'Ops',
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = `#channel/${opsChannel.key}/Ops`;
mocks.api.getChannels.mockResolvedValue([publicChannel, opsChannel]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent(`channel:${opsChannel.key}:Ops`);
}
});
act(() => {
window.location.hash = `#channel/${publicChannel.key}/Public`;
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
});
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`);
}
});
});
it('navigates to a contact conversation when popstate fires with a contact hash', async () => {
const aliceContact = {
public_key: 'b'.repeat(64),
name: 'Alice',
type: 1,
flags: 0,
direct_path: null,
direct_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
window.location.hash = '';
mocks.api.getContacts.mockResolvedValue([aliceContact]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`);
}
});
act(() => {
window.location.hash = `#contact/${aliceContact.public_key}/Alice`;
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
});
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent(`contact:${aliceContact.public_key}:Alice`);
}
});
});
});
it('stays on radio settings section even when radio is disconnected', async () => {
window.location.hash = '#settings/radio';
mocks.api.getRadioConfig.mockRejectedValue(new Error('radio offline'));
+35 -1
View File
@@ -90,7 +90,41 @@ describe('useAppShell', () => {
result.current.handleCloseSettingsView();
});
expect(window.location.hash).toBe('#channel/test/Public');
await waitFor(() => {
expect(window.location.hash).toBe('#channel/test/Public');
});
});
it('pushes a new history entry when opening settings', async () => {
const { result } = renderHook(() => useAppShell());
const lengthBefore = window.history.length;
act(() => {
result.current.handleToggleSettingsView();
});
await waitFor(() => {
expect(window.location.hash).toBe('#settings/radio');
});
expect(window.history.length).toBe(lengthBefore + 1);
});
it('closes settings when popstate fires with a non-settings hash', async () => {
window.location.hash = '#settings/radio';
const { result } = renderHook(() => useAppShell());
expect(result.current.showSettings).toBe(true);
act(() => {
window.location.hash = '#channel/abc/Public';
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
});
await waitFor(() => {
expect(result.current.showSettings).toBe(false);
});
});
it('toggles the cracker shell without affecting sidebar state', () => {
+16
View File
@@ -172,9 +172,25 @@ export function updateUrlHash(conv: Conversation | null): void {
}
}
// Update URL hash and add a new browser history entry
export function pushUrlHash(conv: Conversation | null): void {
const newHash = getConversationHash(conv);
if (newHash !== window.location.hash) {
window.history.pushState(null, '', newHash || window.location.pathname);
}
}
export function updateSettingsHash(section: SettingsSection): void {
const newHash = getSettingsHash(section);
if (newHash !== window.location.hash) {
window.history.replaceState(null, '', newHash);
}
}
// Push a settings hash as a new browser history entry
export function pushSettingsHash(section: SettingsSection): void {
const newHash = getSettingsHash(section);
if (newHash !== window.location.hash) {
window.history.pushState(null, '', newHash);
}
}