From b9199617d57178b136ece0b8d649565cd463a0ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 19:35:55 -0700 Subject: [PATCH] fix(mobile): uniwind platform variants stay guarded on both platforms (#13172) Co-authored-by: julusy --- .../lib/uniwind-platform-variants.fixture.cjs | 183 +++++++++++++ .../src/lib/uniwind-platform-variants.test.ts | 253 ++++++++++++++++++ patches/uniwind@1.11.0.patch | 109 +++++++- pnpm-lock.yaml | 6 +- 4 files changed, 539 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs create mode 100644 apps/mobile/src/lib/uniwind-platform-variants.test.ts diff --git a/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs b/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs new file mode 100644 index 0000000000000000000000000000000000000000..8d8017f28f8322239219895ad8ae3d814e5a921d --- /dev/null +++ b/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs @@ -0,0 +1,183 @@ +"use strict"; + +// Fixture for uniwind-platform-variants.test.ts. Run as a plain Node process +// (`node uniwind-platform-variants.fixture.cjs `) so it executes the +// real compiler with no test-runner transforms in the way. Prints one JSON +// object to stdout describing what each platform bundle compiled. +// +// It reproduces what the Metro transformer does for `global.css`: compile the +// Tailwind entry, then run uniwind's CSS processor and stylesheet serializer +// once per platform. Loading `dist/common` needs a small custom loader because +// uniwind declares `"type": "module"` over that CommonJS output and its +// internal `@/...` import alias only exists at uniwind's own build time; the +// loader hands unmodified file bytes to V8 and resolves those aliases the same +// way, so this runs the exact compiled code the Metro transformer bundles. +const fs = require("node:fs"); +const path = require("node:path"); +const { createRequire } = require("node:module"); + +function createUniwindCompilerLoader(uniwindPackageJson) { + const uniwindRoot = path.dirname(fs.realpathSync(uniwindPackageJson)); + const uniwindRequire = createRequire(path.join(uniwindRoot, "package.json")); + const cache = new Map(); + + const load = (absoluteFileWithoutExtension) => { + const resolved = [ + absoluteFileWithoutExtension, + `${absoluteFileWithoutExtension}.js`, + path.join(absoluteFileWithoutExtension, "index.js"), + ].find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }); + if (resolved === undefined) { + throw new Error(`Cannot resolve uniwind module ${absoluteFileWithoutExtension}`); + } + const cached = cache.get(resolved); + if (cached !== undefined) return cached; + + const cjsModule = { exports: {} }; + + const localRequire = (request) => { + if (request.startsWith("@/")) { + return load(path.join(uniwindRoot, "dist/common", request.slice(2))); + } + if (request.startsWith(".")) { + return load(path.resolve(path.dirname(resolved), request)); + } + // uniwind's own dependencies (lightningcss, culori, ...) resolve through + // the package's dependency tree, like they do inside the Metro bundle. + return uniwindRequire(request); + }; + + const code = fs.readFileSync(resolved, "utf8"); + new Function("require", "module", "exports", code)(localRequire, cjsModule, cjsModule.exports); + cache.set(resolved, cjsModule.exports); + return cjsModule.exports; + }; + + const loadBundler = (request) => load(path.join(uniwindRoot, "dist/common", request)); + return { loadBundler, uniwindRequire, uniwindRoot }; +} + +async function main() { + const fixtureDir = fs.realpathSync(path.resolve(process.argv[2])); + const uniwindPackageJson = require.resolve("uniwind/package.json", { + paths: [fixtureDir], + }); + const { loadBundler, uniwindRequire, uniwindRoot } = + createUniwindCompilerLoader(uniwindPackageJson); + const { ProcessorBuilder } = loadBundler("bundler/css-processor/processor.js"); + const { addMetaToStylesTemplate } = loadBundler( + "bundler/css-processor/addMetaToStylesTemplate.js", + ); + const { compileNativeCSS } = loadBundler("bundler/css-compiler/compileNativeCSS.js"); + + const { compile } = uniwindRequire("@tailwindcss/node"); + const { Scanner } = uniwindRequire("@tailwindcss/oxide"); + const compiler = await compile(fs.readFileSync(path.join(fixtureDir, "global.css"), "utf8"), { + base: fixtureDir, + onDependency: () => {}, + }); + const scanner = new Scanner({ + sources: [...compiler.sources, { negated: false, pattern: "**/*.tsx", base: fixtureDir }], + }); + const tailwindCSS = await compiler.build(scanner.scan()); + + const platforms = {}; + // Every ios:/android: utility class Tailwind generated for the fixture. + const compiledGuardedClasses = {}; + for (const match of tailwindCSS.matchAll(/\.(ios|android)\\:([^{ ,]+) \{/g)) { + compiledGuardedClasses[`${match[1]}:${match[2]}`.replace(/\\(.)/g, "$1")] = true; + } + const platformBlock = (name) => + new RegExp(`@media ${name} \\{([\\s\\S]*?)\\n \\}`).exec(tailwindCSS)?.[1] ?? ""; + const utilityCountIn = (block) => (block.match(/^\s+\.[^ ]+ \{/gm) ?? []).length; + const tailwindChecks = { + iosBlocks: (tailwindCSS.match(/@media ios \{/g) ?? []).length, + androidBlocks: (tailwindCSS.match(/@media android \{/g) ?? []).length, + iosUtilities: utilityCountIn(platformBlock("ios")), + androidUtilities: utilityCountIn(platformBlock("android")), + }; + for (const platform of ["ios", "android"]) { + // Same config the app's withUniwindConfig produces (polyfills.rem: 14). + const bundlerConfig = { platform, themes: ["light", "dark"], polyfills: { rem: 14 } }; + const processor = new ProcessorBuilder(bundlerConfig); + processor.transform(tailwindCSS); + const compiled = addMetaToStylesTemplate(processor, platform); + const payload = compileNativeCSS(bundlerConfig, tailwindCSS); + const styles = {}; + for (const [className, entries] of Object.entries(compiled)) { + styles[className] = entries.map( + ({ native, minWidth, maxWidth, active, focus, disabled, dataAttributes }) => ({ + native, + minWidth, + maxWidth, + active: active ?? null, + focus: focus ?? null, + disabled: disabled ?? null, + dataAttributes: dataAttributes ?? null, + }), + ); + } + platforms[platform] = { + styles, + payloadIncludesAllCompiled: Object.keys(compiled).every((className) => + payload.includes(`"${className}"`), + ), + // A utility dropped from this platform's stylesheet must not appear + // anywhere in the payload the bundle embeds for this platform. + payloadLeaks: Object.keys(compiledGuardedClasses) + .filter((className) => compiled[className] === undefined) + .filter((className) => payload.includes(`"${className}"`)), + }; + } + + // Exercise the real Metro transformer entry point (the shipped + // `dist/metro/transformer.cjs`, not a copy) with a stubbed downstream worker, + // to check what the global.css virtual module actually becomes — including + // the native styles fingerprint that lets dev reloads skip reinitializing. + const transformerPath = path.join(uniwindRoot, "dist/metro/transformer.cjs"); + let reinitCode = ""; + const Module = require("node:module"); + const origLoad = Module._load; + Module._load = function (request, ...rest) { + if (request === "metro-transform-worker") { + return { + transform: async (_config, _root, _file, data) => { + reinitCode = data.toString(); + return { output: [{ data: {} }] }; + }, + }; + } + return origLoad.call(this, request, ...rest); + }; + process.chdir(fixtureDir); + try { + const { transform } = require(transformerPath); + await transform( + { uniwind: { cssEntryFile: "./global.css", isExpoProject: false } }, + fixtureDir, + "global.css", + Buffer.from(""), + { platform: "ios", type: "module" }, + ); + } finally { + Module._load = origLoad; + } + const transformerCheck = { + reinitPayload: reinitCode.includes("Uniwind.__reinit(rt =>"), + fingerprintArg: /, '[0-9a-f]{64}'\);\s*$/.test(reinitCode), + themesArg: reinitCode.includes("['light', 'dark']"), + }; + + process.stdout.write(JSON.stringify({ tailwindChecks, transformerCheck, platforms })); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/mobile/src/lib/uniwind-platform-variants.test.ts b/apps/mobile/src/lib/uniwind-platform-variants.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..12fa86b33c088c8d9fcc06926977bf3402c3b205 --- /dev/null +++ b/apps/mobile/src/lib/uniwind-platform-variants.test.ts @@ -0,0 +1,253 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; + +// Regression guard for the uniwind platform-variant leak (audit #15 / #13161): +// Tailwind groups every `ios:` (or `android:`) utility into one shared +// `@media ios { ... }` block, and uniwind's CSS processor used to drop the +// block's media queries after its first nested rule. Everything past the first +// utility compiled unguarded and shipped to both platforms. These tests run +// the installed (patched) uniwind compiler over real Tailwind output, per +// platform, and assert what each bundle receives. The compiler itself runs in +// a plain Node child process (uniwind-platform-variants.fixture.cjs) so no +// test-runner module transforms sit between the test and the shipped code. + +interface CompiledStyle { + native: boolean; + minWidth: number; + maxWidth: number; + active: boolean | null; + focus: boolean | null; + disabled: boolean | null; + dataAttributes: Record | null; +} + +interface FixtureOutput { + tailwindChecks: { + iosBlocks: number; + androidBlocks: number; + iosUtilities: number; + androidUtilities: number; + }; + transformerCheck: { + reinitPayload: boolean; + fingerprintArg: boolean; + themesArg: boolean; + }; + platforms: Record< + string, + { + styles: Record; + payloadIncludesAllCompiled: boolean; + payloadLeaks: string[]; + } + >; +} + +// Mirrors the classNames the audits flagged: multiple platform utilities per +// block (only the first one used to keep its guard), a base utility overridden +// by an `ios:` variant (NewTaskDraftScreen), and opposing `ios:`/`android:` +// font families (worktree-setup-card). +const FIXTURE_SOURCE = `export const Probe = () => ( +
+); +`; + +const IOS_CLASSES = ["ios:flex-1", "ios:pt-[72px]", "ios:font-[family-name:Menlo]"]; +const ANDROID_CLASSES = ["android:shrink", "android:grow-0", "android:font-mono"]; +const SHARED_CLASSES = ["flex-1", "pt-12"]; +const RESPONSIVE_CLASSES = ["sm:p-6", "sm:text-lg"]; + +const runFixture = (globalCss: string, probeSource: string): FixtureOutput => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "uniwind-platform-")); + try { + // So `@import "tailwindcss"` and `@import "uniwind"` resolve like in the app. + NodeFS.symlinkSync( + NodeFS.realpathSync(new URL("../../node_modules", import.meta.url)), + NodePath.join(tempDir, "node_modules"), + "dir", + ); + NodeFS.writeFileSync(NodePath.join(tempDir, "global.css"), globalCss); + NodeFS.writeFileSync(NodePath.join(tempDir, "Probe.tsx"), probeSource); + + const fixture = NodeURL.fileURLToPath( + new URL("./uniwind-platform-variants.fixture.cjs", import.meta.url), + ); + const stdout = NodeChildProcess.execFileSync(process.execPath, [fixture, tempDir], { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: 60_000, + }); + // The fixture's compiler may log warnings to stdout before the JSON payload. + const start = stdout.indexOf('{"tailwindChecks"'); + if (start === -1) { + throw new Error(`Fixture produced no JSON:\n${stdout}`); + } + return JSON.parse(stdout.slice(start)) as FixtureOutput; + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } +}; + +describe("uniwind platform variants compile per platform", () => { + let output: FixtureOutput; + + beforeAll(() => { + output = runFixture('@import "tailwindcss";\n@import "uniwind";\n', FIXTURE_SOURCE); + }, 60_000); + + it("compiles the fixture utilities into shared platform blocks", () => { + // Without this grouping the leak could not happen and the assertions below + // would prove nothing. + expect(output.tailwindChecks).toEqual({ + iosBlocks: 1, + androidBlocks: 1, + iosUtilities: 3, + androidUtilities: 3, + }); + }); + + it("keeps ios: utilities out of the android bundle and guards them on ios", () => { + for (const className of IOS_CLASSES) { + expect( + output.platforms.android?.styles[className], + `android has ${className}`, + ).toBeUndefined(); + expect(output.platforms.ios?.styles[className], `ios lacks ${className}`).toBeDefined(); + expect(output.platforms.ios?.styles[className]?.every((style) => style.native)).toBe(true); + } + }); + + it("keeps android: utilities out of the ios bundle and guards them on android", () => { + for (const className of ANDROID_CLASSES) { + expect(output.platforms.ios?.styles[className], `ios has ${className}`).toBeUndefined(); + expect( + output.platforms.android?.styles[className], + `android lacks ${className}`, + ).toBeDefined(); + expect(output.platforms.android?.styles[className]?.every((style) => style.native)).toBe( + true, + ); + } + }); + + it("keeps shared and responsive utilities usable on both platforms", () => { + for (const className of [...SHARED_CLASSES, ...RESPONSIVE_CLASSES]) { + for (const platform of ["ios", "android"]) { + const styles = output.platforms[platform]?.styles[className]; + expect(styles, `${platform} lacks ${className}`).toBeDefined(); + expect(styles?.every((style) => !style.native)).toBe(true); + } + } + // The leak hid a second regression: only the first utility of a width + // block kept its breakpoint, so the second applied at every screen size. + for (const platform of ["ios", "android"]) { + for (const className of RESPONSIVE_CLASSES) { + const style = output.platforms[platform]?.styles[className]?.at(-1); + expect(style?.minWidth, `${className} lost its breakpoint`).toBeGreaterThan(0); + } + } + }); + + it("serializes every compiled style into the platform payload and leaks nothing", () => { + for (const platform of ["ios", "android"]) { + expect(output.platforms[platform]?.payloadIncludesAllCompiled).toBe(true); + // A platform utility dropped from this platform's stylesheet must not + // survive anywhere in the payload this platform bundles. + expect(output.platforms[platform]?.payloadLeaks, `${platform} payload leaks`).toEqual([]); + } + }); +}); + +// The media-query fix must not lose the surrounding rule context: Tailwind +// also emits media rules *nested inside a class rule* (`@utility` bodies with +// `@variant`, nested breakpoints), and those declarations belong to the +// enclosing class. These expectations match the pre-fix compiler output for +// the same inputs, captured against the unpatched package. +describe("uniwind keeps media rules nested inside class rules attached to the class", () => { + let nested: FixtureOutput; + + beforeAll(() => { + nested = runFixture( + [ + '@import "tailwindcss";', + '@import "uniwind";', + "", + ".container-x {", + " width: 100%;", + " @media (width >= 40rem) {", + " max-width: 40rem;", + " }", + "}", + "", + "@utility foo-x {", + " padding: 2px;", + " @variant ios {", + " padding: 3px;", + " }", + "}", + "", + ].join("\n"), + 'export const Probe = () =>
;\n', + ); + }, 60_000); + + it("keeps the nested breakpoint entry of a nested media rule", () => { + for (const platform of ["ios", "android"]) { + const entries = nested.platforms[platform]?.styles["container-x"]; + expect(entries, `${platform} lacks container-x`).toBeDefined(); + expect(entries?.some((style) => !style.native && style.minWidth === 0)).toBe(true); + expect(entries?.some((style) => !style.native && style.minWidth > 0)).toBe(true); + } + }); + + it("keeps @variant declarations inside @utility on their platform", () => { + const iosEntries = nested.platforms.ios?.styles["foo-x"]; + expect(iosEntries?.some((style) => !style.native)).toBe(true); + expect(iosEntries?.some((style) => style.native)).toBe(true); + + const androidEntries = nested.platforms.android?.styles["foo-x"]; + expect(androidEntries?.some((style) => !style.native)).toBe(true); + expect(androidEntries?.some((style) => style.native)).toBe(false); + }); +}); + +// The patch file carries three independent uniwind fixes (state/data selector +// variants, the Metro native-styles fingerprint, and this media-query scoping +// fix). Regenerating it for one fix must not silently drop the others — this +// suite exercises the shipped transformer and compiler for the other two. +describe("uniwind patch keeps pre-existing selector and transformer behavior", () => { + let output: FixtureOutput; + + beforeAll(() => { + output = runFixture( + '@import "tailwindcss";\n@import "uniwind";\n', + 'export const Probe = () =>
;\n', + ); + }, 60_000); + + it("keeps state and data variants conditioned, and rejects unsupported compounds", () => { + for (const platform of ["ios", "android"]) { + const styles = output.platforms[platform]?.styles; + expect(styles?.["active:opacity-50"]?.every((style) => style.active === true)).toBe(true); + expect(styles?.["disabled:opacity-50"]?.every((style) => style.disabled === true)).toBe(true); + expect(styles?.["data-x:underline"]?.every((style) => style.dataAttributes?.["data-x"])).toBe( + true, + ); + // `[aria-disabled="true"]` is not expressible at runtime; emitting it + // unconditionally (the pre-#9355 behavior) styles the element always. + expect(styles).not.toHaveProperty("aria-disabled:text-red-500"); + } + }); + + it("emits the global.css virtual module with a native styles fingerprint", () => { + expect(output.transformerCheck).toEqual({ + reinitPayload: true, + fingerprintArg: true, + themesArg: true, + }); + }); +}); diff --git a/patches/uniwind@1.11.0.patch b/patches/uniwind@1.11.0.patch index abf0b7b107463392e1974412cf342f732b306844..4a418bf6b077e69ecdae08e19e0cd692fbcafa45 100644 --- a/patches/uniwind@1.11.0.patch +++ b/patches/uniwind@1.11.0.patch @@ -1,5 +1,5 @@ diff --git a/dist/common/bundler/css-processor/processor.js b/dist/common/bundler/css-processor/processor.js -index 7dd369fa3197dbd622832c89ac3854c401e55c3e..ab3ea1a9520907321f9c21c2a3a859262fe5075b 100644 +index 7dd369f..48321d1 100644 --- a/dist/common/bundler/css-processor/processor.js +++ b/dist/common/bundler/css-processor/processor.js @@ -105,6 +105,64 @@ class ProcessorBuilder { @@ -154,8 +154,27 @@ index 7dd369fa3197dbd622832c89ac3854c401e55c3e..ab3ea1a9520907321f9c21c2a3a85926 return; } selector.forEach(selectorToken => { +@@ -199,11 +223,16 @@ class ProcessorBuilder { + const { + mediaQueries + } = rule.value.query; +- this.declarationConfig.mediaQueries.push(...mediaQueries); ++ const outerConfig = this.declarationConfig; ++ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; + rule.value.rules.forEach(rule2 => { ++ this.declarationConfig = { ++ ...outerConfig, ++ mediaQueries: blockMediaQueries ++ }; + this.parseRuleRec(rule2); +- this.declarationConfig = this.getDeclarationConfig(); + }); ++ this.declarationConfig = outerConfig; + return; + } + if (rule.type === "layer-block") { diff --git a/dist/metro/transformer.cjs b/dist/metro/transformer.cjs -index d57ca8161f411f0dc7df827132586e428cafdf1b..3650a353b0db269ad4fc51b70a7158885c992e36 100644 +index d57ca81..83b58d1 100644 --- a/dist/metro/transformer.cjs +++ b/dist/metro/transformer.cjs @@ -7,6 +7,7 @@ const culori = require('culori'); @@ -318,7 +337,23 @@ index d57ca8161f411f0dc7df827132586e428cafdf1b..3650a353b0db269ad4fc51b70a715888 return; } selector.forEach((selectorToken) => { -@@ -1753,10 +1778,11 @@ const transform = async (config$1, projectRoot, filePath, data, options) => { +@@ -1629,11 +1654,13 @@ class ProcessorBuilder { + } + if (rule.type === "media") { + const { mediaQueries } = rule.value.query; +- this.declarationConfig.mediaQueries.push(...mediaQueries); ++ const outerConfig = this.declarationConfig; ++ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; + rule.value.rules.forEach((rule2) => { ++ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }; + this.parseRuleRec(rule2); +- this.declarationConfig = this.getDeclarationConfig(); + }); ++ this.declarationConfig = outerConfig; + return; + } + if (rule.type === "layer-block") { +@@ -1753,10 +1780,11 @@ const transform = async (config$1, projectRoot, filePath, data, options) => { await bundlerConfig.generateArtifacts(cssArtifactPath); const virtualCode = await compileCSS(bundlerConfig); const isWeb = bundlerConfig.platform === config.Platform.Web; @@ -332,7 +367,7 @@ index d57ca8161f411f0dc7df827132586e428cafdf1b..3650a353b0db269ad4fc51b70a715888 "utf-8" ); diff --git a/dist/metro/transformer.mjs b/dist/metro/transformer.mjs -index d3e7475f8f0aaba510b318657dc8374e6c6097cb..6ff8cffc4bb703521b6d86f0ac14be054ba5b9ce 100644 +index d3e7475..f92b73b 100644 --- a/dist/metro/transformer.mjs +++ b/dist/metro/transformer.mjs @@ -5,6 +5,7 @@ import { converter, parse, formatHex, formatHex8 } from 'culori'; @@ -495,7 +530,23 @@ index d3e7475f8f0aaba510b318657dc8374e6c6097cb..6ff8cffc4bb703521b6d86f0ac14be05 return; } selector.forEach((selectorToken) => { -@@ -1746,10 +1771,11 @@ const transform = async (config, projectRoot, filePath, data, options) => { +@@ -1622,11 +1647,13 @@ class ProcessorBuilder { + } + if (rule.type === "media") { + const { mediaQueries } = rule.value.query; +- this.declarationConfig.mediaQueries.push(...mediaQueries); ++ const outerConfig = this.declarationConfig; ++ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; + rule.value.rules.forEach((rule2) => { ++ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }; + this.parseRuleRec(rule2); +- this.declarationConfig = this.getDeclarationConfig(); + }); ++ this.declarationConfig = outerConfig; + return; + } + if (rule.type === "layer-block") { +@@ -1746,10 +1773,11 @@ const transform = async (config, projectRoot, filePath, data, options) => { await bundlerConfig.generateArtifacts(cssArtifactPath); const virtualCode = await compileCSS(bundlerConfig); const isWeb = bundlerConfig.platform === Platform.Web; @@ -509,7 +560,7 @@ index d3e7475f8f0aaba510b318657dc8374e6c6097cb..6ff8cffc4bb703521b6d86f0ac14be05 "utf-8" ); diff --git a/dist/module/bundler/css-processor/processor.js b/dist/module/bundler/css-processor/processor.js -index 58c96b6ab642c2b724d1868d0d48b77bad33be6f..70b759b04913a0e9028c936827b4578d628b4728 100644 +index 58c96b6..6849ca7 100644 --- a/dist/module/bundler/css-processor/processor.js +++ b/dist/module/bundler/css-processor/processor.js @@ -99,6 +99,64 @@ export class ProcessorBuilder { @@ -664,8 +715,24 @@ index 58c96b6ab642c2b724d1868d0d48b77bad33be6f..70b759b04913a0e9028c936827b4578d return; } selector.forEach((selectorToken) => { +@@ -191,11 +215,13 @@ export class ProcessorBuilder { + } + if (rule.type === "media") { + const { mediaQueries } = rule.value.query; +- this.declarationConfig.mediaQueries.push(...mediaQueries); ++ const outerConfig = this.declarationConfig; ++ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries]; + rule.value.rules.forEach((rule2) => { ++ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries }; + this.parseRuleRec(rule2); +- this.declarationConfig = this.getDeclarationConfig(); + }); ++ this.declarationConfig = outerConfig; + return; + } + if (rule.type === "layer-block") { diff --git a/src/bundler/adapters/metro/transformer.ts b/src/bundler/adapters/metro/transformer.ts -index 08059b0fb2394effd2553086216d8933a53fad84..05c051595bfffd313f1d731fcedc1e991af720e1 100644 +index 08059b0..05c0515 100644 --- a/src/bundler/adapters/metro/transformer.ts +++ b/src/bundler/adapters/metro/transformer.ts @@ -5,6 +5,7 @@ import { Platform } from '@/common/consts' @@ -699,7 +766,7 @@ index 08059b0fb2394effd2553086216d8933a53fad84..05c051595bfffd313f1d731fcedc1e99 'utf-8', ) diff --git a/src/bundler/css-processor/processor.ts b/src/bundler/css-processor/processor.ts -index c8b3bb86e2a50c16ebc3f0404f4d9ac050aba61d..b5e185469733aca4289b7b07bc76dbfcd8fd5788 100644 +index c8b3bb8..4af4199 100644 --- a/src/bundler/css-processor/processor.ts +++ b/src/bundler/css-processor/processor.ts @@ -123,6 +123,77 @@ export class ProcessorBuilder { @@ -885,8 +952,32 @@ index c8b3bb86e2a50c16ebc3f0404f4d9ac050aba61d..b5e185469733aca4289b7b07bc76dbfc return } +@@ -241,13 +275,21 @@ export class ProcessorBuilder { + + if (rule.type === 'media') { + const { mediaQueries } = rule.value.query ++ // Media queries have to outlive the reset between the block's sibling ++ // rules: resetting only after each parse left every rule past the first ++ // without the block's queries, shipping ios:/android: variants and later ++ // utilities of a width breakpoint unguarded to every platform. Reset to ++ // the outer config instead of a fresh one so media nested inside a class ++ // rule keeps writing into that class. ++ const outerConfig = this.declarationConfig ++ const blockMediaQueries = [...outerConfig.mediaQueries, ...mediaQueries] + +- this.declarationConfig.mediaQueries.push(...mediaQueries) + rule.value.rules.forEach(rule => { ++ this.declarationConfig = { ...outerConfig, mediaQueries: blockMediaQueries } + this.parseRuleRec(rule) +- this.declarationConfig = this.getDeclarationConfig() + }) + ++ this.declarationConfig = outerConfig + return + } + diff --git a/src/core/config/config.native.ts b/src/core/config/config.native.ts -index 5e0832b5913df6f02604020d7ff5caf3b6a8d912..01aed1be25ca928f3a802996f41cb6b841aaed86 100644 +index 5e0832b..01aed1b 100644 --- a/src/core/config/config.native.ts +++ b/src/core/config/config.native.ts @@ -8,6 +8,8 @@ import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../ty diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bc636397f0a1bc35789e022b0ca47cf11431f87..8922d32ff15131dc3bfa1dcca7df1b319cf62fce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,7 +111,7 @@ patchedDependencies: react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d react-native-screens@4.26.2: 595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0 - uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90 + uniwind@1.11.0: a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625 importers: @@ -478,7 +478,7 @@ importers: version: 3.6.0 uniwind: specifier: 1.11.0 - 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) + 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) devDependencies: '@effect/vitest': specifier: 4.0.0-rc.115 @@ -21808,7 +21808,7 @@ snapshots: universalify@2.0.1: {} - 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): + 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): dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 -- 2.54.0