diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 020b4aad..78a3015d 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -1,3 +1,4 @@ +import { Profiler } from "react"; import { PixelRatio } from "react-native"; import { act, render, screen } from "@testing-library/react-native"; @@ -283,3 +284,56 @@ describe("max-resolution", () => { expect(component.props.style).toStrictEqual(undefined); }); }); + +test("a resize re-renders a media query's element only when the query's answer changes", () => { + registerCSS(` +.my-class { color: blue; } + +@media (min-width: 500px) { + .my-class { color: red; } +} + +@media (min-width: 900px) { + .wide { color: green; } +}`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 800 }); + }); + + let commits = 0; + render( + (commits += 1)}> + + + , + ); + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + commits = 0; + + // A window resized live: every frame a new width, neither query's answer + // changing. Nothing renders. + for (const width of [780, 760, 700, 640, 600, 560, 520]) { + act(() => { + dimensions.set({ ...dimensions.get(), width }); + }); + } + expect(commits).toBe(0); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + // Crossing 500 changes the first query's answer: it renders once. + act(() => { + dimensions.set({ ...dimensions.get(), width: 480 }); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + const crossed = commits; + expect(crossed).toBeGreaterThan(0); + + for (const width of [460, 420, 380]) { + act(() => { + dimensions.set({ ...dimensions.get(), width }); + }); + } + expect(commits).toBe(crossed); +}); diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index afd51250..36973cfb 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -3,7 +3,13 @@ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; -import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { + colorScheme, + vh, + vw, + windowQueryFamily, + type Getter, +} from "../reactivity"; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); @@ -50,39 +56,37 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { case "display-mode": return value === "native" || Platform.OS === value; case "min-width": - return typeof value === "number" && get(vw) >= value; + return typeof value === "number" && windowQuery(get, "vw>=", value); case "max-width": - return typeof value === "number" && get(vw) <= value; + return typeof value === "number" && windowQuery(get, "vw<=", value); case "min-height": - return typeof value === "number" && get(vh) >= value; + return typeof value === "number" && windowQuery(get, "vh>=", value); case "max-height": - return typeof value === "number" && get(vh) <= value; + return typeof value === "number" && windowQuery(get, "vh<=", value); case "orientation": - return value === "landscape" ? get(vh) < get(vw) : get(vh) >= get(vw); + return value === "landscape" + ? windowQuery(get, "landscape", 0) + : !windowQuery(get, "landscape", 0); } if (typeof value !== "number") { return false; } - let left: number | undefined; - const right = value; - switch (mediaQuery[1]) { case "width": - left = get(vw); - break; + return windowQuery(get, `vw${mediaQuery[0]}`, value); case "height": - left = get(vh); - break; + return windowQuery(get, `vh${mediaQuery[0]}`, value); case "resolution": - left = PixelRatio.get(); - break; + return compare(PixelRatio.get(), mediaQuery[0], value); default: return false; } +} - switch (mediaQuery[0]) { +function compare(left: number, operator: string, right: number): boolean { + switch (operator) { case "=": return left === right; case ">": @@ -97,3 +101,23 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { return false; } } + +/** + * The answer of a comparison with the window's size (`vw` or `vh`, then the + * operator), read through its own observable, so the caller is told only when + * the answer changes (`windowQueryFamily`). + */ +function windowQuery(get: Getter, query: string, value: number): boolean { + return get( + windowQueryFamily(`${query}${value}`, { + read: (read) => + query === "landscape" + ? read(vh) < read(vw) + : compare( + read(query.startsWith("vw") ? vw : vh), + query.slice(2), + value, + ), + }), + ); +} diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 1f632247..bff2261e 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -248,6 +248,19 @@ export const vh = observable( (read, value) => value ?? read(dimensions)?.height, ); +/** + * A media feature compared with the window's size, as an observable of its + * answer: it notifies only when the answer flips, not on every change of the + * size it reads. A window resized live changes `vw` on every frame; a rule + * that only asks whether it is at least 640 wide re-evaluates once, when it + * crosses 640, instead of every component with a media query re-running its + * rules on every frame. + */ +export const windowQueryFamily = family( + (_key: string, args: { read: (get: Getter) => boolean }) => + observable((get) => args.read(get)), +); + Dimensions.addEventListener("change", ({ window }) => { observableBatch.current = new Set(); vw.set(window.width);