Skip to content

Commit 260c6fd

Browse files
sunnylqmclaude
andauthored
feat: disableNativeCheck opt-out, 10.51.0 docs and native check e2e (#623)
* feat: disableNativeCheck opt-out, 10.51.0 docs and native check e2e The cold-start check shipped as unconditional behavior: every integrator got one extra background request per launch with no way to decline. That contradicts how the rest of this SDK treats integrator intent, and the plumbing to honor a decline already existed — all three orchestrators already bail on a `disabled` config before any IO. ClientOptions gains disableNativeCheck, documented as the trade it is: one fewer request per cold start in exchange for giving up the recovery path a bricked device depends on. It stays orthogonal to checkStrategy, which governs activation authority rather than whether the check runs. Both READMEs describe the check itself — that it never blocks startup, when it may activate a version, how the console's force-boot rescue works and how to turn the whole thing off — and the version moves to 10.51.0, which is also the floor the consoles gate their force-boot entry on. The feature also shipped with no e2e coverage of its own, which is why it took breaking someone else's suite to surface a contract conflict. The new suite covers the capability minus the one step Detox cannot stage (it cannot attach to an app whose JS never boots): the app performs no JS check at all, yet a force-boot version still installs and activates — an outcome only the native orchestrator can produce. A second case pins the other direction: without the directive, and with automatic checks off, the check may download but must never activate on its own. The mock server grows a /control/force-boot endpoint so the directive is opt-in per test and the other suites keep full control over activation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(e2e): halve the native check suite's app launches The first version spent about nine app launches across two tests: a beforeEach that reset to the packaged bundle and relaunched, then two launches per test. On the iOS simulator every launch is real wall clock, and this suite shares a 40-minute job budget whose green baseline is already ~16 minutes. A fresh install already sits on the packaged bundle, so the reset round-trip buys nothing, and both directions of the assertion can share one install: settle once with no directive and confirm nothing activated, then flip the directive on and confirm the version installs. Four launches instead of nine, with both assertions intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: run the native check e2e as its own iOS job The iOS e2e job had no room left: its last green run took 36 of its 40 allotted minutes, and the native check suite needs about three more on a healthy runner (186s measured in a retry). Every run since has died at the 40-minute wall — not because the suite fails (it passes on both Android matrices and passed on iOS in that retry) but because the job can no longer hold everything. The suite moves to e2e/native/ with its own runner config, mirroring how the harmony and smoke suites are already separated, and the iOS job gains a matrix so core and native run as independent legs with independent budgets and fail-fast off — a timeout in one no longer hides the other's verdict. New test files still land in core automatically; only this directory is carved out. Android keeps both suites in one job by invoking the second config after the first: that job finishes in about nine of its thirty minutes, so it has the budget iOS lacks and splitting it would only cost another emulator. Cost note: the built-app cache key covers package.json and ios/**, so a run that changes those (like this one) now builds twice in parallel instead of once. That is the price of the wall-clock win; runs that only touch JS restore the cache in both legs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: give each Android detox command its own cd android-emulator-runner runs each line of a multi-line script in its own shell, so the standalone `cd` I added never reached the second detox invocation: it ran from the repository root and died with "Could not resolve jest package from the current working directory", taking both Android release jobs down. CodeRabbit flagged exactly this on the diff and CI confirmed it minutes later. Each command now carries its own cd. The iOS job also gains an explicit name, since a matrix with two keys was composing checks called "e2e-ios (native, --config e2e/native/jest.config.js)". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: stop killing healthy iOS work with timeouts that are too tight Two failures on the same run, both self-inflicted by our own caps rather than by anything under test: - The native leg died in "Prepare local update artifacts" with spawnSync ETIMEDOUT. runPushy capped every CLI call at 120s, and a cold Metro bundle on a macOS runner routinely needs longer — the banner printed at 07:05:54 and the process was killed at 07:07:52, making progress the whole time. Raised to 300s; it is a stuck-process guard, and the step/job timeouts still bound the happy path. - The core leg was cancelled at the 40-minute wall with its --retries 1 rerun still in flight (build was 1 min from cache, so the test phase alone ate 30). Budgeting less than one full retry makes retries useless. Raised to 60. Neither masks a product defect: bundleHash's 341s (vs ~2s on Android) is a pre-existing iOS runner pathology tracked separately. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6229055 commit 260c6fd

13 files changed

Lines changed: 295 additions & 17 deletions

File tree

.github/workflows/e2e_android.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,13 @@ jobs:
141141
# --maxWorkers 1 below). Unpin once a run proves 37.x stable.
142142
emulator-build: 15507667
143143
emulator-boot-timeout: 900
144-
script: cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1
144+
# 每条命令自带 cd:android-emulator-runner 逐行起独立 shell,
145+
# 单独一行的 cd 不会保持到下一条(实测 detox 会在仓库根找不到 jest)
146+
script: |
147+
cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1
148+
# 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js);
149+
# Android 这个 job 有预算,不必像 iOS 那样拆成独立 job
150+
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
145151
146152
- name: Upload Detox artifacts
147153
# cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场
@@ -373,7 +379,10 @@ jobs:
373379
# --maxWorkers 1 below). Unpin once a run proves 37.x stable.
374380
emulator-build: 15507667
375381
emulator-boot-timeout: 900
376-
script: cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1
382+
# 每条命令自带 cd,理由同上
383+
script: |
384+
cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1
385+
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
377386
378387
- name: Upload Detox artifacts
379388
# cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场

.github/workflows/e2e_ios.yml

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,25 @@ concurrency:
4242

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

215230
- name: Upload Detox artifacts
216231
# cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场
217232
if: failure() || cancelled()
218233
uses: actions/upload-artifact@v7
219234
with:
220-
name: e2e-ios-detox-artifacts
235+
name: e2e-ios-detox-artifacts-${{ matrix.suite }}
221236
path: Example/e2etest/artifacts
222237
if-no-files-found: ignore
223238
retention-days: 7

Example/e2etest/e2e/jest.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ const config = {
88
testMatch: ['<rootDir>/e2e/**/*.test.ts'],
99
// Harmony tests use their own runner (harmony.jest.config.js), not Detox.
1010
// The debug boot smoke has its own runner config too (smoke/jest.config.js).
11-
testPathIgnorePatterns: ['/e2e/harmony/', '/e2e/smoke/'],
11+
// The native cold-start suite (native/jest.config.js) runs as its own CI job:
12+
// it costs several app launches, and the iOS job's budget has no room left.
13+
testPathIgnorePatterns: ['/e2e/harmony/', '/e2e/smoke/', '/e2e/native/'],
1214
testTimeout: 300000,
1315
maxWorkers: 1,
1416
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const path = require('node:path');
2+
3+
const moduleDir = __dirname;
4+
5+
// 原生冷启动检测专用 runner 配置。与 ../jest.config.js 的唯一区别是 testMatch:
6+
// 该 suite 每条断言都要真实重启 app,单独成 CI job 后与其它 suite 并行,不再
7+
// 挤占 iOS 那个已经贴着 40 分钟上限的预算。本地更新 server 与 ppk 产物仍然
8+
// 需要,所以沿用同一套 globalSetup。
9+
/** @type {import('jest').Config} */
10+
const config = {
11+
rootDir: '../..',
12+
testMatch: ['<rootDir>/e2e/native/**/*.test.ts'],
13+
testTimeout: 300000,
14+
maxWorkers: 1,
15+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
16+
transform: {
17+
'^.+\\.(js|jsx|ts|tsx)$': [
18+
'babel-jest',
19+
{ configFile: path.resolve(moduleDir, '../../babel.config.js') },
20+
],
21+
},
22+
globalSetup: '<rootDir>/e2e/globalSetup.js',
23+
globalTeardown: '<rootDir>/e2e/globalTeardown.js',
24+
reporters: ['detox/runners/jest/reporter'],
25+
testEnvironment: 'detox/runners/jest/testEnvironment',
26+
verbose: true,
27+
};
28+
29+
module.exports = config;
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { by, device, element, waitFor } from 'detox';
2+
import {
3+
getLocalUpdateEndpoint,
4+
LOCAL_UPDATE_HASHES,
5+
LOCAL_UPDATE_LABELS,
6+
LOCAL_UPDATE_PORT,
7+
} from '../localUpdateConfig.ts';
8+
9+
// The native cold-start check (NATIVE_CHECKUPDATE_DESIGN §10) exists for one
10+
// scenario: the running update is broken badly enough that JS never starts, so
11+
// nothing in JS can fetch the fix. Staging a genuinely bricked bundle is not
12+
// testable through Detox — it cannot attach to an app whose JS never boots — so
13+
// this suite covers the same capability minus that one step: the app never
14+
// performs a JS check (checkStrategy: null, and the check button is never
15+
// tapped), yet the device still ends up on a new version. Only the native
16+
// orchestrator can produce that outcome.
17+
//
18+
// The activation itself is driven by the server's per-version forceBoot
19+
// directive, which is exactly how a real rescue is triggered from the console.
20+
21+
const NATIVE_CHECK_SETTLE_MS = 25000;
22+
const READY_TIMEOUT = 30000;
23+
const LABEL_TIMEOUT = 30000;
24+
25+
function getDetoxLaunchArgs() {
26+
if (device.getPlatform() !== 'android') {
27+
return {};
28+
}
29+
return { launchArgs: { detoxEnableSynchronization: '0' } };
30+
}
31+
32+
async function relaunchAppPreservingData() {
33+
await device.launchApp({ newInstance: true, ...getDetoxLaunchArgs() });
34+
// The native check talks to the same local server; keeping those requests out
35+
// of Detox's idle synchronization is what the other suites do too.
36+
await device.setURLBlacklist([`.*:${LOCAL_UPDATE_PORT}.*`]);
37+
}
38+
39+
async function setForceBoot(enabled: boolean) {
40+
const endpoint = getLocalUpdateEndpoint(device.getPlatform());
41+
// Reached from the test runner (the host), not from the device, so localhost
42+
// is correct even when the app talks to 10.0.2.2.
43+
const hostEndpoint = endpoint.replace('10.0.2.2', '127.0.0.1');
44+
const response = await fetch(`${hostEndpoint}/control/force-boot`, {
45+
method: 'POST',
46+
headers: { 'Content-Type': 'application/json' },
47+
body: JSON.stringify({ enabled }),
48+
});
49+
if (!response.ok) {
50+
throw new Error(`failed to set forceBoot=${enabled}: ${response.status}`);
51+
}
52+
}
53+
54+
async function waitForReady() {
55+
await waitFor(element(by.id('bundle-label')))
56+
.toBeVisible()
57+
.withTimeout(READY_TIMEOUT);
58+
}
59+
60+
async function waitForBundleLabel(label: string) {
61+
await waitFor(element(by.id('bundle-label')))
62+
.toHaveText(`bundleLabel: ${label}`)
63+
.withTimeout(LABEL_TIMEOUT);
64+
}
65+
66+
async function waitForHash(hash: string) {
67+
await waitFor(element(by.id('current-hash')))
68+
.toHaveText(`currentHash: ${hash || '(empty)'}`)
69+
.withTimeout(LABEL_TIMEOUT);
70+
}
71+
72+
describe('Native cold-start check', () => {
73+
beforeAll(async () => {
74+
await setForceBoot(false);
75+
// A fresh install already sits on the packaged bundle, which is the state
76+
// both halves below start from — no reset round-trip needed.
77+
await device.launchApp({ delete: true, ...getDetoxLaunchArgs() });
78+
await device.setURLBlacklist([`.*:${LOCAL_UPDATE_PORT}.*`]);
79+
await waitForReady();
80+
await waitForBundleLabel(LOCAL_UPDATE_LABELS.base);
81+
});
82+
83+
afterAll(async () => {
84+
// Never leave the directive on: the other suites drive activation
85+
// themselves and would race a forced one.
86+
await setForceBoot(false);
87+
});
88+
89+
// Both directions live in one test on purpose: every extra app launch costs
90+
// real wall clock on the iOS simulator, and this suite shares a 40-minute
91+
// job budget with the rest of the e2e.
92+
it('activates only what the server forces, with no JS check involved', async () => {
93+
// Without the directive the round may download, but with automatic checks
94+
// off (checkStrategy: null) it must never activate on its own.
95+
await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS));
96+
await relaunchAppPreservingData();
97+
await waitForReady();
98+
await waitForBundleLabel(LOCAL_UPDATE_LABELS.base);
99+
await waitForHash('');
100+
101+
// Flip the directive before the launch whose round should honor it.
102+
await setForceBoot(true);
103+
await relaunchAppPreservingData();
104+
await waitForReady();
105+
await waitForBundleLabel(LOCAL_UPDATE_LABELS.base);
106+
await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS));
107+
108+
// Turn it off before observing, so the next launch cannot walk further
109+
// along the update chain while the assertions run.
110+
await setForceBoot(false);
111+
await relaunchAppPreservingData();
112+
await waitForReady();
113+
await waitForBundleLabel(LOCAL_UPDATE_LABELS.full);
114+
await waitForHash(LOCAL_UPDATE_HASHES.full);
115+
});
116+
});

Example/e2etest/scripts/local-e2e-server.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ const appKeyToPlatform = Object.fromEntries(
2222
])
2323
);
2424

25+
// Flipped by the /control/force-boot endpoint. When on, every update response
26+
// carries config.forceBoot so the client's native cold-start check activates
27+
// the version it downloads — the brick-rescue path, which by definition has to
28+
// work without any JS check. Off by default so the other suites keep full
29+
// control over when a version becomes active.
30+
let forceBootEnabled = false;
31+
2532
const contentTypes: Record<string, string> = {
2633
'.json': 'application/json; charset=utf-8',
2734
'.ppk': 'application/octet-stream',
@@ -170,6 +177,17 @@ const server = Bun.serve({
170177
});
171178
}
172179

180+
if (url.pathname === '/control/force-boot') {
181+
if (request.method !== 'POST') {
182+
return new Response('method not allowed', { status: 405 });
183+
}
184+
const body = (await request.json().catch(() => ({}))) as {
185+
enabled?: unknown;
186+
};
187+
forceBootEnabled = body.enabled === true;
188+
return json({ forceBoot: forceBootEnabled });
189+
}
190+
173191
if (url.pathname.startsWith('/checkUpdate/')) {
174192
if (request.method !== 'POST') {
175193
return new Response('method not allowed', { status: 405 });
@@ -188,9 +206,16 @@ const server = Bun.serve({
188206
const currentHash = typeof payload.hash === 'string' ? payload.hash : '';
189207
const diffV = typeof payload.diffV === 'number' ? payload.diffV : 0;
190208

191-
return json(
192-
buildUpdateResponse(platform, currentHash, diffV, url.origin)
209+
const response = buildUpdateResponse(
210+
platform,
211+
currentHash,
212+
diffV,
213+
url.origin
193214
);
215+
if (forceBootEnabled && 'update' in response) {
216+
return json({ ...response, config: { forceBoot: true } });
217+
}
218+
return json(response);
194219
}
195220

196221
if (url.pathname.startsWith('/artifacts/')) {

Example/e2etest/scripts/prepare-local-update-artifacts.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ const { diffCommands } = require(path.join(cliRoot, 'lib/exports.js')) as {
118118
diffCommands: DiffCommandRunner;
119119
};
120120

121+
// A cold Metro bundle on a GitHub macOS runner regularly needs well over two
122+
// minutes, so the old 120s cap killed a healthy `pushy bundle` mid-flight
123+
// (spawnSync ETIMEDOUT). This is a stuck-process guard, not a perf budget —
124+
// the step and job timeouts already bound the happy path.
125+
const PUSHY_TIMEOUT_MS = 300_000;
126+
121127
function runPushy(args: string[], cwd: string) {
122128
const cliNodeModules = path.join(cliRoot, 'node_modules');
123129
const projectNodeModules = path.join(projectRoot, 'node_modules');
@@ -135,7 +141,7 @@ function runPushy(args: string[], cwd: string) {
135141
PUSHY_REGISTRY: localRegistry,
136142
RNU_API: localRegistry,
137143
},
138-
timeout: 120_000,
144+
timeout: PUSHY_TIMEOUT_MS,
139145
});
140146

141147
if (result.error) {

README-CN.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,19 @@
2727
7. 支持崩溃回滚,安全可靠,结合健康度监控可及时发现并止损问题版本。
2828
8. meta 信息及开放 API,提供更高扩展性。
2929
9. 提供 **MCP 服务**:把热更新服务接进 Claude Desktop、IDE 或自建 Agent,用自然语言排查"这台设备为什么没收到更新",并可与 GitHub、Sentry、CI 等工具组合定位问题。全程只读、按应用授权([Pushy 文档](https://pushy.reactnative.cn/docs/mcp) / [Cresc 文档](https://cresc.dev/docs/mcp))。
30-
10. 提供付费的专人技术支持。
30+
10. **原生冷启动自愈**:即使热更版本坏到 JS 完全跑不起来(白屏、启动即崩),设备也能在下次启动时由原生侧自动拉到修复版——不需要用户重装,也不需要你发新的应用商店版本(详见下方[原生冷启动检测](#原生冷启动检测))。
31+
11. 提供付费的专人技术支持。
32+
33+
### 原生冷启动检测
34+
35+
自 10.51.0 起,每次冷启动后数秒会在后台线程执行一次**不依赖 app bundle** 的更新检查(下载、打补丁与状态切换全部在原生侧完成)。它的存在只为一件事:**当前热更版本坏到 JS 起不来时,仍有一条能把修复版拉下来的通路**——常规更新流程仍由应用内的 JS 负责,检查结果会被 JS 侧复用,不会重复请求。
36+
37+
几个需要知道的点:
38+
39+
- **不阻塞启动**:延迟数秒、跑在后台线程,成果在**下次启动**生效。
40+
- **是否自动激活取决于你的配置**`updateStrategy``silentAndNow` / `silentAndLater` 且未关闭自动检查(`checkStrategy` 不为 `null`)时,原生侧才会把下载好的版本设为下次启动生效;其余情况只下载,激活权仍在 JS。
41+
- **救砖指令**:控制台可按版本标记「强制启动」,被标记的版本无视上述策略直接在下次启动生效——这是把已被坏版本卡死的设备捞回来的手段。设备本地的崩溃回滚保护仍然优先,已回滚过的版本不会被再装回去。
42+
- **可以关闭**`disableNativeCheck: true`。关闭后每次冷启动少一次后台请求,代价是**放弃上述自愈能力**——被坏热更卡死的设备将无法自动恢复。仅在这次请求本身构成问题时(流量/耗电预算、隐私清单申报、需用户同意后才可联网)才建议关闭。
3143

3244
### Diff 算法对比
3345

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,19 @@ See the docs:
2525
7. Built-in crash rollback keeps updates safe and reliable, and health monitoring helps you catch and stop a bad release early.
2626
8. Meta information and open APIs make the system more extensible.
2727
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)).
28-
10. Paid technical support is available.
28+
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)).
29+
11. Paid technical support is available.
30+
31+
## Native cold-start check
32+
33+
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.
34+
35+
What to know:
36+
37+
- **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.
38+
- **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.
39+
- **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.
40+
- **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).
2941

3042
## Diff Algorithm Comparison
3143

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-native-update",
3-
"version": "10.50.0",
3+
"version": "10.51.0",
44
"description": "react-native hot update",
55
"main": "src/index",
66
"types": "src/index.ts",

0 commit comments

Comments
 (0)