Skip to content

Commit 4a2c805

Browse files
authored
fix: fast-refresh working across lazy() boundaries (#10)
1 parent 46ddfae commit 4a2c805

File tree

5 files changed

+136
-57
lines changed

5 files changed

+136
-57
lines changed

src/metro/index.ts

Lines changed: 99 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
/* eslint-disable */
22
import { versions } from "node:process";
3-
import { sep } from "node:path";
3+
import { dirname, relative, sep } from "node:path";
44

55
import connect from "connect";
66
import type { MetroConfig } from "metro-config";
77

88
import { compile } from "../compiler/compiler";
99
import { setupTypeScript } from "./typescript";
10-
import { getInjectionCode } from "./injection-code";
10+
import { getNativeInjectionCode, getWebInjectionCode } from "./injection-code";
1111
import { nativeResolver, webResolver } from "./resolver";
1212

1313
export interface WithReactNativeCSSOptions {
@@ -85,23 +85,43 @@ export function withReactNativeCSS<
8585
if (!bundler.__react_native_css__patched) {
8686
bundler.__react_native_css__patched = true;
8787

88-
const cssFiles = new Map();
88+
const nativeCSSFiles = new Map();
89+
const webCSSFiles = new Set<string>();
8990

90-
const injectionCommonJS = require.resolve("../runtime/native/metro");
91-
const injectionFilePaths = [
91+
const nativeInjectionPath = require.resolve(
92+
"../runtime/native/metro",
93+
);
94+
const nativeInjectionFilepaths = [
9295
// CommonJS
93-
injectionCommonJS,
96+
nativeInjectionPath,
9497
// ES Module
95-
injectionCommonJS.replace(`dist${sep}commonjs`, `dist${sep}module`),
98+
nativeInjectionPath.replace(
99+
`dist${sep}commonjs`,
100+
`dist${sep}module`,
101+
),
96102
// TypeScript
97-
injectionCommonJS
103+
nativeInjectionPath
104+
.replace(`dist${sep}commonjs`, `src`)
105+
.replace(".js", ".ts"),
106+
];
107+
108+
const webInjectionPath = require.resolve("../runtime/web/metro");
109+
const webInjectionFilepaths = [
110+
// CommonJS
111+
webInjectionPath,
112+
// ES Module
113+
webInjectionPath.replace(`dist${sep}commonjs`, `dist${sep}module`),
114+
// TypeScript
115+
webInjectionPath
98116
.replace(`dist${sep}commonjs`, `src`)
99117
.replace(".js", ".ts"),
100118
];
101119

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

123+
const watcher = bundler.getWatcher();
124+
105125
// Patch with our functionality
106126
bundler.transformFile = async function (
107127
filePath: string,
@@ -110,53 +130,80 @@ export function withReactNativeCSS<
110130
) {
111131
const isCss = /\.(s?css|sass)$/.test(filePath);
112132

113-
// Handle CSS files on native platforms
114-
if (isCss && transformOptions.platform !== "web") {
115-
const real = await transformFile(
116-
filePath,
117-
{
118-
...transformOptions,
119-
// Force the platform to web for CSS files
120-
platform: "web",
121-
// Let the transformer know that we will handle compilation
122-
customTransformOptions: {
123-
...transformOptions.customTransformOptions,
124-
reactNativeCSSCompile: false,
125-
},
126-
},
127-
fileBuffer,
128-
);
129-
130-
const lastTransform = cssFiles.get(filePath);
131-
const last = lastTransform?.[0];
132-
const next = real.output[0].data.css.code.toString();
133-
134-
// The CSS file has changed, we need to recompile the injection file
135-
if (next !== last) {
136-
cssFiles.set(filePath, [next, compile(next, {})]);
137-
138-
bundler.getWatcher().emit("change", {
139-
eventsQueue: injectionFilePaths.map((filePath) => ({
140-
filePath,
141-
metadata: {
142-
modifiedTime: Date.now(),
143-
size: 1, // Can be anything
144-
type: "virtual", // Can be anything
133+
if (transformOptions.platform === "web") {
134+
if (isCss) {
135+
webCSSFiles.add(filePath);
136+
} else if (webInjectionFilepaths.includes(filePath)) {
137+
fileBuffer = getWebInjectionCode(Array.from(webCSSFiles));
138+
}
139+
140+
return transformFile(filePath, transformOptions, fileBuffer);
141+
} else {
142+
// Handle CSS files on native platforms
143+
if (isCss) {
144+
const webTransform = await transformFile(
145+
filePath,
146+
{
147+
...transformOptions,
148+
// Force the platform to web for CSS files
149+
platform: "web",
150+
// Let the transformer know that we will handle compilation
151+
customTransformOptions: {
152+
...transformOptions.customTransformOptions,
153+
reactNativeCSSCompile: false,
145154
},
146-
type: "change",
147-
})),
148-
});
155+
},
156+
fileBuffer,
157+
);
158+
159+
const lastTransform = nativeCSSFiles.get(filePath);
160+
const last = lastTransform?.[0];
161+
const next = webTransform.output[0].data.css.code.toString();
162+
163+
// The CSS file has changed, we need to recompile the injection file
164+
if (next !== last) {
165+
nativeCSSFiles.set(filePath, [next, compile(next, {})]);
166+
167+
watcher.emit("change", {
168+
eventsQueue: nativeInjectionFilepaths.map((filePath) => ({
169+
filePath,
170+
metadata: {
171+
modifiedTime: Date.now(),
172+
size: 1, // Can be anything
173+
type: "virtual", // Can be anything
174+
},
175+
type: "change",
176+
})),
177+
});
178+
}
179+
180+
const nativeTransform = await transformFile(
181+
filePath,
182+
transformOptions,
183+
fileBuffer,
184+
);
185+
186+
// Tell Expo to skip caching this file
187+
nativeTransform.output[0].data.css = {
188+
skipCache: true,
189+
// Expo requires a `code` property
190+
code: "",
191+
};
192+
193+
return nativeTransform;
194+
} else if (nativeInjectionFilepaths.includes(filePath)) {
195+
// If this is the injection file, we to swap its content with the
196+
// compiled CSS files
197+
fileBuffer = getNativeInjectionCode(
198+
Array.from(nativeCSSFiles.keys()).map((key) =>
199+
relative(dirname(filePath), key),
200+
),
201+
Array.from(nativeCSSFiles.values()).map(([, value]) => value),
202+
);
149203
}
150-
} else if (injectionFilePaths.includes(filePath)) {
151-
// If this is the injection file, we to swap its content with the
152-
// compiled CSS files
153-
fileBuffer = getInjectionCode(
154-
"./api",
155-
Array.from(cssFiles.values()).map(([, value]) => value),
156-
);
157-
}
158204

159-
return transformFile(filePath, transformOptions, fileBuffer);
205+
return transformFile(filePath, transformOptions, fileBuffer);
206+
}
160207
};
161208
}
162209

src/metro/injection-code.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,36 @@
1-
export function getInjectionCode(filePath: string, values: unknown[]) {
2-
const importPath = `import { StyleCollection } from "${filePath}";`;
1+
/**
2+
* This is a hack around Expo's handling of CSS files.
3+
* When a component is inside a lazy() barrier, it is inside a different JS bundle.
4+
* So when it updates, it only updates its local bundle, not the global one which contains the CSS files.
5+
*
6+
* To fix this, we force our code to always import the CSS files.
7+
* Now the CSS files are in every bundle.
8+
*
9+
* We achieve this by collecting all CSS files and injecting them into a special file
10+
* which is included inside react-native-css's runtime.
11+
*
12+
* This is why both of these function add imports for the CSS files.
13+
*/
14+
15+
export function getWebInjectionCode(filePaths: string[]) {
16+
const importStatements = filePaths
17+
.map((filePath) => `import "${filePath}";`)
18+
.join("\n");
19+
20+
return Buffer.from(importStatements);
21+
}
22+
23+
export function getNativeInjectionCode(
24+
cssFilePaths: string[],
25+
values: unknown[],
26+
) {
27+
const importStatements = cssFilePaths
28+
.map((filePath) => `import "${filePath}";`)
29+
.join("\n");
30+
const importPath = `import { StyleCollection } from "./api";`;
331
const contents = values
432
.map((value) => `StyleCollection.inject(${JSON.stringify(value)});`)
533
.join("\n");
634

7-
return Buffer.from(`${importPath}\n${contents}`);
35+
return Buffer.from(`${importStatements}\n${importPath}\n${contents}`);
836
}

src/metro/resolver.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ export function nativeResolver(
3333
// Skip anything that isn't importing a React Native component
3434
!(
3535
moduleName.startsWith("react-native") ||
36-
moduleName.startsWith("react-native/") ||
37-
resolution.filePath.includes("react-native/Libraries/Components/View/")
36+
moduleName.startsWith("react-native/")
3837
)
3938
) {
4039
return resolution;

src/runtime/web/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
1+
// Import this file for Metro to override
2+
import "./metro";
3+
14
export type * from "../runtime.types";
25
export * from "./api";

src/runtime/web/metro.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// This file will be overwritten by Metro
2+
export {};

0 commit comments

Comments
 (0)