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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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);
});
});