Repository navigation
fix(core): Overlay, AppShell and MobileNav work with no matchMedia - #10327
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR Analysis Report🔎 Preview ·
|
| 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) | ![]() |
![]() |
![]() |
| AppShell · Playground (neutral, light) | ![]() |
![]() |
![]() |
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
|
/approve-spec 40c1f20 |
|
AI review status for this pull request.
|
There was a problem hiding this comment.
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 oneuseMediaQuery('(hover: none)')call. - The reduced-motion reads in AppShell and MobileNav do not yet honour a theme's
reducedMotion: 'always'. They did not onmaineither.
[Automated review]
| const mql = window.matchMedia('(hover: none)'); | ||
| mql.addEventListener('change', callback); | ||
| return () => mql.removeEventListener('change', callback); | ||
| return watchMediaQuery(window, '(hover: none)', callback); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Agreed it would read better. It is a cleanup with no defect, so it stays out of this fix as a follow-up.
|
On the reduced-motion note: |






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 nomatchMediaunless 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 wherematchMediaexists nothing changes.Problem and solution fit
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.matchesMediaQueryandwatchMediaQueryinutils/coarsePointer.tsfor this case: they answer false and watch nothing where the window has nomatchMedia. It routed the presentation resolvers anduseMediaQuerythrough them, but these three reads remained.Expected behavior and authority
matchMedia, as in a test DOM.useMediaQuery's documented answer for a window with nomatchMedia(its server default), and fix(core): a presentation resolves with no matchMedia, as in a test DOM #10275's fix for the presentation hooks.Smallest restoration
Three reads change in
Overlay/useOverlay.tsx,AppShell/AppShell.tsxandMobileNav/MobileNav.tsx. No API, default or visual change.Follow-up ideas, not in this pull request:
matchMediathemselves, onto the helpers.window.matchMedia(call outsideutils/coarsePointer.ts.Evidence
matchMediastubbed 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 withTypeError: window.matchMedia is not a function. The failures are atuseOverlay.tsx:48,AppShell.tsx:656andMobileNav.tsx:344.matchMedia, which include touch Overlay, the AppShell close hold and MobileNav's close edge cases (reduced motion makes the close immediate).__tests__/withoutMatchMedia.test.tsx.Scope
Testing
On main a0c7e22, at 40c1f20.
__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 withTypeError: window.matchMedia is not a functionatuseOverlay.tsx:48,AppShell.tsx:656andMobileNav.tsx:344. With the fix all 3 pass.coarsePointer.test.tsandpresentationWithoutMatchMedia.test.tsx: 19 files, 234 tests passed. They include touch Overlay, the AppShell close hold andMobileNavCloseEdgeCases.test.tsx.pnpm test:ui: 896 files, 21,953 tests passed.pnpm check:knowledge,pnpm check:sync,pnpm check:changesets,pnpm review:pre(no findings) andpnpm specs:for --diff.