Skip to content

fix(sharedResizeObserver): independent subscriptions per element - #5817

Merged
freddymeta merged 6 commits into
mainfrom
fix/shared-resize-observer-independent-callbacks
Sep 2, 2026
Merged

fix(sharedResizeObserver): independent subscriptions per element#5817
freddymeta merged 6 commits into
mainfrom
fix/shared-resize-observer-independent-callbacks

Conversation

@freddymeta

@freddymeta freddymeta commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

AST-010 §Implementation-requirements 9 asks for this and asks that it stay independently reviewable, so it is split out of #5783 rather than riding along in it.

The bug

callbacks was a Map<Element, ResizeCallback>one callback per element. callbacks.set(element, cb) overwrote, so a second hook observing the same node silently replaced the first, and either one calling unobserveResize(element) blinded the other.

Two hooks on one element is ordinary rather than exotic: a TabList root, a useOverflow container and a useTruncation target are all nodes another hook may reasonably watch. It is also unavoidable for AST-010 — FR11 puts every region of a multi-region hook on one shared container.

What changes

  • reference-counted callbacks per element, so duplicate callback references keep independent lifetimes without duplicate resize work;
  • observeResize returns an unsubscribe that removes only its own registration — all seven callers in the package now use it;
  • unobserveResize(element, callback) does the same by hand;
  • the callback-less unobserveResize(element) still drops every callback on the element, kept for a caller that owns its element outright;
  • dispatch snapshots the callback keys, so a callback may unsubscribe while the batch is running without skipping its neighbour.

What this replaces

A test named replaces callback when same element is observed twice asserted the old behaviour deliberately. I think that contract is the bug rather than a decision — it is what makes FR11 unimplementable — but it was explicit, so I am flagging the removal rather than quietly rewriting it.

Verification

  • The exact duplicate-reference regression fails on the reviewed head: after the first cleanup, the still-mounted registration receives 0 callbacks instead of 1. It passes on this head, including an idempotent repeat of the first cleanup.
  • sharedResizeObserver.test.ts: 11/11 pass.
  • Observer + Tokenizer focused suites: 83/83 pass.
  • pnpm lint:strict, pnpm build, and core typecheck pass on current main.
  • The exact six-component browser RTL audit passes: 4 measured · 2 verified N-A · 0 gap · 0 stale; the merged roster coverage suite passes 12/12.
  • Full pnpm test reaches the same two ContextMenu BottomSheet timing failures on clean current main and this branch; ContextMenu.test.tsx passes 48/48 in isolation. CI is the clean-run check.

The module held ONE callback per element — `callbacks.set(element, cb)`
overwrote — so a second hook observing the same node silently replaced
the first, and either one calling `unobserveResize(element)` blinded the
other. Two hooks on one element is ordinary: a TabList root, a
useOverflow container and a useTruncation target are all nodes another
hook may reasonably watch.

`observeResize` now returns an unsubscribe that removes only its own
registration, and all seven callers in the package use it.
`unobserveResize(element, callback)` does the same by hand; the
callback-less form still drops every callback on the element and stays
for a caller that owns its element outright.

Dispatch iterates a copy of the set, so a callback may unsubscribe while
the batch is running without skipping its neighbour.

AST-010 §Implementation-requirements 9 asks for this and asks that it
stay independently reviewable, so it is its own change rather than part
of the percentage-sizing work that needs it.

8534 core tests pass. The four new observer tests each fail against the
old module.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Sep 1, 2026
@vercel

vercel Bot commented Sep 1, 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 Sep 2, 2026 10:18am UTC

Request Review

@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Sep 1, 2026
github-actions Bot added a commit that referenced this pull request Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

AppShell (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 574 -
Complexity N/A Very High (96) -
Chat (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 5464 -
Complexity N/A Very High (447) -
TabList (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1166 -
Complexity N/A Very High (90) -
Table (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1950 -
Complexity N/A Very High (112) -
Text (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 520 -
Complexity N/A High (24) -
Tokenizer (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 737 -
Complexity N/A Very High (107) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB

Accessibility Audit

Status: 2 accessibility violation(s) found — 2 serious.

Text - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/23 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
Tokenizer - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/23 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Visual Regression

Status: Skipped — Broad stable scope is deferred to the daily release gate. It covers 4332 trusted baseline shots instead of recapturing them for this PR. View the report


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@cixzhang cixzhang 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.

Thanks for separating this prerequisite. A still-mounted region can stop following container resizes after a sibling unmounts: two observeResize calls sharing one callback collapse in the Set, and either cleanup removes both. Exact-head Chromium and Vitest both got zero callbacks where one should remain. Could each call keep an independent lifetime, with this duplicate-reference case added as a regression test?

[Reviewed by Robohands]

@github-actions
github-actions Bot requested a review from cixzhang September 2, 2026 09:43
@freddymeta

freddymeta commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the duplicate-reference lifetime bug from the latest review.

  • Each element now reference-counts registrations per callback, so two observeResize(element, sameCallback) calls remain independent.
  • Returned cleanup functions are idempotent; cleaning the first registration twice still leaves the second alive.
  • Added the exact failing-first regression: reviewed head delivered 0 callbacks after the first cleanup; this head delivers 1, then 0 after the second cleanup.
  • Migrated the Tokenizer observer caller added on current main, so no production caller uses whole-element cleanup.

Verification: 11/11 observer tests; 83/83 observer + Tokenizer tests; lint:strict, build, and core typecheck pass. Full-suite control hits the same two ContextMenu timing failures on clean main and this branch; that file passes 48/48 alone. CI is running on the pushed head.

— Puck, on behalf of Freddy

CI follow-up (ae2c0229a): pr-rtl exposed four real coverage gaps plus a synthetic unknown/chat duplicate. Added measured TabList coverage and source/story-verified N-A reasons for Table/Text. Current main supplied measured Chat coverage plus the unit-tested story-only roster fix; the final merge keeps that implementation. The exact AppShell/Chat/TabList/Table/Text/Tokenizer browser audit passes: 4 measured · 2 verified N-A · 0 gap · 0 stale.

…ae139

# Conflicts:
#	apps/storybook/rtl-audit/rtl-audit.mjs
github-actions Bot added a commit that referenced this pull request Sep 2, 2026
github-actions Bot added a commit that referenced this pull request Sep 2, 2026

@cixzhang cixzhang 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.

Thanks, this closes the shared-callback lifetime gap. The prior-head regression now fails before and passes here, including duplicate callback references and idempotent cleanup.

[Reviewed by Robohands]

@github-actions github-actions Bot removed the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Sep 2, 2026
@freddymeta
freddymeta merged commit d55751f into main Sep 2, 2026
28 checks passed
@github-actions
github-actions Bot deleted the fix/shared-resize-observer-independent-callbacks branch September 3, 2026 06:22
Geervan pushed a commit to Geervan/astryx that referenced this pull request Sep 3, 2026
* fix(useResizable): percentage configuration with an explicit basis (AST-010)

Implements the accepted AST-010 contract. Percentages configure pixel
state; they do not create a responsive selection mode.

- `minSize` / `maxSize` accept the same exact number, `Npx`, or 0–100
  `N%` vocabulary as `defaultSize`. Deprecated pixel aliases remain exact
  mutually-exclusive unions; unified props win untyped conflicts.
- caller-owned `containerRef` selects the percentage basis. It follows
  replacement elements, measures the content-box active axis, and releases
  the element left behind. Without a ref, the released one-time viewport /
  1200px server fallback remains.
- defaults resolve once into pixel selection. Bounds re-resolve and clamp
  that selection permanently; a clamped default cannot revive when its
  basis grows.
- hidden, detached, and not-yet-laid-out containers report zero, which is
  treated as unmeasured. Temporary fallback geometry is not persisted over
  a legal saved size.
- pointer, keyboard, snaps, collapse, callbacks, persistence, ARIA, and
  `resize(number)` stay pixel-based. A trusted pointercancel, lost capture,
  or unmount releases the basis frozen for the gesture without claiming a
  completed resize.
- ResizeHandle warns when its direction disagrees with the hook. The new
  `_direction` and `_onResizeCancel` fields are optional because
  `ResizableProps` is exported and old object literals must keep compiling.

Browser evidence on this commit:

  replacement: basis 400->800, max 200->400, size 200->200
  hidden mount: saved 321px remains 321px; storage unchanged
  touchCancel: max 500 -> frozen 500 -> 300; size=300; drag styles clear

The touch-cancel probe uses CDP's real touchCancel. An untrusted synthetic
PointerEvent did not reach React and was rejected as a harness bug rather
than product evidence.

Restacked on corrected facebook#5817 head 490cbfa; this layer touches no shared
observer or migrated-observer consumer files.

* fix(useResizable): keep the one-pass pixel-only mount, and measure Resizable/Layout RTL

A pixel-only configuration needs no container measurement, so it must reach
its size on the first render pass. Latching the selection lazily made the
render-phase adjustment run at mount for every such region, doubling hook
work: 1 pass to 2 at one mount, 50 to 100 at fifty. The selection is now
initialized outright whenever the basis is already real; only a supplied
container still defers, because its measurement does not exist until after
commit, and that correction is unchanged.

Adds the curated RTL targets this change needs: Layout D2 (start/end panel
order) and Resizable D4 (handle side), closing both scoped coverage gaps.

* fix(useResizable): add structured percentage sizing

Add Table-aligned pixel() and percent() descriptors with one pixel floor or ceiling while keeping state, persistence, callbacks, paint, and ARIA in resolved pixels.

* Fix Resizable ref and direction regressions

---------

Co-authored-by: freddymeta <freddymeta@users.noreply.github.com>
Co-authored-by: freddymeta <170298698+freddymeta@users.noreply.github.com>
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. community Authored by a community contributor (not on the eng/design team)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants