| author | |
| committer | |
| log | b9199617d57178b136ece0b8d649565cd463a0ff |
| tree | 23d240ff75420545395e578cedd5f553f1ec8a42 |
| parent | 6975efd3dd52c95dd978ff26f591139d8c5c2503 |
| signature | Signed by PGP key B5690EEEBB952194 |
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. | ||
| 15 | const fs = require("node:fs"); | ||
| 16 | const path = require("node:path"); | ||
| 17 | const { createRequire } = require("node:module"); | ||
| 18 | |||
| 19 | function 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 | |||
| 66 | async 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 | |||
| 180 | main().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 @@ | ||
| 1 | import * as NodeChildProcess from "node:child_process"; | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeOS from "node:os"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import * as NodeURL from "node:url"; | ||
| 6 | import { 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 | |||
| 18 | interface 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 | |||
| 28 | interface 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). | ||
| 54 | const 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 | |||
| 59 | const IOS_CLASSES = ["ios:flex-1", "ios:pt-[72px]", "ios:font-[family-name:Menlo]"]; | ||
| 60 | const ANDROID_CLASSES = ["android:shrink", "android:grow-0", "android:font-mono"]; | ||
| 61 | const SHARED_CLASSES = ["flex-1", "pt-12"]; | ||
| 62 | const RESPONSIVE_CLASSES = ["sm:p-6", "sm:text-lg"]; | ||
| 63 | |||
| 64 | const 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 | |||
| 95 | describe("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. | ||
| 170 | describe("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. | ||
| 222 | describe("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 @@ |
| 1 | diff --git a/dist/common/bundler/css-processor/processor.js b/dist/common/bundler/css-processor/processor.js | 1 | diff --git a/dist/common/bundler/css-processor/processor.js b/dist/common/bundler/css-processor/processor.js |
| 2 | index 7dd369fa3197dbd622832c89ac3854c401e55c3e..ab3ea1a9520907321f9c21c2a3a859262fe5075b 100644 | 2 | index 7dd369f..48321d1 100644 |
| 3 | --- a/dist/common/bundler/css-processor/processor.js | 3 | --- a/dist/common/bundler/css-processor/processor.js |
| 4 | +++ b/dist/common/bundler/css-processor/processor.js | 4 | +++ 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") { | ||
| 157 | diff --git a/dist/metro/transformer.cjs b/dist/metro/transformer.cjs | 176 | diff --git a/dist/metro/transformer.cjs b/dist/metro/transformer.cjs |
| 158 | index d57ca8161f411f0dc7df827132586e428cafdf1b..3650a353b0db269ad4fc51b70a7158885c992e36 100644 | 177 | index d57ca81..83b58d1 100644 |
| 159 | --- a/dist/metro/transformer.cjs | 178 | --- a/dist/metro/transformer.cjs |
| 160 | +++ b/dist/metro/transformer.cjs | 179 | +++ 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 | ); |
| 334 | diff --git a/dist/metro/transformer.mjs b/dist/metro/transformer.mjs | 369 | diff --git a/dist/metro/transformer.mjs b/dist/metro/transformer.mjs |
| 335 | index d3e7475f8f0aaba510b318657dc8374e6c6097cb..6ff8cffc4bb703521b6d86f0ac14be054ba5b9ce 100644 | 370 | index d3e7475..f92b73b 100644 |
| 336 | --- a/dist/metro/transformer.mjs | 371 | --- a/dist/metro/transformer.mjs |
| 337 | +++ b/dist/metro/transformer.mjs | 372 | +++ 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 | ); |
| 511 | diff --git a/dist/module/bundler/css-processor/processor.js b/dist/module/bundler/css-processor/processor.js | 562 | diff --git a/dist/module/bundler/css-processor/processor.js b/dist/module/bundler/css-processor/processor.js |
| 512 | index 58c96b6ab642c2b724d1868d0d48b77bad33be6f..70b759b04913a0e9028c936827b4578d628b4728 100644 | 563 | index 58c96b6..6849ca7 100644 |
| 513 | --- a/dist/module/bundler/css-processor/processor.js | 564 | --- a/dist/module/bundler/css-processor/processor.js |
| 514 | +++ b/dist/module/bundler/css-processor/processor.js | 565 | +++ 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") { | ||
| 667 | diff --git a/src/bundler/adapters/metro/transformer.ts b/src/bundler/adapters/metro/transformer.ts | 734 | diff --git a/src/bundler/adapters/metro/transformer.ts b/src/bundler/adapters/metro/transformer.ts |
| 668 | index 08059b0fb2394effd2553086216d8933a53fad84..05c051595bfffd313f1d731fcedc1e991af720e1 100644 | 735 | index 08059b0..05c0515 100644 |
| 669 | --- a/src/bundler/adapters/metro/transformer.ts | 736 | --- a/src/bundler/adapters/metro/transformer.ts |
| 670 | +++ b/src/bundler/adapters/metro/transformer.ts | 737 | +++ 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 | ) |
| 701 | diff --git a/src/bundler/css-processor/processor.ts b/src/bundler/css-processor/processor.ts | 768 | diff --git a/src/bundler/css-processor/processor.ts b/src/bundler/css-processor/processor.ts |
| 702 | index c8b3bb86e2a50c16ebc3f0404f4d9ac050aba61d..b5e185469733aca4289b7b07bc76dbfcd8fd5788 100644 | 769 | index c8b3bb8..4af4199 100644 |
| 703 | --- a/src/bundler/css-processor/processor.ts | 770 | --- a/src/bundler/css-processor/processor.ts |
| 704 | +++ b/src/bundler/css-processor/processor.ts | 771 | +++ 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 | return | 953 | 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 | |||
| 888 | diff --git a/src/core/config/config.native.ts b/src/core/config/config.native.ts | 979 | diff --git a/src/core/config/config.native.ts b/src/core/config/config.native.ts |
| 889 | index 5e0832b5913df6f02604020d7ff5caf3b6a8d912..01aed1be25ca928f3a802996f41cb6b841aaed86 100644 | 980 | index 5e0832b..01aed1b 100644 |
| 890 | --- a/src/core/config/config.native.ts | 981 | --- a/src/core/config/config.native.ts |
| 891 | +++ b/src/core/config/config.native.ts | 982 | +++ b/src/core/config/config.native.ts |
| 892 | @@ -8,6 +8,8 @@ import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../ty | 983 | @@ -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: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 | 111 | react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 |
| 112 | react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d | 112 | react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d |
| 113 | react-native-screens@4.26.2: 595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0 | 113 | react-native-screens@4.26.2: 595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0 |
| 114 | uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90 | 114 | uniwind@1.11.0: a91f3953483cfd434436de6358ff5b63c49974ef148fad91246c26ba95332625 |
| 115 | 115 | ||
| 116 | importers: | 116 | importers: |
| 117 | 117 | ||
| ... | @@ -478,7 +478,7 @@ importers: | ... | @@ -478,7 +478,7 @@ importers: |
| 478 | version: 3.6.0 | 478 | version: 3.6.0 |
| 479 | uniwind: | 479 | uniwind: |
| 480 | specifier: 1.11.0 | 480 | 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.115 | 484 | specifier: 4.0.0-rc.115 |
| ... | @@ -21808,7 +21808,7 @@ snapshots: | ... | @@ -21808,7 +21808,7 @@ snapshots: |
| 21808 | 21808 | ||
| 21809 | universalify@2.0.1: {} | 21809 | universalify@2.0.1: {} |
| 21810 | 21810 | ||
| 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.3 | 21813 | '@tailwindcss/node': 4.3.3 |
| 21814 | '@tailwindcss/oxide': 4.3.3 | 21814 | '@tailwindcss/oxide': 4.3.3 |