Skip to content

fix(core): Overlay, AppShell and MobileNav work with no matchMedia - #10327

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:fix/core-media-queries-without-matchmedia
Nov 6, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:fix/core-media-queries-without-matchmedia

Conversation

@vjeux

@vjeux vjeux commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

User impact

An app whose own jsdom tests render an Overlay, an AppShell or a MobileNav crashes with window.matchMedia is not a function. jsdom has no matchMedia unless a test stubs it. Overlay throws on its first render. AppShell throws as it mounts with React 19.2 or later, with its mobile nav closed (the default). MobileNav throws when an open nav closes. With this change all three work there, and where matchMedia exists nothing changes.

Problem and solution fit

  • Each of the three reads window.matchMedia(...) with no check that it exists. Overlay reads (hover: none) in its external-store snapshot, which runs during render. AppShell reads (prefers-reduced-motion: reduce) for its mobile-nav close hold, in an effect on mount. MobileNav reads it for its close delay.
  • fix(core): a presentation resolves with no matchMedia, as in a test DOM #10275 added matchesMediaQuery and watchMediaQuery in utils/coarsePointer.ts for this case: they answer false and watch nothing where the window has no matchMedia. It routed the presentation resolvers and useMediaQuery through them, but these three reads remained.
  • The fix routes the three reads through the same helpers. MobileNav reads the query in its dialog's own window.

Expected behavior and authority

Smallest restoration

Three reads change in Overlay/useOverlay.tsx, AppShell/AppShell.tsx and MobileNav/MobileNav.tsx. No API, default or visual change.

Follow-up ideas, not in this pull request:

  • Move core's other media-query reads, which already check for matchMedia themselves, onto the helpers.
  • A lint rule against a raw window.matchMedia( call outside utils/coarsePointer.ts.

Evidence

  • Reproduction before the change: with matchMedia stubbed to undefined, as fix(core): a presentation resolves with no matchMedia, as in a test DOM #10275's test does, each new case fails on main with TypeError: window.matchMedia is not a function. The failures are at useOverlay.tsx:48, AppShell.tsx:656 and MobileNav.tsx:344.
  • Result after the change: the three cases pass.
  • Representative unchanged path: the Overlay, AppShell and MobileNav suites with the shared test setup's matchMedia, which include touch Overlay, the AppShell close hold and MobileNav's close edge cases (reduced motion makes the close immediate).
  • Regression test: __tests__/withoutMatchMedia.test.tsx.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

On main a0c7e22, at 40c1f20.

  • Red first: __tests__/withoutMatchMedia.test.tsx ("renders an Overlay over its content", "mounts an AppShell with its content", "closes an open MobileNav after its slide-out") fails 3 of 3 on main's source. Each fails with TypeError: window.matchMedia is not a function at useOverlay.tsx:48, AppShell.tsx:656 and MobileNav.tsx:344. With the fix all 3 pass.
  • The Overlay, AppShell and MobileNav suites, coarsePointer.test.ts and presentationWithoutMatchMedia.test.tsx: 19 files, 234 tests passed. They include touch Overlay, the AppShell close hold and MobileNavCloseEdgeCases.test.tsx.
  • pnpm test:ui: 896 files, 21,953 tests passed.
  • The AppShell and MobileNav browser specs on a Storybook built at this head, Chromium: 39 accessibility cases and 2 interaction cases passed.
  • Lint (0 errors), strict ESLint, core typecheck, pnpm check:knowledge, pnpm check:sync, pnpm check:changesets, pnpm review:pre (no findings) and pnpm specs:for --diff.

In a DOM with no matchMedia, such as an app's jsdom test, Overlay
threw on its first render, AppShell as it mounted, and MobileNav as
it closed. Each read window.matchMedia with no check that it exists.
They now read and watch through matchesMediaQuery and
watchMediaQuery, which answer false and watch nothing there.
MobileNav reads the query in its dialog's own window.
@vercel

vercel Bot commented Nov 6, 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 6, 2026 6:08pm UTC

Request Review

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

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 40c1f20

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
MobileNavToggle 52.1 kB (0) · 9.3 kB CSS (0) — ✓ · ✓ · ✗
AppShell · Storybook 81.7 kB (−1 B) · 12.8 kB CSS (0) 1,143 (+3) ✗ · ✓ · ✓
SideNav 81.5 kB (+11 B) · 10.7 kB CSS (0) — — · — · —
3 more components
Component Size LoC Visual · A11y · RTL
Overlay · Storybook 21.6 kB (+6 B) · 3.9 kB CSS (0) 457 (−1) ✓ · ✓ · ✓
MobileNav 61.9 kB (+4 B) · 9.9 kB CSS (0) — ✓ · ✓ · ✓
TopNav 63.5 kB (+1 B) · 10.2 kB CSS (0) — — · — · —
Visual changes: 2 snapshots in 1 component

A change is a question for the reviewer, not a failure.

Component · story Before After Diff
AppShell · Playground (neutral, dark) before frame after frame diff frame
AppShell · Playground (neutral, light) before frame after frame diff frame

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

API changes: cli +14 · ~5 · −0 (against the merge base's committed report)

Committed API report behind this build: cli 19 rows, core 24 rows; run pnpm api:report.

19 API changes (advisory)
Package Where Signature Usage
+ cli ./api · Types · type SearchResultEntry deprecated?: ComponentDeprecation | undefined import type {SearchResultEntry} from '@astryxdesign/cli/api'; const x: SearchResultEntry = {deprecated: …}
+ cli ./authoring · Types · interface ComponentDeprecation interface ComponentDeprecation import type {ComponentDeprecation} from '@astryxdesign/cli/authoring'
+ cli ./authoring · Types · interface ComponentIconSlotDoc interface ComponentIconSlotDoc import type {ComponentIconSlotDoc} from '@astryxdesign/cli/authoring'
+ cli ./authoring · Types · interface ComponentIconSlotStateDoc interface ComponentIconSlotStateDoc import type {ComponentIconSlotStateDoc} from '@astryxdesign/cli/authoring'
+ cli ./authoring · Types · interface MultiComponentDoc deprecated?: ComponentDeprecation import type {MultiComponentDoc} from '@astryxdesign/cli/authoring'; const x: MultiComponentDoc = {deprecated: …}
+ cli ./authoring · Types · interface SingleComponentDoc deprecated?: ComponentDeprecation import type {SingleComponentDoc} from '@astryxdesign/cli/authoring'; const x: SingleComponentDoc = {deprecated: …}
+ cli ./doc · Types · interface ComponentBaseDoc deprecated?: ComponentDeprecation import type {ComponentBaseDoc} from '@astryxdesign/cli/doc'; const x: ComponentBaseDoc = {deprecated: …}
+ cli ./doc · Types · interface SubComponentDoc deprecated?: ComponentDeprecation import type {SubComponentDoc} from '@astryxdesign/cli/doc'; const x: SubComponentDoc = {deprecated: …}
+ cli ./doc · Also exported from an earlier entry point · ComponentDeprecation ComponentDeprecation (./authoring) import {ComponentDeprecation} from '@astryxdesign/cli/doc'
+ cli ./doc · Also exported from an earlier entry point · ComponentIconSlotDoc ComponentIconSlotDoc (./authoring) import {ComponentIconSlotDoc} from '@astryxdesign/cli/doc'
+ cli ./doc · Also exported from an earlier entry point · ComponentIconSlotStateDoc ComponentIconSlotStateDoc (./authoring) import {ComponentIconSlotStateDoc} from '@astryxdesign/cli/doc'
+ cli ./template · Also exported from an earlier entry point · ComponentDeprecation ComponentDeprecation (./authoring) import {ComponentDeprecation} from '@astryxdesign/cli/template'
+ cli ./template · Also exported from an earlier entry point · ComponentIconSlotDoc ComponentIconSlotDoc (./authoring) import {ComponentIconSlotDoc} from '@astryxdesign/cli/template'
+ cli ./template · Also exported from an earlier entry point · ComponentIconSlotStateDoc ComponentIconSlotStateDoc (./authoring) import {ComponentIconSlotStateDoc} from '@astryxdesign/cli/template'
~ cli ./api · Functions · function search function search: (query: string, options?: { cwd?: string | undefined; type?: "template" | "component" | "hook" | "theme" | "doc" | undefined; limit?: number | undefined }) => Promise<SearchResponse> → function search: (query: string, options?: { cwd?: string | undefined; type?: "template" | "component" | "doc" | "theme" | "hook" | undefined; limit?: number | undefined }) => Promise<SearchResponse> import {search} from '@astryxdesign/cli/api'; search(…)
~ cli ./authoring · Types · interface MultiComponentDoc theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[] } → theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[]; componentIcons?: ComponentIconSlotDoc[] } import type {MultiComponentDoc} from '@astryxdesign/cli/authoring'; const x: MultiComponentDoc = {theming: …}
~ cli ./authoring · Types · interface SingleComponentDoc theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[] } → theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[]; componentIcons?: ComponentIconSlotDoc[] } import type {SingleComponentDoc} from '@astryxdesign/cli/authoring'; const x: SingleComponentDoc = {theming: …}
~ cli ./doc · Types · interface ComponentBaseDoc theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[] } → theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[]; componentIcons?: ComponentIconSlotDoc[] } import type {ComponentBaseDoc} from '@astryxdesign/cli/doc'; const x: ComponentBaseDoc = {theming: …}
~ cli ./doc · Types · interface SubComponentDoc theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[] } → theming?: { container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[]; componentIcons?: ComponentIconSlotDoc[] } import type {SubComponentDoc} from '@astryxdesign/cli/doc'; const x: SubComponentDoc = {theming: …}

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

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 6, 2026
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 40c1f20

@astracat-bot

astracat-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 6, 2026, 6:22 PM UTC

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

Approved pending CI: CI has not run on this head yet. This is a draft, so CI starts once the pull request is marked ready for review; the ci:run label is already on it.

The three reads now go through the shared matchMedia helpers. The new test fails on main at each cited line and passes here, and nothing changes where matchMedia exists.

Optional follow-ups, not needed for this pull request:

  • Overlay's own (hover: none) store could become one useMediaQuery('(hover: none)') call.
  • The reduced-motion reads in AppShell and MobileNav do not yet honour a theme's reducedMotion: 'always'. They did not on main either.

[Automated review]

const mql = window.matchMedia('(hover: none)');
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
return watchMediaQuery(window, '(hover: none)', callback);

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: this store does what useMediaQuery('(hover: none)') already does, with one shared watch per query (hooks/useMediaQuery.ts). Calling it here would replace getIsTouchDevice, subscribeToHoverChange and getServerSnapshot. It is fine as a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed it would read better. It is a cleanup with no defect, so it stays out of this fix as a follow-up.

@vjeux
vjeux marked this pull request as ready for review November 6, 2026 18:37
@vjeux

vjeux commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

On the reduced-motion note: reducedMotion: 'always' is not shipped yet, and moving core's script readers onto the theme's mark is #9763's program, which lists AppShell and MobileNav among its pending readers. So it stays out of this fix.

@cixzhang
cixzhang merged commit a9c9114 into meta:main Nov 6, 2026
121 checks passed
@vjeux
vjeux deleted the fix/core-media-queries-without-matchmedia branch November 6, 2026 19:56

This branch was successfully deployed

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

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) 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.

2 participants