Forked from bvaughn/attaching-manual-event-listeners-in-passive-effect.js
Created
September 28, 2021 16:05
-
-
Save oirodolfo/7ee13a6d3b1f887226588aa84d77e8fb to your computer and use it in GitHub Desktop.
Attaching manual event listeners in a passive effect
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Simplistic (probably most common) approach. | |
// This approach assumes either that: | |
// 1) passive effects are always run synchronously, after paint, or | |
// 2) passive effects never attach handlers for bubbling events | |
// If both of the above are wrong (as can be the case) then problems might occur! | |
useEffect(() => { | |
const handleDocumentClick = (event: MouseEvent) => { | |
// It's possible that a "click" event rendered the component with this effect, | |
// in which case this event handler might be called for the same event (as it bubbles). | |
// In most scenarios, this is not desirable. | |
// ... | |
}; | |
const ownerDocument = ((modalRef.current: any): HTMLDivElement).ownerDocument; | |
ownerDocument.addEventListener("click", handleDocumentClick); | |
return () => { | |
ownerDocument.removeEventListener("click", handleDocumentClick); | |
}; | |
}, []); | |
// There are 3 alternatives. | |
// I've listed them in order of my preference (most to least). | |
// Alternate design 1: Event time | |
// This uses the event.timeStamp field to avoid reacting to events that were dispatched before the effect. | |
// Note that the "timeStamp" property does not exist for all event types. | |
// Also note that for older browsers (e.g. Chrome 49 ~ 2016) this field is millisecond vs microseconds precision. | |
// My thoughts are that React is unlikely to ever render+commit+flush effects in <1ms so this shouldn't matter. | |
useEffect(() => { | |
const timeOfEffect = performance.now(); | |
const handleDocumentClick = (event: MouseEvent) => { | |
if (timeOfEffect > event.timeStamp) { | |
// Ignore events that were fired before the effect ran, | |
// in case this effect is being run while an event is currently bubbling. | |
// In that case, we don't want to react to a pre-existing event. | |
return; | |
} | |
// ... | |
}; | |
const ownerDocument = ((modalRef.current: any): HTMLDivElement).ownerDocument; | |
ownerDocument.addEventListener("click", handleDocumentClick); | |
return () => { | |
ownerDocument.removeEventListener("click", handleDocumentClick); | |
}; | |
}, []); | |
// Alternate design 2: setTimeout | |
// This approach uses setTimeout to delay adding the handler until any current event has finished bubbling. | |
// This requires extra conditional cleanup logic to avoid leaking. | |
useEffect(() => { | |
const handleDocumentClick = (event: MouseEvent) => { | |
// ... | |
}; | |
let ownerDocument = null; | |
let timeoutID = setTimeout(() => { | |
timeoutID = null; | |
ownerDocument = ((modalRef.current: any): HTMLDivElement).ownerDocument; | |
ownerDocument.addEventListener("click", handleDocumentClick); | |
}, 0); | |
return () => { | |
if (timeoutID !== null) { | |
// Don't attach handlers if we're unmounted before the timeout has run. | |
// This is important! Without it, we might leak! | |
clearTimeout(timeoutID); | |
} | |
if (ownerDocument !== null) { | |
ownerDocument.removeEventListener("click", handleDocumentClick); | |
} | |
}; | |
}, []); | |
// Alternate design 3: Microtasks | |
// This approach uses queueMicrotask to delay adding the handler until any current event has finished bubbling. | |
// This requires extra conditional logic to avoid running code after unmount. | |
// It also requires a polyfill check _and_ extra ref logic to handle Offscreen hide/show. | |
const scheduleMicrotask = | |
typeof queueMicrotask === "function" | |
? queueMicrotask | |
: typeof Promise !== "undefined" | |
? (callback) => | |
Promise.resolve(null) | |
.then(callback) | |
.catch((error) => { | |
setTimeout(() => { | |
throw error; | |
}); | |
}) | |
: setTimeout; | |
const isHiddenRef = useRef(false); | |
useEffect(() => { | |
// Reset this in case we've been hidden and shown again (via Offscreen API). | |
isHiddenRef.current = false; | |
const handleDocumentClick = (event: MouseEvent) => { | |
// ... | |
}; | |
let ownerDocument = null; | |
scheduleMicrotask(() => { | |
if (isHiddenRef.current === true) { | |
// Can't cancel a microtask; | |
// But don't add a handler if the effect has already been destroyed, or we'd leak! | |
return; | |
} | |
ownerDocument = ((modalRef.current: any): HTMLDivElement).ownerDocument; | |
ownerDocument.addEventListener("click", handleDocumentClick); | |
}, 0); | |
return () => { | |
isHiddenRef.current = true; | |
if (ownerDocument !== null) { | |
ownerDocument.removeEventListener("click", handleDocumentClick); | |
} | |
}; | |
}, []); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment