Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions resources/js/components/ui/Publish/Container.vue
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ onMounted(() => {
});

onUnmounted(() => {
clearDirtyState();
Statamic.$events.$emit('publish-container-destroyed', { name: props.name });
});

Expand Down
50 changes: 46 additions & 4 deletions resources/js/composables/dirty-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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];
}
}
Expand All @@ -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 => {
Expand All @@ -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);
}
Expand Down
121 changes: 121 additions & 0 deletions resources/js/tests/dirty-state.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
Loading