From 881c640f2c8d12b1671e671c3fa5a06dd95857a3 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Fri, 1 May 2026 11:22:26 -0400 Subject: [PATCH 1/3] [6.x] Warn on dirty state when using browser back/forward Closes #14055 Intercept popstate in `dirty-state.js` before Inertia's listener so back/forward navigation (including trackpad swipe gestures) prompts the user instead of silently discarding unsaved form changes. --- resources/js/composables/dirty-state.js | 56 ++++++++++- resources/js/tests/dirty-state.test.js | 123 ++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 resources/js/tests/dirty-state.test.js diff --git a/resources/js/composables/dirty-state.js b/resources/js/composables/dirty-state.js index 308e501b9a4..3b44449ae16 100644 --- a/resources/js/composables/dirty-state.js +++ b/resources/js/composables/dirty-state.js @@ -3,6 +3,8 @@ import { router } from '@inertiajs/vue3'; const dirty = ref([]); let inertiaWarningListener = null; +let lastUrl = typeof window !== 'undefined' ? window.location.href : null; +let lastState = typeof window !== 'undefined' ? window.history.state : null; function names() { return dirty.value; @@ -26,10 +28,12 @@ function remove(name) { dirty.value = dirty.value.filter((n) => n !== name); } +function isWarningEnabled() { + return Statamic.$preferences.get('confirm_dirty_navigation', true); +} + function enableWarning() { - if (! Statamic.$preferences.get('confirm_dirty_navigation', true)) { - return; - } + if (! isWarningEnabled()) return; // For Inertia navigation (e.g. through Link component) inertiaWarningListener ??= router.on('before', event => { @@ -43,13 +47,57 @@ function enableWarning() { return confirmed; }); - // For browser navigation (e.g. back button, refresh, closing tab) + // For real page unload (refresh, tab close, cross-origin nav). + // popstate (back/forward, trackpad swipe) is handled separately below. window.onbeforeunload = () => ''; } function disableWarning() { window.onbeforeunload = null; inertiaWarningListener && inertiaWarningListener(); + inertiaWarningListener = null; +} + +// Intercept browser back/forward (popstate) navigation. Inertia's popstate +// handler swaps pages without firing its `before` event, so we register at +// module load — before `createInertiaApp()` calls `eventHandler.init()` — +// to ensure our listener runs first and can block Inertia via +// `stopImmediatePropagation()`. See statamic/cms#14055. +if (typeof window !== 'undefined') { + // Track Inertia's current URL/state so we can re-push it if the user cancels + // a back navigation. By the time popstate fires, window.location/state are + // already the previous page's, so we have to capture this proactively. + document.addEventListener('inertia:navigate', () => { + lastUrl = window.location.href; + lastState = window.history.state; + }); + + window.addEventListener('popstate', (event) => { + if (! dirty.value.length) return; + if (! isWarningEnabled()) return; + + // Block Inertia's listener so it doesn't `setQuietly(..., { preserveState: false })` + // and wipe the in-memory form data before we've confirmed. + event.stopImmediatePropagation(); + + // Re-push the page we were just on so the URL/Inertia state are restored + // while the (synchronous) confirm() is open and after a cancel. + if (lastUrl && lastState) { + window.history.pushState(lastState, '', lastUrl); + } + + const confirmed = confirm(__('statamic::messages.dirty_navigation_warning')); + + if (! confirmed) return; + + clear(); + disableWarning(); + + // We're now on a re-pushed entry of the dirty page. Going back fires + // popstate again with the user's intended target; dirty is clean so + // Inertia handles it normally. + window.history.back(); + }); } function state(name, state) { diff --git a/resources/js/tests/dirty-state.test.js b/resources/js/tests/dirty-state.test.js new file mode 100644 index 00000000000..305975a0a2a --- /dev/null +++ b/resources/js/tests/dirty-state.test.js @@ -0,0 +1,123 @@ +import { test, expect, beforeEach, vi } from 'vitest'; + +// Mock @inertiajs/vue3 router before importing the composable so it captures +// the mock instead of the real router. +vi.mock('@inertiajs/vue3', () => { + const listeners = { before: [], success: [] }; + return { + router: { + on: (event, callback) => { + listeners[event].push(callback); + return () => { + listeners[event] = listeners[event].filter((cb) => cb !== callback); + }; + }, + __listeners: listeners, + }, + }; +}); + +const setupGlobals = () => { + global.Statamic = { + $preferences: { + get: () => true, + }, + }; + global.__ = (key) => key; +}; + +let useDirtyState; + +beforeEach(async () => { + vi.resetModules(); + setupGlobals(); + window.history.replaceState({ page: 'A', url: '/a' }, '', '/a'); + useDirtyState = (await import('../composables/dirty-state.js')).default; +}); + +test('popstate is ignored when nothing is dirty', () => { + const { count } = useDirtyState(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + window.dispatchEvent(new PopStateEvent('popstate', { state: null })); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(count()).toBe(0); + + confirmSpy.mockRestore(); +}); + +test('popstate prompts the user when the form is dirty', () => { + const { add, count } = useDirtyState(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const backSpy = vi.spyOn(window.history, 'back').mockImplementation(() => {}); + + add('entry'); + expect(count()).toBe(1); + + window.dispatchEvent(new PopStateEvent('popstate', { state: { page: 'A' } })); + + expect(confirmSpy).toHaveBeenCalledWith('statamic::messages.dirty_navigation_warning'); + expect(count()).toBe(0); // dirty cleared on confirmation + expect(backSpy).toHaveBeenCalled(); + + confirmSpy.mockRestore(); + backSpy.mockRestore(); +}); + +test('cancelling the prompt re-pushes the dirty page state and keeps form dirty', () => { + const { add, count } = useDirtyState(); + + // Capture the dirty page's URL/state via inertia:navigate + window.history.replaceState({ page: 'B', url: '/b' }, '', '/b'); + document.dispatchEvent(new CustomEvent('inertia:navigate')); + + add('entry'); + + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const pushSpy = vi.spyOn(window.history, 'pushState'); + const backSpy = vi.spyOn(window.history, 'back').mockImplementation(() => {}); + + window.dispatchEvent(new PopStateEvent('popstate', { state: { page: 'A' } })); + + expect(confirmSpy).toHaveBeenCalled(); + expect(count()).toBe(1); // still dirty + expect(backSpy).not.toHaveBeenCalled(); + expect(pushSpy).toHaveBeenCalledWith({ page: 'B', url: '/b' }, '', expect.stringContaining('/b')); + + confirmSpy.mockRestore(); + pushSpy.mockRestore(); + backSpy.mockRestore(); +}); + +test('popstate stops propagation so Inertia\'s listener cannot wipe form data', () => { + const { add } = useDirtyState(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + add('entry'); + + let inertiaListenerFired = false; + const inertiaListener = () => { inertiaListenerFired = true; }; + window.addEventListener('popstate', inertiaListener); + + window.dispatchEvent(new PopStateEvent('popstate', { state: { page: 'A' } })); + + expect(inertiaListenerFired).toBe(false); + + window.removeEventListener('popstate', inertiaListener); + confirmSpy.mockRestore(); +}); + +test('popstate is ignored when confirm_dirty_navigation preference is disabled', () => { + global.Statamic.$preferences.get = () => false; + + const { add } = useDirtyState(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + add('entry'); + + window.dispatchEvent(new PopStateEvent('popstate', { state: { page: 'A' } })); + + expect(confirmSpy).not.toHaveBeenCalled(); + confirmSpy.mockRestore(); +}); From cb280e9340927fff3072e66a9c6e35e221f820dc Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 1 May 2026 16:21:37 -0400 Subject: [PATCH 2/3] Clear dirty state on publish container unmount and simplify URL capture Container.vue now removes its dirty entry in onUnmounted, which fixes the stale-state leak that caused subsequent link clicks to prompt for a form that no longer exists after a silent popstate-induced page swap. dirty-state.js captures the dirty URL/state on the 0->1 add() transition instead of via a global inertia:navigate listener, removing module-load state and an event listener. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../js/components/ui/Publish/Container.vue | 1 + resources/js/composables/dirty-state.js | 24 ++++++++----------- resources/js/tests/dirty-state.test.js | 4 +--- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/resources/js/components/ui/Publish/Container.vue b/resources/js/components/ui/Publish/Container.vue index 6d22f0b966b..98698554cf6 100644 --- a/resources/js/components/ui/Publish/Container.vue +++ b/resources/js/components/ui/Publish/Container.vue @@ -356,6 +356,7 @@ onMounted(() => { }); onUnmounted(() => { + clearDirtyState(); Statamic.$events.$emit('publish-container-destroyed', { name: props.name }); }); diff --git a/resources/js/composables/dirty-state.js b/resources/js/composables/dirty-state.js index 3b44449ae16..644d76cad5a 100644 --- a/resources/js/composables/dirty-state.js +++ b/resources/js/composables/dirty-state.js @@ -3,8 +3,8 @@ import { router } from '@inertiajs/vue3'; const dirty = ref([]); let inertiaWarningListener = null; -let lastUrl = typeof window !== 'undefined' ? window.location.href : null; -let lastState = typeof window !== 'undefined' ? window.history.state : null; +let dirtyUrl = null; +let dirtyState = null; function names() { return dirty.value; @@ -20,6 +20,10 @@ function count() { function add(name) { if (dirty.value.indexOf(name) == -1) { + if (! dirty.value.length) { + dirtyUrl = window.location.href; + dirtyState = window.history.state; + } dirty.value = [...dirty.value, name]; } } @@ -64,14 +68,6 @@ function disableWarning() { // to ensure our listener runs first and can block Inertia via // `stopImmediatePropagation()`. See statamic/cms#14055. if (typeof window !== 'undefined') { - // Track Inertia's current URL/state so we can re-push it if the user cancels - // a back navigation. By the time popstate fires, window.location/state are - // already the previous page's, so we have to capture this proactively. - document.addEventListener('inertia:navigate', () => { - lastUrl = window.location.href; - lastState = window.history.state; - }); - window.addEventListener('popstate', (event) => { if (! dirty.value.length) return; if (! isWarningEnabled()) return; @@ -80,10 +76,10 @@ if (typeof window !== 'undefined') { // and wipe the in-memory form data before we've confirmed. event.stopImmediatePropagation(); - // Re-push the page we were just on so the URL/Inertia state are restored - // while the (synchronous) confirm() is open and after a cancel. - if (lastUrl && lastState) { - window.history.pushState(lastState, '', lastUrl); + // Re-push the dirty page we were just on so the URL/Inertia state are + // restored while the (synchronous) confirm() is open and after a cancel. + if (dirtyUrl && dirtyState) { + window.history.pushState(dirtyState, '', dirtyUrl); } const confirmed = confirm(__('statamic::messages.dirty_navigation_warning')); diff --git a/resources/js/tests/dirty-state.test.js b/resources/js/tests/dirty-state.test.js index 305975a0a2a..e6989f1462f 100644 --- a/resources/js/tests/dirty-state.test.js +++ b/resources/js/tests/dirty-state.test.js @@ -68,10 +68,8 @@ test('popstate prompts the user when the form is dirty', () => { test('cancelling the prompt re-pushes the dirty page state and keeps form dirty', () => { const { add, count } = useDirtyState(); - // Capture the dirty page's URL/state via inertia:navigate + // The dirty URL/state is captured at the moment add() is called. window.history.replaceState({ page: 'B', url: '/b' }, '', '/b'); - document.dispatchEvent(new CustomEvent('inertia:navigate')); - add('entry'); const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); From b97a6c2a5b470e20d1200d487e0c52deddda5cb2 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 1 May 2026 16:26:53 -0400 Subject: [PATCH 3/3] Drop SSR guard around popstate listener Co-Authored-By: Claude Opus 4.7 (1M context) --- resources/js/composables/dirty-state.js | 46 ++++++++++++------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/resources/js/composables/dirty-state.js b/resources/js/composables/dirty-state.js index 644d76cad5a..67dc312b9f2 100644 --- a/resources/js/composables/dirty-state.js +++ b/resources/js/composables/dirty-state.js @@ -67,34 +67,32 @@ function disableWarning() { // module load — before `createInertiaApp()` calls `eventHandler.init()` — // to ensure our listener runs first and can block Inertia via // `stopImmediatePropagation()`. See statamic/cms#14055. -if (typeof window !== 'undefined') { - window.addEventListener('popstate', (event) => { - if (! dirty.value.length) return; - if (! isWarningEnabled()) return; - - // Block Inertia's listener so it doesn't `setQuietly(..., { preserveState: false })` - // and wipe the in-memory form data before we've confirmed. - event.stopImmediatePropagation(); - - // Re-push the dirty page we were just on so the URL/Inertia state are - // restored while the (synchronous) confirm() is open and after a cancel. - if (dirtyUrl && dirtyState) { - window.history.pushState(dirtyState, '', dirtyUrl); - } +window.addEventListener('popstate', (event) => { + if (! dirty.value.length) return; + if (! isWarningEnabled()) return; - const confirmed = confirm(__('statamic::messages.dirty_navigation_warning')); + // Block Inertia's listener so it doesn't `setQuietly(..., { preserveState: false })` + // and wipe the in-memory form data before we've confirmed. + event.stopImmediatePropagation(); + + // Re-push the dirty page we were just on so the URL/Inertia state are + // restored while the (synchronous) confirm() is open and after a cancel. + if (dirtyUrl && dirtyState) { + window.history.pushState(dirtyState, '', dirtyUrl); + } - if (! confirmed) return; + const confirmed = confirm(__('statamic::messages.dirty_navigation_warning')); - clear(); - disableWarning(); + if (! confirmed) return; - // We're now on a re-pushed entry of the dirty page. Going back fires - // popstate again with the user's intended target; dirty is clean so - // Inertia handles it normally. - window.history.back(); - }); -} + clear(); + disableWarning(); + + // We're now on a re-pushed entry of the dirty page. Going back fires + // popstate again with the user's intended target; dirty is clean so + // Inertia handles it normally. + window.history.back(); +}); function state(name, state) { state ? add(name) : remove(name);