Skip to content

Commit d53e419

Browse files
robingenzclaude
andcommitted
feat: the baseline measured on the demo at fe2d9ed — sizes from builds whose build step runs offline, and cold starts
The release APK grows by 578 KB and the simulator app by 3.7 MB; the cold start grows by 76 ms on the emulator and by nothing measurable on the simulator. The with variant keeps its build step, HOTCODEPUSH_OFFLINE set by the size script and the workflows, so no build asks the API. The first-paint marker is logged at the error level, which an iOS release build keeps, and the first launch after the install is not counted. The React Native demo passes the lifecycle contract on both platforms in the device test. Closes #1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 432fc16 commit d53e419

7 files changed

Lines changed: 73 additions & 43 deletions

File tree

‎.github/workflows/baseline.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ permissions:
99
contents: read
1010

1111
env:
12+
# The demo's build step asks no API: these builds are never shipped.
13+
HOTCODEPUSH_OFFLINE: '1'
1214
JAVA_VERSION: 21
1315

1416
jobs:
@@ -41,6 +43,6 @@ jobs:
4143
working-directory: package
4244
run: npm ci && npm run build && npm pack --pack-destination "$RUNNER_TEMP"
4345
- name: Prepare the demo with the package
44-
run: node package/benchmarks/prepare-demo.mjs demo "$RUNNER_TEMP/with" with "$(ls "$RUNNER_TEMP"/hotcodepush-react-native-code-push-*.tgz)" --no-binary-create
46+
run: node package/benchmarks/prepare-demo.mjs demo "$RUNNER_TEMP/with" with "$(ls "$RUNNER_TEMP"/hotcodepush-react-native-code-push-*.tgz)"
4547
- name: Measure the sizes and compare them with the baseline
4648
run: node package/benchmarks/measure-size.mjs "$RUNNER_TEMP/with" > "$RUNNER_TEMP/sizes.json" && node package/benchmarks/guard-size.mjs package/benchmarks/baseline.json "$RUNNER_TEMP/sizes.json" >> "$GITHUB_STEP_SUMMARY"

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ permissions:
1010
contents: read
1111

1212
env:
13+
# The demo's build step asks no API: these builds are never shipped.
14+
HOTCODEPUSH_OFFLINE: '1'
1315
JAVA_VERSION: 21
1416

1517
# The native code compiles inside an app: the platform jobs install the package from this commit into the demo at
16-
# the commit the baseline names and build it. Debug builds, which bundle no JavaScript, with the build step unwired.
18+
# the commit the baseline names and build it. Debug builds, which bundle no JavaScript and so create no binary.
1719
jobs:
1820
javascript:
1921
name: Lint, typecheck, test and build the JavaScript
@@ -56,7 +58,7 @@ jobs:
5658
working-directory: package
5759
run: npm ci && npm run build && npm pack --pack-destination "$RUNNER_TEMP"
5860
- name: Prepare the demo with the package
59-
run: node package/benchmarks/prepare-demo.mjs demo "$RUNNER_TEMP/with" with "$(ls "$RUNNER_TEMP"/hotcodepush-react-native-code-push-*.tgz)" --no-binary-create
61+
run: node package/benchmarks/prepare-demo.mjs demo "$RUNNER_TEMP/with" with "$(ls "$RUNNER_TEMP"/hotcodepush-react-native-code-push-*.tgz)"
6062
- run: ./gradlew assembleDebug --console=plain
6163
working-directory: ${{ runner.temp }}/with/android
6264
ios:
@@ -87,7 +89,7 @@ jobs:
8789
working-directory: package
8890
run: npm ci && npm run build && npm pack --pack-destination "$RUNNER_TEMP"
8991
- name: Prepare the demo with the package
90-
run: node package/benchmarks/prepare-demo.mjs demo "$RUNNER_TEMP/with" with "$(ls "$RUNNER_TEMP"/hotcodepush-react-native-code-push-*.tgz)" --no-binary-create
92+
run: node package/benchmarks/prepare-demo.mjs demo "$RUNNER_TEMP/with" with "$(ls "$RUNNER_TEMP"/hotcodepush-react-native-code-push-*.tgz)"
9193
- run: xcodebuild -workspace HotCodePushDemo.xcworkspace -scheme HotCodePushDemo -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO -quiet build
9294
working-directory: ${{ runner.temp }}/with/ios
9395
preview:

‎benchmarks/README.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,31 @@ What the package adds to an app, measured on the demo app and guarded from then
1111
| Cold start added, Android | from the activity's start (`ActivityTaskManager: START` in logcat) to the first paint, on the Pixel_9_Pro emulator; the median of five cold launches with the package minus the median without |
1212
| Cold start added, iOS | from the launch call to the first paint, on an iPhone simulator; the same medians |
1313

14-
The first paint is the `[baseline] first paint` line both variants log on their first animation frame, read from the JavaScript console: logcat on Android, the unified log on iOS.
14+
The first paint is the `[baseline] first paint` line both variants log on their first animation frame, at the error level, since an iOS release build drops what the console logs below it; it is read from logcat on Android and from the unified log on iOS.
15+
The first launch after the install is not counted, since the system verifies and compiles the app on it.
1516
Both numbers come from release builds, since a debug build asks Metro for its JavaScript and the SDK stays off in it.
1617
The "without" variant is the demo with the package and every line `init` wired removed — the build step in Xcode and Gradle, the bundle the two apps ask the SDK for, the pinned pod — and the screen swapped for the same screen with nothing behind it.
1718

18-
The sizes are measured with the build step unwired in the "with" variant too, `--no-binary-create`: the step needs a login, the resource file it writes weighs a few kilobytes against a binary of tens of megabytes, and the guard in CI holds no credential.
19-
The cold starts are measured with the build step in place, against a stack the CLI is logged in to, since an app without its resource file starts no SDK.
19+
The sizes are measured on builds whose build step runs offline, `HOTCODEPUSH_OFFLINE=1`, which the size script sets: `binary create` writes the resource file, creates no binary and asks no account, so the guard in CI holds no credential.
20+
The cold starts are measured on builds made against a stack the CLI is logged in to, since a build without a channel checks for nothing at its start.
2021

2122
## Where the bytes sit
2223

2324
On the current baseline the Android release APK grows by about 578 KB.
24-
About 310 KB of it is `libappmodules.so`, the Turbo Module's generated C++ once for each of the four ABIs the demo ships, of which an app bundle delivers one; about 270 KB is `classes2.dex`, the package's code and the shared core's, with OkHttp 5 and Okio in place of the OkHttp 4 React Native brings; the rest is the package's JavaScript in the bundle.
25-
The simulator app grows by about 3.6 MB, nearly all of it in the `HotCodePushDemo` binary: the module and the core are linked statically, and a simulator build is a two-slice fat binary, so a device build carries about half of that; the rest is the core's privacy-manifest bundle and about 12 KB of JavaScript.
25+
About 310 KB of it is `libappmodules.so`, the Turbo Module's generated C++ once for each of the four ABIs the demo ships, of which an app bundle delivers one; about 270 KB is `classes2.dex`, the package's code and the shared core's, with OkHttp 5 and Okio in place of the OkHttp 4 React Native brings; the rest is the package's JavaScript in the bundle and the resource file.
26+
The simulator app grows by about 3.7 MB, nearly all of it in the `HotCodePushDemo` binary: the module and the core are linked statically, and a simulator build is a two-slice fat binary, so a device build carries about half of that; the rest is the core's privacy-manifest bundle, about 12 KB of JavaScript and the resource file.
2627

2728
## Running it
2829

2930
```sh
3031
npm run build && npm pack --pack-destination /tmp
31-
node benchmarks/prepare-demo.mjs ../react-native-code-push-demo /tmp/baseline/with with /tmp/hotcodepush-react-native-code-push-0.0.0.tgz --no-binary-create
32+
node benchmarks/prepare-demo.mjs ../react-native-code-push-demo /tmp/baseline/with with /tmp/hotcodepush-react-native-code-push-0.0.0.tgz
3233
node benchmarks/prepare-demo.mjs ../react-native-code-push-demo /tmp/baseline/without without
3334
node benchmarks/measure-size.mjs /tmp/baseline/with
3435
node benchmarks/measure-size.mjs /tmp/baseline/without
3536
```
3637

37-
For the cold starts, prepare the "with" variant again without `--no-binary-create`, with `HOTCODEPUSH_TOKEN` set and `hotcodepush.json` naming an app of that account, then:
38+
For the cold starts, set `HOTCODEPUSH_TOKEN` and let the "with" variant's `hotcodepush.json` name an app of that account, then:
3839

3940
```sh
4041
node benchmarks/measure-cold-start.mjs /tmp/baseline/with --android emulator-5554 --ios <udid>

‎benchmarks/baseline.json‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,43 @@
11
{
2-
"measuredAt": "2026-10-03",
2+
"measuredAt": "2026-10-04",
33
"package": {
4-
"commit": "86af7a05ebf8bfac615f21af8e683f3b8f340342"
4+
"commit": "432fc160bb6bc957db884b8009ba46114baac6d0"
55
},
66
"demo": {
7-
"commit": "f81de286cfe01f65d67f020ff408078b686cba86"
7+
"commit": "fe2d9ed064e2bd912f0c1a77731353944aab6c50"
88
},
99
"android": {
1010
"releaseApkBytes": {
11-
"with": 47543773,
11+
"with": 47544434,
1212
"without": 46966076,
13-
"added": 577697
13+
"added": 578358
14+
},
15+
"coldStartMs": {
16+
"with": 663,
17+
"without": 587,
18+
"added": 76,
19+
"device": "Pixel_9_Pro emulator, headless with the host GPU, Android 17 (API 37), 16 KB pages",
20+
"runsMs": {
21+
"with": [643, 690, 663, 644, 678],
22+
"without": [595, 589, 587, 567, 532]
23+
}
1424
}
1525
},
1626
"ios": {
1727
"simulatorAppBytes": {
18-
"with": 40884443,
19-
"without": 37248622,
20-
"added": 3635821
28+
"with": 40904188,
29+
"without": 37248618,
30+
"added": 3655570
31+
},
32+
"coldStartMs": {
33+
"with": 463,
34+
"without": 463,
35+
"added": 0,
36+
"device": "iPhone 17 Pro simulator, iOS 26.5",
37+
"runsMs": {
38+
"with": [463, 467, 463, 473, 454],
39+
"without": [463, 456, 447, 483, 473]
40+
}
2141
}
2242
}
2343
}

‎benchmarks/measure-cold-start.mjs‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// Cold-start milliseconds of a prepared demo variant: from the launch to the first paint — the
33
// `[baseline] first paint` console line the demo logs on its first animation frame — median of N
44
// cold launches on the Pixel_9_Pro emulator and the iPhone simulator, both on release builds, since
5-
// a debug build asks Metro for its JavaScript. Prints JSON with every run.
5+
// a debug build asks Metro for its JavaScript. The first launch after the install is not counted:
6+
// the system verifies and compiles the app on it. Prints JSON with every counted run.
67
import { execFileSync, spawn } from 'node:child_process';
78
import { join } from 'node:path';
89

@@ -41,7 +42,7 @@ async function measureAndroid() {
4142
});
4243
adb('install', '-r', apk);
4344
const samples = [];
44-
for (let run = 0; run < runs; run++) {
45+
for (let run = 0; run <= runs; run++) {
4546
adb('shell', 'am', 'force-stop', bundleId);
4647
await waitFor(() => adb('shell', 'pidof', bundleId).trim() === '');
4748
adb('logcat', '-c');
@@ -61,7 +62,7 @@ async function measureAndroid() {
6162
const painted = epochOf(lines.find(line => line.includes(marker)));
6263
samples.push(Math.round(painted - started));
6364
}
64-
return summarize(samples);
65+
return summarize(samples.slice(1));
6566
}
6667

6768
async function measureIos() {
@@ -93,7 +94,7 @@ async function measureIos() {
9394
join(derivedData, `Build/Products/Release-iphonesimulator/${appName}.app`),
9495
);
9596
const samples = [];
96-
for (let run = 0; run < runs; run++) {
97+
for (let run = 0; run <= runs; run++) {
9798
simctl('terminate', iosUdid, bundleId);
9899
await sleep(1500);
99100
// React Native writes the JavaScript console to the unified log, which the stream is listening to before the launch.
@@ -110,7 +111,7 @@ async function measureIos() {
110111
]);
111112
let paintedAt = null;
112113
log.stdout.on('data', chunk => {
113-
if (paintedAt === null && String(chunk).includes(marker)) {
114+
if (paintedAt === null && hasAppLine(String(chunk))) {
114115
paintedAt = Date.now();
115116
}
116117
});
@@ -122,7 +123,14 @@ async function measureIos() {
122123
samples.push(paintedAt - launchedAt);
123124
}
124125
simctl('terminate', iosUdid, bundleId);
125-
return summarize(samples);
126+
return summarize(samples.slice(1));
127+
}
128+
129+
// The stream opens with a line that repeats its own predicate, the marker in it; the app's lines name the process.
130+
function hasAppLine(output) {
131+
return output
132+
.split('\n')
133+
.some(line => line.includes(marker) && line.includes(`${appName}[`));
126134
}
127135

128136
function summarize(samples) {

‎benchmarks/measure-size.mjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ if (!target) {
1111
process.exit(2);
1212
}
1313

14+
// The build step runs offline: it writes the resource file, creates no binary and asks no account.
15+
const env = { ...process.env, HOTCODEPUSH_OFFLINE: '1' };
1416
const sizes = {};
1517
execFileSync('./gradlew', ['assembleRelease', '--console=plain', '-q'], {
1618
cwd: join(target, 'android'),
19+
env,
1720
stdio: ['ignore', 'ignore', 'inherit'],
1821
});
1922
sizes.android = {
@@ -42,7 +45,7 @@ if (!flags.includes('--android-only')) {
4245
'-quiet',
4346
'build',
4447
],
45-
{ stdio: ['ignore', 'ignore', 'inherit'] },
48+
{ env, stdio: ['ignore', 'ignore', 'inherit'] },
4649
);
4750
sizes.ios = {
4851
simulatorAppBytes: directorySize(

‎benchmarks/prepare-demo.mjs‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,19 @@
22
// Copies the demo app into a scratch directory as one of the two variants the baseline compares:
33
// `with` installs the package tarball, `without` removes the package and every line `init` wired
44
// and swaps the screen for the same screen with nothing behind it. Both log the first paint.
5-
// `--no-binary-create` unwires the build step of the `with` variant, so its release build needs no login:
6-
// the resource file it would write weighs nothing against the binary, and the sizes are measured without it.
75
import { execFileSync } from 'node:child_process';
86
import { cpSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
97
import { join } from 'node:path';
108

11-
const [source, target, variant, ...rest] = process.argv.slice(2);
12-
const tarball = rest.find(argument => !argument.startsWith('--'));
13-
const isBinaryCreateUnwired =
14-
variant === 'without' || rest.includes('--no-binary-create');
9+
const [source, target, variant, tarball] = process.argv.slice(2);
1510
if (
1611
!source ||
1712
!target ||
1813
!['with', 'without'].includes(variant) ||
1914
(variant === 'with' && !tarball)
2015
) {
2116
console.error(
22-
'usage: prepare-demo.mjs <demo> <target> with <package.tgz> [--no-binary-create] | without',
17+
'usage: prepare-demo.mjs <demo> <target> with <package.tgz> | without',
2318
);
2419
process.exit(2);
2520
}
@@ -39,17 +34,9 @@ cpSync(source, target, {
3934
filter: path => !excluded.has(path.split('/').pop()),
4035
});
4136

37+
// At the error level: an iOS release build drops what the console logs below it.
4238
const firstPaintMarker =
43-
"requestAnimationFrame(() => console.log('[baseline] first paint'));";
44-
if (isBinaryCreateUnwired) {
45-
editFile('android/app/build.gradle', text =>
46-
removeLines(text, 'hotcodepush.gradle'),
47-
);
48-
editFile(
49-
'ios/HotCodePushDemo.xcodeproj/project.pbxproj',
50-
removeBinaryCreatePhase,
51-
);
52-
}
39+
"requestAnimationFrame(() => console.error('[baseline] first paint'));";
5340
if (variant === 'with') {
5441
editFile('App.tsx', text =>
5542
text.replace(
@@ -60,6 +47,13 @@ if (variant === 'with') {
6047
run('npm', ['install', tarball, '--no-audit', '--no-fund']);
6148
} else {
6249
run('npm', ['uninstall', PACKAGE_NAME, '--no-audit', '--no-fund']);
50+
editFile('android/app/build.gradle', text =>
51+
removeLines(text, 'hotcodepush.gradle'),
52+
);
53+
editFile(
54+
'ios/HotCodePushDemo.xcodeproj/project.pbxproj',
55+
removeBinaryCreatePhase,
56+
);
6357
editFile('ios/HotCodePushDemo/AppDelegate.swift', text =>
6458
removeLines(text, 'import HotcodepushReactNativeCodePush').replace(
6559
'HotCodePush.bundleURL()',

0 commit comments

Comments
 (0)