diff --git a/.config/jest.config.cjs b/.config/jest.config.cjs index 3f321fc9..bcc7dd4f 100644 --- a/.config/jest.config.cjs +++ b/.config/jest.config.cjs @@ -1,5 +1,5 @@ -/* eslint-disable no-undef */ -/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable no-undef, @typescript-eslint/no-require-imports */ + /* eslint-disable @typescript-eslint/no-unsafe-assignment */ const jestExpo = require("jest-expo/jest-preset"); diff --git a/package.json b/package.json index bd598919..f51381ad 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,17 @@ "./types": { "types": "./types.d.ts" }, + "./style-collection": { + "source": "./src/runtime/native/style-collection/index.ts", + "import": { + "types": "./dist/typescript/module/src/style-collection/index.d.ts", + "default": "./dist/module/style-collection/index.js" + }, + "require": { + "types": "./dist/typescript/commonjs/src/style-collection/index.d.ts", + "default": "./dist/commonjs/style-collection/index.js" + } + }, "./metro": { "source": "./src/metro/index.ts", "import": { diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 79337a90..476a4703 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -105,9 +105,10 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { // Use the lightningcss library to traverse the CSS AST and extract style declarations and animations lightningcss({ - filename: "style.css", // This is ignored, but required code: typeof code === "string" ? new TextEncoder().encode(code) : code, visitor, + filename: options.filename ?? "style.css", + projectRoot: options.projectRoot ?? process.cwd(), }); return { diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 16accbfe..57631a91 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -5,8 +5,9 @@ import type { MediaFeatureNameFor_MediaFeatureId } from "lightningcss"; import { VAR_SYMBOL } from "../runtime/native/reactivity"; export interface CompilerOptions { + filename?: string; + projectRoot?: string; inlineRem?: number | false; - grouping?: (string | RegExp)[]; selectorPrefix?: string; stylesheetOrder?: number; features?: FeatureFlagRecord; diff --git a/src/jest/index.ts b/src/jest/index.ts index 4d41ed66..e6af2a0e 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -2,8 +2,9 @@ import { Appearance, Dimensions } from "react-native"; import { inspect } from "node:util"; +import { StyleCollection } from "react-native-css/style-collection"; + import { compile, type CompilerOptions } from "../compiler"; -import { StyleCollection } from "../runtime/native/injection"; import { colorScheme, dimensions, rem } from "../runtime/native/reactivity"; declare global { diff --git a/src/metro/index.ts b/src/metro/index.ts index a3dd7322..275d5453 100644 --- a/src/metro/index.ts +++ b/src/metro/index.ts @@ -53,16 +53,18 @@ export function withReactNativeCSS< const originalMiddleware = config.server?.enhanceMiddleware; const originalResolver = config.resolver?.resolveRequest; - const poisonPillPath = "./poison.pill"; - return { ...config, transformerPath: require.resolve("./metro-transformer"), + transformer: { + ...config.transformer, + reactNativeCSS: options, + }, resolver: { ...config.resolver, sourceExts: [...(config?.resolver?.sourceExts || []), "css"], resolveRequest: (context, moduleName, platform) => { - if (moduleName === poisonPillPath) { + if (moduleName.includes("poison.pill")) { return { type: "empty" }; } @@ -161,7 +163,7 @@ export function withReactNativeCSS< // Let the transformer know that we will handle compilation customTransformOptions: { ...transformOptions.customTransformOptions, - reactNativeCSSCompile: false, + reactNativeCSS: options, }, }, fileBuffer, diff --git a/src/metro/injection-code.ts b/src/metro/injection-code.ts index e050c7f7..d263ece3 100644 --- a/src/metro/injection-code.ts +++ b/src/metro/injection-code.ts @@ -1,17 +1,13 @@ /** - * This is a hack around Expo's handling of CSS files. + * This is a hack around Metro's handling of bundles. * 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. + * This means that the CSS file will not be re-evaluated when a component in a different bundle updates, + * breaking tools like Tailwind CSS * - * 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. + * To fix this, we force our code to always import the CSS files, so now the CSS files are in every bundle. */ - export function getWebInjectionCode(filePaths: string[]) { const importStatements = filePaths .map((filePath) => `import "${filePath}";`) @@ -27,12 +23,12 @@ export function getNativeInjectionCode( 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( - `${importStatements}\n${importPath}\n${contents};export {};`, + `import { StyleCollection } from "react-native-css/style-collection";\n${importStatements}\n${contents};export {};`, ); } diff --git a/src/metro/metro-transformer.ts b/src/metro/metro-transformer.ts index b3ae95b6..ea7f181e 100644 --- a/src/metro/metro-transformer.ts +++ b/src/metro/metro-transformer.ts @@ -5,28 +5,44 @@ import type { TransformResponse, } from "metro-transform-worker"; +import { compile, type CompilerOptions } from "../compiler"; +import { getNativeInjectionCode } from "./injection-code"; + const worker = // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-argument require(unstable_transformerPath) as typeof import("metro-transform-worker"); -export function transform( +export async function transform( config: JsTransformerConfig, projectRoot: string, filePath: string, data: Buffer, - options: JsTransformOptions, + options: JsTransformOptions & { + reactNativeCSS?: CompilerOptions | undefined; + }, ): Promise { const isCss = options.type !== "asset" && /\.(s?css|sass)$/.test(filePath); - const skipCompile = - options.customTransformOptions && - "reactNativeCSSCompile" in options.customTransformOptions && - options.customTransformOptions.reactNativeCSSCompile === false; - if (!isCss || skipCompile) { + if (options.platform === "web" || !isCss) { return worker.transform(config, projectRoot, filePath, data, options); } - // TODO - compile the CSS file inline + const cssFile = (await worker.transform(config, projectRoot, filePath, data, { + ...options, + platform: "web", + })) as TransformResponse & { + output: [{ data: { css: { code: Buffer } } }]; + }; + + const css = cssFile.output[0].data.css.code.toString(); + + const productionJS = compile(css, { + ...options.reactNativeCSS, + filename: filePath, + projectRoot: projectRoot, + }).stylesheet(); + + data = Buffer.from(getNativeInjectionCode([], [productionJS])); - return worker.transform(config, projectRoot, filePath, data, options); + return worker.transform(config, projectRoot, `${filePath}.js`, data, options); } diff --git a/src/poison.pill.ts b/src/poison.pill.ts index d625e9ae..2d7c9ab3 100644 --- a/src/poison.pill.ts +++ b/src/poison.pill.ts @@ -3,9 +3,9 @@ const canWarn = process.env.NODE_ENV !== "test"; if (canWarn) { throw new Error(`react-native-css has encountered a setup error. -┌─────-─┐ -| Metro | -└─────-─┘ +┌───────┐ +│ Metro │ +└───────┘ Either your metro.config.js is missing the 'withReactNativeCSS' wrapper OR the resolver.resolveRequest function of your config is being overridden, and not calling the parent resolver. @@ -19,15 +19,15 @@ module.exports = with3rdPartyPlugin( ) \`\`\` -┌─────------─┐ -| NativeWind | -└─────------─┘ +┌────────────┐ +│ NativeWind │ +└────────────┘ -If you are using NativeWind with the 'withNativeWind' function, follow the Metro instructions above, but use 'withNativeWind' instead of 'withReactNativeCSS'. +If you are using NativeWind follow the Metro instructions above but use 'withNativeWind' instead of 'withReactNativeCSS'. -┌─────----------─┐ -| Other bundlers | -└─────----------─┘ +┌────────────────┐ +│ Other bundlers │ +└────────────────┘ If you are using another bundler (Vite, Webpack, etc), or non-Metro framework (Next.js, Remix, etc), please ensure you have included 'react-native-css/babel' as a babel preset. `); diff --git a/src/runtime/native/api.ts b/src/runtime/native/api.ts index 53f344f7..9ffc619f 100644 --- a/src/runtime/native/api.ts +++ b/src/runtime/native/api.ts @@ -21,7 +21,7 @@ import { } from "./reactivity"; import { resolveValue } from "./styles/resolve"; -export { StyleCollection } from "./injection"; +export { StyleCollection } from "react-native-css/style-collection"; /** * Generates a new Higher-Order component the wraps the base component and applies the styles. diff --git a/src/runtime/native/injection.ts b/src/runtime/native/injection.ts deleted file mode 100644 index 3850aad3..00000000 --- a/src/runtime/native/injection.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { - Animation_V2, - ReactNativeCssStyleSheet, - StyleRuleSet, -} from "../../compiler"; -import { DEFAULT_CONTAINER_NAME } from "./conditions/container-query"; -import { - family, - observable, - observableBatch, - type Observable, -} from "./reactivity"; -import { rootVariables } from "./root"; - -export function StyleCollection() { - return null; -} - -export const inlineStylesMap = new WeakMap(); - -StyleCollection.styles = family>(() => { - return observable([], isDeepEqual); -}); -StyleCollection.keyframes = family>(() => { - return observable([], isDeepEqual); -}); - -StyleCollection.inject = function (options: ReactNativeCssStyleSheet) { - observableBatch.current = new Set(); - - StyleCollection.styles("will-change-variable").set([ - { - s: [0], - v: [], - }, - ]); - - StyleCollection.styles("will-change-container").set([ - { - s: [0], - c: [DEFAULT_CONTAINER_NAME], - }, - ]); - - StyleCollection.styles("will-change-animation").set([ - { - s: [0], - a: true, - }, - ]); - - StyleCollection.styles("will-change-pressable").set([ - { - s: [0], - p: { - h: 1, - }, - }, - ]); - - if (options.s) { - for (const style of options.s) { - StyleCollection.styles(style[0]).set(style[1]); - } - } - - if (options.k) { - for (const keyframes of options.k) { - StyleCollection.keyframes(keyframes[0]).set(keyframes[1]); - } - } - - if (options.vr) { - for (const entry of options.vr) { - rootVariables(entry[0]).set(entry[1]); - } - } - - if (options.vu) { - for (const entry of options.vu) { - rootVariables(entry[0]).set(entry[1]); - } - } - - for (const effect of observableBatch.current) { - effect.run(); - } - - observableBatch.current = undefined; -}; - -function isDeepEqual(a: unknown, b: unknown): boolean { - const aArray = Array.isArray(a); - const bArray = Array.isArray(b); - const requiresKeyComparison = - typeof a === "object" && typeof b === "object" && aArray === bArray; - - // Only compare keys when both are an object or array - // This does not account for complex types like Date/Regex, because we don't use them - if (!requiresKeyComparison) return a === b; - - // Make either are not null - if (!a || !b) { - return a === b; - } - - // Shortcut for arrays - if (aArray && bArray && a.length !== b.length) { - return false; - } - - // Compare a to b - for (const key in a) { - if ( - !isDeepEqual( - (a as Record)[key], - (b as Record)[key], - ) - ) { - return false; - } - } - - // Compare b to a - for (const key in b) { - if (!(key in a)) { - return false; - } - } - - return true; -} diff --git a/src/runtime/native/react/rules.ts b/src/runtime/native/react/rules.ts index 849276dc..048c3144 100644 --- a/src/runtime/native/react/rules.ts +++ b/src/runtime/native/react/rules.ts @@ -1,10 +1,11 @@ /* eslint-disable */ +import { StyleCollection } from "react-native-css/style-collection"; + import type { InlineVariable, StyleRule } from "../../../compiler"; import { getDeepPath } from "../../utils"; import { testRule } from "../conditions"; import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query"; import type { RenderGuard } from "../conditions/guards"; -import { StyleCollection } from "../injection"; import { activeFamily, containerLayoutFamily, diff --git a/src/runtime/native/styles/shorthands/animation.ts b/src/runtime/native/styles/shorthands/animation.ts index 4d7d765c..72cc1141 100644 --- a/src/runtime/native/styles/shorthands/animation.ts +++ b/src/runtime/native/styles/shorthands/animation.ts @@ -1,6 +1,7 @@ /* eslint-disable */ +import { StyleCollection } from "react-native-css/style-collection"; + import { applyShorthand } from "../../../utils"; -import { StyleCollection } from "../../injection"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "./_handler"; diff --git a/src/runtime/native/styles/variables.ts b/src/runtime/native/styles/variables.ts index 9caa523e..8c6cabff 100644 --- a/src/runtime/native/styles/variables.ts +++ b/src/runtime/native/styles/variables.ts @@ -1,7 +1,11 @@ +import { + rootVariables, + universalVariables, +} from "react-native-css/style-collection"; + import type { StyleDescriptor, StyleFunction } from "../../../compiler"; import { isStyleDescriptorArray } from "../../utils"; import { VAR_SYMBOL, type Getter } from "../reactivity"; -import { rootVariables, universalVariables } from "../root"; import type { ResolveValueOptions, SimpleResolveValue } from "./resolve"; export function varResolver( diff --git a/src/style-collection/index.ts b/src/style-collection/index.ts new file mode 100644 index 00000000..844f6ca6 --- /dev/null +++ b/src/style-collection/index.ts @@ -0,0 +1,141 @@ +import type { + Animation_V2, + ReactNativeCssStyleSheet, + StyleRuleSet, +} from "../compiler"; +import { DEFAULT_CONTAINER_NAME } from "../runtime/native/conditions/container-query"; +import { + family, + observable, + observableBatch, + type Observable, +} from "../runtime/native/reactivity"; +import { rootVariables, universalVariables } from "./root"; + +export { rootVariables, universalVariables }; + +interface StyleCollectionType { + styles: ReturnType>>; + keyframes: ReturnType>>; + inject: (options: ReactNativeCssStyleSheet) => void; +} + +declare global { + var __react_native_css_style_collection: StyleCollectionType | undefined; +} + +globalThis.__react_native_css_style_collection ??= { + styles: family>(() => { + return observable([], isDeepEqual); + }), + keyframes: family>(() => { + return observable([], isDeepEqual); + }), + inject: function (options: ReactNativeCssStyleSheet) { + observableBatch.current = new Set(); + + StyleCollection.styles("will-change-variable").set([ + { + s: [0], + v: [], + }, + ]); + + StyleCollection.styles("will-change-container").set([ + { + s: [0], + c: [DEFAULT_CONTAINER_NAME], + }, + ]); + + StyleCollection.styles("will-change-animation").set([ + { + s: [0], + a: true, + }, + ]); + + StyleCollection.styles("will-change-pressable").set([ + { + s: [0], + p: { + h: 1, + }, + }, + ]); + + if (options.s) { + for (const style of options.s) { + StyleCollection.styles(style[0]).set(style[1]); + } + } + + if (options.k) { + for (const keyframes of options.k) { + StyleCollection.keyframes(keyframes[0]).set(keyframes[1]); + } + } + + if (options.vr) { + for (const entry of options.vr) { + rootVariables(entry[0]).set(entry[1]); + } + } + + if (options.vu) { + for (const entry of options.vu) { + rootVariables(entry[0]).set(entry[1]); + } + } + + for (const effect of observableBatch.current) { + effect.run(); + } + + observableBatch.current = undefined; + }, +}; + +export const StyleCollection = globalThis.__react_native_css_style_collection; + +function isDeepEqual(a: unknown, b: unknown): boolean { + const aArray = Array.isArray(a); + const bArray = Array.isArray(b); + const requiresKeyComparison = + typeof a === "object" && typeof b === "object" && aArray === bArray; + + // Only compare keys when both are an object or array + // This does not account for complex types like Date/Regex, because we don't use them + if (!requiresKeyComparison) return a === b; + + // Make either are not null + if (!a || !b) { + return a === b; + } + + // Shortcut for arrays + if (aArray && bArray && a.length !== b.length) { + return false; + } + + // Compare a to b + for (const key in a) { + if ( + !isDeepEqual( + (a as Record)[key], + (b as Record)[key], + ) + ) { + return false; + } + } + + // Compare b to a + for (const key in b) { + if (!(key in a)) { + return false; + } + } + + return true; +} diff --git a/src/runtime/native/root.ts b/src/style-collection/root.ts similarity index 81% rename from src/runtime/native/root.ts rename to src/style-collection/root.ts index 60567656..17b84780 100644 --- a/src/runtime/native/root.ts +++ b/src/style-collection/root.ts @@ -1,7 +1,11 @@ import type { StyleDescriptor, VariableValue } from "react-native-css/compiler"; -import { testMediaQuery } from "./conditions/media-query"; -import { family, observable, type Observable } from "./reactivity"; +import { testMediaQuery } from "../runtime/native/conditions/media-query"; +import { + family, + observable, + type Observable, +} from "../runtime/native/reactivity"; const rootVariableFamily = () => { return family>(() => {