authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 19:35:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 19:35:55-07:00
logb9199617d57178b136ece0b8d649565cd463a0ff
tree23d240ff75420545395e578cedd5f553f1ec8a42
parent6975efd3dd52c95dd978ff26f591139d8c5c2503
signature Signed by PGP key B5690EEEBB952194

fix(mobile): uniwind platform variants stay guarded on both platforms (#13172)

Co-authored-by: julusy <julius@t3.codes>

4 files changed, 539 insertions(+), 12 deletions(-)

apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs created+183
...@@ -0,0 +1,183 @@
1"use strict";
2
3// Fixture for uniwind-platform-variants.test.ts. Run as a plain Node process
4// (`node uniwind-platform-variants.fixture.cjs <fixtureDir>`) so it executes the
5// real compiler with no test-runner transforms in the way. Prints one JSON
6// object to stdout describing what each platform bundle compiled.
7//
8// It reproduces what the Metro transformer does for `global.css`: compile the
9// Tailwind entry, then run uniwind's CSS processor and stylesheet serializer
10// once per platform. Loading `dist/common` needs a small custom loader because
11// uniwind declares `"type": "module"` over that CommonJS output and its
12// internal `@/...` import alias only exists at uniwind's own build time; the
13// loader hands unmodified file bytes to V8 and resolves those aliases the same
14// way, so this runs the exact compiled code the Metro transformer bundles.
15const fs = require("node:fs");
16const path = require("node:path");
17const { createRequire } = require("node:module");
18
19function createUniwindCompilerLoader(uniwindPackageJson) {
20 const uniwindRoot = path.dirname(fs.realpathSync(uniwindPackageJson));
21 const uniwindRequire = createRequire(path.join(uniwindRoot, "package.json"));
22 const cache = new Map();
23
24 const load = (absoluteFileWithoutExtension) => {
25 const resolved = [
26 absoluteFileWithoutExtension,
27 `${absoluteFileWithoutExtension}.js`,
28 path.join(absoluteFileWithoutExtension, "index.js"),
29 ].find((candidate) => {
30 try {
31 return fs.statSync(candidate).isFile();
32 } catch {
33 return false;
34 }
35 });
36 if (resolved === undefined) {
37 throw new Error(`Cannot resolve uniwind module ${absoluteFileWithoutExtension}`);
38 }
39 const cached = cache.get(resolved);
40 if (cached !== undefined) return cached;
41
42 const cjsModule = { exports: {} };
43
44 const localRequire = (request) => {
45 if (request.startsWith("@/")) {
46 return load(path.join(uniwindRoot, "dist/common", request.slice(2)));
47 }
48 if (request.startsWith(".")) {
49 return load(path.resolve(path.dirname(resolved), request));
50 }
51 // uniwind's own dependencies (lightningcss, culori, ...) resolve through
52 // the package's dependency tree, like they do inside the Metro bundle.
53 return uniwindRequire(request);
54 };
55
56 const code = fs.readFileSync(resolved, "utf8");
57 new Function("require", "module", "exports", code)(localRequire, cjsModule, cjsModule.exports);
58 cache.set(resolved, cjsModule.exports);
59 return cjsModule.exports;
60 };
61
62 const loadBundler = (request) => load(path.join(uniwindRoot, "dist/common", request));
63 return { loadBundler, uniwindRequire, uniwindRoot };
64}
65
66async function main() {
67 const fixtureDir = fs.realpathSync(path.resolve(process.argv[2]));
68 const uniwindPackageJson = require.resolve("uniwind/package.json", {
69 paths: [fixtureDir],
70 });
71 const { loadBundler, uniwindRequire, uniwindRoot } =
72 createUniwindCompilerLoader(uniwindPackageJson);
73 const { ProcessorBuilder } = loadBundler("bundler/css-processor/processor.js");
74 const { addMetaToStylesTemplate } = loadBundler(
75 "bundler/css-processor/addMetaToStylesTemplate.js",
76 );
77 const { compileNativeCSS } = loadBundler("bundler/css-compiler/compileNativeCSS.js");
78
79 const { compile } = uniwindRequire("@tailwindcss/node");
80 const { Scanner } = uniwindRequire("@tailwindcss/oxide");
81 const compiler = await compile(fs.readFileSync(path.join(fixtureDir, "global.css"), "utf8"), {
82 base: fixtureDir,
83 onDependency: () => {},
84 });
85 const scanner = new Scanner({
86 sources: [...compiler.sources, { negated: false, pattern: "**/*.tsx", base: fixtureDir }],
87 });
88 const tailwindCSS = await compiler.build(scanner.scan());
89
90 const platforms = {};
91 // Every ios:/android: utility class Tailwind generated for the fixture.
92 const compiledGuardedClasses = {};
93 for (const match of tailwindCSS.matchAll(/\.(ios|android)\\:([^{ ,]+) \{/g)) {
94 compiledGuardedClasses[`${match[1]}:${match[2]}`.replace(/\\(.)/g, "$1")] = true;
95 }
96 const platformBlock = (name) =>
97 new RegExp(`@media ${name} \\{([\\s\\S]*?)\\n \\}`).exec(tailwindCSS)?.[1] ?? "";
98 const utilityCountIn = (block) => (block.match(/^\s+\.[^ ]+ \{/gm) ?? []).length;
99 const tailwindChecks = {
100 iosBlocks: (tailwindCSS.match(/@media ios \{/g) ?? []).length,
101 androidBlocks: (tailwindCSS.match(/@media android \{/g) ?? []).length,
102 iosUtilities: utilityCountIn(platformBlock("ios")),
103 androidUtilities: utilityCountIn(platformBlock("android")),
104 };
105 for (const platform of ["ios", "android"]) {
106 // Same config the app's withUniwindConfig produces (polyfills.rem: 14).
107 const bundlerConfig = { platform, themes: ["light", "dark"], polyfills: { rem: 14 } };
108 const processor = new ProcessorBuilder(bundlerConfig);
109 processor.transform(tailwindCSS);
110 const compiled = addMetaToStylesTemplate(processor, platform);
111 const payload = compileNativeCSS(bundlerConfig, tailwindCSS);
112 const styles = {};
113 for (const [className, entries] of Object.entries(compiled)) {
114 styles[className] = entries.map(
115 ({ native, minWidth, maxWidth, active, focus, disabled, dataAttributes }) => ({
116 native,
117 minWidth,
118 maxWidth,
119 active: active ?? null,
120 focus: focus ?? null,
121 disabled: disabled ?? null,
122 dataAttributes: dataAttributes ?? null,
123 }),
124 );
125 }
126 platforms[platform] = {
127 styles,
128 payloadIncludesAllCompiled: Object.keys(compiled).every((className) =>
129 payload.includes(`"${className}"`),
130 ),
131 // A utility dropped from this platform's stylesheet must not appear
132 // anywhere in the payload the bundle embeds for this platform.
133 payloadLeaks: Object.keys(compiledGuardedClasses)
134 .filter((className) => compiled[className] === undefined)
135 .filter((className) => payload.includes(`"${className}"`)),
136 };
137 }
138
139 // Exercise the real Metro transformer entry point (the shipped
140 // `dist/metro/transformer.cjs`, not a copy) with a stubbed downstream worker,
141 // to check what the global.css virtual module actually becomes — including
142 // the native styles fingerprint that lets dev reloads skip reinitializing.
143 const transformerPath = path.join(uniwindRoot, "dist/metro/transformer.cjs");
144 let reinitCode = "";
145 const Module = require("node:module");
146 const origLoad = Module._load;
147 Module._load = function (request, ...rest) {
148 if (request === "metro-transform-worker") {
149 return {
150 transform: async (_config, _root, _file, data) => {
151 reinitCode = data.toString();
152 return { output: [{ data: {} }] };
153 },
154 };
155 }
156 return origLoad.call(this, request, ...rest);
157 };
158 process.chdir(fixtureDir);
159 try {
160 const { transform } = require(transformerPath);
161 await transform(
162 { uniwind: { cssEntryFile: "./global.css", isExpoProject: false } },
163 fixtureDir,
164 "global.css",
165 Buffer.from(""),
166 { platform: "ios", type: "module" },
167 );
168 } finally {
169 Module._load = origLoad;
170 }
171 const transformerCheck = {
172 reinitPayload: reinitCode.includes("Uniwind.__reinit(rt =>"),
173 fingerprintArg: /, '[0-9a-f]{64}'\);\s*$/.test(reinitCode),
174 themesArg: reinitCode.includes("['light', 'dark']"),
175 };
176
177 process.stdout.write(JSON.stringify({ tailwindChecks, transformerCheck, platforms }));
178}
179
180main().catch((error) => {
181 console.error(error);
182 process.exit(1);
183});
apps/mobile/src/lib/uniwind-platform-variants.test.ts created+253
...@@ -0,0 +1,253 @@
1import * as NodeChildProcess from "node:child_process";
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6import { beforeAll, describe, expect, it } from "vite-plus/test";
7
8// Regression guard for the uniwind platform-variant leak (audit #15 / #13161):
9// Tailwind groups every `ios:` (or `android:`) utility into one shared
10// `@media ios { ... }` block, and uniwind's CSS processor used to drop the
11// block's media queries after its first nested rule. Everything past the first
12// utility compiled unguarded and shipped to both platforms. These tests run
13// the installed (patched) uniwind compiler over real Tailwind output, per
14// platform, and assert what each bundle receives. The compiler itself runs in
15// a plain Node child process (uniwind-platform-variants.fixture.cjs) so no
16// test-runner module transforms sit between the test and the shipped code.
17
18interface CompiledStyle {
19 native: boolean;
20 minWidth: number;
21 maxWidth: number;
22 active: boolean | null;
23 focus: boolean | null;
24 disabled: boolean | null;
25 dataAttributes: Record<string, string> | null;
26}
27
28interface FixtureOutput {
29 tailwindChecks: {
30 iosBlocks: number;
31 androidBlocks: number;
32 iosUtilities: number;
33 androidUtilities: number;
34 };
35 transformerCheck: {
36 reinitPayload: boolean;
37 fingerprintArg: boolean;
38 themesArg: boolean;
39 };
40 platforms: Record<
41 string,
42 {
43 styles: Record<string, CompiledStyle[] | undefined>;
44 payloadIncludesAllCompiled: boolean;
45 payloadLeaks: string[];
46 }
47 >;
48}
49
50// Mirrors the classNames the audits flagged: multiple platform utilities per
51// block (only the first one used to keep its guard), a base utility overridden
52// by an `ios:` variant (NewTaskDraftScreen), and opposing `ios:`/`android:`
53// font families (worktree-setup-card).
54const FIXTURE_SOURCE = `export const Probe = () => (
55 <div className="android:shrink android:grow-0 ios:flex-1 pt-12 ios:pt-[72px] ios:font-[family-name:Menlo] android:font-mono flex-1 sm:p-6 sm:text-lg" />
56);
57`;
58
59const IOS_CLASSES = ["ios:flex-1", "ios:pt-[72px]", "ios:font-[family-name:Menlo]"];
60const ANDROID_CLASSES = ["android:shrink", "android:grow-0", "android:font-mono"];
61const SHARED_CLASSES = ["flex-1", "pt-12"];
62const RESPONSIVE_CLASSES = ["sm:p-6", "sm:text-lg"];
63
64const runFixture = (globalCss: string, probeSource: string): FixtureOutput => {
65 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "uniwind-platform-"));
66 try {
67 // So `@import "tailwindcss"` and `@import "uniwind"` resolve like in the app.
68 NodeFS.symlinkSync(
69 NodeFS.realpathSync(new URL("../../node_modules", import.meta.url)),
70 NodePath.join(tempDir, "node_modules"),
71 "dir",
72 );
73 NodeFS.writeFileSync(NodePath.join(tempDir, "global.css"), globalCss);
74 NodeFS.writeFileSync(NodePath.join(tempDir, "Probe.tsx"), probeSource);
75
76 const fixture = NodeURL.fileURLToPath(
77 new URL("./uniwind-platform-variants.fixture.cjs", import.meta.url),
78 );
79 const stdout = NodeChildProcess.execFileSync(process.execPath, [fixture, tempDir], {
80 encoding: "utf8",
81 maxBuffer: 32 * 1024 * 1024,
82 timeout: 60_000,
83 });
84 // The fixture's compiler may log warnings to stdout before the JSON payload.
85 const start = stdout.indexOf('{"tailwindChecks"');
86 if (start === -1) {
87 throw new Error(`Fixture produced no JSON:\n${stdout}`);
88 }
89 return JSON.parse(stdout.slice(start)) as FixtureOutput;
90 } finally {
91 NodeFS.rmSync(tempDir, { recursive: true, force: true });
92 }
93};
94
95describe("uniwind platform variants compile per platform", () => {
96 let output: FixtureOutput;
97
98 beforeAll(() => {
99 output = runFixture('@import "tailwindcss";\n@import "uniwind";\n', FIXTURE_SOURCE);
100 }, 60_000);
101
102 it("compiles the fixture utilities into shared platform blocks", () => {
103 // Without this grouping the leak could not happen and the assertions below
104 // would prove nothing.
105 expect(output.tailwindChecks).toEqual({
106 iosBlocks: 1,
107 androidBlocks: 1,
108 iosUtilities: 3,
109 androidUtilities: 3,
110 });
111 });
112
113 it("keeps ios: utilities out of the android bundle and guards them on ios", () => {
114 for (const className of IOS_CLASSES) {
115 expect(
116 output.platforms.android?.styles[className],
117 `android has ${className}`,
118 ).toBeUndefined();
119 expect(output.platforms.ios?.styles[className], `ios lacks ${className}`).toBeDefined();
120 expect(output.platforms.ios?.styles[className]?.every((style) => style.native)).toBe(true);
121 }
122 });
123
124 it("keeps android: utilities out of the ios bundle and guards them on android", () => {
125 for (const className of ANDROID_CLASSES) {
126 expect(output.platforms.ios?.styles[className], `ios has ${className}`).toBeUndefined();
127 expect(
128 output.platforms.android?.styles[className],
129 `android lacks ${className}`,
130 ).toBeDefined();
131 expect(output.platforms.android?.styles[className]?.every((style) => style.native)).toBe(
132 true,
133 );
134 }
135 });
136
137 it("keeps shared and responsive utilities usable on both platforms", () => {
138 for (const className of [...SHARED_CLASSES, ...RESPONSIVE_CLASSES]) {
139 for (const platform of ["ios", "android"]) {
140 const styles = output.platforms[platform]?.styles[className];
141 expect(styles, `${platform} lacks ${className}`).toBeDefined();
142 expect(styles?.every((style) => !style.native)).toBe(true);
143 }
144 }
145 // The leak hid a second regression: only the first utility of a width
146 // block kept its breakpoint, so the second applied at every screen size.
147 for (const platform of ["ios", "android"]) {
148 for (const className of RESPONSIVE_CLASSES) {
149 const style = output.platforms[platform]?.styles[className]?.at(-1);
150 expect(style?.minWidth, `${className} lost its breakpoint`).toBeGreaterThan(0);
151 }
152 }
153 });
154
155 it("serializes every compiled style into the platform payload and leaks nothing", () => {
156 for (const platform of ["ios", "android"]) {
157 expect(output.platforms[platform]?.payloadIncludesAllCompiled).toBe(true);
158 // A platform utility dropped from this platform's stylesheet must not
159 // survive anywhere in the payload this platform bundles.
160 expect(output.platforms[platform]?.payloadLeaks, `${platform} payload leaks`).toEqual([]);
161 }
162 });
163});
164
165// The media-query fix must not lose the surrounding rule context: Tailwind
166// also emits media rules *nested inside a class rule* (`@utility` bodies with
167// `@variant`, nested breakpoints), and those declarations belong to the
168// enclosing class. These expectations match the pre-fix compiler output for
169// the same inputs, captured against the unpatched package.
170describe("uniwind keeps media rules nested inside class rules attached to the class", () => {
171 let nested: FixtureOutput;
172
173 beforeAll(() => {
174 nested = runFixture(
175 [
176 '@import "tailwindcss";',
177 '@import "uniwind";',
178 "",
179 ".container-x {",
180 " width: 100%;",
181 " @media (width >= 40rem) {",
182 " max-width: 40rem;",
183 " }",
184 "}",
185 "",
186 "@utility foo-x {",
187 " padding: 2px;",
188 " @variant ios {",
189 " padding: 3px;",
190 " }",
191 "}",
192 "",
193 ].join("\n"),
194 'export const Probe = () => <div className="container-x foo-x" />;\n',
195 );
196 }, 60_000);
197
198 it("keeps the nested breakpoint entry of a nested media rule", () => {
199 for (const platform of ["ios", "android"]) {
200 const entries = nested.platforms[platform]?.styles["container-x"];
201 expect(entries, `${platform} lacks container-x`).toBeDefined();
202 expect(entries?.some((style) => !style.native && style.minWidth === 0)).toBe(true);
203 expect(entries?.some((style) => !style.native && style.minWidth > 0)).toBe(true);
204 }
205 });
206
207 it("keeps @variant declarations inside @utility on their platform", () => {
208 const iosEntries = nested.platforms.ios?.styles["foo-x"];
209 expect(iosEntries?.some((style) => !style.native)).toBe(true);
210 expect(iosEntries?.some((style) => style.native)).toBe(true);
211
212 const androidEntries = nested.platforms.android?.styles["foo-x"];
213 expect(androidEntries?.some((style) => !style.native)).toBe(true);
214 expect(androidEntries?.some((style) => style.native)).toBe(false);
215 });
216});
217
218// The patch file carries three independent uniwind fixes (state/data selector
219// variants, the Metro native-styles fingerprint, and this media-query scoping
220// fix). Regenerating it for one fix must not silently drop the others — this
221// suite exercises the shipped transformer and compiler for the other two.
222describe("uniwind patch keeps pre-existing selector and transformer behavior", () => {
223 let output: FixtureOutput;
224
225 beforeAll(() => {
226 output = runFixture(
227 '@import "tailwindcss";\n@import "uniwind";\n',
228 'export const Probe = () => <div className="active:opacity-50 disabled:opacity-50 data-x:underline aria-disabled:text-red-500" />;\n',
229 );
230 }, 60_000);
231
232 it("keeps state and data variants conditioned, and rejects unsupported compounds", () => {
233 for (const platform of ["ios", "android"]) {
234 const styles = output.platforms[platform]?.styles;
235 expect(styles?.["active:opacity-50"]?.every((style) => style.active === true)).toBe(true);
236 expect(styles?.["disabled:opacity-50"]?.every((style) => style.disabled === true)).toBe(true);
237 expect(styles?.["data-x:underline"]?.every((style) => style.dataAttributes?.["data-x"])).toBe(
238 true,
239 );
240 // `[aria-disabled="true"]` is not expressible at runtime; emitting it
241 // unconditionally (the pre-#9355 behavior) styles the element always.
242 expect(styles).not.toHaveProperty("aria-disabled:text-red-500");
243 }
244 });
245
246 it("emits the global.css virtual module with a native styles fingerprint", () => {
247 expect(output.transformerCheck).toEqual({
248 reinitPayload: true,
249 fingerprintArg: true,
250 themesArg: true,
251 });
252 });
253});
patches/uniwind@1.11.0.patch+100-9
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1diff --git a/dist/common/bundler/css-processor/processor.js b/dist/common/bundler/css-processor/processor.js1diff --git a/dist/common/bundler/css-processor/processor.js b/dist/common/bundler/css-processor/processor.js
2index 7dd369fa3197dbd622832c89ac3854c401e55c3e..ab3ea1a9520907321f9c21c2a3a859262fe5075b 1006442index 7dd369f..48321d1 100644
3--- a/dist/common/bundler/css-processor/processor.js3--- a/dist/common/bundler/css-processor/processor.js
4+++ b/dist/common/bundler/css-processor/processor.js4+++ b/dist/common/bundler/css-processor/processor.js
5@@ -105,6 +105,64 @@ class ProcessorBuilder {5@@ -105,6 +105,64 @@ class ProcessorBuilder {
...@@ -154,8 +154,27 @@ index 7dd369fa3197dbd622832c89ac3854c401e55c3e..ab3ea1a9520907321f9c21c2a3a85926...@@ -154,8 +154,27 @@ index 7dd369fa3197dbd622832c89ac3854c401e55c3e..ab3ea1a9520907321f9c21c2a3a85926
154 return;154 return;
155 }155 }
156 selector.forEach(selectorToken => {156 selector.forEach(selectorToken => {
157@@ -199,11 +223,16 @@ class ProcessorBuilder {
158 const {
159 mediaQueries
160 } = rule.value.query;
161- this.declarationConfig.mediaQueries.push(...mediaQueries);
162+ const outerConfig = this.declarationConfig;
163+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries];
164 rule.value.rules.forEach(rule2 => {
165+ this.declarationConfig = {
166+ ...outerConfig,
167+ mediaQueries: blockMediaQueries
168+ };
169 this.parseRuleRec(rule2);
170- this.declarationConfig = this.getDeclarationConfig();
171 });
172+ this.declarationConfig = outerConfig;
173 return;
174 }
175 if (rule.type === "layer-block") {
157diff --git a/dist/metro/transformer.cjs b/dist/metro/transformer.cjs176diff --git a/dist/metro/transformer.cjs b/dist/metro/transformer.cjs
158index d57ca8161f411f0dc7df827132586e428cafdf1b..3650a353b0db269ad4fc51b70a7158885c992e36 100644177index d57ca81..83b58d1 100644
159--- a/dist/metro/transformer.cjs178--- a/dist/metro/transformer.cjs
160+++ b/dist/metro/transformer.cjs179+++ b/dist/metro/transformer.cjs
161@@ -7,6 +7,7 @@ const culori = require('culori');180@@ -7,6 +7,7 @@ const culori = require('culori');
...@@ -318,7 +337,23 @@ index d57ca8161f411f0dc7df827132586e428cafdf1b..3650a353b0db269ad4fc51b70a715888...@@ -318,7 +337,23 @@ index d57ca8161f411f0dc7df827132586e428cafdf1b..3650a353b0db269ad4fc51b70a715888
318 return;337 return;
319 }338 }
320 selector.forEach((selectorToken) => {339 selector.forEach((selectorToken) => {
321@@ -1753,10 +1778,11 @@ const transform = async (config$1, projectRoot, filePath, data, options) => {340@@ -1629,11 +1654,13 @@ class ProcessorBuilder {
341 }
342 if (rule.type === "media") {
343 const { mediaQueries } = rule.value.query;
344- this.declarationConfig.mediaQueries.push(...mediaQueries);
345+ const outerConfig = this.declarationConfig;
346+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries];
347 rule.value.rules.forEach((rule2) => {
348+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries };
349 this.parseRuleRec(rule2);
350- this.declarationConfig = this.getDeclarationConfig();
351 });
352+ this.declarationConfig = outerConfig;
353 return;
354 }
355 if (rule.type === "layer-block") {
356@@ -1753,10 +1780,11 @@ const transform = async (config$1, projectRoot, filePath, data, options) => {
322 await bundlerConfig.generateArtifacts(cssArtifactPath);357 await bundlerConfig.generateArtifacts(cssArtifactPath);
323 const virtualCode = await compileCSS(bundlerConfig);358 const virtualCode = await compileCSS(bundlerConfig);
324 const isWeb = bundlerConfig.platform === config.Platform.Web;359 const isWeb = bundlerConfig.platform === config.Platform.Web;
...@@ -332,7 +367,7 @@ index d57ca8161f411f0dc7df827132586e428cafdf1b..3650a353b0db269ad4fc51b70a715888...@@ -332,7 +367,7 @@ index d57ca8161f411f0dc7df827132586e428cafdf1b..3650a353b0db269ad4fc51b70a715888
332 "utf-8"367 "utf-8"
333 );368 );
334diff --git a/dist/metro/transformer.mjs b/dist/metro/transformer.mjs369diff --git a/dist/metro/transformer.mjs b/dist/metro/transformer.mjs
335index d3e7475f8f0aaba510b318657dc8374e6c6097cb..6ff8cffc4bb703521b6d86f0ac14be054ba5b9ce 100644370index d3e7475..f92b73b 100644
336--- a/dist/metro/transformer.mjs371--- a/dist/metro/transformer.mjs
337+++ b/dist/metro/transformer.mjs372+++ b/dist/metro/transformer.mjs
338@@ -5,6 +5,7 @@ import { converter, parse, formatHex, formatHex8 } from 'culori';373@@ -5,6 +5,7 @@ import { converter, parse, formatHex, formatHex8 } from 'culori';
...@@ -495,7 +530,23 @@ index d3e7475f8f0aaba510b318657dc8374e6c6097cb..6ff8cffc4bb703521b6d86f0ac14be05...@@ -495,7 +530,23 @@ index d3e7475f8f0aaba510b318657dc8374e6c6097cb..6ff8cffc4bb703521b6d86f0ac14be05
495 return;530 return;
496 }531 }
497 selector.forEach((selectorToken) => {532 selector.forEach((selectorToken) => {
498@@ -1746,10 +1771,11 @@ const transform = async (config, projectRoot, filePath, data, options) => {533@@ -1622,11 +1647,13 @@ class ProcessorBuilder {
534 }
535 if (rule.type === "media") {
536 const { mediaQueries } = rule.value.query;
537- this.declarationConfig.mediaQueries.push(...mediaQueries);
538+ const outerConfig = this.declarationConfig;
539+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries];
540 rule.value.rules.forEach((rule2) => {
541+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries };
542 this.parseRuleRec(rule2);
543- this.declarationConfig = this.getDeclarationConfig();
544 });
545+ this.declarationConfig = outerConfig;
546 return;
547 }
548 if (rule.type === "layer-block") {
549@@ -1746,10 +1773,11 @@ const transform = async (config, projectRoot, filePath, data, options) => {
499 await bundlerConfig.generateArtifacts(cssArtifactPath);550 await bundlerConfig.generateArtifacts(cssArtifactPath);
500 const virtualCode = await compileCSS(bundlerConfig);551 const virtualCode = await compileCSS(bundlerConfig);
501 const isWeb = bundlerConfig.platform === Platform.Web;552 const isWeb = bundlerConfig.platform === Platform.Web;
...@@ -509,7 +560,7 @@ index d3e7475f8f0aaba510b318657dc8374e6c6097cb..6ff8cffc4bb703521b6d86f0ac14be05...@@ -509,7 +560,7 @@ index d3e7475f8f0aaba510b318657dc8374e6c6097cb..6ff8cffc4bb703521b6d86f0ac14be05
509 "utf-8"560 "utf-8"
510 );561 );
511diff --git a/dist/module/bundler/css-processor/processor.js b/dist/module/bundler/css-processor/processor.js562diff --git a/dist/module/bundler/css-processor/processor.js b/dist/module/bundler/css-processor/processor.js
512index 58c96b6ab642c2b724d1868d0d48b77bad33be6f..70b759b04913a0e9028c936827b4578d628b4728 100644563index 58c96b6..6849ca7 100644
513--- a/dist/module/bundler/css-processor/processor.js564--- a/dist/module/bundler/css-processor/processor.js
514+++ b/dist/module/bundler/css-processor/processor.js565+++ b/dist/module/bundler/css-processor/processor.js
515@@ -99,6 +99,64 @@ export class ProcessorBuilder {566@@ -99,6 +99,64 @@ export class ProcessorBuilder {
...@@ -664,8 +715,24 @@ index 58c96b6ab642c2b724d1868d0d48b77bad33be6f..70b759b04913a0e9028c936827b4578d...@@ -664,8 +715,24 @@ index 58c96b6ab642c2b724d1868d0d48b77bad33be6f..70b759b04913a0e9028c936827b4578d
664 return;715 return;
665 }716 }
666 selector.forEach((selectorToken) => {717 selector.forEach((selectorToken) => {
718@@ -191,11 +215,13 @@ export class ProcessorBuilder {
719 }
720 if (rule.type === "media") {
721 const { mediaQueries } = rule.value.query;
722- this.declarationConfig.mediaQueries.push(...mediaQueries);
723+ const outerConfig = this.declarationConfig;
724+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries];
725 rule.value.rules.forEach((rule2) => {
726+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries };
727 this.parseRuleRec(rule2);
728- this.declarationConfig = this.getDeclarationConfig();
729 });
730+ this.declarationConfig = outerConfig;
731 return;
732 }
733 if (rule.type === "layer-block") {
667diff --git a/src/bundler/adapters/metro/transformer.ts b/src/bundler/adapters/metro/transformer.ts734diff --git a/src/bundler/adapters/metro/transformer.ts b/src/bundler/adapters/metro/transformer.ts
668index 08059b0fb2394effd2553086216d8933a53fad84..05c051595bfffd313f1d731fcedc1e991af720e1 100644735index 08059b0..05c0515 100644
669--- a/src/bundler/adapters/metro/transformer.ts736--- a/src/bundler/adapters/metro/transformer.ts
670+++ b/src/bundler/adapters/metro/transformer.ts737+++ b/src/bundler/adapters/metro/transformer.ts
671@@ -5,6 +5,7 @@ import { Platform } from '@/common/consts'738@@ -5,6 +5,7 @@ import { Platform } from '@/common/consts'
...@@ -699,7 +766,7 @@ index 08059b0fb2394effd2553086216d8933a53fad84..05c051595bfffd313f1d731fcedc1e99...@@ -699,7 +766,7 @@ index 08059b0fb2394effd2553086216d8933a53fad84..05c051595bfffd313f1d731fcedc1e99
699 'utf-8',766 'utf-8',
700 )767 )
701diff --git a/src/bundler/css-processor/processor.ts b/src/bundler/css-processor/processor.ts768diff --git a/src/bundler/css-processor/processor.ts b/src/bundler/css-processor/processor.ts
702index c8b3bb86e2a50c16ebc3f0404f4d9ac050aba61d..b5e185469733aca4289b7b07bc76dbfcd8fd5788 100644769index c8b3bb8..4af4199 100644
703--- a/src/bundler/css-processor/processor.ts770--- a/src/bundler/css-processor/processor.ts
704+++ b/src/bundler/css-processor/processor.ts771+++ b/src/bundler/css-processor/processor.ts
705@@ -123,6 +123,77 @@ export class ProcessorBuilder {772@@ -123,6 +123,77 @@ export class ProcessorBuilder {
...@@ -885,8 +952,32 @@ index c8b3bb86e2a50c16ebc3f0404f4d9ac050aba61d..b5e185469733aca4289b7b07bc76dbfc...@@ -885,8 +952,32 @@ index c8b3bb86e2a50c16ebc3f0404f4d9ac050aba61d..b5e185469733aca4289b7b07bc76dbfc
885 952
886 return953 return
887 }954 }
955@@ -241,13 +275,21 @@ export class ProcessorBuilder {
956
957 if (rule.type === 'media') {
958 const { mediaQueries } = rule.value.query
959+ // Media queries have to outlive the reset between the block's sibling
960+ // rules: resetting only after each parse left every rule past the first
961+ // without the block's queries, shipping ios:/android: variants and later
962+ // utilities of a width breakpoint unguarded to every platform. Reset to
963+ // the outer config instead of a fresh one so media nested inside a class
964+ // rule keeps writing into that class.
965+ const outerConfig = this.declarationConfig
966+ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]
967
968- this.declarationConfig.mediaQueries.push(...mediaQueries)
969 rule.value.rules.forEach(rule => {
970+ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }
971 this.parseRuleRec(rule)
972- this.declarationConfig = this.getDeclarationConfig()
973 })
974
975+ this.declarationConfig = outerConfig
976 return
977 }
978
888diff --git a/src/core/config/config.native.ts b/src/core/config/config.native.ts979diff --git a/src/core/config/config.native.ts b/src/core/config/config.native.ts
889index 5e0832b5913df6f02604020d7ff5caf3b6a8d912..01aed1be25ca928f3a802996f41cb6b841aaed86 100644980index 5e0832b..01aed1b 100644
890--- a/src/core/config/config.native.ts981--- a/src/core/config/config.native.ts
891+++ b/src/core/config/config.native.ts982+++ b/src/core/config/config.native.ts
892@@ -8,6 +8,8 @@ import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../ty983@@ -8,6 +8,8 @@ import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../ty
pnpm-lock.yaml+3-3
...@@ -111,7 +111,7 @@ patchedDependencies:...@@ -111,7 +111,7 @@ patchedDependencies:
111 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675111 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
112 react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d112 react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d
113 react-native-screens@4.26.2: 595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0113 react-native-screens@4.26.2: 595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0
114 uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90114 uniwind@1.11.0: a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625
115115
116importers:116importers:
117117
...@@ -478,7 +478,7 @@ importers:...@@ -478,7 +478,7 @@ importers:
478 version: 3.6.0478 version: 3.6.0
479 uniwind:479 uniwind:
480 specifier: 1.11.0480 specifier: 1.11.0
481 version: 1.11.0(patch_hash=17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.3)481 version: 1.11.0(patch_hash=a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.3)
482 devDependencies:482 devDependencies:
483 '@effect/vitest':483 '@effect/vitest':
484 specifier: 4.0.0-rc.115484 specifier: 4.0.0-rc.115
...@@ -21808,7 +21808,7 @@ snapshots:...@@ -21808,7 +21808,7 @@ snapshots:
2180821808
21809 universalify@2.0.1: {}21809 universalify@2.0.1: {}
2181021810
21811 uniwind@1.11.0(patch_hash=17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.3):21811 uniwind@1.11.0(patch_hash=a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.3):
21812 dependencies:21812 dependencies:
21813 '@tailwindcss/node': 4.3.321813 '@tailwindcss/node': 4.3.3
21814 '@tailwindcss/oxide': 4.3.321814 '@tailwindcss/oxide': 4.3.3