Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/__tests__/native/media-query.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Profiler } from "react";
import { PixelRatio } from "react-native";

import { act, render, screen } from "@testing-library/react-native";
Expand Down Expand Up @@ -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(
<Profiler id="media" onRender={() => (commits += 1)}>
<View testID={testID} className="my-class" />
<View className="wide" />
</Profiler>,
);
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);
});
56 changes: 40 additions & 16 deletions src/native/conditions/media-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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 ">":
Expand All @@ -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,
),
}),
);
}
13 changes: 13 additions & 0 deletions src/native/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,19 @@ export const vh = observable<number>(
(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<boolean>((get) => args.read(get)),
);

Dimensions.addEventListener("change", ({ window }) => {
observableBatch.current = new Set();
vw.set(window.width);
Expand Down