diff --git a/src/metro/index.ts b/src/metro/index.ts index 63b7de1..4250914 100644 --- a/src/metro/index.ts +++ b/src/metro/index.ts @@ -1,13 +1,13 @@ /* eslint-disable */ import { versions } from "node:process"; -import { sep } from "node:path"; +import { dirname, relative, sep } from "node:path"; import connect from "connect"; import type { MetroConfig } from "metro-config"; import { compile } from "../compiler/compiler"; import { setupTypeScript } from "./typescript"; -import { getInjectionCode } from "./injection-code"; +import { getNativeInjectionCode, getWebInjectionCode } from "./injection-code"; import { nativeResolver, webResolver } from "./resolver"; export interface WithReactNativeCSSOptions { @@ -85,16 +85,34 @@ export function withReactNativeCSS< if (!bundler.__react_native_css__patched) { bundler.__react_native_css__patched = true; - const cssFiles = new Map(); + const nativeCSSFiles = new Map(); + const webCSSFiles = new Set(); - const injectionCommonJS = require.resolve("../runtime/native/metro"); - const injectionFilePaths = [ + const nativeInjectionPath = require.resolve( + "../runtime/native/metro", + ); + const nativeInjectionFilepaths = [ // CommonJS - injectionCommonJS, + nativeInjectionPath, // ES Module - injectionCommonJS.replace(`dist${sep}commonjs`, `dist${sep}module`), + nativeInjectionPath.replace( + `dist${sep}commonjs`, + `dist${sep}module`, + ), // TypeScript - injectionCommonJS + nativeInjectionPath + .replace(`dist${sep}commonjs`, `src`) + .replace(".js", ".ts"), + ]; + + const webInjectionPath = require.resolve("../runtime/web/metro"); + const webInjectionFilepaths = [ + // CommonJS + webInjectionPath, + // ES Module + webInjectionPath.replace(`dist${sep}commonjs`, `dist${sep}module`), + // TypeScript + webInjectionPath .replace(`dist${sep}commonjs`, `src`) .replace(".js", ".ts"), ]; @@ -102,6 +120,8 @@ export function withReactNativeCSS< // Keep the original const transformFile = bundler.transformFile.bind(bundler); + const watcher = bundler.getWatcher(); + // Patch with our functionality bundler.transformFile = async function ( filePath: string, @@ -110,53 +130,80 @@ export function withReactNativeCSS< ) { const isCss = /\.(s?css|sass)$/.test(filePath); - // Handle CSS files on native platforms - if (isCss && transformOptions.platform !== "web") { - const real = await transformFile( - filePath, - { - ...transformOptions, - // Force the platform to web for CSS files - platform: "web", - // Let the transformer know that we will handle compilation - customTransformOptions: { - ...transformOptions.customTransformOptions, - reactNativeCSSCompile: false, - }, - }, - fileBuffer, - ); - - const lastTransform = cssFiles.get(filePath); - const last = lastTransform?.[0]; - const next = real.output[0].data.css.code.toString(); - - // The CSS file has changed, we need to recompile the injection file - if (next !== last) { - cssFiles.set(filePath, [next, compile(next, {})]); - - bundler.getWatcher().emit("change", { - eventsQueue: injectionFilePaths.map((filePath) => ({ - filePath, - metadata: { - modifiedTime: Date.now(), - size: 1, // Can be anything - type: "virtual", // Can be anything + if (transformOptions.platform === "web") { + if (isCss) { + webCSSFiles.add(filePath); + } else if (webInjectionFilepaths.includes(filePath)) { + fileBuffer = getWebInjectionCode(Array.from(webCSSFiles)); + } + + return transformFile(filePath, transformOptions, fileBuffer); + } else { + // Handle CSS files on native platforms + if (isCss) { + const webTransform = await transformFile( + filePath, + { + ...transformOptions, + // Force the platform to web for CSS files + platform: "web", + // Let the transformer know that we will handle compilation + customTransformOptions: { + ...transformOptions.customTransformOptions, + reactNativeCSSCompile: false, }, - type: "change", - })), - }); + }, + fileBuffer, + ); + + const lastTransform = nativeCSSFiles.get(filePath); + const last = lastTransform?.[0]; + const next = webTransform.output[0].data.css.code.toString(); + + // The CSS file has changed, we need to recompile the injection file + if (next !== last) { + nativeCSSFiles.set(filePath, [next, compile(next, {})]); + + watcher.emit("change", { + eventsQueue: nativeInjectionFilepaths.map((filePath) => ({ + filePath, + metadata: { + modifiedTime: Date.now(), + size: 1, // Can be anything + type: "virtual", // Can be anything + }, + type: "change", + })), + }); + } + + const nativeTransform = await transformFile( + filePath, + transformOptions, + fileBuffer, + ); + + // Tell Expo to skip caching this file + nativeTransform.output[0].data.css = { + skipCache: true, + // Expo requires a `code` property + code: "", + }; + + return nativeTransform; + } else if (nativeInjectionFilepaths.includes(filePath)) { + // If this is the injection file, we to swap its content with the + // compiled CSS files + fileBuffer = getNativeInjectionCode( + Array.from(nativeCSSFiles.keys()).map((key) => + relative(dirname(filePath), key), + ), + Array.from(nativeCSSFiles.values()).map(([, value]) => value), + ); } - } else if (injectionFilePaths.includes(filePath)) { - // If this is the injection file, we to swap its content with the - // compiled CSS files - fileBuffer = getInjectionCode( - "./api", - Array.from(cssFiles.values()).map(([, value]) => value), - ); - } - return transformFile(filePath, transformOptions, fileBuffer); + return transformFile(filePath, transformOptions, fileBuffer); + } }; } diff --git a/src/metro/injection-code.ts b/src/metro/injection-code.ts index 75f5451..f64ef08 100644 --- a/src/metro/injection-code.ts +++ b/src/metro/injection-code.ts @@ -1,8 +1,36 @@ -export function getInjectionCode(filePath: string, values: unknown[]) { - const importPath = `import { StyleCollection } from "${filePath}";`; +/** + * This is a hack around Expo's handling of CSS files. + * When a component is inside a lazy() barrier, it is inside a different JS bundle. + * So when it updates, it only updates its local bundle, not the global one which contains the CSS files. + * + * To fix this, we force our code to always import the CSS files. + * Now the CSS files are in every bundle. + * + * We achieve this by collecting all CSS files and injecting them into a special file + * which is included inside react-native-css's runtime. + * + * This is why both of these function add imports for the CSS files. + */ + +export function getWebInjectionCode(filePaths: string[]) { + const importStatements = filePaths + .map((filePath) => `import "${filePath}";`) + .join("\n"); + + return Buffer.from(importStatements); +} + +export function getNativeInjectionCode( + cssFilePaths: string[], + values: unknown[], +) { + const importStatements = cssFilePaths + .map((filePath) => `import "${filePath}";`) + .join("\n"); + const importPath = `import { StyleCollection } from "./api";`; const contents = values .map((value) => `StyleCollection.inject(${JSON.stringify(value)});`) .join("\n"); - return Buffer.from(`${importPath}\n${contents}`); + return Buffer.from(`${importStatements}\n${importPath}\n${contents}`); } diff --git a/src/metro/resolver.ts b/src/metro/resolver.ts index fc17f8a..c1e53a0 100644 --- a/src/metro/resolver.ts +++ b/src/metro/resolver.ts @@ -33,8 +33,7 @@ export function nativeResolver( // Skip anything that isn't importing a React Native component !( moduleName.startsWith("react-native") || - moduleName.startsWith("react-native/") || - resolution.filePath.includes("react-native/Libraries/Components/View/") + moduleName.startsWith("react-native/") ) ) { return resolution; diff --git a/src/runtime/web/index.ts b/src/runtime/web/index.ts index 6995bfc..264318a 100644 --- a/src/runtime/web/index.ts +++ b/src/runtime/web/index.ts @@ -1,2 +1,5 @@ +// Import this file for Metro to override +import "./metro"; + export type * from "../runtime.types"; export * from "./api"; diff --git a/src/runtime/web/metro.ts b/src/runtime/web/metro.ts new file mode 100644 index 0000000..aa582da --- /dev/null +++ b/src/runtime/web/metro.ts @@ -0,0 +1,2 @@ +// This file will be overwritten by Metro +export {};