Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 11 additions & 2 deletions .github/workflows/e2e_android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,13 @@ jobs:
# --maxWorkers 1 below). Unpin once a run proves 37.x stable.
emulator-build: 15507667
emulator-boot-timeout: 900
script: cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1
# 每条命令自带 cd:android-emulator-runner 逐行起独立 shell,
# 单独一行的 cd 不会保持到下一条(实测 detox 会在仓库根找不到 jest)
script: |
cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1
# 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js);
# Android 这个 job 有预算,不必像 iOS 那样拆成独立 job
cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js

- name: Upload Detox artifacts
# cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场
Expand Down Expand Up @@ -373,7 +379,10 @@ jobs:
# --maxWorkers 1 below). Unpin once a run proves 37.x stable.
emulator-build: 15507667
emulator-boot-timeout: 900
script: cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1
# 每条命令自带 cd,理由同上
script: |
cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1
cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js

- name: Upload Detox artifacts
# cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场
Expand Down
25 changes: 20 additions & 5 deletions .github/workflows/e2e_ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,25 @@ concurrency:

jobs:
e2e-ios:
# 不带 matrix 值全量拼名,否则 check 叫 "e2e-ios (native, --config e2e/…)"
name: e2e-ios (${{ matrix.suite }})
runs-on: macos-26
# 绿色基线 ~16 分钟;40 分钟覆盖最坏情况:一次 --retries 1 失败重试
# (失败用例会等满各级长超时)
timeout-minutes: 40
# 绿色基线 ~16 分钟。60 分钟覆盖最坏情况:一个用例等满 300s testTimeout,
# 再叠一次 --retries 1 全量重试。40 分钟不够——实测重试跑到一半被腰斩,
# 把"慢但能恢复"的运行变成 cancelled,等于白开了 retries。
timeout-minutes: 60
strategy:
# 两条 leg 各自独立成败:一条超时不该掩盖另一条的结论
fail-fast: false
matrix:
include:
# 既有 suite(local-merge / bundle-hash),沿用默认 runner 配置
- suite: core
jest-config: ''
# 原生冷启动检测:每条断言都要真实重启 app,单独跑才不会把 core
# 顶过 40 分钟(拆分前实测 core 已占 36 分)
- suite: native
jest-config: '--config e2e/native/jest.config.js'
steps:
- name: Checkout react-native-update
uses: actions/checkout@v7
Expand Down Expand Up @@ -210,14 +225,14 @@ jobs:
env:
RNU_CLI_ROOT: ${{ github.workspace }}/react-native-update-cli
RNU_E2E_SKIP_PREPARE: 'true'
run: cd Example/e2etest && E2E_PLATFORM=ios bunx detox test --configuration ios.sim.release --retries 1
run: cd Example/e2etest && E2E_PLATFORM=ios bunx detox test --configuration ios.sim.release --retries 1 ${{ matrix.jest-config }}

- name: Upload Detox artifacts
# cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场
if: failure() || cancelled()
uses: actions/upload-artifact@v7
with:
name: e2e-ios-detox-artifacts
name: e2e-ios-detox-artifacts-${{ matrix.suite }}
path: Example/e2etest/artifacts
if-no-files-found: ignore
retention-days: 7
4 changes: 3 additions & 1 deletion Example/e2etest/e2e/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ const config = {
testMatch: ['<rootDir>/e2e/**/*.test.ts'],
// Harmony tests use their own runner (harmony.jest.config.js), not Detox.
// The debug boot smoke has its own runner config too (smoke/jest.config.js).
testPathIgnorePatterns: ['/e2e/harmony/', '/e2e/smoke/'],
// The native cold-start suite (native/jest.config.js) runs as its own CI job:
// it costs several app launches, and the iOS job's budget has no room left.
testPathIgnorePatterns: ['/e2e/harmony/', '/e2e/smoke/', '/e2e/native/'],
testTimeout: 300000,
maxWorkers: 1,
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
Expand Down
29 changes: 29 additions & 0 deletions Example/e2etest/e2e/native/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const path = require('node:path');

const moduleDir = __dirname;

// 原生冷启动检测专用 runner 配置。与 ../jest.config.js 的唯一区别是 testMatch:
// 该 suite 每条断言都要真实重启 app,单独成 CI job 后与其它 suite 并行,不再
// 挤占 iOS 那个已经贴着 40 分钟上限的预算。本地更新 server 与 ppk 产物仍然
// 需要,所以沿用同一套 globalSetup。
/** @type {import('jest').Config} */
const config = {
rootDir: '../..',
testMatch: ['<rootDir>/e2e/native/**/*.test.ts'],
testTimeout: 300000,
maxWorkers: 1,
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': [
'babel-jest',
{ configFile: path.resolve(moduleDir, '../../babel.config.js') },
],
},
globalSetup: '<rootDir>/e2e/globalSetup.js',
globalTeardown: '<rootDir>/e2e/globalTeardown.js',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
verbose: true,
};

module.exports = config;
116 changes: 116 additions & 0 deletions Example/e2etest/e2e/native/native-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { by, device, element, waitFor } from 'detox';
import {
getLocalUpdateEndpoint,
LOCAL_UPDATE_HASHES,
LOCAL_UPDATE_LABELS,
LOCAL_UPDATE_PORT,
} from '../localUpdateConfig.ts';

// The native cold-start check (NATIVE_CHECKUPDATE_DESIGN §10) exists for one
// scenario: the running update is broken badly enough that JS never starts, so
// nothing in JS can fetch the fix. Staging a genuinely bricked bundle is not
// testable through Detox — it cannot attach to an app whose JS never boots — so
// this suite covers the same capability minus that one step: the app never
// performs a JS check (checkStrategy: null, and the check button is never
// tapped), yet the device still ends up on a new version. Only the native
// orchestrator can produce that outcome.
//
// The activation itself is driven by the server's per-version forceBoot
// directive, which is exactly how a real rescue is triggered from the console.

const NATIVE_CHECK_SETTLE_MS = 25000;
const READY_TIMEOUT = 30000;
const LABEL_TIMEOUT = 30000;

function getDetoxLaunchArgs() {
if (device.getPlatform() !== 'android') {
return {};
}
return { launchArgs: { detoxEnableSynchronization: '0' } };
}

async function relaunchAppPreservingData() {
await device.launchApp({ newInstance: true, ...getDetoxLaunchArgs() });
// The native check talks to the same local server; keeping those requests out
// of Detox's idle synchronization is what the other suites do too.
await device.setURLBlacklist([`.*:${LOCAL_UPDATE_PORT}.*`]);
}

async function setForceBoot(enabled: boolean) {
const endpoint = getLocalUpdateEndpoint(device.getPlatform());
// Reached from the test runner (the host), not from the device, so localhost
// is correct even when the app talks to 10.0.2.2.
const hostEndpoint = endpoint.replace('10.0.2.2', '127.0.0.1');
const response = await fetch(`${hostEndpoint}/control/force-boot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
throw new Error(`failed to set forceBoot=${enabled}: ${response.status}`);
}
}

async function waitForReady() {
await waitFor(element(by.id('bundle-label')))
.toBeVisible()
.withTimeout(READY_TIMEOUT);
}

async function waitForBundleLabel(label: string) {
await waitFor(element(by.id('bundle-label')))
.toHaveText(`bundleLabel: ${label}`)
.withTimeout(LABEL_TIMEOUT);
}

async function waitForHash(hash: string) {
await waitFor(element(by.id('current-hash')))
.toHaveText(`currentHash: ${hash || '(empty)'}`)
.withTimeout(LABEL_TIMEOUT);
}

describe('Native cold-start check', () => {
beforeAll(async () => {
await setForceBoot(false);
// A fresh install already sits on the packaged bundle, which is the state
// both halves below start from — no reset round-trip needed.
await device.launchApp({ delete: true, ...getDetoxLaunchArgs() });
await device.setURLBlacklist([`.*:${LOCAL_UPDATE_PORT}.*`]);
await waitForReady();
await waitForBundleLabel(LOCAL_UPDATE_LABELS.base);
});

afterAll(async () => {
// Never leave the directive on: the other suites drive activation
// themselves and would race a forced one.
await setForceBoot(false);
});

// Both directions live in one test on purpose: every extra app launch costs
// real wall clock on the iOS simulator, and this suite shares a 40-minute
// job budget with the rest of the e2e.
it('activates only what the server forces, with no JS check involved', async () => {
// Without the directive the round may download, but with automatic checks
// off (checkStrategy: null) it must never activate on its own.
await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS));
await relaunchAppPreservingData();
await waitForReady();
await waitForBundleLabel(LOCAL_UPDATE_LABELS.base);
await waitForHash('');

// Flip the directive before the launch whose round should honor it.
await setForceBoot(true);
await relaunchAppPreservingData();
await waitForReady();
await waitForBundleLabel(LOCAL_UPDATE_LABELS.base);
await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS));

// Turn it off before observing, so the next launch cannot walk further
// along the update chain while the assertions run.
await setForceBoot(false);
await relaunchAppPreservingData();
await waitForReady();
await waitForBundleLabel(LOCAL_UPDATE_LABELS.full);
await waitForHash(LOCAL_UPDATE_HASHES.full);
});
});
29 changes: 27 additions & 2 deletions Example/e2etest/scripts/local-e2e-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ const appKeyToPlatform = Object.fromEntries(
])
);

// Flipped by the /control/force-boot endpoint. When on, every update response
// carries config.forceBoot so the client's native cold-start check activates
// the version it downloads — the brick-rescue path, which by definition has to
// work without any JS check. Off by default so the other suites keep full
// control over when a version becomes active.
let forceBootEnabled = false;

const contentTypes: Record<string, string> = {
'.json': 'application/json; charset=utf-8',
'.ppk': 'application/octet-stream',
Expand Down Expand Up @@ -170,6 +177,17 @@ const server = Bun.serve({
});
}

if (url.pathname === '/control/force-boot') {
if (request.method !== 'POST') {
return new Response('method not allowed', { status: 405 });
}
const body = (await request.json().catch(() => ({}))) as {
enabled?: unknown;
};
forceBootEnabled = body.enabled === true;
return json({ forceBoot: forceBootEnabled });
}

if (url.pathname.startsWith('/checkUpdate/')) {
if (request.method !== 'POST') {
return new Response('method not allowed', { status: 405 });
Expand All @@ -188,9 +206,16 @@ const server = Bun.serve({
const currentHash = typeof payload.hash === 'string' ? payload.hash : '';
const diffV = typeof payload.diffV === 'number' ? payload.diffV : 0;

return json(
buildUpdateResponse(platform, currentHash, diffV, url.origin)
const response = buildUpdateResponse(
platform,
currentHash,
diffV,
url.origin
);
if (forceBootEnabled && 'update' in response) {
return json({ ...response, config: { forceBoot: true } });
}
return json(response);
}

if (url.pathname.startsWith('/artifacts/')) {
Expand Down
8 changes: 7 additions & 1 deletion Example/e2etest/scripts/prepare-local-update-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ const { diffCommands } = require(path.join(cliRoot, 'lib/exports.js')) as {
diffCommands: DiffCommandRunner;
};

// A cold Metro bundle on a GitHub macOS runner regularly needs well over two
// minutes, so the old 120s cap killed a healthy `pushy bundle` mid-flight
// (spawnSync ETIMEDOUT). This is a stuck-process guard, not a perf budget —
// the step and job timeouts already bound the happy path.
const PUSHY_TIMEOUT_MS = 300_000;

function runPushy(args: string[], cwd: string) {
const cliNodeModules = path.join(cliRoot, 'node_modules');
const projectNodeModules = path.join(projectRoot, 'node_modules');
Expand All @@ -135,7 +141,7 @@ function runPushy(args: string[], cwd: string) {
PUSHY_REGISTRY: localRegistry,
RNU_API: localRegistry,
},
timeout: 120_000,
timeout: PUSHY_TIMEOUT_MS,
});

if (result.error) {
Expand Down
14 changes: 13 additions & 1 deletion README-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,19 @@
7. 支持崩溃回滚,安全可靠,结合健康度监控可及时发现并止损问题版本。
8. meta 信息及开放 API,提供更高扩展性。
9. 提供 **MCP 服务**:把热更新服务接进 Claude Desktop、IDE 或自建 Agent,用自然语言排查"这台设备为什么没收到更新",并可与 GitHub、Sentry、CI 等工具组合定位问题。全程只读、按应用授权([Pushy 文档](https://pushy.reactnative.cn/docs/mcp) / [Cresc 文档](https://cresc.dev/docs/mcp))。
10. 提供付费的专人技术支持。
10. **原生冷启动自愈**:即使热更版本坏到 JS 完全跑不起来(白屏、启动即崩),设备也能在下次启动时由原生侧自动拉到修复版——不需要用户重装,也不需要你发新的应用商店版本(详见下方[原生冷启动检测](#原生冷启动检测))。
11. 提供付费的专人技术支持。

### 原生冷启动检测

自 10.51.0 起,每次冷启动后数秒会在后台线程执行一次**不依赖 app bundle** 的更新检查(下载、打补丁与状态切换全部在原生侧完成)。它的存在只为一件事:**当前热更版本坏到 JS 起不来时,仍有一条能把修复版拉下来的通路**——常规更新流程仍由应用内的 JS 负责,检查结果会被 JS 侧复用,不会重复请求。

几个需要知道的点:

- **不阻塞启动**:延迟数秒、跑在后台线程,成果在**下次启动**生效。
- **是否自动激活取决于你的配置**:`updateStrategy` 为 `silentAndNow` / `silentAndLater` 且未关闭自动检查(`checkStrategy` 不为 `null`)时,原生侧才会把下载好的版本设为下次启动生效;其余情况只下载,激活权仍在 JS。
- **救砖指令**:控制台可按版本标记「强制启动」,被标记的版本无视上述策略直接在下次启动生效——这是把已被坏版本卡死的设备捞回来的手段。设备本地的崩溃回滚保护仍然优先,已回滚过的版本不会被再装回去。
- **可以关闭**:`disableNativeCheck: true`。关闭后每次冷启动少一次后台请求,代价是**放弃上述自愈能力**——被坏热更卡死的设备将无法自动恢复。仅在这次请求本身构成问题时(流量/耗电预算、隐私清单申报、需用户同意后才可联网)才建议关闭。

### Diff 算法对比

Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,19 @@ See the docs:
7. Built-in crash rollback keeps updates safe and reliable, and health monitoring helps you catch and stop a bad release early.
8. Meta information and open APIs make the system more extensible.
9. An **MCP server** lets you connect the update service to Claude Desktop, an IDE or your own agent, ask in plain language why a device never received an update, and investigate alongside GitHub, Sentry or CI. Everything is read-only and scoped per app ([Cresc docs](https://cresc.dev/docs/mcp) / [Pushy docs](https://pushy.reactnative.cn/docs/mcp)).
10. Paid technical support is available.
10. **Native cold-start recovery**: even when an update is broken badly enough that JS never runs (white screen, crash on launch), the device pulls the fixed version on its next launch from the native side — no reinstall, no app-store release (see [Native cold-start check](#native-cold-start-check)).
11. Paid technical support is available.

## Native cold-start check

Since 10.51.0 every cold start runs one background update check a few seconds after launch that **does not depend on the app bundle** — the request, the download, the patch and the version switch all happen natively. It exists for exactly one reason: **when the running update is broken enough that JS never starts, something still has to be able to fetch the fix.** Normal updates remain the JS flow's job; the JS check reuses this result instead of issuing its own request.

What to know:

- **It never blocks startup**: it is delayed by a few seconds, runs off the main thread, and its result takes effect on the *next* launch.
- **Whether it activates depends on your configuration**: only with `updateStrategy` set to `silentAndNow` / `silentAndLater` *and* automatic checks left on (`checkStrategy` not `null`) will the native side mark a downloaded version for the next launch. Otherwise it downloads and leaves activation to JS.
- **Rescue directive**: the dashboard can mark a version "force boot", which activates on the next launch regardless of the strategies above — this is how a fleet stuck on a broken version is recovered. The device-local crash-rollback guard still wins: a version this device already rolled back from is never reinstalled.
- **It can be turned off**: `disableNativeCheck: true` removes one background request per cold start, at the cost of **giving up the recovery above** — a device bricked by a bad update can no longer heal itself. Choose it only when that request is itself the problem (traffic/battery budgets, privacy manifests, consent-gated networking).

## Diff Algorithm Comparison

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-native-update",
"version": "10.50.0",
"version": "10.51.0",
"description": "react-native hot update",
"main": "src/index",
"types": "src/index.ts",
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,25 @@ describe('syncNativeConfig', () => {
expect(config.afterDownload).toBe('setNeedUpdate');
});

test('disableNativeCheck writes a disabling config the natives bail on', async () => {
const syncNativeConfig = mock(() => Promise.resolve());
setupClientMocks({ syncNativeConfig });
const { Pushy } = await importFreshClient('sync-config-optout');
new Pushy({
appKey: 'demo-app',
updateStrategy: 'silentAndNow',
disableNativeCheck: true,
});

const config = JSON.parse(
(syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0]
);
expect(config.disabled).toBe(true);
// No endpoints: the orchestrators return on `disabled` before any IO.
expect(config.endpoints).toBeUndefined();
expect(config.appKey).toBe('demo-app');
});

test('alert strategies keep activation with JS (afterDownload none)', async () => {
const syncNativeConfig = mock(() => Promise.resolve());
setupClientMocks({ syncNativeConfig });
Expand Down
Loading
Loading