Last active
July 6, 2022 23:34
-
-
Save bvaughn/fc1c3f27f1aab91c7378f2264f7c3aa1 to your computer and use it in GitHub Desktop.
Attaching manual event listeners in a passive effect
This file contains 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 asynchronously, 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
The third approach doesn’t work because micro-tasks fire between bubbles events. You can use postTask instead though.
Another approach could be to use window.event.
This doesn’t work if the event is triggered inside a different synchronous event inside a click event though.
I believe that in practice this doesn’t come up a lot because you typically want to listen to the capture event anyway.