diff --git a/src/App.js b/src/App.js index 50292ea..d960371 100644 --- a/src/App.js +++ b/src/App.js @@ -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(() => { diff --git a/src/tests/LocalStorageScoping.test.js b/src/tests/LocalStorageScoping.test.js new file mode 100644 index 0000000..ba3750c --- /dev/null +++ b/src/tests/LocalStorageScoping.test.js @@ -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(); + }); +});