diff --git a/CONTEXT.md b/CONTEXT.md index 24ff1a2d..ccdb069d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -124,6 +124,7 @@ Metro integration: - 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. - Resolver swaps React Native component imports to Uniwind-aware implementations where needed. +- `uniwind` and `uniwind/*` requests resolve from `/package.json`, so every importer gets the app's copy. If the configured resolver returns a source file outside this package (e.g. Expo autolinking resolution picks a hoisted public `uniwind` while Pro is installed under an alias such as `"uniwind": "npm:uniwind-pro"`), the request is resolved again with Metro's default `metro-resolver`. Vite integration: diff --git a/packages/uniwind/jest.config.native.js b/packages/uniwind/jest.config.native.js index de6032e0..280762b9 100644 --- a/packages/uniwind/jest.config.native.js +++ b/packages/uniwind/jest.config.native.js @@ -9,5 +9,6 @@ export default { moduleNameMapper: { '^react-native$': '/../../node_modules/react-native', '^@/(.*)$': '/src/$1', + '^\\./transformer\\.cjs$': '/src/bundler/adapters/metro/transformer.ts', }, } diff --git a/packages/uniwind/src/bundler/adapters/metro/metro.ts b/packages/uniwind/src/bundler/adapters/metro/metro.ts index 2323b833..8569f898 100644 --- a/packages/uniwind/src/bundler/adapters/metro/metro.ts +++ b/packages/uniwind/src/bundler/adapters/metro/metro.ts @@ -2,10 +2,12 @@ import { UniwindBundlerConfig } from '@/bundler/config' import type { UniwindConfig } from '@/bundler/types' import { Platform } from '@/common/consts' import type { MetroConfig } from 'metro-config' +import type * as MetroResolverModule from 'metro-resolver' import type { CustomResolver } from 'metro-resolver' +import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { cacheStore, patchMetroGraphToIncludeCssInLazyGraphs, patchMetroGraphToSupportUncachedModules } from './patches' -import { nativeResolver, webResolver } from './resolvers' +import { isInternalOrigin, nativeResolver, webResolver } from './resolvers' const isUniwindRequest = (moduleName: string) => moduleName === 'uniwind' || moduleName.startsWith('uniwind/') @@ -27,6 +29,7 @@ export const withUniwindConfig = ( ): T => { const bundlerConfig = UniwindBundlerConfig.fromMetroConfig(uniwindConfig) const pinnedUniwindOrigin = join(config.projectRoot ?? process.cwd(), 'package.json') + const { resolve: metroResolve } = createRequire(require.resolve('metro/package.json'))('metro-resolver') as typeof MetroResolverModule patchMetroGraphToIncludeCssInLazyGraphs(resolve(process.cwd(), uniwindConfig.cssEntryFile)) patchMetroGraphToSupportUncachedModules() @@ -52,14 +55,20 @@ export const withUniwindConfig = ( const baseResolver = config.resolver?.resolveRequest ?? context.resolveRequest const resolver: CustomResolver = (nextContext, nextModuleName, nextPlatform) => { if (isUniwindRequest(nextModuleName)) { - return baseResolver( - { - ...nextContext, - originModulePath: pinnedUniwindOrigin, - }, - nextModuleName, - nextPlatform, - ) + const pinnedContext = { + ...nextContext, + originModulePath: pinnedUniwindOrigin, + } + const resolution = baseResolver(pinnedContext, nextModuleName, nextPlatform) + + // fix for Expo's autolinking resolver which resolves by package name and lands on another hoisted + // uniwind copy when this one is installed under an alias (e.g. pnpm + npm:uniwind-pro) + // instead use default metro-resolver resolveRequest + if (resolution.type === 'sourceFile' && !isInternalOrigin(resolution.filePath)) { + return metroResolve({ ...pinnedContext, resolveRequest: metroResolve }, nextModuleName, nextPlatform) + } + + return resolution } return baseResolver(nextContext, nextModuleName, nextPlatform) diff --git a/packages/uniwind/src/bundler/adapters/metro/resolvers.ts b/packages/uniwind/src/bundler/adapters/metro/resolvers.ts index f6e68532..a5cd24e2 100644 --- a/packages/uniwind/src/bundler/adapters/metro/resolvers.ts +++ b/packages/uniwind/src/bundler/adapters/metro/resolvers.ts @@ -11,7 +11,7 @@ type ResolverConfig = { let cachedInternalBasePath: string | null = null -const isInternalOrigin = (originModulePath: string) => { +export const isInternalOrigin = (originModulePath: string) => { if (cachedInternalBasePath === null) { try { cachedInternalBasePath = dirname(realpathSync(require.resolve('uniwind/package.json'))) diff --git a/packages/uniwind/tests/native/bundler/metro.test.ts b/packages/uniwind/tests/native/bundler/metro.test.ts new file mode 100644 index 00000000..6e3cd6c5 --- /dev/null +++ b/packages/uniwind/tests/native/bundler/metro.test.ts @@ -0,0 +1,68 @@ +import type { MetroConfig } from 'metro-config' +import type { CustomResolutionContext, CustomResolver, Resolution } from 'metro-resolver' +import { realpathSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { withUniwindConfig } from '../../../src/bundler/adapters/metro/metro' + +const mockMetroResolve = jest.fn>() + +jest.mock('node:module', () => ({ + ...jest.requireActual('node:module'), + createRequire: () => () => ({ resolve: mockMetroResolve }), +})) + +jest.mock('../../../src/bundler/adapters/metro/patches', () => ({ + cacheStore: {}, + patchMetroGraphToIncludeCssInLazyGraphs: () => {}, + patchMetroGraphToSupportUncachedModules: () => {}, +})) + +const projectRoot = join('/', 'workspace', 'apps', 'pro-app') +const internalRoot = dirname(realpathSync(require.resolve('uniwind/package.json'))) +const internalEntry = join(internalRoot, 'src', 'index.ts') +const hoistedEntry = join('/', 'workspace', 'node_modules', 'uniwind', 'src', 'index.ts') + +const resolveUniwind = (configuredResolver: CustomResolver) => { + const config = withUniwindConfig({ + projectRoot, + resolver: { resolveRequest: configuredResolver }, + } as MetroConfig, { cssEntryFile: './global.css' }) + const context = { + originModulePath: join(projectRoot, 'src', 'App.tsx'), + resolveRequest: configuredResolver, + } as CustomResolutionContext + + return config.resolver!.resolveRequest!(context, 'uniwind', 'ios') +} + +beforeEach(() => { + mockMetroResolve.mockReset() +}) + +test('resolves uniwind again with metro-resolver when the configured resolver picks another copy', () => { + mockMetroResolve.mockReturnValue({ type: 'sourceFile', filePath: internalEntry }) + const configuredResolver = jest.fn>(() => ({ type: 'sourceFile', filePath: hoistedEntry })) + + const resolution = resolveUniwind(configuredResolver) + + expect(configuredResolver).toHaveBeenCalledTimes(1) + expect(mockMetroResolve).toHaveBeenCalledTimes(1) + + const [context, moduleName, platform] = mockMetroResolve.mock.calls[0]! + + expect(context.originModulePath).toBe(join(projectRoot, 'package.json')) + expect(context.resolveRequest).toBe(mockMetroResolve) + expect(moduleName).toBe('uniwind') + expect(platform).toBe('ios') + expect(resolution).toEqual({ type: 'sourceFile', filePath: internalEntry }) +}) + +test('keeps the configured resolution when it points to this uniwind copy', () => { + const configuredResolver = jest.fn>(() => ({ type: 'sourceFile', filePath: internalEntry })) + + const resolution = resolveUniwind(configuredResolver) + + expect(mockMetroResolve).not.toHaveBeenCalled() + expect(configuredResolver.mock.calls[0]![0].originModulePath).toBe(join(projectRoot, 'package.json')) + expect(resolution).toEqual({ type: 'sourceFile', filePath: internalEntry }) +})