Skip to content

useTypingObserver: capture the window reference for cleanup#78772

Open
xavier-lc wants to merge 8 commits into
WordPress:trunkfrom
xavier-lc:fix/observe-typing-null-default-view
Open

useTypingObserver: capture the window reference for cleanup#78772
xavier-lc wants to merge 8 commits into
WordPress:trunkfrom
xavier-lc:fix/observe-typing-null-default-view

Conversation

@xavier-lc

@xavier-lc xavier-lc commented May 28, 2026

Copy link
Copy Markdown
Contributor

What?

Capture the iframe window once, while the node is still attached, and reuse that reference for the rest of useTypingObserver's effect.

Why?

When the block editor is hosted inside an iframe (the default in 15.x), React can fire the useRefEffect cleanup after the iframe document has been detached from its window. At that point document.defaultView === null, so the unguarded property access throws:

TypeError: Cannot read properties of null (reading 'clearTimeout')

The throw aborts the rest of the cleanup — the removeEventListener calls that follow clearTimeout never run, leaking focus, keydown, and selectionchange listeners on the unmounting node.

The matching setTimeout and getSelection accesses are guarded for symmetry: the invariant the hook now enforces is we never assume the iframe's window is still attached, regardless of which callback runs.

How?

defaultView is a live getter that becomes null when the frame detaches. We read it once at mount (always safe) and reuse the captured reference in the handlers and cleanup, so nothing re-reads node.ownerDocument.defaultView at teardown time.

Testing Instructions

No behavior change in normal use.

Screenshots or screencast

N/A — no UI change.

Use of AI Tools

This PR was authored with assistance from Claude Code (Anthropic).

@xavier-lc xavier-lc requested a review from ellatrix as a code owner May 28, 2026 11:43
@github-actions github-actions Bot added the [Package] Block editor /packages/block-editor label May 28, 2026
@github-actions

github-actions Bot commented May 28, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @xavier.lozano.carreras@a8c.com.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: xavier.lozano.carreras@a8c.com.

Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: xavier-lc <xavilc@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@Mamaduka Mamaduka added the [Type] Bug An existing feature does not function as intended label May 28, 2026
@jsnajdr

jsnajdr commented May 29, 2026

Copy link
Copy Markdown
Member

@xavier-lc is there any way how to reproduce the bug in "real life"? The PR description describes only a manual scenario where I set iframe.contentDocument to null. But how did you discover the bug?

The crash only surfaces when the editor's canvas iframe is removed from the DOM before React runs the ref cleanup.

How can this happen? The iframe is managed by React, does React itself remove the element before finishing cleanup? That looks unlikely.


return () => {
node.ownerDocument.defaultView.clearTimeout( timerId );
node.ownerDocument.defaultView?.clearTimeout( timerId );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the very best way how to fix this is to store the reference to document/window when mounting, and then using the same object when cleaning up:

useRefEffect( ( node ) => {
  const win = node.ownerDocument.defaultView;
  win.addEventListener();
  return () => { win.removeEventListener(); };
} );

This also guarantees that we're removing the listener from the object where we actually added it, not from some different one.

Some notes:

  • we probably need to fix only the cleanup and nothing else. For example, inside event handlers the window object is always there. On an inactive/loading/unloading frame, events like focus or selectionchange wouldn't probably happen in the first place.
  • to call setTimeout and clearTimeout, we don't really need the iframe's window object. We can just call it on the top-level document.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the feedback, I've applied the changes.

return <div ref={ ref } data-testid="target" />;
}

describe( 'useTypingObserver', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this unit test is not very helpful, it tests a very internal edge case and even that edge case is being mocked. I recommend to remove it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's removed now.

@xavier-lc xavier-lc force-pushed the fix/observe-typing-null-default-view branch 2 times, most recently from 42fc8ff to c653873 Compare June 9, 2026 15:56
@xavier-lc xavier-lc changed the title useTypingObserver: guard against null defaultView during cleanup useTypingObserver: capture the window reference for cleanup Jun 9, 2026
@xavier-lc xavier-lc requested a review from jsnajdr June 9, 2026 17:37
Xavier Lozano Carreras added 8 commits June 10, 2026 09:56
When the block editor is hosted inside an iframe, React can fire the
useRefEffect cleanup after the iframe document has been detached from
its window. At that point document.defaultView === null, so the
unguarded `node.ownerDocument.defaultView.clearTimeout( timerId )`
inside the cleanup throws, which also aborts the removeEventListener
calls that follow it (leaking those listeners on the unmounting node).

Optional-chain the defaultView access in the cleanup, plus the matching
setTimeout in stopTypingOnNonTextField and getSelection in
stopTypingOnSelectionUncollapse, so the hook never assumes the iframe's
window is still attached.
Mounts the hook in the isTyping state so the cleanup branch that touches
`defaultView` is reached, overrides `ownerDocument.defaultView` to null
to simulate the iframe being detached from its window, then unmounts.

Asserts both that the cleanup does not throw and that the
`selectionchange`, `focus`, and `keydown` listeners are still removed in
that scenario — the leak path the original throw was masking.
The test mocked an internal edge case (manually nulling `ownerDocument.defaultView`) that doesn't correspond to a reachable real-world state, so it asserted against a synthetic condition rather than observable behavior.
The cleanup function called `node.ownerDocument.defaultView.clearTimeout()`. When the editor canvas iframe is detached from its window before React runs the ref cleanup, `defaultView` is `null` and the access throws, aborting the rest of the cleanup and leaking the `focus`, `keydown`, and `selectionchange` listeners.

The typing timer never needed the iframe's window in the first place, so set and clear it on the top-level `setTimeout`/`clearTimeout` instead. The timer id stays valid regardless of the frame's state, and the cleanup no longer touches `defaultView`.
@xavier-lc xavier-lc force-pushed the fix/observe-typing-null-default-view branch from 216e520 to 9406512 Compare June 10, 2026 07:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Block editor /packages/block-editor [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants