Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 99 additions & 52 deletions src/metro/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -85,23 +85,43 @@ 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<string>();

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"),
];

// Keep the original
const transformFile = bundler.transformFile.bind(bundler);

const watcher = bundler.getWatcher();

// Patch with our functionality
bundler.transformFile = async function (
filePath: string,
Expand All @@ -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);
}
};
}

Expand Down
34 changes: 31 additions & 3 deletions src/metro/injection-code.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
3 changes: 1 addition & 2 deletions src/metro/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/web/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
// Import this file for Metro to override
import "./metro";

export type * from "../runtime.types";
export * from "./api";
2 changes: 2 additions & 0 deletions src/runtime/web/metro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// This file will be overwritten by Metro
export {};