Skip to content

Rich Text: Update useAnchor to receive ref object - #82191

Open
aduth wants to merge 2 commits into
trunkfrom
update/rich-text-use-anchor-ref
Open

aduth wants to merge 2 commits into
trunkfrom
update/rich-text-use-anchor-ref

Conversation

@aduth

@aduth aduth commented Aug 28, 2026 •

Copy link
Copy Markdown
Member

What?

Updates @wordpress/rich-text's useAnchor to receive a ref object instead of an element, with internal normalization to preserve backwards-compatibility while deprecating editableContentElement.

Why?

This hook currently emits ESLint errors from eslint-plugin-react-hook's refs rule, and those errors are currently being suppressed. To avoid perpetuating suppressions (most recently in #74040), we should fix the root issue.

The issue is legitimate, and React is clear that ref values should not be read in the render path:

Do not write or read ref.current during rendering, except for initialization. This makes your component’s behavior unpredictable.

https://react.dev/reference/react/useRef#caveats

How?

Adds new editableContentRef option to useAnchor, updates existing call sites, and normalizes internal implementation to use ref value instead in its own lifecycle.

Testing Instructions

Existing test coverage would be expected to identify any regressions of these changes.

Use of AI Tools

Used Claude Code + Opus 5 to understand change following from #74040 to fix, with changes reviewed by myself.

@aduth aduth added the [Type] Code Quality Issues or PRs that relate to code quality label Aug 28, 2026
@aduth
aduth requested review from a team, ajitbohra and ellatrix as code owners August 28, 2026 18:01
@aduth aduth added the [Package] Rich text /packages/rich-text label Aug 28, 2026
@github-actions github-actions Bot added [Package] Components /packages/components [Package] Format library /packages/format-library labels Aug 28, 2026
@github-actions

Copy link
Copy Markdown

Size Change: +90 B (0%)

Total Size: 7.91 MB

📦 View Changed
Filename Size Change
build/scripts/components/index.min.js 266 kB -5 B (0%)
build/scripts/format-library/index.min.js 39.4 kB -6 B (-0.02%)
build/scripts/rich-text/index.min.js 14.9 kB +101 B (+0.68%)

compressed-size-action

element.removeEventListener( 'focusout', detach );
};
}, [ editableContentElement, tagName, className, isActive, wasActive ] );
}, [ contentRef, tagName, className, isActive, wasActive ] );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

contentRef is in the dependency array, but React only compares the ref object's identity. When React reuses that object and changes .current from element A to element B, this effect does not rerun, so the anchor and listeners stay attached to A. Could we rebind when the ref target changes and cover that case with a test?

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.

This is a pre-existing issue with the useAnchor hook. When someone assigns to contentRef.current somewhere in another component far away, it doesn't trigger a rerender. The hook call:

useAnchor( { editableContentElement: contentRef.current } );

will not be performed again, and the contentRef.current expression will not read the new value. The rerender needs to be triggered by some other, unrelated cause. That's why the ESLint warning is pointing to something real when forbidding the ref read during render.

But our refactor here makes it worse: now even an unrelated rerender won't trigger an update. The effect will use the original contentRef.current value, until another dependency (isActive etc) changes.

To really fix this we would have to completely refactor useAnchor and its usages. Instead of receiving a ref as an argument, it needs to return one, and do the "reactive" work inside useRefEffect.

Comment on lines 189 to 192
}: {
editableContentElement: HTMLElement | null;
editableContentRef?: RefObject< HTMLElement | null >;
editableContentElement?: HTMLElement | null;
settings?: WPFormat;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This shape allows for multiple anchors.

Could we model the anchor source as a one-of type that requires either editableContentRef or the deprecated editableContentElement, and mark the legacy property with @deprecated? The current shape accepts neither or both, so invalid calls compile and consumers get no editor deprecation hint.

const contentRef = useMemo(
() => editableContentRef ?? { current: editableContentElement ?? null },
[ editableContentRef, editableContentElement ]
);

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.

Can be done slightly differently, making the legacy ref object constant:

const elementRef = useRef( editableContentElement );
useLayoutEffect( () => {
  elementRef.current = editableContentElement;
}, [ editableContentElement ] );
const contentRef = editableContentRef ?? elementRef;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That would work, but the anchor effect still needs to rerun when editableContentElement changes. The memoized wrapper currently provides that trigger, we'll need to take that into account

element.removeEventListener( 'focusout', detach );
};
}, [ editableContentElement, tagName, className, isActive, wasActive ] );
}, [ contentRef, tagName, className, isActive, wasActive ] );

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.

This is a pre-existing issue with the useAnchor hook. When someone assigns to contentRef.current somewhere in another component far away, it doesn't trigger a rerender. The hook call:

useAnchor( { editableContentElement: contentRef.current } );

will not be performed again, and the contentRef.current expression will not read the new value. The rerender needs to be triggered by some other, unrelated cause. That's why the ESLint warning is pointing to something real when forbidding the ref read during render.

But our refactor here makes it worse: now even an unrelated rerender won't trigger an update. The effect will use the original contentRef.current value, until another dependency (isActive etc) changes.

To really fix this we would have to completely refactor useAnchor and its usages. Instead of receiving a ref as an argument, it needs to return one, and do the "reactive" work inside useRefEffect.

@ellatrix

Copy link
Copy Markdown
Member

@aduth @jsnajdr We already have the editable element as state in rich text, so might as well pass it down I guess. I'm not sure how useAnchor has become so convoluted over the years. #83475 tries to address both.

@aduth

aduth commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

I appreciate everyone taking a look here, and sorry I haven't had a chance to catch up and respond to the feedback. If it's the case that #83475 can act as a better alternative solution here, I'm happy to close this one in favor of the other. The main reason I opened this one was largely to address perpetuation of added entries to suppressions.json, and I'm happy with however it gets solved 🙂

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

🤖 PR meta 🤖

🎉 Props

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

Co-authored-by: aduth <aduth@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>
Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: ellatrix <ellatrix@git.wordpress.org>

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

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Components /packages/components [Package] Format library /packages/format-library [Package] Rich text /packages/rich-text [Type] Code Quality Issues or PRs that relate to code quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants