Skip to content

refactor(Overlay): take the first child's radius in the wrapper's ref callback, not an Effect - #9861

Closed
cixzhang wants to merge 3 commits into
mainfrom
effect-overlay-radius
Closed

cixzhang wants to merge 3 commits into
mainfrom
effect-overlay-radius

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

Why: no user action owns this work; it belongs to the moment the wrapper attaches. Overlay copies its first child's corner radius onto its wrapper so the clipped scrim follows the media's rounded corners. It did this in a mount-only layout Effect that read the wrapper from a ref. The read needs the wrapper element, so the wrapper's ref callback owns it (architecture:react-component-runtime INV3 and INV6). The Effect also needed a lint suppression to write the style.

Intended invariant

When the first child has a corner radius, the wrapper gets the same radius before the first paint. When the first child has none, the wrapper gets no inline radius. The caller's ref still gets the wrapper.

Change

What: packages/core/src/Overlay/Overlay.tsx.

  • The useIsomorphicLayoutEffect in Overlay (about line 99) is removed, with its import and its lint suppression.
  • A module-level ref callback, mirrorFirstChildRadius (about line 57), does the same read and write. It joins the wrapper's merged ref after the caller's ref and overlay.containerRef.
  • The callback copies once per wrapper node (a module-level WeakSet). useMergedRefs rebuilds the merged ref when the caller's ref changes identity, so an inline callback ref re-runs it on every render. Like the mount-only Effect, the copy must not run again and replace a radius that the caller set later.

React calls a ref callback during commit, after the children attach, which is the same phase as a layout Effect.

Evidence

  • Failure before the change: a layout Effect did node-attach work and needed a lint suppression.
  • Success after the change: no Effect; the wrapper's ref callback copies the radius.
  • Product/runtime behavior verified unchanged: new tests check that the wrapper takes a 12px child radius, takes no radius from a child with none, that a callback ref gets the same wrapper, and that a re-render with an inline callback ref keeps a radius set after mount (40px stays 40px; this test fails without the once-per-node guard and passes on main). The existing Overlay tests pass without changes.
  • CI impact: none expected. One Core source file, its unit test, and a patch Changeset.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

No behavior change.

Risk

Low. Server rendering runs neither a ref callback nor a layout Effect, so it is unchanged. useOverlay hook consumers are not affected; only the component copies the radius.

Testing

@vercel

vercel Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 4, 2026 3:28am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 4, 2026
@astracat-bot

astracat-bot Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 4, 2026, 3:54 AM UTC

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · a313ca0

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
Overlay · Storybook 19.8 kB (−3 B) · 4.0 kB CSS (0) 458 (0) ✓ · ✓ · ✓

Package size: measured; main has no measurement of the merge base to compare yet.

API changes: none in this pull request

Committed API report behind this build: core 2 rows; run pnpm api:report.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

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.

Changes requested: moving the radius copy from a layout Effect into the wrapper's ref callback is the right home for this work, but as written it changes behavior for one kind of caller.

  • Overlay.tsx:113: useMergedRefs rebuilds the merged ref whenever the caller's ref changes identity, so with an inline callback ref (ref={el => …}) the radius copy runs again on every render, replacing a caller's later style.borderRadius with the first child's radius and calling getComputedStyle each render, where main copied once per mount.

Copying once per wrapper node (for example, skipping a wrapper that was already copied to) and adding a test that re-renders with an inline callback ref would make the "no behavior change" claim hold. A small non-blocking wording suggestion for the new comment is inline.

[Automated review]

<div
{...rest}
ref={useMergedRefs(ref, overlay.containerRef)}
ref={useMergedRefs(ref, overlay.containerRef, mirrorFirstChildRadius)}

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.

Blocking. useMergedRefs rebuilds this callback whenever the caller's ref changes identity. With an inline callback ref (ref={el => …}), React re-attaches it on every render, and mirrorFirstChildRadius reads getComputedStyle and writes the radius again. On main the copy ran once per mount. A caller's later style.borderRadius is replaced by the child's radius: 40px stays 40px on main and becomes 12px here. One way to copy once per wrapper node:

const copiedWrappers = new WeakSet<HTMLElement>();

function mirrorFirstChildRadius(container: HTMLElement | null) {
  if (container == null || copiedWrappers.has(container)) {
    return;
  }
  copiedWrappers.add(container);
  const firstChild = container.firstElementChild;
  if (firstChild == null) {
    return;
  }
  const radius = getComputedStyle(firstChild).borderRadius;
  if (radius && radius !== '0px') {
    container.style.borderRadius = radius;
  }
}

A test that re-renders with an inline callback ref and a changed style.borderRadius would pin it.

Comment thread packages/core/src/Overlay/Overlay.tsx Outdated
Comment on lines +52 to +54
// The wrapper takes the radius of its first child, so the scrim clips to the
// media corners. Only the component does this. A hook consumer sets the
// radius on its own element.

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.

Non-blocking: the first sentence restates mirrorFirstChildRadius. A version that keeps only the reason and the constraint:

// The scrim clips to the media's rounded corners.
// A useOverlay consumer sets the radius on its own element.

@astracat-bot astracat-bot Bot left a comment

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.

Approval waits on one owner decision; nothing else blocks, and the earlier inline-ref blocker is fixed and pinned by the new re-render test.

  • Overlay.tsx:59-62: copying once per wrapper node also stops main's re-read of the first child when a hidden <Activity> or a re-suspended Suspense boundary shows the same Overlay again, so a radius changed while hidden or a late first child no longer reaches the wrapper while a radius set later on the wrapper now stays, and no current record says which behavior Overlay promises.

An engineering owner needs to confirm whether copy-once-per-wrapper-node is the intended contract on re-show; if it is, the change can land as is, ideally with the Changeset saying so instead of "Nothing a consumer imports or renders changes". A non-blocking wording suggestion for the new comment is inline.

[Automated review]

Comment on lines +59 to +62
if (container == null || wrappersWithRadius.has(container)) {
return;
}
wrappersWithRadius.add(container);

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.

Owner decision needed. This guard also skips the copy when a hidden <Activity> or a re-suspended Suspense boundary shows the same Overlay again; React re-attaches the same wrapper there. On main the layout Effect re-ran at that point and re-read the first child: a child radius changed while hidden (12px to 48px) reached the wrapper, and a radius set later on the wrapper was replaced. Here the wrapper keeps its first copy (12px, and the caller's 48px stays). If once per wrapper node is the intended contract on re-show, this is fine as is.

Comment on lines +52 to +54
// The scrim clips to the media's rounded corners. Copy the radius once per
// wrapper node: a caller's inline callback ref re-runs the merged ref on each
// render, and a later radius from the caller must stay.

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.

Non-blocking: the second sentence is 28 words with three topics. A version with one topic per sentence:

// The scrim clips to the media's rounded corners.
// Copy the radius once per wrapper node. An inline callback ref re-runs
// the merged ref on each render. A radius that the caller sets later stays.
// A useOverlay consumer sets the radius on its own element.

This branch was successfully deployed

1 active deployment
Preview — a313ca01 Deployed Nov 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant