Skip to content

FE-1181: Fix entity slide-over jitter from scroll-lock feedback loop#8995

Merged
alex-e-leon merged 2 commits into
mainfrom
fe/fix-slide-stack-scroll-lock-oscillation
Jul 10, 2026
Merged

FE-1181: Fix entity slide-over jitter from scroll-lock feedback loop#8995
alex-e-leon merged 2 commits into
mainfrom
fe/fix-slide-stack-scroll-lock-oscillation

Conversation

@claude

@claude claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Requested by Alex Leon · Slack thread

🌟 What is the purpose of this PR?

Before: with the entity editor slide-over open (e.g. on /entities), the whole panel — contents and its own scrollbar — vibrates side-to-side by ~15px (a scrollbar width) continuously, at roughly every frame, while the page behind it stays still.

After: the panel stays still.

How: the shake is the document scrollbar being toggled every frame by a feedback loop inside our own useScrollLock hook (previously apps/hash-frontend/src/shared/use-scroll-lock.ts) — not MUI, and not the entities-table height measurement. The slide stack calls useScrollLock when a slide opens (slide-stack.tsx:126), which hides the always-on document scrollbar (globals.scss sets body { overflow-y: scroll }) and compensates with an integer padding-right. The hook also live-tracked the scrollbar size with a ResizeObserver on body, and its layout effect depended on that tracked value:

  1. Lock applies overflow: hidden; padding-right: 15px → scrollbar disappears → the slide (position: fixed; right: 0) shifts right by the scrollbar width, following the viewport edge.
  2. At fractional effective scrollbar widths (e.g. non-integer device-pixel-ratio geometry, where the bar's true width isn't a whole number of CSS px but the window.innerWidth - clientWidth compensation is), hiding the bar changes body's content box by a sub-pixel amount → the observer fires → it measures the scrollbar size while the lock has hidden the scrollbar, reads 0 → the effect re-runs and its cleanup releases the lock even though it is still active.
  3. The scrollbar reappears → the slide snaps back left → the observer fires again, reads 15 → the lock re-applies → loop, indefinitely at ~30Hz.

The page behind doesn't move because the padding compensation keeps in-flow content constant to within a sub-pixel; only the viewport-anchored fixed slide follows the toggling scrollbar. On exact integer geometry the loop can't sustain (the body box doesn't change), which is why it only reproduces on some machines/scale factors.

The fix measures the scrollbar size once, synchronously, at the moment the lock is applied, and drops the live tracking entirely — the effect no longer depends on a value that its own side effect changes, so the feedback path is gone. This is the same principle documented in useAvoidScrollWidthChange (libs/@hashintel/ds-components): never trust a scrollbar measurement taken while the bar is absent. Per review, the hook now lives in @hashintel/ds-components (src/util/use-scroll-lock.ts, next to useAvoidScrollWidthChange), with the scrollbar measurement both hooks share extracted into a src/util/scrollbar-size.ts util.

Note: the working hypothesis was a regression from #8783/#8786 interacting with the #8660 measurement approach. The evidence contradicts that: in reproduction, the #8660 ResizeObserver fires once per toggle but contentTop never changes, so it is not part of the loop. The latent bug has been in useScrollLock all along; the recent PRs merely changed the page on which people happened to have the slide-over open.

🔗 Related links

  • Reproduction + fix verified in an isolated Playwright/Chromium harness replicating use-scroll-lock.ts, the slide-stack.tsx fixed-position slide, and body { overflow-y: scroll }: at --force-device-scale-factor=1.5 the slide's getBoundingClientRect().x alternates 512 ↔ 496.67 (15.33px = the real fractional scrollbar width) with 60 flips per 120 frames; with the fix, 0 flips at scale factors 1, 1.5 and 2, with the lock still applied and held.

🚫 Blocked by

Nothing.

🔍 What does this change?

  • useScrollLock moved from apps/hash-frontend/src/shared/use-scroll-lock.ts to libs/@hashintel/ds-components/src/util/use-scroll-lock.ts (next to useAvoidScrollWidthChange) and is exported from @hashintel/ds-components, per review.
  • The fix itself: removed useLastScrollbarSize (the ResizeObserver + state that the effect depended on). The layout effect now measures the scrollbar size when active becomes true and applies/removes the lock only on active/elementToLock changes. Behaviour is otherwise preserved: same measurement method (MUI's getScrollbarSize approach), still a no-op when the scrollbar size is 0 or when something else already set overflow: hidden.
  • New libs/@hashintel/ds-components/src/util/scrollbar-size.ts: the scrollbar measurement previously duplicated in both hooks — the element-level "offsetWidth - clientWidth minus borders" formula (per axis: getConsumedScrollbarWidth / getConsumedScrollbarHeight) and the document-level window.innerWidth - documentElement.clientWidth variant (getDocumentScrollbarSize). Both useScrollLock and useAvoidScrollWidthChange now consume it; useAvoidScrollWidthChange's behaviour is unchanged. The two hooks stay separate — measure-once-at-lock-time vs live-tracking is an intentional distinction.
  • Updated the three hash-frontend call sites (src/pages/shared/slide-stack.tsx, src/components/grid/utils/override-custom-renderers.tsx, src/pages/shared/block-collection/shared/mention-suggester.tsx) to import useScrollLock from @hashintel/ds-components.
  • Added a changeset for @hashintel/ds-components (patch).

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • modifies an npm-publishable library and I have added a changeset file(s)

📜 Does this require a change to the docs?

The changes in this PR:

  • are internal and do not require a docs change

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

  • Unchanged existing behaviour: the hook still applies no lock at all when the measured scrollbar size is 0 (e.g. macOS overlay scrollbars), matching the previous logic.
  • If the window is resized while a lock is held, the padding compensation is not re-derived — but the scrollbar is hidden during the lock, so there is nothing meaningful to re-measure (the old live-tracking "handled" this case by erroneously releasing the lock).

🐾 Next steps

  • Consider scrollbar-gutter: stable on the root as a belt-and-braces way to make document-scrollbar toggling layout-neutral everywhere, which would also stop the slide-over's one-time ~15px shift into the vacated gutter when it opens.
  • Consider whether the slide stack could move to the ds-components Drawer/useAvoidScrollWidthChange machinery when it is next reworked (useScrollLock itself now lives in ds-components as of this PR).

🛡 What tests cover this?

  • None automated in-repo. Verified in an isolated browser harness as described above (oscillation reproduced against the old logic, eliminated with the new logic, lock still engages).

❓ How to test this?

  1. Set a fractional display scale (e.g. Chrome on a display where the scrollbar's true width isn't an integer number of CSS px — --force-device-scale-factor=1.5 reproduces it deterministically) with classic (non-overlay) scrollbars.
  2. Open /entities and click a row to open the entity editor slide-over.
  3. Before: the slide-over shakes horizontally by a scrollbar width continuously. After: it stays still, and background scrolling remains locked while it is open.

📹 Demo

See the screen recording in the Slack thread for the before state.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JxtJJuAiNutazSAFv4msbA


Generated by Claude Code

…t scroll-lock time

useScrollLock live-tracked the locked element's scrollbar size with a
ResizeObserver. While the lock itself hides the scrollbar, that measurement
reads 0, which released the lock, which restored the scrollbar, which
measured non-zero and re-applied the lock — an infinite loop toggling the
document scrollbar every frame whenever hiding it changed the body's box by
any sub-pixel amount (fractional device-pixel-ratio scrollbar widths vs the
integer compensation). The entity editor slide stack is fixed-positioned at
right: 0, so it visibly shook by a scrollbar width at ~30Hz.

Measure the scrollbar once when the lock is applied instead: the effect no
longer depends on a value its own side effect changes, so there is no
feedback path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxtJJuAiNutazSAFv4msbA
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview, Comment Jul 9, 2026 6:38pm
hashdotdesign-tokens Ready Ready Preview, Comment Jul 9, 2026 6:38pm
petrinaut Ready Ready Preview Jul 9, 2026 6:38pm

@github-actions github-actions Bot added area/apps > hash* Affects HASH (a `hash-*` app) type/eng > frontend Owned by the @frontend team area/apps labels Jul 9, 2026
@claude claude Bot changed the title Fix entity editor slide-over oscillating horizontally when opened H-6678: Fix entity slide-over jitter from scroll-lock feedback loop Jul 9, 2026
@claude claude Bot changed the title H-6678: Fix entity slide-over jitter from scroll-lock feedback loop FE-1181: Fix entity slide-over jitter from scroll-lock feedback loop Jul 9, 2026
@claude claude Bot marked this pull request as ready for review July 9, 2026 16:56
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches a published design-system package and global body scroll-lock behavior used by slide-overs and overlays; the logic change is narrow but affects layout-sensitive UI paths.

Overview
Fixes horizontal jitter on entity slide-overs (and similar overlays) by changing how useScrollLock measures the scrollbar: it now reads width once when the lock turns on, instead of live-tracking with a ResizeObserver. That removes a feedback loop where hiding the scrollbar made the observer read 0, cleanup released the lock, the bar came back, and the cycle repeated every frame on some DPI/scale settings.

useScrollLock is moved from hash-frontend into @hashintel/ds-components and exported from the package. Hash-frontend call sites (slide stack, mention suggester, grid editor scroll lock) now import it from @hashintel/ds-components.

Adds an internal scrollbar-size helper (document, element width/height consumption) and wires useAvoidScrollWidthChange to use it instead of inline measurement. Patch changeset for @hashintel/ds-components.

Reviewed by Cursor Bugbot for commit 8f925c6. Bugbot is set up for automated code reviews on this repo. Configure here.

…measurement

Move the hook from apps/hash-frontend/src/shared/use-scroll-lock.ts to
libs/@hashintel/ds-components/src/util/use-scroll-lock.ts, next to
useAvoidScrollWidthChange, and export it from the package entrypoint.

Extract the duplicated scrollbar measurement into a shared
src/util/scrollbar-size.ts: the element-level "offsetWidth - clientWidth
- borders" formula (per axis) and the document-level
"window.innerWidth - documentElement.clientWidth" variant, consumed by
both hooks. The hooks themselves stay separate on purpose: useScrollLock
measures once at lock time (the oscillation fix), while
useAvoidScrollWidthChange live-tracks; useAvoidScrollWidthChange's
behaviour is unchanged.

Update the three hash-frontend call sites to import from
@hashintel/ds-components and add a changeset for the package.

Requested by Alex Leon in review of the oscillation fix.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxtJJuAiNutazSAFv4msbA
@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) area/apps > hash.design Affects the `hash.design` design site (app) labels Jul 9, 2026

@alex-e-leon alex-e-leon 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.

LGTM - Once I've done a round of manual testing I'll merge

@alex-e-leon alex-e-leon added this pull request to the merge queue Jul 10, 2026
Merged via the queue into main with commit 5db8b5d Jul 10, 2026
52 checks passed
@alex-e-leon alex-e-leon deleted the fe/fix-slide-stack-scroll-lock-oscillation branch July 10, 2026 09:00
This was referenced Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps > hash.design Affects the `hash.design` design site (app) area/apps > hash* Affects HASH (a `hash-*` app) area/apps area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

2 participants