diff --git a/src/components/tests/preventDefault.test.jsx b/src/components/tests/preventDefault.test.jsx new file mode 100644 index 00000000..5babda36 --- /dev/null +++ b/src/components/tests/preventDefault.test.jsx @@ -0,0 +1,93 @@ +/** + * @jest-environment jsdom + */ +/** + * Copyright 2026, Yahoo Inc. + * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import I13nAnchor from '../I13nAnchor'; +import I13nButton from '../I13nButton'; +import setupI13n from '../../core/setupI13n'; + +const RootApp = setupI13n(({ children }) => children, {}, []); + +const flush = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +describe('preventDefault from user click handlers', () => { + const windowLocation = window.location; + + beforeEach(() => { + delete window.location; + window.location = { assign: jest.fn() }; + }); + + afterEach(() => { + window.location = windowLocation; + }); + + // https://github.com/yahoo/react-i13n/issues/569 + it('should not submit the parent form when the I13nButton onClick calls preventDefault', async () => { + const onSubmit = jest.fn((e) => e.preventDefault()); + const { getByText } = render( + +
+ e.preventDefault()}>submit +
+
+ ); + + fireEvent.click(getByText('submit')); + await flush(); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('should fire the form submit event when the I13nButton click is not prevented', async () => { + const onSubmit = jest.fn((e) => e.preventDefault()); + const { getByText } = render( + +
+ submit +
+
+ ); + + fireEvent.click(getByText('submit')); + await flush(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + // https://github.com/yahoo/react-i13n/issues/568 + it('should not follow the I13nAnchor href when an inner button calls preventDefault', async () => { + const { getByText } = render( + + + + + + ); + + fireEvent.click(getByText('inner')); + await flush(); + + expect(window.location.assign).not.toHaveBeenCalled(); + }); + + it('should follow the I13nAnchor href when the click is not prevented', async () => { + const { getByText } = render( + + link + + ); + + fireEvent.click(getByText('link')); + await flush(); + + expect(window.location.assign).toHaveBeenCalledWith('https://www.yahoo.com/'); + }); +}); diff --git a/src/libs/clickHandler.js b/src/libs/clickHandler.js index b1e1f2f5..fd26d646 100644 --- a/src/libs/clickHandler.js +++ b/src/libs/clickHandler.js @@ -92,29 +92,55 @@ const clickHandler = (e, options = {}) => { isRedirectLink = false; } + // a handler that ran before this listener already cancelled the default action + let isUserPrevented = !!e.defaultPrevented; + if (isPreventDefault) { if (e.preventDefault) { - e.preventDefault(); + const nativePreventDefault = e.preventDefault; + nativePreventDefault.call(e); + // handlers that run after this listener (e.g. React onClick, delegated to the root) + // can still cancel the redirect by calling preventDefault + e.preventDefault = function preventDefault(...args) { + isUserPrevented = true; + return nativePreventDefault.apply(this, args); + }; } else { e.returnValue = false; } } - executeEvent('click', { i13nNode, e }, () => { - if (isRedirectLink) { - if (isForm) { - target.form?.submit(); + const redirect = () => { + if (isUserPrevented) { + return; + } + + if (isForm) { + const { form } = target; + // requestSubmit fires the submit event and runs validation, form.submit() skips both + if (form?.requestSubmit) { + form.requestSubmit(target); } else { - const linkTarget = getLinkTarget(target, props); - - if (linkTarget === '_top') { - window.top.location.href = href; - } else if (linkTarget === '_parent') { - window.parent.location.href = href; - } else { - window.location.assign(href); - } + form?.submit(); } + } else { + const linkTarget = getLinkTarget(target, props); + + if (linkTarget === '_top') { + window.top.location.href = href; + } else if (linkTarget === '_parent') { + window.parent.location.href = href; + } else { + window.location.assign(href); + } + } + }; + + executeEvent('click', { i13nNode, e }, () => { + if (isRedirectLink) { + // the callback can run synchronously, wait for the click dispatch to finish + // so every other click handler gets the chance to call preventDefault + setTimeout(redirect, 0); } }); }; diff --git a/src/libs/tests/clickHandler.test.js b/src/libs/tests/clickHandler.test.js index 8b5dcafc..3f4752fb 100644 --- a/src/libs/tests/clickHandler.test.js +++ b/src/libs/tests/clickHandler.test.js @@ -249,9 +249,11 @@ describe('clickHandler', () => { mockOptions.props.target = '_top'; mockOptions.executeEvent = function (eventName, payload, callback) { callback(); - expect(executedActions).toEqual(['preventDefault']); - expect(global.window.top.location.href).toEqual('foo'); - done(); + setTimeout(() => { + expect(executedActions).toEqual(['preventDefault']); + expect(global.window.top.location.href).toEqual('foo'); + done(); + }, 0); }; clickHandler(mockClickEvent, mockOptions); @@ -274,11 +276,89 @@ describe('clickHandler', () => { mockOptions.props.target = '_parent'; mockOptions.executeEvent = function (eventName, payload, callback) { callback(); - expect(executedActions).toEqual(['preventDefault']); - expect(global.window.parent.location.href).toEqual('foo'); + setTimeout(() => { + expect(executedActions).toEqual(['preventDefault']); + expect(global.window.parent.location.href).toEqual('foo'); + done(); + }, 0); + }; + + clickHandler(mockClickEvent, mockOptions); + }); + + it('should submit the form with requestSubmit so submit handlers and validation run', (done) => { + const button = { + tagName: 'BUTTON', + type: 'submit' + }; + button.form = { + requestSubmit(submitter) { + expect(submitter).toBe(button); + done(); + }, + submit() { + done(new Error('form.submit() should not be called when requestSubmit exists')); + } + }; + mockClickEvent.currentTarget = button; + mockOptions.executeEvent = (eventName, payload, callback) => { callback(); }; + + clickHandler(mockClickEvent, mockOptions); + }); + + // https://github.com/yahoo/react-i13n/issues/569 + it('should not submit the form if a later handler calls preventDefault', (done) => { + const submit = jest.fn(); + mockClickEvent.currentTarget = { + tagName: 'BUTTON', + type: 'submit', + form: { submit } + }; + mockOptions.executeEvent = (eventName, payload, callback) => { callback(); }; + + clickHandler(mockClickEvent, mockOptions); + // e.g. a React onClick, which runs after the native listener + mockClickEvent.preventDefault(); + + setTimeout(() => { + expect(submit).not.toHaveBeenCalled(); + done(); + }, 0); + }); + + // https://github.com/yahoo/react-i13n/issues/568 + it('should not redirect if a later handler calls preventDefault', (done) => { + mockClickEvent.currentTarget = { + tagName: 'A', + href: 'https://foobar.com' + }; + mockOptions.executeEvent = (eventName, payload, callback) => { callback(); }; + + clickHandler(mockClickEvent, mockOptions); + mockClickEvent.preventDefault(); + + setTimeout(() => { + expect(window.location.assign).not.toHaveBeenCalled(); done(); + }, 0); + }); + + it('should not redirect if an earlier handler already called preventDefault', (done) => { + mockClickEvent.currentTarget = { + tagName: 'A', + href: 'https://foobar.com' + }; + mockClickEvent.defaultPrevented = true; + mockOptions.executeEvent = (eventName, payload, callback) => { + expect(eventName).toEqual('click'); + callback(); }; clickHandler(mockClickEvent, mockOptions); + + setTimeout(() => { + expect(window.location.assign).not.toHaveBeenCalled(); + done(); + }, 0); }); });