diff --git a/src/sidebar/components/Annotation/AnnotationEditor.tsx b/src/sidebar/components/Annotation/AnnotationEditor.tsx index 1eccfc1095c..6c6fa38c9dc 100644 --- a/src/sidebar/components/Annotation/AnnotationEditor.tsx +++ b/src/sidebar/components/Annotation/AnnotationEditor.tsx @@ -28,6 +28,7 @@ import { useSidebarStore } from '../../store'; import type { Draft } from '../../store/modules/drafts'; import MarkdownEditor from '../MarkdownEditor'; import TagEditor from '../TagEditor'; +import { useUnsavedChanges } from '../hooks/unsaved-changes'; import AnnotationLicense from './AnnotationLicense'; import AnnotationPublishControl from './AnnotationPublishControl'; @@ -75,6 +76,12 @@ function AnnotationEditor({ const text = draft.text; const isEmpty = !text && !tags.length; + // Warn user if they try to close the tab while there is an open, non-empty + // draft. + // + // WARNING: This does not work in all browsers. See hook docs for details. + useUnsavedChanges(!isEmpty); + const onEditTags = useCallback( (tags: string[]) => { store.createDraft(draft.annotation, { ...draft, tags }); diff --git a/src/sidebar/components/Annotation/test/AnnotationEditor-test.js b/src/sidebar/components/Annotation/test/AnnotationEditor-test.js index 6f54cd8dd88..9eb2155ab1a 100644 --- a/src/sidebar/components/Annotation/test/AnnotationEditor-test.js +++ b/src/sidebar/components/Annotation/test/AnnotationEditor-test.js @@ -18,6 +18,7 @@ describe('AnnotationEditor', () => { let fakeSettings; let fakeToastMessenger; let fakeGroupsService; + let fakeUseUnsavedChanges; let fakeStore; @@ -74,8 +75,13 @@ describe('AnnotationEditor', () => { defaultAuthority: sinon.stub().returns(''), }; + fakeUseUnsavedChanges = sinon.stub(); + $imports.$mock(mockImportedComponents()); $imports.$mock({ + '../hooks/unsaved-changes': { + useUnsavedChanges: fakeUseUnsavedChanges, + }, '../../store': { useSidebarStore: () => fakeStore }, '../../helpers/theme': { applyTheme: fakeApplyTheme }, }); @@ -279,6 +285,19 @@ describe('AnnotationEditor', () => { assert.notCalled(fakeAnnotationsService.save); }); + it('warns if user closes tab while there is a non-empty draft', () => { + // If the draft is empty, the warning is disabled. + const wrapper = createComponent(); + assert.calledWith(fakeUseUnsavedChanges, false); + + // If the draft changes to non-empty, the warning is enabled. + fakeUseUnsavedChanges.resetHistory(); + const draft = fixtures.defaultDraft(); + draft.text = 'something is here'; + wrapper.setProps({ draft }); + assert.calledWith(fakeUseUnsavedChanges, true); + }); + describe('handling publish options', () => { it('sets the publish control to disabled if the annotation is empty', () => { // default draft has no tags or text diff --git a/src/sidebar/components/hooks/test/unsaved-changes-test.js b/src/sidebar/components/hooks/test/unsaved-changes-test.js new file mode 100644 index 00000000000..93112520cef --- /dev/null +++ b/src/sidebar/components/hooks/test/unsaved-changes-test.js @@ -0,0 +1,62 @@ +import { mount } from '@hypothesis/frontend-testing'; + +import { useUnsavedChanges, hasUnsavedChanges } from '../unsaved-changes'; + +function TestUseUnsavedChanges({ unsaved, fakeWindow }) { + useUnsavedChanges(unsaved, fakeWindow); + return
; +} + +describe('useUnsavedChanges', () => { + let fakeWindow; + + function dispatchBeforeUnload() { + const event = new Event('beforeunload', { cancelable: true }); + fakeWindow.dispatchEvent(event); + return event; + } + + function createWidget(unsaved) { + return mount( + , + ); + } + + beforeEach(() => { + // Use a dummy window to avoid triggering any handlers that respond to + // "beforeunload" on the real window. + fakeWindow = new EventTarget(); + }); + + it('does not increment unsaved-changes count if argument is false', () => { + createWidget(false); + assert.isFalse(hasUnsavedChanges()); + }); + + it('does not register "beforeunload" handler if argument is false', () => { + createWidget(false); + const event = dispatchBeforeUnload(); + assert.isFalse(event.defaultPrevented); + }); + + it('increments unsaved-changes count if argument is true', () => { + const wrapper = createWidget(true); + assert.isTrue(hasUnsavedChanges()); + wrapper.unmount(); + assert.isFalse(hasUnsavedChanges()); + }); + + it('registers "beforeunload" handler if argument is true', () => { + const wrapper = createWidget(true); + const event = dispatchBeforeUnload(); + assert.isTrue(event.defaultPrevented); + + // We don't test `event.returnValue` here because it returns `false` after + // assignment in Chrome, even though the handler assigns it `true`. + + // Unmount the widget, this should remove the handler. + wrapper.unmount(); + const event2 = dispatchBeforeUnload(); + assert.isFalse(event2.defaultPrevented); + }); +}); diff --git a/src/sidebar/components/hooks/unsaved-changes.ts b/src/sidebar/components/hooks/unsaved-changes.ts new file mode 100644 index 00000000000..a86162e9173 --- /dev/null +++ b/src/sidebar/components/hooks/unsaved-changes.ts @@ -0,0 +1,55 @@ +import { useEffect } from 'preact/hooks'; + +/** Count of components with unsaved changes. */ +let unsavedCount = 0; + +function preventUnload(e: BeforeUnloadEvent) { + // `preventDefault` is the modern API for preventing unload. + e.preventDefault(); + + // Setting `returnValue` to a truthy value is a legacy method needed for + // Firefox. Note that in Chrome, reading `returnValue` will return false + // afterwards. + e.returnValue = true; +} + +/** + * Return true if any active components have indicated they have unsaved changes + * using {@link useUnsavedChanges}. + */ +export function hasUnsavedChanges() { + return unsavedCount > 0; +} + +/** + * Hook that registers the current component as having unsaved changes that + * would be lost in the event of a navigation. + * + * WARNING: As of May 2025, this works in Chrome and Firefox, but not Safari. + * See https://github.com/hypothesis/support/issues/59#issuecomment-2886335334. + * + * @param hasUnsavedChanges - True if current component has unsaved changes + * @param window_ - Test seam + */ +export function useUnsavedChanges( + hasUnsavedChanges: boolean, + /* istanbul ignore next - test seam */ + window_ = window, +) { + useEffect(() => { + if (!hasUnsavedChanges) { + return () => {}; + } + + unsavedCount += 1; + if (unsavedCount === 1) { + window_.addEventListener('beforeunload', preventUnload); + } + return () => { + unsavedCount -= 1; + if (unsavedCount === 0) { + window_.removeEventListener('beforeunload', preventUnload); + } + }; + }, [hasUnsavedChanges, window_]); +}