From 56fff701c48e8135262cefa679638f39e19c52a7 Mon Sep 17 00:00:00 2001 From: raulfernandez Date: Fri, 11 Sep 2026 17:07:43 +0200 Subject: [PATCH] fix(metro): track imported CSS dependencies for hot reload Collect Tailwind stylesheet dependencies in the native Metro entry so token-only edits trigger its existing uncached recompilation. Handle imported CSS as empty modules in plain Metro while preserving Expo and web handling and stylesheet fingerprints. Add compiler regressions and real Metro HMR tests for Expo and bare iOS/Android. The HMR fixture builds current sources in isolation and passes without repository dist output. Refs https://github.com/uni-stack/uniwind/discussions/666 --- CONTEXT.md | 3 +- .../src/bundler/adapters/metro/transformer.ts | 18 +- .../src/bundler/css-compiler/compileCSS.ts | 4 +- .../bundler/css-compiler/compileTailwind.ts | 7 +- .../native/bundler/fixtures/metro-css-hmr.cjs | 126 ++++++++++++++ .../native/bundler/metro-css-hmr.test.ts | 24 +++ .../native/bundler/metro-css-imports.test.ts | 164 ++++++++++++++++++ 7 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 packages/uniwind/tests/native/bundler/fixtures/metro-css-hmr.cjs create mode 100644 packages/uniwind/tests/native/bundler/metro-css-hmr.test.ts create mode 100644 packages/uniwind/tests/native/bundler/metro-css-imports.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index f3ca4ab4..412c1f56 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -117,7 +117,8 @@ 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. +- Metro transformer handles the configured CSS entry file specially. 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. +- 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. - Web platform CSS transforms into CSS plus web runtime setup. diff --git a/packages/uniwind/src/bundler/adapters/metro/transformer.ts b/packages/uniwind/src/bundler/adapters/metro/transformer.ts index da6a0352..ac2e9931 100644 --- a/packages/uniwind/src/bundler/adapters/metro/transformer.ts +++ b/packages/uniwind/src/bundler/adapters/metro/transformer.ts @@ -63,13 +63,28 @@ export const transform = async ( } if (!isCss) { + if (!config.uniwind.isExpoProject && options.platform !== Platform.Web && options.type !== 'asset' && filePath.endsWith('.css')) { + // Plain Metro parses CSS as JavaScript; these modules only register watched files. + return worker.transform(config, projectRoot, `${filePath}.js`, Buffer.from(''), options) + } + return worker.transform(config, projectRoot, filePath, data, options) } const bundlerConfig = UniwindBundlerConfig.fromMetroConfig(config.uniwind, options.platform) await bundlerConfig.generateArtifacts(cssArtifactPath) - const virtualCode = await compileCSS(bundlerConfig) const isWeb = bundlerConfig.platform === Platform.Web + const importedStylesheets = new Set() + const virtualCode = await compileCSS(bundlerConfig, dependency => { + if (!isWeb && dependency.endsWith('.css') && !dependency.includes(`${path.sep}node_modules${path.sep}`)) { + importedStylesheets.add(dependency) + } + }) + const importedStylesheetRequires = Array.from(importedStylesheets).sort().map(stylesheet => { + const relativePath = path.relative(path.dirname(bundlerConfig.cssPath), stylesheet).split(path.sep).join('/') + + return `require(${JSON.stringify(relativePath.startsWith('../') ? relativePath : `./${relativePath}`)});` + }) const nativeStylesFingerprint = isWeb ? undefined : createHash('sha256') @@ -82,6 +97,7 @@ export const transform = async ( isWeb ? virtualCode : [ + ...importedStylesheetRequires, `const { Uniwind } = require('uniwind');`, `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');`, ].join(''), diff --git a/packages/uniwind/src/bundler/css-compiler/compileCSS.ts b/packages/uniwind/src/bundler/css-compiler/compileCSS.ts index bc3e7566..0853a1c6 100644 --- a/packages/uniwind/src/bundler/css-compiler/compileCSS.ts +++ b/packages/uniwind/src/bundler/css-compiler/compileCSS.ts @@ -4,8 +4,8 @@ import { compileNativeCSS } from './compileNativeCSS' import { compileTailwind } from './compileTailwind' import { compileWebCSS } from './compileWebCSS' -export const compileCSS = async (bundlerConfig: UniwindBundlerConfig) => { - const tailwindCSS = await compileTailwind(bundlerConfig) +export const compileCSS = async (bundlerConfig: UniwindBundlerConfig, onDependency?: (dependency: string) => void) => { + const tailwindCSS = await compileTailwind(bundlerConfig, onDependency) if (bundlerConfig.platform === Platform.Web) { return compileWebCSS(bundlerConfig, tailwindCSS) diff --git a/packages/uniwind/src/bundler/css-compiler/compileTailwind.ts b/packages/uniwind/src/bundler/css-compiler/compileTailwind.ts index 402c2ea3..2bf9cf6b 100644 --- a/packages/uniwind/src/bundler/css-compiler/compileTailwind.ts +++ b/packages/uniwind/src/bundler/css-compiler/compileTailwind.ts @@ -4,11 +4,14 @@ import fs from 'fs' import path from 'path' import type { UniwindBundlerConfig } from '../config' -export const compileTailwind = async (bundlerConfig: UniwindBundlerConfig) => { +export const compileTailwind = async ( + bundlerConfig: UniwindBundlerConfig, + onDependency: (dependency: string) => void = () => void 0, +) => { const css = await fs.promises.readFile(bundlerConfig.cssPath, 'utf-8') const compiler = await compile(css, { base: path.dirname(bundlerConfig.cssPath), - onDependency: () => void 0, + onDependency, }) const scanner = new Scanner({ sources: [ diff --git a/packages/uniwind/tests/native/bundler/fixtures/metro-css-hmr.cjs b/packages/uniwind/tests/native/bundler/fixtures/metro-css-hmr.cjs new file mode 100644 index 00000000..ead23529 --- /dev/null +++ b/packages/uniwind/tests/native/bundler/fixtures/metro-css-hmr.cjs @@ -0,0 +1,126 @@ +const assert = require('node:assert/strict') +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const { createRequire } = require('node:module') + +// Run directly: node packages/uniwind/tests/native/bundler/fixtures/metro-css-hmr.cjs bare ios +const packageRoot = path.resolve(__dirname, '../../../..') +const repo = path.resolve(packageRoot, '../..') +const req = createRequire(path.join(repo, 'package.json')) +const { default: IncrementalBundler } = req('metro/private/IncrementalBundler') +const [kind = 'bare', platform = 'ios'] = process.argv.slice(2) +const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'uniwind-live-metro-'))) +const entry = path.join(root, 'index.js') +const css = path.join(root, 'global.css') +const tokens = path.join(root, 'tokens.css') +const nested = path.join(root, 'nested.css') +const runtime = path.join(root, 'runtime.js') +const results = { kind, platform, pid: process.pid, updates: [] } + +async function main() { + fs.symlinkSync(path.join(repo, 'node_modules'), path.join(root, 'node_modules'), 'dir') + const isolatedPackage = path.join(root, 'uniwind') + // Build current sources in isolation so the test cannot use stale dist output. + execFileSync(process.execPath, [ + '--input-type=module', + '-e', + ` + import { build } from 'unbuild'; + await build(process.cwd(), false, { + hooks: { + 'build:prepare': ctx => { + ctx.options.entries = ctx.options.entries.filter(entry => entry.name === 'metro/index' || entry.name === 'metro/transformer'); + ctx.options.outDir = process.argv[1]; + // This fixture does not build the other package exports. + ctx.options.failOnWarn = false; + }, + }, + }); + `, + path.join(isolatedPackage, 'dist'), + ], { cwd: packageRoot, stdio: 'pipe', timeout: 15_000 }) + const { withUniwindConfig } = require(path.join(isolatedPackage, 'dist/metro/index.cjs')) + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'uniwind-hmr-fixture', private: true })) + fs.writeFileSync( + path.join(root, 'babel.config.js'), + `module.exports = { presets: [${JSON.stringify(req.resolve(kind === 'expo' ? 'babel-preset-expo' : '@react-native/babel-preset'))}] };`, + ) + fs.writeFileSync(entry, `require('./global.css'); global.__fixtureSession = 'retained';`) + fs.writeFileSync(runtime, 'module.exports = { Uniwind: { __reinit() {} } };') + fs.writeFileSync(css, '@import "./tokens.css"; @tailwind utilities; @source inline("bg-brand");') + fs.writeFileSync(tokens, '@import "./nested.css";') + fs.writeFileSync(nested, '@theme static { --color-brand: #123456; }') + const { getDefaultConfig } = req(kind === 'expo' ? '@expo/metro-config' : '@react-native/metro-config') + let config = await getDefaultConfig(root) + config.maxWorkers = 1 + config.watchFolders = [root, path.join(repo, 'node_modules'), path.join(repo, 'packages/uniwind')] + config.resolver.useWatchman = false + config.resolver.nodeModulesPaths = [path.join(repo, 'node_modules')] + config.resolver.resolveRequest = (context, moduleName, target) => + moduleName === 'uniwind' + ? { type: 'sourceFile', filePath: runtime } + : context.resolveRequest(context, moduleName, target) + config.reporter = { update() {} } + config = withUniwindConfig(config, { + cssEntryFile: path.relative(process.cwd(), css), + dtsFile: path.join(root, 'uniwind-types.d.ts'), + }) + results.expoWorker = config.transformer.uniwind.isExpoProject + assert.equal(results.expoWorker, kind === 'expo') + const bundler = new IncrementalBundler(config, { watch: true }) + let unlisten + try { + await bundler.ready() + let { revision } = await bundler.initializeGraph(entry, { + dev: true, + hot: true, + minify: false, + platform, + type: 'module', + unstable_transformProfile: 'default', + }, { customResolverOptions: {} }) + assert(revision.graph.dependencies.has(tokens), 'direct CSS missing from Metro graph') + assert(revision.graph.dependencies.has(nested), 'nested CSS missing from Metro graph') + const codeFor = file => revision.graph.dependencies.get(file).output.find(output => output.type.startsWith('js/')).data.code + assert(codeFor(css).includes('#123456'), 'initial token missing') + results.cssModules = [css, tokens, nested].map(file => path.basename(file)) + for (const color of ['#654321', '#123456']) { + const change = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Metro did not report a token-only change in 15s')), 15000) + const watcher = bundler.getBundler().getWatcher() + const listener = event => { + if (Array.from(event.changes.modifiedFiles, ([file]) => file).some(file => path.join(event.rootDir, file) === nested)) { + clearTimeout(timeout) + resolve() + } + } + watcher.on('change', listener) + unlisten = () => { + clearTimeout(timeout) + watcher.off('change', listener) + } + }) + fs.writeFileSync(nested, `@theme static { --color-brand: ${color}; }`) + await change + unlisten() + const update = await bundler.updateGraph(revision, false) + revision = update.revision + assert(update.delta.modified.has(css), 'CSS entry did not appear in the HMR delta') + assert(!update.delta.modified.has(entry), 'JavaScript entry changed during a token-only update') + assert(codeFor(css).includes(color), `Updated token ${color} missing from CSS entry`) + results.updates.push({ color, modified: [...update.delta.modified.keys()].map(file => path.basename(file)), reset: update.delta.reset }) + assert.equal(update.delta.reset, false) + } + console.log(JSON.stringify(results, null, 2)) + } finally { + unlisten?.() + await bundler.end() + } +} + +main().catch(error => { + console.error(error) + process.exitCode = 1 +}).finally(() => fs.rmSync(root, { recursive: true, force: true })) diff --git a/packages/uniwind/tests/native/bundler/metro-css-hmr.test.ts b/packages/uniwind/tests/native/bundler/metro-css-hmr.test.ts new file mode 100644 index 00000000..a5f0d505 --- /dev/null +++ b/packages/uniwind/tests/native/bundler/metro-css-hmr.test.ts @@ -0,0 +1,24 @@ +import { execFileSync } from 'node:child_process' +import path from 'node:path' + +// The child process uses real Metro workers and isolates their watchers from Jest. +test.each([ + ['bare', 'ios'], + ['bare', 'android'], + ['expo', 'ios'], + ['expo', 'android'], +])('delivers token-only HMR updates with %s Metro on %s', (kind, platform) => { + const output = execFileSync(process.execPath, [path.join(__dirname, 'fixtures/metro-css-hmr.cjs'), kind, platform], { + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, NODE_ENV: 'development' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const result = JSON.parse(output) + + expect(result.expoWorker).toBe(kind === 'expo') + expect(result.updates).toEqual([ + { color: '#654321', modified: expect.arrayContaining(['nested.css', 'global.css']), reset: false }, + { color: '#123456', modified: expect.arrayContaining(['nested.css', 'global.css']), reset: false }, + ]) +}, 35_000) diff --git a/packages/uniwind/tests/native/bundler/metro-css-imports.test.ts b/packages/uniwind/tests/native/bundler/metro-css-imports.test.ts new file mode 100644 index 00000000..5399d78a --- /dev/null +++ b/packages/uniwind/tests/native/bundler/metro-css-imports.test.ts @@ -0,0 +1,164 @@ +import { transform as workerTransform } from 'metro-transform-worker' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { transform } from '../../../src/bundler/adapters/metro/transformer' +import { UniwindBundlerConfig } from '../../../src/bundler/config' + +jest.mock('metro-transform-worker', () => ({ transform: jest.fn() })) +jest.mock('@expo/metro-config', () => ({ unstable_transformerPath: 'metro-transform-worker' })) + +const mockWorker = jest.mocked(workerTransform) +const options = { + dev: true, + minify: false, + platform: 'ios', + type: 'module', + inlineRequires: false, + experimentalImportSupport: false, + inlinePlatform: true, + unstable_transformProfile: 'default', +} satisfies Parameters[4] + +let root: string +let entry: string + +beforeEach(() => { + root = realpathSync(mkdtempSync(path.join(tmpdir(), 'uniwind-css-imports-'))) + entry = path.join(root, 'app', 'global.css') + mkdirSync(path.dirname(entry), { recursive: true }) + writeFileSync(path.join(root, 'app', 'tokens.css'), '@theme static { --color-brand: #123456; }') + writeFileSync(entry, '@import "./tokens.css"; @tailwind utilities; @source inline("bg-brand");') + // Artifact generation is independent of the compiler/Metro dependency contract under test. + jest.spyOn(UniwindBundlerConfig.prototype, 'generateArtifacts').mockResolvedValue(undefined) + mockWorker.mockImplementation(async (_config, _root, _file, data) => ({ + dependencies: [], + output: [{ type: 'js/module', data: { code: data.toString(), lineCount: 1, map: [], functionMap: null } }], + })) +}) + +afterEach(() => { + jest.restoreAllMocks() + mockWorker.mockClear() + rmSync(root, { force: true, recursive: true }) +}) + +const transformFile = async (file = entry, platform = 'ios', isExpoProject = false, type: 'module' | 'asset' = 'module') => { + const config = { + uniwind: { cssEntryFile: path.relative(process.cwd(), entry), isExpoProject }, + } as Parameters[0] + const result = await transform(config, root, path.relative(root, file), readFileSync(file), { ...options, platform, type }) + + return result.output[0].data.code as string +} + +const runNativeModule = (code: string) => { + const reinit = jest.fn() + const required: Array = [] + const requireModule = (name: string) => { + required.push(name) + return { Uniwind: { __reinit: reinit } } + } + + new Function('require', code)(requireModule) + const [stylesheet, , fingerprint] = reinit.mock.calls[0] + + return { required, fingerprint, variables: stylesheet({}).vars } +} + +test.each(['ios', 'android'])('registers direct, nested and shared CSS imports once on %s', async platform => { + mkdirSync(path.join(root, 'shared')) + mkdirSync(path.join(root, 'app', 'node_modules', '@fixture'), { recursive: true }) + writeFileSync(path.join(root, 'shared', 'theme.css'), '@theme static { --spacing-shared: 8px; }') + symlinkSync(path.join(root, 'shared'), path.join(root, 'app', 'node_modules', '@fixture', 'theme'), 'dir') + writeFileSync(path.join(root, 'app', 'nested.css'), '@theme static { --spacing-nested: 4px; }') + writeFileSync(path.join(root, 'app', 'tokens.css'), '@import "./nested.css"; @theme static { --color-brand: #123456; }') + writeFileSync( + entry, + [ + '@import "./tokens.css";', + '@import "./nested.css";', + '@import "@fixture/theme/theme.css";', + '@tailwind utilities;', + '@source inline("bg-brand");', + ].join('\n'), + ) + + const { required } = runNativeModule(await transformFile(entry, platform)) + + expect(required.sort()).toEqual(['../shared/theme.css', './nested.css', './tokens.css', 'uniwind']) +}) + +test('does not register CSS from installed dependencies', async () => { + mkdirSync(path.join(root, 'app', 'node_modules', 'vendor'), { recursive: true }) + writeFileSync(path.join(root, 'app', 'node_modules', 'vendor', 'theme.css'), '@theme static { --spacing-vendor: 2px; }') + writeFileSync(entry, '@import "./tokens.css"; @import "vendor/theme.css";') + + expect(runNativeModule(await transformFile()).required).toEqual(['./tokens.css', 'uniwind']) +}) + +test('recompiles token-only edits and updates the stylesheet fingerprint', async () => { + const before = runNativeModule(await transformFile()) + writeFileSync(path.join(root, 'app', 'tokens.css'), '@theme static { --color-brand: #654321; }') + const after = runNativeModule(await transformFile()) + + expect(before.required).toContain('./tokens.css') + expect(before.variables['--color-brand']({})).toBe('#123456') + expect(after.variables['--color-brand']({})).toBe('#654321') + expect(after.fingerprint).not.toBe(before.fingerprint) +}) + +test('updates import dependencies without changing an identical stylesheet fingerprint', async () => { + const before = runNativeModule(await transformFile()) + writeFileSync(path.join(root, 'app', 'extra.css'), '/* no styles */') + writeFileSync(entry, `${readFileSync(entry, 'utf8')}\n@import "./extra.css";`) + const added = runNativeModule(await transformFile()) + writeFileSync(entry, '@import "./tokens.css"; @tailwind utilities; @source inline("bg-brand");') + const removed = runNativeModule(await transformFile()) + + expect(added.required).toEqual(['./extra.css', './tokens.css', 'uniwind']) + expect(removed.required).toEqual(before.required) + expect(added.fingerprint).toBe(before.fingerprint) + expect(removed.fingerprint).toBe(before.fingerprint) +}) + +test.each(['ios', 'android'])('transforms non-entry CSS into an empty JS module in plain Metro on %s', async platform => { + const file = path.join(root, 'app', 'tokens.css') + + expect(await transformFile(file, platform)).toBe('') + expect(mockWorker.mock.calls[0][2]).toBe(path.join('app', 'tokens.css.js')) +}) + +test('leaves non-entry CSS handling to Expo', async () => { + const file = path.join(root, 'app', 'tokens.css') + + expect(await transformFile(file, 'ios', true)).toBe(readFileSync(file, 'utf8')) + expect(mockWorker.mock.calls[0][2]).toBe(path.join('app', 'tokens.css')) + expect(runNativeModule(await transformFile(entry, 'ios', true)).required).toContain('./tokens.css') +}) + +test('preserves web CSS output without native dependency requires', async () => { + const code = await transformFile(entry, 'web') + + expect(code).toContain('--color-brand: #123456') + expect(code).not.toContain('require(') + expect(mockWorker.mock.calls[0][2]).toBe(path.join('app', 'global.css')) + const file = path.join(root, 'app', 'tokens.css') + expect(await transformFile(file, 'web')).toBe(readFileSync(file, 'utf8')) +}) + +test('preserves asset and JavaScript transformations', async () => { + const file = path.join(root, 'app', 'tokens.css') + expect(await transformFile(file, 'ios', false, 'asset')).toBe(readFileSync(file, 'utf8')) + const script = path.join(root, 'app', 'index.js') + writeFileSync(script, 'module.exports = 123') + expect(await transformFile(script)).toBe('module.exports = 123') +}) + +test('keeps hidden directories and spaces in local CSS paths relative', async () => { + mkdirSync(path.join(root, 'app', '.theme')) + writeFileSync(path.join(root, 'app', '.theme', 'brand tokens.css'), '@theme static { --color-brand: #123456; }') + writeFileSync(entry, '@import "./.theme/brand tokens.css";') + + expect(runNativeModule(await transformFile()).required).toEqual(['./.theme/brand tokens.css', 'uniwind']) +})