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
93 changes: 93 additions & 0 deletions src/components/tests/preventDefault.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<RootApp>
<form onSubmit={onSubmit}>
<I13nButton onClick={(e) => e.preventDefault()}>submit</I13nButton>
</form>
</RootApp>
);

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(
<RootApp>
<form onSubmit={onSubmit}>
<I13nButton>submit</I13nButton>
</form>
</RootApp>
);

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(
<RootApp>
<I13nAnchor href="https://www.yahoo.com">
<button type="button" onClick={(e) => e.preventDefault()}>inner</button>
</I13nAnchor>
</RootApp>
);

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(
<RootApp>
<I13nAnchor href="https://www.yahoo.com">link</I13nAnchor>
</RootApp>
);

fireEvent.click(getByText('link'));
await flush();

expect(window.location.assign).toHaveBeenCalledWith('https://www.yahoo.com/');
});
});
54 changes: 40 additions & 14 deletions src/libs/clickHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
};
Expand Down
90 changes: 85 additions & 5 deletions src/libs/tests/clickHandler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
});
Loading