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 308e501b9a4..67dc312b9f2 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 dirtyUrl = null; +let dirtyState = null; function names() { return dirty.value; @@ -18,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]; } } @@ -26,10 +32,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,15 +51,49 @@ 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. +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); + } + + 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) { state ? add(name) : remove(name); } diff --git a/resources/js/tests/dirty-state.test.js b/resources/js/tests/dirty-state.test.js new file mode 100644 index 00000000000..e6989f1462f --- /dev/null +++ b/resources/js/tests/dirty-state.test.js @@ -0,0 +1,121 @@ +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(); + + // The dirty URL/state is captured at the moment add() is called. + window.history.replaceState({ page: 'B', url: '/b' }, '', '/b'); + 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(); +});