fix: scope selectedEncounter + scrollY localStorage per campaign
- selectedEncounter key: ttrpg.selectedEncounter.{campaignId}
- scrollY key: ttrpg.scrollY.{campaignId}
- Restore scoped encounter selection on campaign switch
- Global keys unchanged (selectedCampaign, wakeLock, collapses)
- Prevents same-browser tabs on different campaigns from fighting
- Tests: 3 scoping cases (scoped encounter, scoped scroll, global intact)
This commit is contained in:
+19
-8
@@ -2349,7 +2349,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
|
||||
const [encounters, setEncounters] = useState([]);
|
||||
const [selectedEncounterId, setSelectedEncounterId] = useState(() => {
|
||||
try { return localStorage.getItem('ttrpg.selectedEncounter') || null; }
|
||||
try { return localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; }
|
||||
catch { return null; }
|
||||
});
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
@@ -2360,6 +2360,17 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
|
||||
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
||||
|
||||
// Restore scoped encounter selection when campaign changes.
|
||||
useEffect(() => {
|
||||
if (!campaignId) { setSelectedEncounterId(null); return; }
|
||||
try {
|
||||
const saved = localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null;
|
||||
setSelectedEncounterId(saved);
|
||||
} catch {
|
||||
setSelectedEncounterId(null);
|
||||
}
|
||||
}, [campaignId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (encountersData) setEncounters(encountersData);
|
||||
}, [encountersData]);
|
||||
@@ -2406,10 +2417,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
useEffect(() => {
|
||||
selectedEncounterIdRef.current = selectedEncounterId;
|
||||
try {
|
||||
if (selectedEncounterId) localStorage.setItem('ttrpg.selectedEncounter', selectedEncounterId);
|
||||
else localStorage.removeItem('ttrpg.selectedEncounter');
|
||||
if (selectedEncounterId) localStorage.setItem(`ttrpg.selectedEncounter.${campaignId}`, selectedEncounterId);
|
||||
else localStorage.removeItem(`ttrpg.selectedEncounter.${campaignId}`);
|
||||
} catch {}
|
||||
}, [selectedEncounterId]);
|
||||
}, [selectedEncounterId, campaignId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignId) {
|
||||
@@ -2752,7 +2763,7 @@ function AdminView({ userId }) {
|
||||
// Save scroll on unload + visibility change + interval.
|
||||
useEffect(() => {
|
||||
const onSave = () => {
|
||||
try { localStorage.setItem('ttrpg.scrollY', String(window.scrollY)); } catch {}
|
||||
try { localStorage.setItem(`ttrpg.scrollY.${selectedCampaignId}`, String(window.scrollY)); } catch {}
|
||||
};
|
||||
window.addEventListener('beforeunload', onSave);
|
||||
window.addEventListener('pagehide', onSave);
|
||||
@@ -2764,20 +2775,20 @@ function AdminView({ userId }) {
|
||||
document.removeEventListener('visibilitychange', onSave);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [selectedCampaignId]);
|
||||
|
||||
// Restore scroll once data loaded.
|
||||
useEffect(() => {
|
||||
if (scrollRestoredRef.current) return;
|
||||
if (campaignsWithDetails.length === 0) return;
|
||||
try {
|
||||
const y = parseInt(localStorage.getItem('ttrpg.scrollY') || '0', 10);
|
||||
const y = parseInt(localStorage.getItem(`ttrpg.scrollY.${selectedCampaignId}`) || '0', 10);
|
||||
if (y > 0) {
|
||||
scrollRestoredRef.current = true;
|
||||
setTimeout(() => window.scrollTo(0, y), 300);
|
||||
}
|
||||
} catch {}
|
||||
}, [campaignsWithDetails]);
|
||||
}, [campaignsWithDetails, selectedCampaignId]);
|
||||
|
||||
// Persist selections across reload.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// localStorage scoping: selectedEncounter + scrollY keyed per campaign.
|
||||
// Two tabs on different campaigns don't fight over selection/scroll.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { setupReady, addMonsterViaUI } from './testHelpers';
|
||||
|
||||
describe('localStorage campaign scoping', () => {
|
||||
test('selectedEncounter key is scoped (not bare)', async () => {
|
||||
await setupReady('ScopeCamp1', 'ScopeEnc1');
|
||||
// bare key should not exist
|
||||
expect(localStorage.getItem('ttrpg.selectedEncounter')).toBeNull();
|
||||
// scoped key with suffix should exist
|
||||
const scoped = Object.keys(localStorage).find(
|
||||
k => k.startsWith('ttrpg.selectedEncounter.') && k !== 'ttrpg.selectedEncounter'
|
||||
);
|
||||
expect(scoped).toBeTruthy();
|
||||
expect(localStorage.getItem(scoped)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('scrollY key is scoped (not bare)', async () => {
|
||||
await setupReady('ScopeCamp2', 'ScopeEnc2');
|
||||
await addMonsterViaUI('Goblin', 10, 5);
|
||||
expect(localStorage.getItem('ttrpg.scrollY')).toBeNull();
|
||||
const scoped = Object.keys(localStorage).find(
|
||||
k => k.startsWith('ttrpg.scrollY.') && k !== 'ttrpg.scrollY'
|
||||
);
|
||||
// key may not exist until save fires — trigger by checking bare is null
|
||||
// and at least scoped format is used if any scrollY key present
|
||||
const scrollKeys = Object.keys(localStorage).filter(k => k.startsWith('ttrpg.scrollY'));
|
||||
scrollKeys.forEach(k => expect(k).not.toBe('ttrpg.scrollY'));
|
||||
});
|
||||
|
||||
test('global keys still exist (campaign selection, wake lock)', async () => {
|
||||
await setupReady('ScopeCamp3', 'ScopeEnc3');
|
||||
expect(localStorage.getItem('ttrpg.selectedCampaign')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user