Skip to content

Attach scroll/wheel to root: alternative scroll browser locking fix #13460

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 11 commits into from
1 change: 1 addition & 0 deletions fixtures/dom/src/components/Header.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class Header extends React.Component {
<option value="/pointer-events">Pointer Events</option>
<option value="/mouse-events">Mouse Events</option>
<option value="/selection-events">Selection Events</option>
<option value="/scroll">Scroll Events</option>
</select>
</label>
<label htmlFor="react_version">
Expand Down
72 changes: 72 additions & 0 deletions fixtures/dom/src/components/fixtures/scroll/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const React = global.React;

function wait(time) {
console.log('Blocking!');
var startTime = new Date().getTime();
var endTime = startTime + time;
while (new Date().getTime() < endTime) {
// wait for it...
}
console.log('Not blocking!');
}

var scrollable = {
width: 300,
height: 200,
overflowY: 'auto',
margin: '0 auto',
background: '#ededed',
};

class ScrollFixture extends React.Component {
componentDidMount() {
// jank up the main thread!
this.jank = setInterval(() => wait(3000), 4000);
}

componentWillUnmount() {
clearInterval(this.jank);
}

onWheel() {
console.log('wheel');
}

onTouchStart() {
console.log('touch start');
}

onTouchMove() {
console.log('touch move');
}

render() {
let listItems = [];

// This is to produce a long enough page to allow for scrolling
for (var i = 0; i < 50; i++) {
listItems.push(<li key={i}>List item #{i + 1}</li>);
}

return (
<section>
<h2>Scroll Testing</h2>
<p>
Mouse wheel, track pad scroll, and touch events should not be blocked
by JS execution in IE Edge, Safari, and Firefox.
</p>
<div
style={scrollable}
onTouchStart={this.onTouchStart}
onTouchMove={this.onTouchMove}
onWheel={this.onWheel}>
<h2>I am scrollable!</h2>
<ul>{listItems}</ul>
</div>
<ul>{listItems}</ul>
</section>
);
}
}

export default ScrollFixture;
Original file line number Diff line number Diff line change
Expand Up @@ -365,4 +365,36 @@ describe('ReactBrowserEventEmitter', () => {
expect(dependencies.indexOf(setEventListeners[i])).toBeTruthy();
}
});

describe('local listener attachment', function() {
it('does attach a new listener for the same event type', () => {
const spy = jest.fn();

ReactDOM.render(<div onScroll={() => spy()} />, container);
ReactDOM.render(<div onScroll={() => spy()} />, container);

const el = container.querySelector('div');

el.dispatchEvent(new Event('scroll'));

expect(spy).toHaveBeenCalledTimes(1);
});

it('does not call old listeners on a second update with a new handler', () => {
const a = jest.fn();
const b = jest.fn();

ReactDOM.render(<div onScroll={a} />, container);
ReactDOM.render(<div onScroll={b} />, container);

const el = container.querySelector('div');

el.dispatchEvent(new Event('scroll'));

// The first handler should have been torn down
expect(a).toHaveBeenCalledTimes(0);
// The second handler is now attached
expect(b).toHaveBeenCalledTimes(1);
});
});
});
75 changes: 75 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMComponent-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2603,4 +2603,79 @@ describe('ReactDOMComponent', () => {
expect(node.getAttribute('onx')).toBe('bar');
});
});

describe('Trapping local event listeners', () => {
it('triggers events local captured events from children without listeners', () => {
const callback = jest.fn();
const container = document.createElement('div');

ReactDOM.render(
<div id="top" onScroll={callback}>
<div id="middle">
<div id="bottom" />
</div>
</div>,
container,
);

const event = document.createEvent('Event');

event.initEvent('scroll', true, true);
container.querySelector('#bottom').dispatchEvent(event);

expect(callback).toHaveBeenCalledTimes(1);
});

it('does not double dispatch events at the deepest leaf', () => {
const top = jest.fn();
const middle = jest.fn();
const bottom = jest.fn();
const container = document.createElement('div');

ReactDOM.render(
<div id="top" onScroll={top}>
<div id="middle" onScroll={middle}>
<div id="bottom" onScroll={bottom} />
</div>
</div>,
container,
);

const target = container.querySelector('#bottom');
const event = document.createEvent('Event');

event.initEvent('scroll', true, true);
target.dispatchEvent(event);

expect(top).toHaveBeenCalledTimes(1);
expect(middle).toHaveBeenCalledTimes(1);
expect(bottom).toHaveBeenCalledTimes(1);
});

it('does not double dispatch events at the middle leaf', () => {
const top = jest.fn();
const middle = jest.fn();
const bottom = jest.fn();
const container = document.createElement('div');

ReactDOM.render(
<div id="top" onScroll={top}>
<div id="middle" onScroll={middle}>
<div id="bottom" onScroll={bottom} />
</div>
</div>,
container,
);

const target = container.querySelector('#middle');
const event = document.createEvent('Event');

event.initEvent('scroll', true, true);
target.dispatchEvent(event);

expect(top).toHaveBeenCalledTimes(1);
expect(middle).toHaveBeenCalledTimes(1);
expect(bottom).toHaveBeenCalledTimes(0);
});
});
});
5 changes: 3 additions & 2 deletions packages/react-dom/src/client/ReactDOMFiberComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,11 @@ function ensureListeningTo(rootContainerElement, registrationName) {
const isDocumentOrFragment =
rootContainerElement.nodeType === DOCUMENT_NODE ||
rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE;
const doc = isDocumentOrFragment
const mountAt = isDocumentOrFragment
? rootContainerElement
: rootContainerElement.ownerDocument;
listenTo(registrationName, doc);
const root = isDocumentOrFragment ? mountAt : rootContainerElement;
listenTo(registrationName, mountAt, root);
}

function getOwnerDocumentFromRootContainer(
Expand Down
47 changes: 33 additions & 14 deletions packages/react-dom/src/events/ReactBrowserEventEmitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
TOP_FOCUS,
TOP_INVALID,
TOP_SCROLL,
TOP_WHEEL,
getRawEventName,
mediaEventTypes,
} from './DOMTopLevelEventTypes';
Expand Down Expand Up @@ -90,14 +91,14 @@ let reactTopListenersCounter = 0;
*/
const topListenersIDKey = '_reactListenersID' + ('' + Math.random()).slice(2);

function getListeningForDocument(mountAt: any) {
// In IE8, `mountAt` is a host object and doesn't have `hasOwnProperty`
function getListenerTrackingFor(node: any) {
// In IE8, `node` is a host object and doesn't have `hasOwnProperty`
// directly.
if (!Object.prototype.hasOwnProperty.call(mountAt, topListenersIDKey)) {
mountAt[topListenersIDKey] = reactTopListenersCounter++;
alreadyListeningTo[mountAt[topListenersIDKey]] = {};
if (!Object.prototype.hasOwnProperty.call(node, topListenersIDKey)) {
node[topListenersIDKey] = reactTopListenersCounter++;
alreadyListeningTo[node[topListenersIDKey]] = {};
}
return alreadyListeningTo[mountAt[topListenersIDKey]];
return alreadyListeningTo[node[topListenersIDKey]];
}

/**
Expand All @@ -124,25 +125,43 @@ function getListeningForDocument(mountAt: any) {
export function listenTo(
registrationName: string,
mountAt: Document | Element,
root: Document | Element,
) {
const isListening = getListeningForDocument(mountAt);
const mountAtListeners = getListenerTrackingFor(mountAt);
const dependencies = registrationNameDependencies[registrationName];

for (let i = 0; i < dependencies.length; i++) {
const dependency = dependencies[i];
if (!(isListening.hasOwnProperty(dependency) && isListening[dependency])) {

if (
!(
mountAtListeners.hasOwnProperty(dependency) &&
mountAtListeners[dependency]
)
) {
switch (dependency) {
case TOP_SCROLL:
trapCapturedEvent(TOP_SCROLL, mountAt);
break;
case TOP_WHEEL:
const rootListeners = getListenerTrackingFor(root);

if (
!(
rootListeners.hasOwnProperty(dependency) &&
rootListeners[dependency]
)
) {
trapCapturedEvent(dependency, root);
rootListeners[dependency] = true;
}
return;
case TOP_FOCUS:
case TOP_BLUR:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaearon you mentioned that touch events were essential (#9333 (comment)), would you like me to add them to the list here?

trapCapturedEvent(TOP_FOCUS, mountAt);
trapCapturedEvent(TOP_BLUR, mountAt);
// We set the flag for a single dependency later in this function,
// but this ensures we mark both as attached rather than just one.
isListening[TOP_BLUR] = true;
isListening[TOP_FOCUS] = true;
mountAtListeners[TOP_BLUR] = true;
mountAtListeners[TOP_FOCUS] = true;
break;
case TOP_CANCEL:
case TOP_CLOSE:
Expand All @@ -163,7 +182,7 @@ export function listenTo(
}
break;
}
isListening[dependency] = true;
mountAtListeners[dependency] = true;
}
}
}
Expand All @@ -172,7 +191,7 @@ export function isListeningToAllDependencies(
registrationName: string,
mountAt: Document | Element,
) {
const isListening = getListeningForDocument(mountAt);
const isListening = getListenerTrackingFor(mountAt);
const dependencies = registrationNameDependencies[registrationName];
for (let i = 0; i < dependencies.length; i++) {
const dependency = dependencies[i];
Expand Down