From 87d9c5bdab27bbe6c914a55dc22ab26346440483 Mon Sep 17 00:00:00 2001 From: Dima Lebedynskyi Date: Fri, 9 Oct 2026 11:30:23 -0700 Subject: [PATCH] fix(metro): skip the generated uniwind.css when tracking imported CSS Since #676, development native entries require every stylesheet Tailwind reports that isn't under node_modules. When uniwind is linked (a workspace package, `npm link`, a `file:` install), Tailwind resolves `@import 'uniwind'` to the package's uniwind.css by its real path, outside node_modules. The entry then requires the artifact that its own transform rewrites. Every write changes a module in Metro's graph and re-runs the uncached entry transform. A single server rebuilds the entry once more at startup, and two Metro servers with different theme lists rewrite the artifact for each other indefinitely. Leave out the stylesheet whose real path is the generated artifact's. Local stylesheets and linked workspace stylesheets are still required. --- CONTEXT.md | 2 +- .../src/bundler/adapters/metro/transformer.ts | 11 +++- .../tests/native/bundler/transformer.test.ts | 62 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 packages/uniwind/tests/native/bundler/transformer.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 6eed4c84..5959a8d4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -121,7 +121,7 @@ Metro integration: - `withUniwindConfig(config, uniwindConfig)` patches Metro graph support for uncached modules. - Metro adds `css` as source extension and removes it from asset extensions. -- Metro transformer handles the configured CSS entry file specially. In development, native entries declare imported local CSS files as Metro dependencies, including nested imports and workspace files resolved outside `node_modules`, so token-only edits trigger recompilation. Dependencies are collected afresh on each compile. +- Metro transformer handles the configured CSS entry file specially. In development, native entries declare imported local CSS files as Metro dependencies, including nested imports and workspace files resolved outside `node_modules`, so token-only edits trigger recompilation. The generated `uniwind.css` is never declared (compared by real path): when uniwind is linked, `@import 'uniwind'` resolves to it outside `node_modules`, and every artifact write would rebuild the entry. Dependencies are collected afresh on each compile. - Non-entry native CSS is an empty module in plain Metro; Expo keeps its own CSS handling. Web CSS handling is unchanged. - Metro transformer worker selection is lazy, cached per Expo/non-Expo config type, and follows Expo transformer paths or Expo-specific config markers. - Native platform CSS transforms into a JS module that calls `Uniwind.__reinit(...)` with a fingerprint of the generated styles and themes. During development, the native runtime skips reinitialization when that fingerprint is unchanged. diff --git a/packages/uniwind/src/bundler/adapters/metro/transformer.ts b/packages/uniwind/src/bundler/adapters/metro/transformer.ts index c280f4c8..83ed0276 100644 --- a/packages/uniwind/src/bundler/adapters/metro/transformer.ts +++ b/packages/uniwind/src/bundler/adapters/metro/transformer.ts @@ -3,6 +3,7 @@ import { compileCSS } from '@/bundler/css-compiler' import type { UniwindMetroConfig } from '@/bundler/types' import { Platform } from '@/common/consts' import type * as ExpoMetroConfig from '@expo/metro-config' +import fs from 'fs' import type * as MetroTransformWorker from 'metro-transform-worker' import type { JsTransformerConfig, JsTransformOptions } from 'metro-transform-worker' import { createHash } from 'node:crypto' @@ -10,6 +11,14 @@ import path from 'path' const cssArtifactPath = path.resolve(__dirname, '../../uniwind.css') +// Local stylesheets a development entry requires, so editing one re-runs its transform. When uniwind is linked +// (a workspace package, `npm link`), `@import 'uniwind'` resolves to the generated artifact outside node_modules. +// Requiring it would rebuild the entry after each write, and Metro servers with different themes after each other's. +export const isImportedStylesheet = (stylesheet: string, artifactPath: string) => + stylesheet.endsWith('.css') + && !stylesheet.includes(`${path.sep}node_modules${path.sep}`) + && fs.realpathSync(stylesheet) !== fs.realpathSync(artifactPath) + // Cache workers separately for Expo (`true`) and plain Metro (`false`) configs. const workerCache = new Map() @@ -76,7 +85,7 @@ export const transform = async ( const isWeb = bundlerConfig.platform === Platform.Web const importedStylesheets = new Set() const virtualCode = await compileCSS(bundlerConfig, dependency => { - if (!isWeb && options.dev && dependency.endsWith('.css') && !dependency.includes(`${path.sep}node_modules${path.sep}`)) { + if (!isWeb && options.dev && isImportedStylesheet(dependency, cssArtifactPath)) { importedStylesheets.add(dependency) } }) diff --git a/packages/uniwind/tests/native/bundler/transformer.test.ts b/packages/uniwind/tests/native/bundler/transformer.test.ts new file mode 100644 index 00000000..6455b4ec --- /dev/null +++ b/packages/uniwind/tests/native/bundler/transformer.test.ts @@ -0,0 +1,62 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' +import { isImportedStylesheet } from '../../../src/bundler/adapters/metro/transformer' +import { UniwindBundlerConfig } from '../../../src/bundler/config' +import { compileTailwind } from '../../../src/bundler/css-compiler/compileTailwind' + +let root = '' + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'uniwind-transformer-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +const writeFiles = (files: Record) => { + Object.entries(files).forEach(([file, content]) => { + mkdirSync(dirname(join(root, file)), { recursive: true }) + writeFileSync(join(root, file), content) + }) +} + +// The stylesheets Tailwind reports while compiling the entry, as the transformer receives them. +const compileDependencies = async (cssEntryFile: string) => { + const dependencies: Array = [] + const bundlerConfig = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile: relative(process.cwd(), join(root, cssEntryFile)) }, 'ios') + + await compileTailwind(bundlerConfig, dependency => dependencies.push(dependency)) + + return dependencies +} + +test.each([ + ['its real path', () => join(realpathSync(root), 'packages', 'uniwind', 'uniwind.css')], + ['the linked path', () => join(root, 'app', 'node_modules', 'uniwind', 'uniwind.css')], +])('skips the generated artifact of a linked uniwind, given %s', async (_, artifactPath) => { + // A workspace package or `npm link`: node_modules/uniwind links to a directory outside node_modules. + writeFiles({ + 'packages/uniwind/package.json': JSON.stringify({ name: 'uniwind', style: './uniwind.css' }), + 'packages/uniwind/uniwind.css': '@theme { --color-artifact: #000000; }', + 'packages/brand/package.json': JSON.stringify({ name: 'brand', style: './brand.css' }), + 'packages/brand/brand.css': '@theme { --color-brand: #111111; }', + 'app/node_modules/theme/package.json': JSON.stringify({ name: 'theme', style: './theme.css' }), + 'app/node_modules/theme/theme.css': '@theme { --color-theme: #222222; }', + 'app/global.css': ['@import "uniwind";', '@import "brand";', '@import "theme";', '@import "./tokens.css";'].join('\n'), + 'app/tokens.css': '@theme { --color-token: #333333; }', + }) + symlinkSync(join(root, 'packages', 'uniwind'), join(root, 'app', 'node_modules', 'uniwind'), 'dir') + symlinkSync(join(root, 'packages', 'brand'), join(root, 'app', 'node_modules', 'brand'), 'dir') + + const realRoot = realpathSync(root) + const dependencies = await compileDependencies('app/global.css') + + // Tailwind reports linked stylesheets by their real paths, outside node_modules. + expect(dependencies).toContain(join(realRoot, 'packages', 'uniwind', 'uniwind.css')) + expect(dependencies.filter(dependency => isImportedStylesheet(dependency, artifactPath())).sort()).toEqual([ + join(realRoot, 'app', 'tokens.css'), + join(realRoot, 'packages', 'brand', 'brand.css'), + ]) +})