Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
efede75
fix(react-native): 完善三端兼容性与 HMR 验收
sonofmagic Aug 19, 2026
3387f48
test(react-native): 增强 Web 运行时失败诊断
sonofmagic Aug 19, 2026
b4a7deb
fix(react-native): 等待 Metro manifest 生成完成
sonofmagic Aug 19, 2026
4232708
fix(react-native): 按项目根目录等待 Metro manifest
sonofmagic Aug 19, 2026
a5ee784
fix(react-native): 优先等待已注册 Metro manifest
sonofmagic Aug 19, 2026
c51023a
test(react-native): 保留 Web 静态导出失败证据
sonofmagic Aug 19, 2026
ecc7852
fix(react-native): 同步跨进程 Metro manifest
sonofmagic Aug 19, 2026
0f85d62
test(react-native): 记录 Metro worker manifest 诊断
sonofmagic Aug 19, 2026
7367d5a
fix(react-native): 使用确定性 Metro manifest 入口
sonofmagic Aug 19, 2026
e8610a0
test(react-native): 保留 Metro 导出子进程日志
sonofmagic Aug 19, 2026
f8c6a2c
test(react-native): 使用冷缓存 Metro 导出验收
sonofmagic Aug 19, 2026
67e151a
test(react-native): 输出 manifest 生成错误
sonofmagic Aug 19, 2026
74adf38
ci(react-native): 构建 shared 依赖产物
sonofmagic Aug 19, 2026
a859637
ci(react-native): 按拓扑构建发布依赖
sonofmagic Aug 19, 2026
71b2a9e
test(react-native): 输出 Web Metro 诊断
sonofmagic Aug 19, 2026
b5d6659
test(react-native): 稳定冷启动与基线更新
sonofmagic Aug 19, 2026
4e96c91
fix(react-native): 稳定原生运行时报告
sonofmagic Aug 19, 2026
743a1e8
fix(react-native): 稳定 Expo 原生模拟器验收
sonofmagic Aug 19, 2026
3c29d8d
fix(react-native): 修复三端兼容性 CI
sonofmagic Aug 20, 2026
10b92ab
fix(react-native): 避免原生冷启动重启竞态
sonofmagic Aug 20, 2026
4f7994a
fix(react-native): 稳定 hosted Android 模拟器
sonofmagic Aug 20, 2026
f47625b
fix(react-native): 修复 hosted 原生运行时启动
sonofmagic Aug 20, 2026
13dbd7b
fix(react-native): 避免原生探测触发 undici 崩溃
sonofmagic Aug 20, 2026
5e3cc7f
fix(react-native): 恢复 hosted Android 异常首启
sonofmagic Aug 20, 2026
f5c8f7b
fix(react-native): 避免类型入口加载重复运行时
sonofmagic Aug 20, 2026
09e0242
fix(react-native): 固定原生 Metro 运行时实例
sonofmagic Aug 20, 2026
f1f8f19
fix(react-native): 清除 hosted Android 系统阻塞弹窗
sonofmagic Aug 20, 2026
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
13 changes: 13 additions & 0 deletions .changeset/calm-react-native-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@weapp-tailwindcss/react-native': patch
---

收紧 React Native 样式编译边界,只生成平台明确支持的属性和条件变体,并补充比例值、百分比透明度、项目根目录及 CSS 入口类型支持,避免未知 CSS 属性或浏览器 selector 变体被误报为可用原生样式。

稳定 Babel 静态 lookup 使用的 StyleSheet ID,避免 TSX 或 CSS HMR 改变规则顺序后串用其他 class 的样式;同时加入 Expo Web、Android、iOS 共用的 118 项兼容性报告、截图和独立 TSX/CSS HMR 门禁。

Metro transformer 在未透传自定义 id 或 manifest 路径时也会按项目根目录等待异步样式生成完成,并通过跨进程 ready 标记同步 manifest 与 virtual module,避免构建速度较慢或 HMR 刷新时使用空 manifest 导致静态 className 未被转换。

React Native 类型增强入口改为纯类型依赖,不再从 workspace 包目录额外加载另一份 React Native 运行时,避免 Metro bundle 与 Expo 原生二进制使用不同 patch 版本后出现原生模块注册表为空的问题。

Android 与 iOS 的 Metro resolver 进一步把 `react`、`react-native` 及其子路径锚定到应用项目根目录,确保 linked workspace 包与原生二进制共用同一份 React Native runtime;Web 保留 Expo 原有的平台解析与 `react-native-web` 映射。
128 changes: 128 additions & 0 deletions .github/workflows/react-native-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
name: React Native Compatibility

on:
pull_request:
types: [opened, synchronize]
paths:
- 'packages/react-native/**'
- 'packages/weapp-tailwindcss/src/generator/**'
- 'examples/react-native-expo/**'
- 'e2e/react-native/**'
- 'e2e/react-native-*.test.ts'
- package.json
- pnpm-lock.yaml
- .github/workflows/react-native-compatibility.yml
workflow_dispatch:

permissions:
contents: read

jobs:
web:
name: Expo Web compatibility
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build --filter=@weapp-tailwindcss/react-native...
- name: Install Playwright Chromium
run: |
for attempt in 1 2 3 4; do
if pnpm exec playwright install chromium; then
exit 0
fi
echo "Playwright Chromium install failed, retrying (${attempt}/4)"
sleep $((attempt * 10))
done
pnpm exec playwright install chromium
- run: pnpm e2e:react-native-compatibility
- run: pnpm e2e:react-native:web
- uses: actions/upload-artifact@v4
if: always()
with:
name: react-native-web-${{ github.run_id }}
path: e2e/.artifacts/react-native-*/**
if-no-files-found: ignore
include-hidden-files: true
retention-days: 14

android:
name: Expo Android 11 / API 30
runs-on: ubuntu-latest
timeout-minutes: 50
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build --filter=@weapp-tailwindcss/react-native...
- name: Build Android debug APK
run: |
pnpm --filter @weapp-tailwindcss/example-react-native-expo exec expo prebuild --platform android --no-install
cd examples/react-native-expo/android
./gradlew app:assembleDebug -PreactNativeArchitectures=x86_64
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
arch: x86_64
profile: pixel_2
cores: 2
ram-size: 4096M
heap-size: 512M
disable-animations: true
disable-spellchecker: true
script: RN_ANDROID_DEVICE_ID=emulator-5554 RN_ANDROID_BINARY=examples/react-native-expo/android/app/build/outputs/apk/debug/app-debug.apk pnpm e2e:react-native:android
- uses: actions/upload-artifact@v4
if: always()
with:
name: react-native-android-${{ github.run_id }}
path: e2e/.artifacts/react-native-android/**
if-no-files-found: warn
include-hidden-files: true
retention-days: 14

ios:
name: Expo iOS 18.5
runs-on: macos-15
timeout-minutes: 50
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: brew install cocoapods
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build --filter=@weapp-tailwindcss/react-native...
- name: Boot iOS simulator
run: |
udid="$(xcrun simctl create ReactNativeCompatibility 'iPhone 16 Pro' 'com.apple.CoreSimulator.SimRuntime.iOS-18-5')"
echo "RN_IOS_DEVICE_ID=$udid" >> "$GITHUB_ENV"
xcrun simctl boot "$udid"
xcrun simctl bootstatus "$udid" -b
- run: pnpm e2e:react-native:ios
- uses: actions/upload-artifact@v4
if: always()
with:
name: react-native-ios-${{ github.run_id }}
path: e2e/.artifacts/react-native-ios/**
if-no-files-found: warn
include-hidden-files: true
retention-days: 14
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ website/tests/index.spec.ts-snapshots
/.playwright-cli/
/.playwright-mcp/
e2e/.artifacts/
examples/react-native-expo/.expo/
examples/react-native-expo/android/
examples/react-native-expo/ios/
.e2e-runtime/
.tmp/
.codex-tmp/
Expand Down
6 changes: 6 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
- 总矩阵一致性:`pnpm exec vitest run -c ./e2e/vitest.e2e.config.ts e2e/e2e-matrix.test.ts`
- 静态小程序快照:`E2E_SKIP_OPEN_AUTOMATOR=1 pnpm e2e:static`
- React Native / Expo Metro export:`pnpm e2e:react-native-expo`
- React Native 三端 118 项兼容性 gate:`pnpm e2e:react-native-compatibility`
- React Native Expo Web 真实运行时、截图与 TSX/CSS HMR:`pnpm e2e:react-native:web`
- React Native Android 运行时、截图与 HMR:`pnpm e2e:react-native:android`
- React Native iOS 运行时、截图与 HMR:`pnpm e2e:react-native:ios`
- React Native 三端汇总 gate:`pnpm e2e:react-native:all`
- React Native 受控静态基线更新:`pnpm e2e:react-native:update`
- 多平台构建输出:`pnpm e2e:multiplatform-build`
- 单个平台构建输出:`E2E_MULTIPLATFORM_BUILD_CASE="<demo> <platform>" pnpm e2e:multiplatform-build`
- demo watch/HMR:`pnpm e2e:hot-update:demo`
Expand Down
26 changes: 26 additions & 0 deletions e2e/react-native-android-window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { findAndroidAnrWaitTap } from './react-native/android-window'

describe('React Native Android window guard', () => {
it('locates the ANR wait action independently of text and attribute order', () => {
const windowXml = `
<hierarchy>
<node bounds="[20,40][220,140]" resource-id="android:id/aerr_close" text="Cerrar la app" />
<node text="Esperar" bounds="[20,140][220,260]" resource-id="android:id/aerr_wait" />
</hierarchy>
`
expect(findAndroidAnrWaitTap(windowXml)).toEqual({ x: 120, y: 200 })
})

it('does not treat an unrelated wait button as an ANR dialog', () => {
expect(findAndroidAnrWaitTap('<node bounds="[0,0][100,100]" resource-id="android:id/aerr_wait" />')).toBeUndefined()
})

it('rejects malformed or empty ANR action bounds', () => {
const windowXml = `
<node resource-id="android:id/aerr_close" bounds="[0,0][100,100]" />
<node resource-id="android:id/aerr_wait" bounds="[40,40][40,80]" />
`
expect(findAndroidAnrWaitTap(windowXml)).toBeUndefined()
})
})
134 changes: 134 additions & 0 deletions e2e/react-native-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
import { compatibilityCases, RN_CATALOG_HASH } from './react-native/catalog'
import { nativeReportConclusion, reportFromStaticEvidence, validateReactNativeReport } from './react-native/reports'
import { collectStaticEvidence } from './react-native/static-evidence'
import { runWebRuntime } from './react-native/web-runtime'

const repoRoot = path.resolve(import.meta.dirname, '..')
const examplePackage = '@weapp-tailwindcss/example-react-native-expo'
const reactNativePackage = '@weapp-tailwindcss/react-native'

async function findBundle(root: string, platform: string) {
const metadata = JSON.parse(await fs.readFile(path.join(root, 'metadata.json'), 'utf8')) as { fileMetadata: Record<string, { bundle?: string }> }
const reported = metadata.fileMetadata[platform]?.bundle
if (reported) {
return path.join(root, reported)
}
const pending = [root]
while (pending.length) {
const current = pending.pop()!
for (const entry of await fs.readdir(current, { withFileTypes: true })) {
const target = path.join(current, entry.name)
if (entry.isDirectory()) {
pending.push(target)
continue
}
if (/\.(?:js|bundle)$/.test(entry.name) && !entry.name.endsWith('.map')) {
return target
}
}
}
return undefined
}

describe('React Native compatibility catalog', () => {
it('keeps the shared 118-case catalog stable', () => {
expect(compatibilityCases).toHaveLength(118)
expect(RN_CATALOG_HASH).toMatch(/^[a-f0-9]{64}$/)
expect(new Set(compatibilityCases.map(item => item.id)).size).toBe(118)
})

it('generates a complete static manifest and a gated report', async () => {
const { manifest, results } = await collectStaticEvidence()
expect(manifest.classSet.length).toBeGreaterThan(0)
expect(results).toHaveLength(118)
expect(results.every(result => result.status === 'supported' || result.reason)).toBe(true)
const report = reportFromStaticEvidence('web', results, {
deviceName: 'static-export',
osName: 'Web',
osVersion: 'node',
runtimeIdentifier: 'metro-static',
abi: 'n/a',
viewport: { width: 1280, height: 900, pixelRatio: 1 },
})
expect(validateReactNativeReport(report, 'web')).toBe(report)
expect(nativeReportConclusion(report).split('|')).toHaveLength(118)
}, 300_000)

it('rejects stale or incomplete reports', () => {
const report = reportFromStaticEvidence('web', compatibilityCases.map(item => ({
id: item.id,
status: 'supported' as const,
checkpoints: [{ name: item.evidence === 'build' ? 'build:bundled' : `${item.probe}:probe`, passed: true }],
})), {
deviceName: 'static-export',
osName: 'Web',
osVersion: 'node',
runtimeIdentifier: 'metro-static',
abi: 'n/a',
viewport: { width: 1280, height: 900, pixelRatio: 1 },
})
expect(() => validateReactNativeReport({ ...report, catalogHash: 'stale' }, 'web')).toThrow(/stale/)
expect(() => validateReactNativeReport({ ...report, results: report.results.slice(1) }, 'web')).toThrow(/every catalog case/)
expect(() => validateReactNativeReport({
...report,
results: [{ ...report.results[0]!, status: 'not-tested' }, ...report.results.slice(1)],
} as never, 'web')).toThrow(/invalid status/)
})

it.each(['web', 'android', 'ios'] as const)('validates the committed %s runtime baseline', async (platform) => {
const report = JSON.parse(await fs.readFile(path.join(import.meta.dirname, 'react-native/reports', `${platform}.json`), 'utf8'))
expect(validateReactNativeReport(report, platform)).toBe(report)
})
})

describe('React Native Expo static exports', () => {
it('exports Web, Android and iOS bundles through Metro', async () => {
const outputRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'weapp-tailwindcss-rn-export-'))
try {
// 通用 E2E shard 不保证 workspace 包已按示例依赖拓扑构建。
await execa('pnpm', ['--filter', reactNativePackage, 'build'], { cwd: repoRoot })
for (const platform of ['web', 'android', 'ios'] as const) {
const exportResult = await execa('pnpm', ['--filter', examplePackage, 'exec', 'expo', 'export', '--clear', '--platform', platform, '--output-dir', path.join(outputRoot, platform)], {
cwd: repoRoot,
env: { ...process.env, CI: '1', WEAPP_TW_RN_DEBUG: '1' },
timeout: 300_000,
})
if (process.env.CI) {
await fs.writeFile(path.join(outputRoot, `${platform}-export.log`), `${exportResult.stdout}\n${exportResult.stderr}\n`, 'utf8')
}
const metadata = JSON.parse(await fs.readFile(path.join(outputRoot, platform, 'metadata.json'), 'utf8')) as { bundler: string }
expect(metadata.bundler).toBe('metro')
const bundlePath = await findBundle(path.join(outputRoot, platform), platform)
expect(bundlePath, `${platform} bundle`).toBeTruthy()
const bundle = await fs.readFile(bundlePath!, 'latin1')
expect(bundle).toContain('Tailwind RN')
expect(bundle).toMatch(/twStatic|getStaticStyle/)
if (platform === 'web') {
const runtime = await runWebRuntime(path.join(outputRoot, platform), path.join(outputRoot, 'web-runtime.png'))
expect(runtime.box.width).toBeGreaterThan(100)
expect(runtime.background).toMatch(/rgb\(/)
}
}
}
catch (error) {
if (process.env.CI) {
const childOutput = error as { stdout?: unknown, stderr?: unknown }
await fs.writeFile(
path.join(outputRoot, 'export-error.log'),
`${String(childOutput.stdout ?? '')}\n${String(childOutput.stderr ?? '')}\n${error instanceof Error ? error.stack ?? error.message : String(error)}`,
'utf8',
)
await fs.cp(outputRoot, path.join(repoRoot, 'e2e/.artifacts/react-native-web/static-export-debug'), { recursive: true, force: true })
}
throw error
}
finally {
await fs.rm(outputRoot, { recursive: true, force: true })
}
}, 900_000)
})
21 changes: 21 additions & 0 deletions e2e/react-native-native-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { AddressInfo } from 'node:net'
import { createServer } from 'node:http'
import { describe, expect, it } from 'vitest'
import { getHttpText } from './react-native/native-http'

describe('React Native native HTTP probe', () => {
it('reads the Metro status endpoint without relying on fetch', async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' })
response.end('packager-status:running')
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
try {
const address = server.address() as AddressInfo
await expect(getHttpText(`http://127.0.0.1:${address.port}/status`)).resolves.toBe('packager-status:running')
}
finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
})
Loading
Loading