From 1b777ba32987f4689566ee4508551c0ecc23b5e3 Mon Sep 17 00:00:00 2001 From: harang Date: Mon, 7 Sep 2026 11:37:49 +0900 Subject: [PATCH 1/3] fix: prepare Vite resolver for customResolver removal Keep Vite 8 and newer alias entries declarative and move Uniwind's internal React Native Web delegation into the plugin's pre resolveId hook. Preserve stylesheet interception first and normalize resolver paths for Windows. Constraint: Vite 8 deprecates resolve.alias[].customResolver and Vite 9 removes it Rejected: Drop the internal delegation | Uniwind internals would resolve react-native through the component alias Confidence: high Scope-risk: narrow Directive: Keep stylesheet resolution ahead of internal React Native Web delegation and preserve the Vite 7 resolver branch Tested: web 36 tests; native 171 tests; e2e 9 tests; typecheck, lint, type tests, dprint, package build, Vite 8.0.14 build, and Vite 7.2.2 build Not-tested: Vite 9 prerelease runtime is not available in this repository --- .../uniwind/src/bundler/adapters/vite/vite.ts | 26 ++-- packages/uniwind/tests/web/vite.test.ts | 137 ++++++++++++++++++ 2 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 packages/uniwind/tests/web/vite.test.ts diff --git a/packages/uniwind/src/bundler/adapters/vite/vite.ts b/packages/uniwind/src/bundler/adapters/vite/vite.ts index 60c22a28..77f66ea1 100644 --- a/packages/uniwind/src/bundler/adapters/vite/vite.ts +++ b/packages/uniwind/src/bundler/adapters/vite/vite.ts @@ -80,15 +80,6 @@ const vite8Resolve = { alias: [{ find: /^react-native$/, replacement: componentPath, - customResolver: { - resolveId(this: PluginContext, _: string, importer: string | undefined) { - if (importer !== undefined && normalizePath(importer).includes('uniwind/dist')) { - return this.resolve('react-native-web', importer, { skipSelf: true }) - } - - return componentPath - }, - }, }], } @@ -115,8 +106,21 @@ export const uniwind = (config: UniwindConfig): Plugin => { return { name: 'uniwind', enforce: 'pre', - resolveId: (source, importer) => { - return resolveOrderedCSSStyleSheet(source, importer) + resolveId(source, importer) { + const resolvedStyleSheet = resolveOrderedCSSStyleSheet(source, importer) + + if (resolvedStyleSheet !== undefined) { + return resolvedStyleSheet + } + + if ( + isVite8 + && normalizePath(source) === normalizePath(componentPath) + && importer !== undefined + && normalizePath(importer).includes('uniwind/dist') + ) { + return this.resolve('react-native-web', importer, { skipSelf: true }) + } }, config: () => ({ diff --git a/packages/uniwind/tests/web/vite.test.ts b/packages/uniwind/tests/web/vite.test.ts new file mode 100644 index 00000000..d399a7bf --- /dev/null +++ b/packages/uniwind/tests/web/vite.test.ts @@ -0,0 +1,137 @@ +import path from 'node:path' +import type { Plugin, UserConfig } from 'vite' +import { describe, expect, test, vi } from 'vitest' + +import { uniwind } from '@/bundler/adapters/vite/vite' +import type { UniwindConfig } from '@/bundler/types' + +const config: UniwindConfig = { + cssEntryFile: './tests/test.css', +} + +const getPluginConfig = async (plugin: Plugin) => { + const hook = plugin.config + + if (hook === undefined) { + throw new Error('Expected the Uniwind plugin to define a config hook') + } + + const handler = typeof hook === 'function' ? hook : hook.handler + + return await Reflect.apply(handler, {}, [ + {}, + { + command: 'build', + mode: 'test', + isSsrBuild: false, + isPreview: false, + }, + ]) as UserConfig +} + +const getReactNativeAlias = async (plugin: Plugin) => { + const pluginConfig = await getPluginConfig(plugin) + const aliases = pluginConfig.resolve?.alias + + if (!Array.isArray(aliases)) { + throw new TypeError('Expected the Uniwind plugin to define aliases as an array') + } + + const alias = aliases[0] + + if (alias === undefined || typeof alias === 'string') { + throw new TypeError('Expected the Uniwind plugin to define a React Native alias') + } + + return alias +} + +const runResolveId = async ( + plugin: Plugin, + context: { resolve: ReturnType }, + source: string, + importer: string | undefined, +) => { + const hook = plugin.resolveId + + if (hook === undefined) { + throw new Error('Expected the Uniwind plugin to define a resolveId hook') + } + + const handler = typeof hook === 'function' ? hook : hook.handler + + return await Reflect.apply(handler, context, [source, importer]) +} + +describe('Vite adapter', () => { + test('avoids the deprecated customResolver with Vite 8', async () => { + const plugin = uniwind(config) + const alias = await getReactNativeAlias(plugin) + + expect(alias).not.toHaveProperty('customResolver') + }) + + test('resolves Uniwind internal React Native imports through React Native Web with Vite 8', async () => { + const plugin = uniwind(config) + const alias = await getReactNativeAlias(plugin) + const importer = path.resolve('node_modules/uniwind/dist/module/components/web/View.js').replaceAll('/', '\\') + const source = alias.replacement.replaceAll('/', '\\') + const resolved = { id: path.resolve('node_modules/react-native-web/index.js') } + const resolve = vi.fn().mockResolvedValue(resolved) + + await expect(runResolveId(plugin, { resolve }, source, importer)).resolves.toBe(resolved) + expect(resolve).toHaveBeenCalledWith('react-native-web', importer, { skipSelf: true }) + }) + + test('leaves application React Native imports on the Uniwind component alias', async () => { + const plugin = uniwind(config) + const alias = await getReactNativeAlias(plugin) + const resolve = vi.fn() + + await expect(runResolveId( + plugin, + { resolve }, + alias.replacement, + path.resolve('src/App.tsx'), + )).resolves.toBeUndefined() + expect(resolve).not.toHaveBeenCalled() + }) + + test.each( + [ + ['has no importer', undefined, 'component'], + ['does not target the component alias', 'internal', 'react-native'], + ] as const, + )('does not delegate when the import %s', async (_case, importerType, sourceType) => { + const plugin = uniwind(config) + const alias = await getReactNativeAlias(plugin) + const importer = importerType === 'internal' + ? path.resolve('node_modules/uniwind/dist/module/components/web/View.js') + : undefined + const source = sourceType === 'component' ? alias.replacement : sourceType + const resolve = vi.fn() + + await expect(runResolveId(plugin, { resolve }, source, importer)).resolves.toBeUndefined() + expect(resolve).not.toHaveBeenCalled() + }) + + test('preserves ordered stylesheet resolution before React Native Web delegation', async () => { + const plugin = uniwind(config) + const importer = path.resolve( + 'node_modules/react-native-web/dist/exports/StyleSheet/index.js', + ) + const resolve = vi.fn() + + const resolved = await runResolveId( + plugin, + { resolve }, + './createOrderedCSSStyleSheet', + importer, + ) + + expect(resolved).toBe(path.resolve( + 'src/bundler/adapters/module/components/web/createOrderedCSSStyleSheet.js', + )) + expect(resolve).not.toHaveBeenCalled() + }) +}) From 4908dbf0e91e4dfd44063c6bb0270db41c8a2d5b Mon Sep 17 00:00:00 2001 From: harang Date: Mon, 7 Sep 2026 14:22:17 +0900 Subject: [PATCH 2/3] test: cover the Vite resolver plugin pipeline Resolve an internal React Native import through Vite's real plugin container so the test covers alias rewriting and pre-plugin ordering together. Constraint: Hook-level tests bypass Vite alias sequencing Confidence: high Scope-risk: narrow Directive: Keep this test on the original react-native specifier so it exercises the complete resolver pipeline Tested: Vite adapter 7 tests; web 37 tests; test typecheck; dprint; Vite 8.0.14 example build --- packages/uniwind/tests/web/vite.test.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/uniwind/tests/web/vite.test.ts b/packages/uniwind/tests/web/vite.test.ts index d399a7bf..39e5329c 100644 --- a/packages/uniwind/tests/web/vite.test.ts +++ b/packages/uniwind/tests/web/vite.test.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import type { Plugin, UserConfig } from 'vite' +import { createServer, type Plugin, type UserConfig } from 'vite' import { describe, expect, test, vi } from 'vitest' import { uniwind } from '@/bundler/adapters/vite/vite' @@ -83,6 +83,29 @@ describe('Vite adapter', () => { expect(resolve).toHaveBeenCalledWith('react-native-web', importer, { skipSelf: true }) }) + test('resolves an internal React Native import through the Vite plugin pipeline', async () => { + const plugin = uniwind(config) + + plugin.buildStart = undefined + plugin.generateBundle = undefined + + const server = await createServer({ + configFile: false, + logLevel: 'silent', + plugins: [plugin], + server: { middlewareMode: true }, + }) + + try { + const importer = path.resolve('node_modules/uniwind/dist/module/components/web/View.js') + const resolved = await server.pluginContainer.resolveId('react-native', importer) + + expect(resolved?.id).toContain('react-native-web') + } finally { + await server.close() + } + }) + test('leaves application React Native imports on the Uniwind component alias', async () => { const plugin = uniwind(config) const alias = await getReactNativeAlias(plugin) From 037cbee5cef39d3eedebcbd6d43ccd8fd054b968 Mon Sep 17 00:00:00 2001 From: harang Date: Mon, 7 Sep 2026 14:27:15 +0900 Subject: [PATCH 3/3] test: assert the exact Vite resolver target Compare the aliased React Native resolution with Vite's direct React Native Web resolution for the same importer so unrelated paths cannot satisfy the integration test. Constraint: A substring assertion can pass for the wrong resolved module Confidence: high Scope-risk: narrow Tested: Vite adapter 7 tests; test typecheck; dprint --- packages/uniwind/tests/web/vite.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/uniwind/tests/web/vite.test.ts b/packages/uniwind/tests/web/vite.test.ts index 39e5329c..ff055fbd 100644 --- a/packages/uniwind/tests/web/vite.test.ts +++ b/packages/uniwind/tests/web/vite.test.ts @@ -99,8 +99,10 @@ describe('Vite adapter', () => { try { const importer = path.resolve('node_modules/uniwind/dist/module/components/web/View.js') const resolved = await server.pluginContainer.resolveId('react-native', importer) + const expected = await server.pluginContainer.resolveId('react-native-web', importer) - expect(resolved?.id).toContain('react-native-web') + expect(expected?.id).toBeDefined() + expect(resolved?.id).toBe(expected?.id) } finally { await server.close() }