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);