Skip to content

refactor(AppShell): main's ref is the one owner of its overflow observation and keyboard delegation - #9262

Merged
cixzhang merged 2 commits into
mainfrom
fix/appshell-main-release-on-detach
Nov 1, 2026
Merged

cixzhang merged 2 commits into
mainfrom
fix/appshell-main-release-on-detach

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

Nothing changes for AppShell's users. A maintainer reading AppShell sees one owner for main's overflow observation and keyboard delegation: main's callback ref. Before this change a second owner, an unmount Effect, released the same resources again. The Effect's cleanup always found them already released, so it did nothing, but it made the lifecycle look split between two owners.

Intended invariant

architecture:react-component-runtime (current):

  • INV5: every listener and observer has one declared lifecycle owner.
  • INV6: a callback ref owns attachment, null detachment and replacement.
  • INV3: an Effect exists to synchronize an external system.

Main's ref already owns attach, replacement and detach (#8111), including the detach React performs on unmount.

Change

  • AppShell.tsx: the unmount-only Effect that stopped mainOverflowRef and released mainDelegationRef is removed. setMainNode(null), which React calls on unmount, already stops the overflow observation, and syncMainDelegation() releases the delegation.
  • AppShell.test.tsx: a new case makes main overflow so the keyboard delegation attaches, then unmounts. It records the exact listeners the delegation adds (keydown, keyup, pointerdown, focusin, capture) and the overflow observation's window resize listeners, and requires each one to be removed with the same handler, so the focus-ring Effect's own cleanup of the same event types cannot stand in. It fails when the delegation is not released.
  • .changeset/appshell-main-release-on-detach.md: a [chore] patch note; no behavior changes.

From a review note by vjeux on the fill-height keyboard work.

Evidence

  • pnpm vitest run packages/core/src/AppShell/: 2 files, 68 tests pass, including the new unmount case.
  • ESLint and Prettier are clean on the changed files.

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.

…vation and keyboard delegation

An unmount Effect repeated the release main's callback ref already performs:
React detaches the ref on unmount, and the null call stops the overflow
observation and releases the keyboard delegation. The Effect is removed, so
each resource has one lifecycle owner. A test pins that unmount still
releases both. No behavior changes.
@vercel

vercel Bot commented Nov 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 Nov 1, 2026 6:04am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 6:09 AM UTC

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

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 6482414

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC
AppShell · Storybook 71.5 kB (−19 B) · 12.0 kB CSS (0) 1,011 (−9)

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

API changes: core +2 · ~1 · −0 (report stale: run pnpm api:report)

3 API changes (advisory)
Package Where Signature Usage
+ core . · Types · interface SearchContext interface SearchContext<T extends SearchableItem = SearchableItem> import type {SearchContext} from '@astryxdesign/core'
+ core ./Typeahead · Also exported from an earlier entry point · SearchContext SearchContext (.) import {SearchContext} from '@astryxdesign/core/Typeahead'
~ core . · Types · interface SearchSource search: (query: string) => Promise<T[]> | T[] → search: (query: string, context?: SearchContext<T>) => Promise<T[]> | T[] import type {SearchSource} from '@astryxdesign/core'; const x: SearchSource = {search: …}

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested. Removing the unmount Effect is right: main's ref already stops the overflow observation and releases the keyboard delegation when React detaches it. The new unmount test only proves the first half: its keydown/focusin assertions are also met by the focus-ring Effect's own cleanup, so it still passes when the delegation is never released. Please record the exact listeners added while main becomes a scroll region and assert each one is removed on unmount (suggested code on the test).

[Automated review]

Comment on lines +1255 to +1264
expect(documentRemove).toHaveBeenCalledWith(
'keydown',
expect.any(Function),
true,
);
expect(documentRemove).toHaveBeenCalledWith(
'focusin',
expect.any(Function),
true,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These two assertions are also satisfied by the focus-ring Effect's cleanup (AppShell.tsx lines 1142–1144 remove keydown and focusin with capture true), so this test still passes if syncMainDelegation() never releases the delegation on detach. Recording the listeners added while main becomes a scroll region and expecting each exact one to be removed catches it:

const documentAdd = vi.spyOn(document, 'addEventListener');
setMainGeometry(main, 1200);
remeasure();
const added = documentAdd.mock.calls.filter(
  ([type]) => type === 'keydown' || type === 'focusin',
);
const documentRemove = vi.spyOn(document, 'removeEventListener');
unmount();
for (const [type, listener, options] of added) {
  expect(documentRemove).toHaveBeenCalledWith(type, listener, options);
}

@cixzhang
cixzhang marked this pull request as ready for review November 1, 2026 05:31
…he changeset

The unmount test recorded only event types, which the focus-ring Effect's own
cleanup also removes. It now records the exact listeners the delegation adds
while main becomes a scroll region, and the overflow observation's resize
listeners, and requires each to be removed with the same handler. The test
fails when the delegation is not released.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval recommended. Deleting the unmount Effect leaves main's callback ref as the only release path, which is what the one-owner rule for listeners and observers asks for, and nothing changes for AppShell users. The rewritten unmount test now fails if either the overflow observation or the keyboard delegation is not released when main detaches, so the earlier test gap is closed and nothing more is needed.

[Automated review]

@cixzhang
cixzhang merged commit 853d94a into main Nov 1, 2026
53 of 56 checks passed
@github-actions
github-actions Bot deleted the fix/appshell-main-release-on-detach branch November 2, 2026 06:05

This branch was successfully deployed

1 active deployment
Preview — 64824145 Deployed Nov 1, 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