From b811e9823234bcfc566967bdee9f4d6f665173c7 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 19 Jul 2026 13:21:47 -0400 Subject: [PATCH 01/32] demo: Add cache GC measurement harnesses across Node, browser, and Android Establish opt-in, provenance-verified baselines for data-client cache GC interaction cost before changing GCPolicy behavior. Co-authored-by: Cursor --- .circleci/config.yml | 1 + examples/benchmark-native/.eslintrc.js | 4 + examples/benchmark-native/.gitignore | 85 + examples/benchmark-native/.prettierrc.js | 5 + examples/benchmark-native/.watchmanconfig | 1 + examples/benchmark-native/App.tsx | 199 ++ examples/benchmark-native/README.md | 101 + .../__tests__/build-identity.test.ts | 127 + .../__tests__/frame-semantics.test.ts | 55 + .../__tests__/gcHarness.test.ts | 91 + .../__tests__/rafCollectSequence.test.ts | 77 + .../__tests__/readJsHeap.test.ts | 43 + .../__tests__/scenario-and-frames.test.ts | 100 + .../__tests__/summarize.test.ts | 86 + .../__tests__/validate-config.test.ts | 139 + .../benchmark-native/android/app/build.gradle | 118 + .../android/app/debug.keystore | Bin 0 -> 2257 bytes .../android/app/proguard-rules.pro | 10 + .../android/app/src/main/AndroidManifest.xml | 27 + .../benchmarknative/BenchNativeModule.kt | 385 +++ .../benchmarknative/BenchNativePackage.kt | 17 + .../benchmarknative/MainActivity.kt | 30 + .../benchmarknative/MainApplication.kt | 26 + .../res/drawable/rn_edit_text_material.xml | 37 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3056 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5024 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2096 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2858 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4569 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7098 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6464 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 10676 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9250 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15523 bytes .../app/src/main/res/values/strings.xml | 3 + .../app/src/main/res/values/styles.xml | 9 + .../benchmark-native/android/build.gradle | 21 + .../android/gradle.properties | 44 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/benchmark-native/android/gradlew | 248 ++ examples/benchmark-native/android/gradlew.bat | 98 + .../benchmark-native/android/settings.gradle | 6 + examples/benchmark-native/app.json | 4 + examples/benchmark-native/babel.config.js | 3 + examples/benchmark-native/index.js | 9 + examples/benchmark-native/jest.config.js | 5 + examples/benchmark-native/metro.config.js | 23 + examples/benchmark-native/package.json | 44 + .../scripts/build-android-release.sh | 28 + .../scripts/build-identity.cjs | 101 + .../scripts/build-manifest.cjs | 245 ++ .../scripts/collect-report.sh | 211 ++ .../benchmark-native/scripts/run-matrix.sh | 94 + .../scripts/validate-config.sh | 55 + examples/benchmark-native/src/BenchNative.ts | 77 + examples/benchmark-native/src/frames.ts | 167 ++ examples/benchmark-native/src/gcHarness.ts | 382 +++ examples/benchmark-native/src/globals.d.ts | 17 + examples/benchmark-native/src/measure.ts | 226 ++ .../src/rafCollectSequence.ts | 169 ++ examples/benchmark-native/src/report.ts | 246 ++ .../benchmark-native/src/runOrchestration.ts | 94 + examples/benchmark-native/src/scenario.ts | 106 + examples/benchmark-native/src/types.ts | 189 ++ .../benchmark-native/src/validateConfig.ts | 182 ++ examples/benchmark-native/tsconfig.json | 12 + examples/benchmark-react/.gitignore | 2 + examples/benchmark-react/AGENTS.md | 6 +- examples/benchmark-react/README.md | 31 +- .../benchmark-react/bench/build-manifest.ts | 277 ++ .../bench/gc-interaction-metrics.test.ts | 291 ++ .../bench/gc-provenance.test.ts | 125 + examples/benchmark-react/bench/gc-report.ts | 273 ++ examples/benchmark-react/bench/runner.ts | 367 ++- examples/benchmark-react/bench/scenarios.ts | 69 +- examples/benchmark-react/bench/validate.ts | 182 ++ examples/benchmark-react/package.json | 7 +- .../src/data-client/gcBrowserHarness.ts | 548 ++++ .../src/data-client/gcInteractionMetrics.ts | 169 ++ .../src/data-client/gcInteractionProbe.ts | 235 ++ .../benchmark-react/src/data-client/index.tsx | 34 +- examples/benchmark-react/src/shared/types.ts | 91 +- examples/benchmark/README.md | 104 +- examples/benchmark/gc-build-manifest.js | 502 ++++ examples/benchmark/gc-policy-scenarios.js | 548 ++++ examples/benchmark/gc-policy.js | 401 +++ examples/benchmark/package.json | 4 +- package.json | 1 + plans/garbage-collection.md | 130 +- yarn.lock | 2418 ++++++++++++++++- 91 files changed, 11558 insertions(+), 146 deletions(-) create mode 100644 examples/benchmark-native/.eslintrc.js create mode 100644 examples/benchmark-native/.gitignore create mode 100644 examples/benchmark-native/.prettierrc.js create mode 100644 examples/benchmark-native/.watchmanconfig create mode 100644 examples/benchmark-native/App.tsx create mode 100644 examples/benchmark-native/README.md create mode 100644 examples/benchmark-native/__tests__/build-identity.test.ts create mode 100644 examples/benchmark-native/__tests__/frame-semantics.test.ts create mode 100644 examples/benchmark-native/__tests__/gcHarness.test.ts create mode 100644 examples/benchmark-native/__tests__/rafCollectSequence.test.ts create mode 100644 examples/benchmark-native/__tests__/readJsHeap.test.ts create mode 100644 examples/benchmark-native/__tests__/scenario-and-frames.test.ts create mode 100644 examples/benchmark-native/__tests__/summarize.test.ts create mode 100644 examples/benchmark-native/__tests__/validate-config.test.ts create mode 100644 examples/benchmark-native/android/app/build.gradle create mode 100644 examples/benchmark-native/android/app/debug.keystore create mode 100644 examples/benchmark-native/android/app/proguard-rules.pro create mode 100644 examples/benchmark-native/android/app/src/main/AndroidManifest.xml create mode 100644 examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt create mode 100644 examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativePackage.kt create mode 100644 examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainActivity.kt create mode 100644 examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainApplication.kt create mode 100644 examples/benchmark-native/android/app/src/main/res/drawable/rn_edit_text_material.xml create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 examples/benchmark-native/android/app/src/main/res/values/strings.xml create mode 100644 examples/benchmark-native/android/app/src/main/res/values/styles.xml create mode 100644 examples/benchmark-native/android/build.gradle create mode 100644 examples/benchmark-native/android/gradle.properties create mode 100644 examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 examples/benchmark-native/android/gradlew create mode 100644 examples/benchmark-native/android/gradlew.bat create mode 100644 examples/benchmark-native/android/settings.gradle create mode 100644 examples/benchmark-native/app.json create mode 100644 examples/benchmark-native/babel.config.js create mode 100644 examples/benchmark-native/index.js create mode 100644 examples/benchmark-native/jest.config.js create mode 100644 examples/benchmark-native/metro.config.js create mode 100644 examples/benchmark-native/package.json create mode 100644 examples/benchmark-native/scripts/build-android-release.sh create mode 100644 examples/benchmark-native/scripts/build-identity.cjs create mode 100644 examples/benchmark-native/scripts/build-manifest.cjs create mode 100644 examples/benchmark-native/scripts/collect-report.sh create mode 100644 examples/benchmark-native/scripts/run-matrix.sh create mode 100644 examples/benchmark-native/scripts/validate-config.sh create mode 100644 examples/benchmark-native/src/BenchNative.ts create mode 100644 examples/benchmark-native/src/frames.ts create mode 100644 examples/benchmark-native/src/gcHarness.ts create mode 100644 examples/benchmark-native/src/globals.d.ts create mode 100644 examples/benchmark-native/src/measure.ts create mode 100644 examples/benchmark-native/src/rafCollectSequence.ts create mode 100644 examples/benchmark-native/src/report.ts create mode 100644 examples/benchmark-native/src/runOrchestration.ts create mode 100644 examples/benchmark-native/src/scenario.ts create mode 100644 examples/benchmark-native/src/types.ts create mode 100644 examples/benchmark-native/src/validateConfig.ts create mode 100644 examples/benchmark-native/tsconfig.json create mode 100644 examples/benchmark-react/.gitignore create mode 100644 examples/benchmark-react/bench/build-manifest.ts create mode 100644 examples/benchmark-react/bench/gc-interaction-metrics.test.ts create mode 100644 examples/benchmark-react/bench/gc-provenance.test.ts create mode 100644 examples/benchmark-react/bench/gc-report.ts create mode 100644 examples/benchmark-react/src/data-client/gcBrowserHarness.ts create mode 100644 examples/benchmark-react/src/data-client/gcInteractionMetrics.ts create mode 100644 examples/benchmark-react/src/data-client/gcInteractionProbe.ts create mode 100644 examples/benchmark/gc-build-manifest.js create mode 100644 examples/benchmark/gc-policy-scenarios.js create mode 100644 examples/benchmark/gc-policy.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 6a98ee7dcc55..c4ab1586b7d2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -123,6 +123,7 @@ jobs: - run: name: Add examples/* to yarn workspace command: | + npm pkg delete workspaces[7] npm pkg delete workspaces[6] npm pkg delete workspaces[5] npm pkg delete workspaces[4] diff --git a/examples/benchmark-native/.eslintrc.js b/examples/benchmark-native/.eslintrc.js new file mode 100644 index 000000000000..187894b6af25 --- /dev/null +++ b/examples/benchmark-native/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: '@react-native', +}; diff --git a/examples/benchmark-native/.gitignore b/examples/benchmark-native/.gitignore new file mode 100644 index 000000000000..70fbab4ddb4b --- /dev/null +++ b/examples/benchmark-native/.gitignore @@ -0,0 +1,85 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ +*.keystore +!debug.keystore +.kotlin/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + +# testing +/coverage + +# Local measurement artifacts +/artifacts/ +*.hprof + +# Generated BuildManifest asset (prepare step); scripts remain tracked +android/app/src/main/assets/build-manifest.json + +# Ephemeral local JDK used only for CI-less verification (never commit) +/.jdk/ + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/examples/benchmark-native/.prettierrc.js b/examples/benchmark-native/.prettierrc.js new file mode 100644 index 000000000000..06860c8d1b12 --- /dev/null +++ b/examples/benchmark-native/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + arrowParens: 'avoid', + singleQuote: true, + trailingComma: 'all', +}; diff --git a/examples/benchmark-native/.watchmanconfig b/examples/benchmark-native/.watchmanconfig new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/examples/benchmark-native/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/examples/benchmark-native/App.tsx b/examples/benchmark-native/App.tsx new file mode 100644 index 000000000000..e4ac5f7e29ea --- /dev/null +++ b/examples/benchmark-native/App.tsx @@ -0,0 +1,199 @@ +/** + * Minimal Run/status UI + auto-run from Android intent extras. + * Prevents overlapping runs. Sustained visual update during measurement. + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Pressable, StyleSheet, Text, View, StatusBar } from 'react-native'; + +import BenchNative from './src/BenchNative'; +import { setAnimationTickListener } from './src/measure'; +import { executeMeasurement } from './src/runOrchestration'; +import { scenarioId } from './src/scenario'; +import type { GCScenarioConfig, LaunchConfig } from './src/types'; +import { parseLaunchConfig } from './src/validateConfig'; + +const DEFAULT_CONFIG: GCScenarioConfig = { + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', +}; + +export default function App() { + const [status, setStatus] = useState('Ready'); + const [lastId, setLastId] = useState(null); + const [animPhase, setAnimPhase] = useState(0); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [samples, setSamples] = useState(1); + const [label, setLabel] = useState(undefined); + const runningRef = useRef(false); + const autoStarted = useRef(false); + + const runWithConfig = useCallback( + async (cfg: GCScenarioConfig, sampleCount: number, runLabel?: string) => { + if (runningRef.current) { + setStatus('Busy — overlapping runs blocked'); + return; + } + runningRef.current = true; + setLastId(scenarioId(cfg)); + try { + await executeMeasurement({ + config: cfg, + samples: sampleCount, + label: runLabel, + onStatus: setStatus, + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setStatus(`Error: ${message}`); + } finally { + runningRef.current = false; + } + }, + [], + ); + + const runOnce = useCallback(() => { + void runWithConfig(config, samples, label); + }, [config, label, runWithConfig, samples]); + + useEffect(() => { + setAnimationTickListener(t => { + setAnimPhase(t); + }); + return () => setAnimationTickListener(null); + }, []); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const raw = await BenchNative.getLaunchConfig(); + if (cancelled) return; + let parsed: LaunchConfig; + try { + parsed = parseLaunchConfig(raw); + } catch (e) { + setStatus( + `Invalid launch config: ${e instanceof Error ? e.message : String(e)}`, + ); + return; + } + const cfg: GCScenarioConfig = { + candidateKind: parsed.candidateKind, + pattern: parsed.pattern, + count: parsed.count, + control: parsed.control, + }; + setConfig(cfg); + setSamples(parsed.samples); + setLabel(parsed.label); + if (parsed.autoRun && !autoStarted.current) { + autoStarted.current = true; + setTimeout(() => { + if (cancelled) return; + void runWithConfig(cfg, parsed.samples, parsed.label); + }, 50); + } + } catch (e) { + if (!cancelled) { + setStatus( + `Launch config unavailable: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + })(); + return () => { + cancelled = true; + }; + }, [runWithConfig]); + + const pulse = ((animPhase / 16) % 40) / 40; + + return ( + + + GC Bench (Hermes release) + + {config.candidateKind}/{config.pattern}/{config.count}/{config.control} + + {lastId ? {lastId} : null} + {status} + + + + [ + styles.button, + pressed && styles.buttonPressed, + ]} + onPress={runOnce} + accessibilityRole="button" + accessibilityLabel="Run GC benchmark" + > + Run + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + padding: 24, + justifyContent: 'center', + backgroundColor: '#f2f4f7', + }, + title: { + fontSize: 22, + fontWeight: '600', + marginBottom: 8, + color: '#111', + }, + meta: { + fontSize: 14, + color: '#444', + marginBottom: 4, + }, + id: { + fontSize: 12, + color: '#666', + marginBottom: 12, + fontFamily: 'monospace', + }, + status: { + fontSize: 14, + color: '#222', + marginBottom: 24, + }, + anim: { + width: 48, + height: 48, + backgroundColor: '#2a6f97', + marginBottom: 32, + alignSelf: 'center', + }, + button: { + backgroundColor: '#1b4332', + paddingVertical: 14, + paddingHorizontal: 28, + alignSelf: 'flex-start', + }, + buttonPressed: { + opacity: 0.85, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/examples/benchmark-native/README.md b/examples/benchmark-native/README.md new file mode 100644 index 000000000000..d8745358bc50 --- /dev/null +++ b/examples/benchmark-native/README.md @@ -0,0 +1,101 @@ +# Release-Hermes Android GC benchmark + +Private monorepo workspace `example-benchmark-native`: Android-only React Native **0.86** app (React **19.2.3**, Hermes enabled). Measures `@data-client/core` cache GC interaction cost on a physical device in a **release** build. + +This phase is **manual**, not CI. Emulators are functional checks only — not authoritative. + +**APK shape:** only a **single** universal release APK (`app-release.apk`) is supported. Split / multi-APK installs are rejected by the collect script. + +## Prerequisites + +- Node `>= 22.11`, Yarn 4 (repo root) +- JDK compatible with the RN 0.86 Android Gradle Plugin +- Android SDK (`ANDROID_HOME`), platform tools (`adb`), and exactly one device (or set `ANDROID_SERIAL`) +- Built `@data-client/core` +- Physical mid-range Android device for authoritative runs + +## Install / build + +Authoritative release path (prepare BuildManifest → Gradle → sidecar): + +```bash +yarn install +yarn workspace @data-client/core run build:lib +yarn workspace example-benchmark-native typecheck +yarn workspace example-benchmark-native lint +yarn workspace example-benchmark-native test +yarn workspace example-benchmark-native build:android:release +``` + +This writes: + +- embedded asset `android/app/src/main/assets/build-manifest.json` (generated; gitignored) +- `android/app/build/outputs/apk/release/app-release.apk` +- `artifacts/build-sidecar.json` (APK sha256 + buildId; **collection authority**) + +## Provenance + +- **`buildId`** — source-build identity: deterministic `sha256` over `schemaVersion`, `gitCommit`, `gitDirty`, `sourceDigest` (not APK bytes, not timestamps). +- **`apkSha256`** — artifact identity: hash of the single `app-release.apk` bytes (sidecar + installed APK must match). +- **`sidecarId`** — artifact-aware: `sha256(buildId ∥ apkSha256)`. +- **Sidecar is authority**, not the live checkout. Collection verifies sidecar identity, source digest, local + installed APK sha256, and report embedded `buildId`. +- Intent `label` is optional metadata only. Commit is fingerprinting input to `buildId`, not a separate launch authority. +- Tampered metadata or a forged `buildId` fails `verifyManifestBuildId` / collect. + +## Scenario IDs + +`android/{entity|endpoint|mixed}/{unique|duplicate}/{1000|10000|100000}/interaction/{gc|no-gc}` + +- `duplicate` is entity-only; invalid axes fail validation (host + in-app) + +## Host collection + +```bash +# exactly one device, or: +# ANDROID_SERIAL=… CANDIDATE_KIND=entity … +CANDIDATE_KIND=entity PATTERN=unique COUNT=1000 CONTROL=gc SAMPLES=5 \ + yarn workspace example-benchmark-native collect +``` + +Authoritative **100k**: + +```bash +CANDIDATE_KIND=entity PATTERN=unique COUNT=100000 CONTROL=gc SAMPLES=5 \ + yarn workspace example-benchmark-native collect +CANDIDATE_KIND=entity PATTERN=unique COUNT=100000 CONTROL=no-gc SAMPLES=5 \ + yarn workspace example-benchmark-native collect +``` + +### Matrix + +Full canonical matrix via loops; 100k skipped unless `FULL=1` or filter selects them. + +```bash +SAMPLES=3 yarn workspace example-benchmark-native matrix +SAMPLES=5 yarn workspace example-benchmark-native matrix entity/unique/100000 +FULL=1 SAMPLES=5 yarn workspace example-benchmark-native matrix +``` + +## UI frame semantics + +| Source | Quantity | Missed-frame math | +| --- | --- | --- | +| FrameMetrics | `TOTAL_DURATION` (duration) | `max(0, ceil(duration/period) − 1)` | +| Choreographer | frame-time delta (interval) | `max(0, round(interval/period) − 1)` | +| JS rAF | timestamp intervals | same as Choreographer | + +Every sample records `uiCaptureSource`, frame count, max/total duration aggregates, missed frames, and refresh period/rate. Zero frames or invalid refresh period fail the run. Capture stop is idempotent; JS tears down in `finally`. + +## Memory caveat + +Without forced Hermes GC, JS heap and process PSS/RSS are observational/noisy — not a memory gate alone. Compare repeated `gc` vs `no-gc`. No `System.gc()`. + +## Verification without a device + +```bash +bash -n scripts/*.sh +yarn workspace example-benchmark-native test +yarn workspace example-benchmark-native typecheck +``` + +On-device install/start/pull remains unverified until a device is attached. diff --git a/examples/benchmark-native/__tests__/build-identity.test.ts b/examples/benchmark-native/__tests__/build-identity.test.ts new file mode 100644 index 000000000000..221196859428 --- /dev/null +++ b/examples/benchmark-native/__tests__/build-identity.test.ts @@ -0,0 +1,127 @@ +/** + * Deterministic buildId / sidecarId identity (source vs artifact). + */ +const { + computeBuildId, + computeSidecarId, + verifyManifestBuildId, + verifySidecarIdentity, +} = require('../scripts/build-identity.cjs'); + +const DIGEST_A = 'a'.repeat(64); +const DIGEST_B = 'b'.repeat(64); +const APK_A = 'c'.repeat(64); +const APK_B = 'd'.repeat(64); + +describe('computeBuildId', () => { + it('is deterministic over schemaVersion, gitCommit, gitDirty, sourceDigest', () => { + const a = computeBuildId({ + schemaVersion: 1, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }); + const b = computeBuildId({ + schemaVersion: 1, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + }); + + it('changes when any canonical input changes', () => { + const base = { + schemaVersion: 1 as const, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }; + const id = computeBuildId(base); + expect(computeBuildId({ ...base, gitCommit: 'xyz' })).not.toBe(id); + expect(computeBuildId({ ...base, gitDirty: true })).not.toBe(id); + expect(computeBuildId({ ...base, sourceDigest: DIGEST_B })).not.toBe(id); + }); +}); + +describe('verifyManifestBuildId', () => { + it('accepts a consistent manifest', () => { + const inputs = { + schemaVersion: 1, + gitCommit: 'deadbeef', + gitDirty: true, + sourceDigest: DIGEST_A, + }; + const buildId = computeBuildId(inputs); + expect(verifyManifestBuildId({ ...inputs, buildId })).toBe(buildId); + }); + + it('rejects forged buildId', () => { + expect(() => + verifyManifestBuildId({ + schemaVersion: 1, + buildId: '0'.repeat(64), + gitCommit: 'deadbeef', + gitDirty: false, + sourceDigest: DIGEST_A, + }), + ).toThrow(/buildId mismatch/); + }); + + it('rejects tampered metadata that no longer matches buildId', () => { + const inputs = { + schemaVersion: 1, + gitCommit: 'deadbeef', + gitDirty: false, + sourceDigest: DIGEST_A, + }; + const buildId = computeBuildId(inputs); + expect(() => + verifyManifestBuildId({ + ...inputs, + buildId, + sourceDigest: DIGEST_B, + }), + ).toThrow(/buildId mismatch/); + }); +}); + +describe('computeSidecarId / verifySidecarIdentity', () => { + it('binds source buildId to artifact apkSha256', () => { + const buildId = computeBuildId({ + schemaVersion: 1, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }); + const id1 = computeSidecarId({ buildId, apkSha256: APK_A }); + const id2 = computeSidecarId({ buildId, apkSha256: APK_B }); + expect(id1).not.toBe(id2); + // Same APK + same source → same sidecarId + expect(computeSidecarId({ buildId, apkSha256: APK_A })).toBe(id1); + }); + + it('rejects tampered sidecarId while preserving buildId checks', () => { + const inputs = { + schemaVersion: 1, + gitCommit: 'abc', + gitDirty: false, + sourceDigest: DIGEST_A, + }; + const buildId = computeBuildId(inputs); + const apkSha256 = APK_A; + const sidecarId = computeSidecarId({ buildId, apkSha256 }); + expect( + verifySidecarIdentity({ ...inputs, buildId, apkSha256, sidecarId }), + ).toBe(sidecarId); + expect(() => + verifySidecarIdentity({ + ...inputs, + buildId, + apkSha256, + sidecarId: '0'.repeat(64), + }), + ).toThrow(/sidecarId mismatch/); + }); +}); diff --git a/examples/benchmark-native/__tests__/frame-semantics.test.ts b/examples/benchmark-native/__tests__/frame-semantics.test.ts new file mode 100644 index 000000000000..1919c854ae04 --- /dev/null +++ b/examples/benchmark-native/__tests__/frame-semantics.test.ts @@ -0,0 +1,55 @@ +import { + missedFramesFromDurationMs, + missedFramesFromIntervalMs, + validateUiFrameCapture, +} from '../src/frames'; + +const P = 16.67; + +describe('missedFramesFromDurationMs (FrameMetrics)', () => { + it('boundary table at .99x 1x 1.01x 2x 2.01x', () => { + expect(missedFramesFromDurationMs(0.99 * P, P)).toBe(0); + expect(missedFramesFromDurationMs(1.0 * P, P)).toBe(0); + // just over one period → ceil → 2 periods → 1 miss + expect(missedFramesFromDurationMs(1.01 * P, P)).toBe(1); + expect(missedFramesFromDurationMs(2.0 * P, P)).toBe(1); + expect(missedFramesFromDurationMs(2.01 * P, P)).toBe(2); + }); +}); + +describe('missedFramesFromIntervalMs (Choreographer / JS rAF)', () => { + it('boundary table at .99x 1x 1.01x 2x 2.01x', () => { + expect(missedFramesFromIntervalMs(0.99 * P, P)).toBe(0); + expect(missedFramesFromIntervalMs(1.0 * P, P)).toBe(0); + // 1.01× still rounds to 1 period → 0 miss + expect(missedFramesFromIntervalMs(1.01 * P, P)).toBe(0); + expect(missedFramesFromIntervalMs(2.0 * P, P)).toBe(1); + // 2.01× rounds to 2 → 1 miss (nearest-period) + expect(missedFramesFromIntervalMs(2.01 * P, P)).toBe(1); + }); +}); + +describe('validateUiFrameCapture', () => { + const ok = { + source: 'FrameMetrics' as const, + frameCount: 3, + maxFrameDurationMs: 20, + totalFrameDurationMs: 50, + missedFrames: 0, + refreshPeriodMs: P, + refreshRateHz: 60, + }; + + it('accepts a valid aggregate', () => { + expect(() => validateUiFrameCapture(ok)).not.toThrow(); + }); + + it('rejects invalid refresh period and zero frames', () => { + expect(() => validateUiFrameCapture({ ...ok, refreshPeriodMs: 0 })).toThrow( + /refreshPeriodMs/, + ); + expect(() => validateUiFrameCapture({ ...ok, frameCount: 0 })).toThrow( + /insufficient ui frames/, + ); + }); +}); diff --git a/examples/benchmark-native/__tests__/gcHarness.test.ts b/examples/benchmark-native/__tests__/gcHarness.test.ts new file mode 100644 index 000000000000..79043478b4b4 --- /dev/null +++ b/examples/benchmark-native/__tests__/gcHarness.test.ts @@ -0,0 +1,91 @@ +/** + * Real harness tests against createHarness / queue / reducer (not reimplemented math). + */ +import { + countRemaining, + createHarness, + validateMeasurement, + GC, +} from '../src/gcHarness'; +import type { GCScenarioConfig } from '../src/types'; + +const SMALL = 32; + +function runSweepSample(config: GCScenarioConfig) { + const harness = createHarness(config); + const { policy, expected } = harness; + + expect(policy.queueEntries).toBe(expected.queueEntries); + expect(expected.uniqueTargets).toBe( + config.pattern === 'duplicate' ? 1 : config.count, + ); + + if (config.control === 'no-gc') { + validateMeasurement(config, harness, { + actionCount: 0, + deletionCount: 0, + queueEntries: expected.queueEntries, + uniqueTargets: expected.uniqueTargets, + actionTargetCount: 0, + }); + harness.dispose(); + return; + } + + policy.sweep(); + const action = harness.getCapturedAction(); + expect(action).not.toBeNull(); + expect(action!.type).toBe(GC); + expect(action!.entities.length).toBe(expected.expectedEntitiesInAction); + expect(action!.endpoints.length).toBe(expected.expectedEndpointsInAction); + + if (config.pattern === 'duplicate') { + const unique = new Set(action!.entities.map(p => `${p.key}:${p.pk}`)); + expect(unique.size).toBe(1); + } + + const remaining = countRemaining(harness.getState(), config, expected); + expect(remaining.entityRemaining).toBe(0); + expect(remaining.endpointRemaining).toBe(0); + expect(policy.queueEntries).toBe(0); + + const deletionCount = remaining.entityDeleted + remaining.endpointDeleted; + validateMeasurement(config, harness, { + actionCount: 1, + deletionCount, + queueEntries: expected.queueEntries, + uniqueTargets: expected.uniqueTargets, + actionTargetCount: + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction, + }); + + harness.dispose(); +} + +describe('createHarness end-to-end', () => { + const uniqueKinds = ['entity', 'endpoint', 'mixed'] as const; + + for (const candidateKind of uniqueKinds) { + for (const control of ['gc', 'no-gc'] as const) { + it(`${candidateKind}/unique/${SMALL}/${control}`, () => { + runSweepSample({ + candidateKind, + pattern: 'unique', + count: SMALL, + control, + }); + }); + } + } + + for (const control of ['gc', 'no-gc'] as const) { + it(`entity/duplicate/${SMALL}/${control}`, () => { + runSweepSample({ + candidateKind: 'entity', + pattern: 'duplicate', + count: SMALL, + control, + }); + }); + } +}); diff --git a/examples/benchmark-native/__tests__/rafCollectSequence.test.ts b/examples/benchmark-native/__tests__/rafCollectSequence.test.ts new file mode 100644 index 000000000000..44ffee9e2547 --- /dev/null +++ b/examples/benchmark-native/__tests__/rafCollectSequence.test.ts @@ -0,0 +1,77 @@ +import { + createManualFrameScheduler, + runRafCollectSequence, +} from '../src/rafCollectSequence'; + +describe('runRafCollectSequence failure handling', () => { + it('rejects promptly when collectWork throws and no-ops queued frames', async () => { + const scheduler = createManualFrameScheduler(16.67); + let terminateCalls = 0; + let collectCalls = 0; + const nowMs = 0; + + const pending = runRafCollectSequence({ + scheduleFrame: scheduler.scheduleFrame, + now: () => nowMs, + scheduleTimeout0: cb => { + // Run macrotask probe synchronously for determinism. + cb(); + }, + postFrames: 2, + collectWork: () => { + collectCalls++; + throw new Error('sweep failed'); + }, + onTerminateError: () => { + terminateCalls++; + }, + }); + + // pre → collect (queues post, then throws) + scheduler.flush(1); // pre + expect(scheduler.pending()).toBe(1); + scheduler.flush(1); // collect — throws after queueing post + + // Promise rejects promptly so outer `finally` can stop animation/native capture. + await expect(pending).rejects.toThrow('sweep failed'); + expect(collectCalls).toBe(1); + expect(terminateCalls).toBe(1); + + // Post frame was queued before throw; flushing it must no-op (no second reject / terminate). + const pendingAfter = scheduler.pending(); + expect(pendingAfter).toBeGreaterThanOrEqual(1); + expect(() => scheduler.flush(5)).not.toThrow(); + expect(terminateCalls).toBe(1); + expect(collectCalls).toBe(1); + }); + + it('resolves with timestamps when collectWork succeeds', async () => { + const scheduler = createManualFrameScheduler(10); + let nowMs = 1000; + + const pending = runRafCollectSequence({ + scheduleFrame: scheduler.scheduleFrame, + now: () => { + nowMs += 1; + return nowMs; + }, + scheduleTimeout0: cb => cb(), + postFrames: 2, + collectWork: () => ({ + totalMs: 12.5, + actionCount: 1, + }), + }); + + // pre, collect, post, post-final → resolve via timeout0 + scheduler.flush(1); + scheduler.flush(1); + scheduler.flush(1); + scheduler.flush(1); + + const result = await pending; + expect(result.totalMs).toBe(12.5); + expect(result.actionCount).toBe(1); + expect(result.frameTimestamps.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/examples/benchmark-native/__tests__/readJsHeap.test.ts b/examples/benchmark-native/__tests__/readJsHeap.test.ts new file mode 100644 index 000000000000..faa630f2679f --- /dev/null +++ b/examples/benchmark-native/__tests__/readJsHeap.test.ts @@ -0,0 +1,43 @@ +import { readJsHeapBytes } from '../src/BenchNative'; + +describe('readJsHeapBytes', () => { + const g = globalThis as typeof globalThis & { + performance?: BenchPerformance; + }; + const original = g.performance; + + afterEach(() => { + Object.defineProperty(g, 'performance', { + value: original, + configurable: true, + writable: true, + }); + }); + + it('returns undefined when performance is absent', () => { + Object.defineProperty(g, 'performance', { + value: undefined, + configurable: true, + writable: true, + }); + expect(readJsHeapBytes()).toBeUndefined(); + }); + + it('returns undefined when memory is absent', () => { + Object.defineProperty(g, 'performance', { + value: { now: () => 0 }, + configurable: true, + writable: true, + }); + expect(readJsHeapBytes()).toBeUndefined(); + }); + + it('returns usedJSHeapSize when exposed', () => { + Object.defineProperty(g, 'performance', { + value: { now: () => 0, memory: { usedJSHeapSize: 12345 } }, + configurable: true, + writable: true, + }); + expect(readJsHeapBytes()).toBe(12345); + }); +}); diff --git a/examples/benchmark-native/__tests__/scenario-and-frames.test.ts b/examples/benchmark-native/__tests__/scenario-and-frames.test.ts new file mode 100644 index 000000000000..0c4ef0f4657a --- /dev/null +++ b/examples/benchmark-native/__tests__/scenario-and-frames.test.ts @@ -0,0 +1,100 @@ +import { + computeMaxInputDelayMs, + excessMissedFramesFromIntervals, + frameIntervalsFromTimestamps, + median, + missedFramesFromTimestamps, +} from '../src/frames'; +import { + splitMixedCount, + scenarioId, + parseScenarioId, + listScenarios, +} from '../src/scenario'; + +describe('scenarioId', () => { + it('builds stable android interaction ids', () => { + expect( + scenarioId({ + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + }), + ).toBe('android/entity/unique/1000/interaction/gc'); + }); + + it('round-trips parseScenarioId', () => { + const id = 'android/mixed/unique/10000/interaction/no-gc'; + expect(scenarioId(parseScenarioId(id))).toBe(id); + }); + + it('lists duplicate only for entity', () => { + const ids = listScenarios().map(scenarioId); + expect(ids.some(id => id.includes('/duplicate/'))).toBe(true); + expect(ids.some(id => id.startsWith('android/endpoint/duplicate'))).toBe( + false, + ); + }); +}); + +describe('splitMixedCount', () => { + it('splits evenly with remainder to entities', () => { + expect(splitMixedCount(1000)).toEqual({ entities: 500, endpoints: 500 }); + expect(splitMixedCount(1001)).toEqual({ entities: 501, endpoints: 500 }); + }); +}); + +describe('excessMissedFramesFromIntervals', () => { + it('counts nearest-period excess stalls', () => { + expect(excessMissedFramesFromIntervals([16.6], 16.6)).toBe(0); + expect(excessMissedFramesFromIntervals([33.2], 16.6)).toBe(1); + // slightly under 2× still rounds to 2 periods → 1 missed + expect(excessMissedFramesFromIntervals([1.98 * 16.6], 16.6)).toBe(1); + }); + + it('returns 0 for non-positive period', () => { + expect(excessMissedFramesFromIntervals([50], 0)).toBe(0); + }); +}); + +describe('missedFramesFromTimestamps', () => { + it('detects a multi-period stall in a pre→collect→post rAF sequence', () => { + const period = 16.67; + // pre @0, collect @period, then post only after ~3 periods (2 missed) + // This is what queueing the next rAF *before* blocking sweep enables. + const timestamps = [ + 0, + period, + period + 3 * period, // collect→post gap spans 3 periods + period + 3 * period + period, + ]; + expect(frameIntervalsFromTimestamps(timestamps)).toEqual([ + period, + 3 * period, + period, + ]); + expect(missedFramesFromTimestamps(timestamps, period)).toBe(2); + }); + + it('reports 0 missed when every interval is one display period', () => { + const period = 16.67; + const timestamps = [0, period, 2 * period, 3 * period]; + expect(missedFramesFromTimestamps(timestamps, period)).toBe(0); + }); +}); + +describe('computeMaxInputDelayMs', () => { + it('is max of timer delay and frame excess (not pointer latency)', () => { + expect(computeMaxInputDelayMs(5, [16.6, 40], 16.6)).toBe( + Math.max(5, 40 - 16.6), + ); + }); +}); + +describe('median', () => { + it('handles even and odd lengths', () => { + expect(median([1, 3, 2])).toBe(2); + expect(median([1, 2, 3, 4])).toBe(2.5); + }); +}); diff --git a/examples/benchmark-native/__tests__/summarize.test.ts b/examples/benchmark-native/__tests__/summarize.test.ts new file mode 100644 index 000000000000..feab5db2f7e7 --- /dev/null +++ b/examples/benchmark-native/__tests__/summarize.test.ts @@ -0,0 +1,86 @@ +import { summarizeGCSamples } from '../src/report'; +import type { GCAndroidMeasurement } from '../src/types'; + +function baseSample( + overrides: Partial = {}, +): GCAndroidMeasurement { + return { + schemaVersion: 1, + totalMs: 10, + sliceDurationsMs: [10], + actionCount: 1, + queueEntries: 1000, + uniqueTargets: 1000, + actionTargetCount: 1000, + deletionCount: 1000, + timerDelayMs: 1, + frameIntervalsMs: [16], + displayPeriodMs: 16, + missedFrames: 0, + maxInputDelayMs: 1, + uiCaptureSource: 'FrameMetrics', + uiFrameCount: 10, + uiMaxFrameDurationMs: 16, + uiTotalFrameDurationMs: 160, + uiMissedFrames: 0, + uiRefreshPeriodMs: 16.67, + uiRefreshRateHz: 60, + ...overrides, + }; +} + +describe('summarizeGCSamples', () => { + it('summarizes UI frame and memory delta fields when present', () => { + const summary = summarizeGCSamples([ + baseSample({ + uiFrameCount: 10, + uiMaxFrameDurationMs: 20, + uiTotalFrameDurationMs: 160, + uiMissedFrames: 1, + processPssBeforeKb: 100, + processPssAfterKb: 90, + processPssDeltaKb: -10, + processRssBeforeKb: 200, + processRssAfterKb: 180, + processRssDeltaKb: -20, + jsHeapBeforeBytes: 1000, + jsHeapAfterBytes: 800, + jsHeapDeltaBytes: -200, + }), + baseSample({ + uiFrameCount: 12, + uiMaxFrameDurationMs: 22, + uiTotalFrameDurationMs: 180, + uiMissedFrames: 0, + processPssBeforeKb: 110, + processPssAfterKb: 95, + processPssDeltaKb: -15, + processRssBeforeKb: 210, + processRssAfterKb: 185, + processRssDeltaKb: -25, + jsHeapBeforeBytes: 1100, + jsHeapAfterBytes: 850, + jsHeapDeltaBytes: -250, + }), + ]); + + expect(summary.uiFrameCount?.median).toBe(11); + expect(summary.uiTotalFrameDurationMs?.min).toBe(160); + expect(summary.uiMaxFrameDurationMs?.max).toBe(22); + expect(summary.uiMissedFrames?.median).toBe(0.5); + expect(summary.processPssBeforeKb?.median).toBe(105); + expect(summary.processPssAfterKb?.median).toBe(92.5); + expect(summary.processPssDeltaKb?.median).toBe(-12.5); + expect(summary.processRssDeltaKb?.median).toBe(-22.5); + expect(summary.jsHeapDeltaBytes?.median).toBe(-225); + }); + + it('omits optional memory summaries when unavailable', () => { + const summary = summarizeGCSamples([baseSample()]); + expect(summary.processPssDeltaKb).toBeUndefined(); + expect(summary.processRssBeforeKb).toBeUndefined(); + expect(summary.jsHeapBeforeBytes).toBeUndefined(); + // UI aggregates are required on every sample + expect(summary.uiFrameCount?.median).toBe(10); + }); +}); diff --git a/examples/benchmark-native/__tests__/validate-config.test.ts b/examples/benchmark-native/__tests__/validate-config.test.ts new file mode 100644 index 000000000000..1428bbfe2b4e --- /dev/null +++ b/examples/benchmark-native/__tests__/validate-config.test.ts @@ -0,0 +1,139 @@ +import { + ConfigValidationError, + MAX_SAMPLES, + parseHostEnvConfig, + parseLaunchConfig, + validateSampleCount, + validateScenarioConfig, +} from '../src/validateConfig'; + +describe('validateScenarioConfig', () => { + it('accepts canonical axes', () => { + expect(() => + validateScenarioConfig({ + candidateKind: 'mixed', + pattern: 'unique', + count: 100000, + control: 'no-gc', + }), + ).not.toThrow(); + }); + + it('rejects non-canonical counts', () => { + expect(() => + validateScenarioConfig({ + candidateKind: 'entity', + pattern: 'unique', + count: 500, + control: 'gc', + }), + ).toThrow(ConfigValidationError); + }); + + it('rejects duplicate with non-entity', () => { + expect(() => + validateScenarioConfig({ + candidateKind: 'endpoint', + pattern: 'duplicate', + count: 1000, + control: 'gc', + }), + ).toThrow(/duplicate pattern only supports/); + }); + + it('rejects invalid enums', () => { + expect(() => + validateScenarioConfig({ + candidateKind: 'widget' as any, + pattern: 'unique', + count: 1000, + control: 'gc', + }), + ).toThrow(/candidateKind/); + expect(() => + validateScenarioConfig({ + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'maybe' as any, + }), + ).toThrow(/control/); + }); +}); + +describe('validateSampleCount', () => { + it('accepts 1..MAX_SAMPLES', () => { + expect(() => validateSampleCount(1)).not.toThrow(); + expect(() => validateSampleCount(MAX_SAMPLES)).not.toThrow(); + }); + + it('rejects 0, negative, and oversized', () => { + expect(() => validateSampleCount(0)).toThrow(ConfigValidationError); + expect(() => validateSampleCount(-1)).toThrow(ConfigValidationError); + expect(() => validateSampleCount(MAX_SAMPLES + 1)).toThrow( + ConfigValidationError, + ); + }); +}); + +describe('parseLaunchConfig', () => { + it('applies defaults then validates', () => { + const parsed = parseLaunchConfig({ autoRun: true }); + expect(parsed).toMatchObject({ + autoRun: true, + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + samples: 1, + }); + expect(parsed.label).toBeUndefined(); + }); + + it('accepts optional label without commit authority', () => { + const parsed = parseLaunchConfig({ + autoRun: false, + label: 'baseline', + count: 1000, + }); + expect(parsed.label).toBe('baseline'); + expect((parsed as { commit?: string }).commit).toBeUndefined(); + }); + + it('fails clearly on invalid intent-like input', () => { + expect(() => parseLaunchConfig({ autoRun: true, count: 999 })).toThrow( + /invalid count/, + ); + expect(() => + parseLaunchConfig({ + autoRun: true, + candidateKind: 'endpoint', + pattern: 'duplicate', + count: 1000, + }), + ).toThrow(/duplicate/); + expect(() => parseLaunchConfig({ autoRun: true, samples: 0 })).toThrow( + /samples/, + ); + }); +}); + +describe('parseHostEnvConfig', () => { + it('parses shell-like strings', () => { + expect( + parseHostEnvConfig({ + candidateKind: 'entity', + pattern: 'duplicate', + count: '100000', + control: 'gc', + samples: '5', + }), + ).toEqual({ + candidateKind: 'entity', + pattern: 'duplicate', + count: 100000, + control: 'gc', + samples: 5, + }); + }); +}); diff --git a/examples/benchmark-native/android/app/build.gradle b/examples/benchmark-native/android/app/build.gradle new file mode 100644 index 000000000000..4f4a4ae2a171 --- /dev/null +++ b/examples/benchmark-native/android/app/build.gradle @@ -0,0 +1,118 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + root = file("../../") + // Monorepo: react-native is hoisted to the workspace root node_modules. + reactNativeDir = file("../../../../node_modules/react-native") + codegenDir = file("../../../../node_modules/@react-native/codegen") + cliFile = file("../../../../node_modules/react-native/cli.js") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized". + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + // + // The command to run when bundling. Default is 'bundle' + // bundleCommand = "ram-bundle" + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // Monorepo: hermesc lives in hermes-compiler (absolute + %OS-BIN% token). + // projectDir is android/app; rootDir is android/ — use projectDir for path math. + hermesCommand = "${new File(projectDir, '../../../../node_modules/hermes-compiler/hermesc').canonicalPath}/%OS-BIN%/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = false + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace "com.dataclient.benchmarknative" + defaultConfig { + applicationId "com.dataclient.benchmarknative" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/examples/benchmark-native/android/app/debug.keystore b/examples/benchmark-native/android/app/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..364e105ed39fbfd62001429a68140672b06ec0de GIT binary patch literal 2257 zcmchYXEfYt8;7T1^dLH$VOTZ%2NOdOH5j5LYLtZ0q7x-V8_6gU5)#7dkq{HTmsfNq zB3ZqcAxeY^G10@?efK?Q&)M(qInVv!xjx+IKEL}p*K@LYvIzo#AZG>st5|P)KF1_Z;y){W{<7K{nl!CPuE z_^(!C(Ol0n8 zK13*rzAtW>(wULKPRYLd7G18F8#1P`V*9`(Poj26eOXYyBVZPno~Cvvhx7vPjAuZo zF?VD!zB~QG(!zbw#qsxT8%BSpqMZ4f70ZPn-3y$L8{EVbbN9$H`B&Z1quk9tgp5FM zuxp3pJ0b8u|3+#5bkJ4SRnCF2l7#DyLYXYY8*?OuAwK4E6J{0N=O3QNVzQ$L#FKkR zi-c@&!nDvezOV$i$Lr}iF$XEcwnybQ6WZrMKuw8gCL^U#D;q3t&HpTbqyD%vG=TeDlzCT~MXUPC|Leb-Uk+ z=vnMd(|>ld?Fh>V8poP;q;;nc@en$|rnP0ytzD&fFkCeUE^kG9Kx4wUh!!rpjwKDP zyw_e|a^x_w3E zP}}@$g>*LLJ4i0`Gx)qltL}@;mDv}D*xR^oeWcWdPkW@Uu)B^X&4W1$p6}ze!zudJ zyiLg@uggoMIArBr*27EZV7djDg@W1MaL+rcZ-lrANJQ%%>u8)ZMWU@R2qtnmG(acP z0d_^!t>}5W zpT`*2NR+0+SpTHb+6Js4b;%LJB;B_-ChhnU5py}iJtku*hm5F0!iql8Hrpcy1aYbT z1*dKC5ua6pMX@@iONI?Hpr%h;&YaXp9n!ND7-=a%BD7v&g zOO41M6EbE24mJ#S$Ui0-brR5ML%@|ndz^)YLMMV1atna{Fw<;TF@>d&F|!Z>8eg>>hkFrV)W+uv=`^F9^e zzzM2*oOjT9%gLoub%(R57p-`TXFe#oh1_{&N-YN z<}artH|m=d8TQuKSWE)Z%puU|g|^^NFwC#N=@dPhasyYjoy(fdEVfKR@cXKHZV-`06HsP`|Ftx;8(YD$fFXumLWbGnu$GMqRncXYY9mwz9$ap zQtfZB^_BeNYITh^hA7+(XNFox5WMeG_LtJ%*Q}$8VKDI_p8^pqX)}NMb`0e|wgF7D zuQACY_Ua<1ri{;Jwt@_1sW9zzdgnyh_O#8y+C;LcZq6=4e^cs6KvmK@$vVpKFGbQ= z$)Eux5C|Fx;Gtmv9^#Y-g@7Rt7*eLp5n!gJmn7&B_L$G?NCN`AP>cXQEz}%F%K;vUs{+l4Q{}eWW;ATe2 zqvXzxoIDy(u;F2q1JH7Sf;{jy_j})F+cKlIOmNfjBGHoG^CN zM|Ho&&X|L-36f}Q-obEACz`sI%2f&k>z5c$2TyTSj~vmO)BW~+N^kt`Jt@R|s!){H ze1_eCrlNaPkJQhL$WG&iRvF*YG=gXd1IyYQ9ew|iYn7r~g!wOnw;@n42>enAxBv*A zEmV*N#sxdicyNM=A4|yaOC5MByts}s_Hpfj|y<6G=o=!3S@eIFKDdpR7|FY>L&Wat&oW&cm&X~ z5Bt>Fcq(fgnvlvLSYg&o6>&fY`ODg4`V^lWWD=%oJ#Kbad2u~! zLECFS*??>|vDsNR&pH=Ze0Eo`sC_G`OjoEKVHY|wmwlX&(XBE<@sx3Hd^gtd-fNwUHsylg06p`U2y_={u}Bc + + + + + + + + + + + + diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt new file mode 100644 index 000000000000..b812926ecf72 --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt @@ -0,0 +1,385 @@ +package com.dataclient.benchmarknative + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.os.Debug +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Choreographer +import android.view.FrameMetrics +import android.view.Window +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import java.io.File +import java.lang.ref.WeakReference +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Aggregate-only native probes: UI frame timing (no per-frame bridge calls), + * process memory snapshots, launch extras, report I/O, and embedded build manifest. + * + * FrameMetrics TOTAL_DURATION is a duration → ceil(duration/period)−1. + * Choreographer deltas are intervals → round(interval/period)−1. + */ +class BenchNativeModule( + private val reactContext: ReactApplicationContext, +) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = NAME + + private val mainHandler = Handler(Looper.getMainLooper()) + private var capturing = false + private var captureSource: String = "none" + private var frameMetricsListener: Window.OnFrameMetricsAvailableListener? = null + private var captureWindowRef: WeakReference? = null + private var choreographerCallback: Choreographer.FrameCallback? = null + private var lastChoreographerNs: Long = 0L + + private var frameCount = 0 + private var maxFrameDurationNs = 0L + private var totalFrameDurationNs = 0L + private var missedFrames = 0 + private var refreshPeriodNs = 16_666_666L + private var refreshRateHz = 60.0 + + override fun invalidate() { + // Tear down listeners on the main thread so activity recreation cannot leak. + mainHandler.post { stopCaptureInternal(idempotent = true) } + super.invalidate() + } + + @ReactMethod + fun getLaunchConfig(promise: Promise) { + try { + val extras = MainActivity.launchExtras + val map = Arguments.createMap() + map.putBoolean("autoRun", extras?.getBoolean("autoRun", false) ?: false) + putExtraString(map, extras, "candidateKind") + putExtraString(map, extras, "pattern") + putExtraString(map, extras, "control") + putExtraString(map, extras, "label") + if (extras != null && extras.containsKey("count")) { + map.putInt("count", extras.getInt("count", 1000)) + } + if (extras != null && extras.containsKey("samples")) { + map.putInt("samples", extras.getInt("samples", 1)) + } + promise.resolve(map) + } catch (e: Exception) { + promise.reject("LAUNCH_CONFIG", e) + } + } + + @ReactMethod + fun getBuildManifest(promise: Promise) { + try { + val json = + reactContext.assets.open(BUILD_MANIFEST_ASSET).bufferedReader().use { it.readText() } + val map = Arguments.createMap() + map.putString("json", json) + promise.resolve(map) + } catch (e: Exception) { + promise.reject( + "BUILD_MANIFEST", + "build-manifest.json missing — run yarn build:android:release (prepare→gradle→finalize)", + e, + ) + } + } + + @ReactMethod + fun getEnvironment(promise: Promise) { + try { + val activity = reactContext.currentActivity + val display = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + activity?.display + } else { + @Suppress("DEPRECATION") + activity?.windowManager?.defaultDisplay + } + val hz = display?.refreshRate?.toDouble() ?: 60.0 + val periodMs = if (hz > 0) 1000.0 / hz else 1000.0 / 60.0 + + val map = Arguments.createMap() + map.putInt("apiLevel", Build.VERSION.SDK_INT) + map.putString("release", Build.VERSION.RELEASE ?: "") + map.putString("manufacturer", Build.MANUFACTURER ?: "") + map.putString("model", Build.MODEL ?: "") + map.putString("device", Build.DEVICE ?: "") + map.putString("brand", Build.BRAND ?: "") + map.putString( + "buildType", + if (BuildConfig.DEBUG) "debug" else "release", + ) + map.putString("applicationId", BuildConfig.APPLICATION_ID) + map.putBoolean("hermesEnabled", isHermesEnabled()) + map.putDouble("refreshRateHz", hz) + map.putDouble("refreshPeriodMs", periodMs) + + val hermesProps = Arguments.createMap() + hermesProps.putString("BuildConfig.IS_HERMES_ENABLED", isHermesEnabled().toString()) + try { + val clazz = Class.forName("com.facebook.react.common.build.ReactBuildConfig") + val field = clazz.getField("IS_HERMES_ENABLED") + hermesProps.putString("ReactBuildConfig.IS_HERMES_ENABLED", field.getBoolean(null).toString()) + } catch (_: Throwable) { + // optional + } + map.putMap("hermesRuntimeProperties", hermesProps) + + promise.resolve(map) + } catch (e: Exception) { + promise.reject("ENVIRONMENT", e) + } + } + + @ReactMethod + fun getMemorySnapshot(promise: Promise) { + try { + val info = Debug.MemoryInfo() + Debug.getMemoryInfo(info) + val map = Arguments.createMap() + map.putInt("totalPssKb", info.totalPss) + map.putInt("totalPrivateDirtyKb", info.totalPrivateDirty) + if (Build.VERSION.SDK_INT >= 34) { + try { + val rssBytes = + android.os.Process::class.java + .getMethod("getRssInBytes") + .invoke(null) as Long + map.putDouble("rssKb", rssBytes / 1024.0) + } catch (_: Throwable) { + // omit rssKb + } + } + val am = reactContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + map.putInt("memoryClassMb", am.memoryClass) + promise.resolve(map) + } catch (e: Exception) { + promise.reject("MEMORY", e) + } + } + + @ReactMethod + fun startUiFrameCapture(promise: Promise) { + mainHandler.post { + try { + if (capturing) { + promise.reject("ALREADY_CAPTURING", "UI frame capture already running") + return@post + } + resetCaptureCounters() + val activity = reactContext.currentActivity + if (activity == null) { + promise.reject("NO_ACTIVITY", "No current activity for frame capture") + return@post + } + updateRefreshPeriod(activity) + if (refreshPeriodNs <= 0L || refreshRateHz <= 0.0) { + promise.reject("INVALID_REFRESH", "refresh period/rate must be positive") + return@post + } + + val window = activity.window + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && window != null) { + val listener = + Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> + val totalNs = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) + recordFrameMetricsDuration(totalNs) + } + window.addOnFrameMetricsAvailableListener(listener, mainHandler) + frameMetricsListener = listener + captureWindowRef = WeakReference(window) + captureSource = "FrameMetrics" + capturing = true + val result = Arguments.createMap() + result.putBoolean("started", true) + result.putString("source", captureSource) + promise.resolve(result) + } else { + startChoreographerCapture(promise) + } + } catch (e: Exception) { + try { + startChoreographerCapture(promise) + } catch (e2: Exception) { + promise.reject("FRAME_CAPTURE_START", e2) + } + } + } + } + + @ReactMethod + fun stopUiFrameCapture(promise: Promise) { + mainHandler.post { + try { + // Idempotent: safe for finally/cleanup when already stopped. + val map = stopCaptureInternal(idempotent = true) + promise.resolve(map) + } catch (e: Exception) { + capturing = false + promise.reject("FRAME_CAPTURE_STOP", e) + } + } + } + + @ReactMethod + fun writeReport(json: String, promise: Promise) { + try { + val file = File(reactContext.filesDir, REPORT_FILE) + file.writeText(json) + Log.i(LOG_TAG, "REPORT_READY path=${file.absolutePath} bytes=${json.length}") + val map = Arguments.createMap() + map.putString("path", file.absolutePath) + promise.resolve(map) + } catch (e: Exception) { + promise.reject("WRITE_REPORT", e) + } + } + + private fun stopCaptureInternal(idempotent: Boolean): WritableMap { + if (capturing) { + val listener = frameMetricsListener + val window = captureWindowRef?.get() ?: reactContext.currentActivity?.window + if (listener != null && window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + try { + window.removeOnFrameMetricsAvailableListener(listener) + } catch (_: Throwable) { + // ignore — activity may already be gone + } + } + frameMetricsListener = null + captureWindowRef = null + + val cb = choreographerCallback + if (cb != null) { + try { + Choreographer.getInstance().removeFrameCallback(cb) + } catch (_: Throwable) { + // ignore + } + } + choreographerCallback = null + capturing = false + } else if (!idempotent) { + throw IllegalStateException("UI frame capture is not running") + } + + val map = Arguments.createMap() + map.putString("source", captureSource) + map.putInt("frameCount", frameCount) + map.putDouble("maxFrameDurationMs", maxFrameDurationNs / 1_000_000.0) + map.putDouble("totalFrameDurationMs", totalFrameDurationNs / 1_000_000.0) + map.putInt("missedFrames", missedFrames) + map.putDouble("refreshPeriodMs", refreshPeriodNs / 1_000_000.0) + map.putDouble("refreshRateHz", refreshRateHz) + map.putBoolean("wasCapturing", frameCount > 0 || captureSource != "none") + return map + } + + private fun startChoreographerCapture(promise: Promise) { + lastChoreographerNs = 0L + val callback = + object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (!capturing) return + if (lastChoreographerNs > 0L) { + recordChoreographerInterval(frameTimeNanos - lastChoreographerNs) + } + lastChoreographerNs = frameTimeNanos + Choreographer.getInstance().postFrameCallback(this) + } + } + choreographerCallback = callback + captureSource = "Choreographer" + capturing = true + Choreographer.getInstance().postFrameCallback(callback) + val result = Arguments.createMap() + result.putBoolean("started", true) + result.putString("source", captureSource) + promise.resolve(result) + } + + /** FrameMetrics TOTAL_DURATION — duration semantics (ceil). */ + private fun recordFrameMetricsDuration(durationNs: Long) { + if (durationNs <= 0L) return + frameCount++ + totalFrameDurationNs += durationNs + if (durationNs > maxFrameDurationNs) { + maxFrameDurationNs = durationNs + } + if (refreshPeriodNs > 0L) { + val periods = ceil(durationNs.toDouble() / refreshPeriodNs.toDouble()).toInt() + missedFrames += max(0, periods - 1) + } + } + + /** Choreographer frame-time delta — interval semantics (round). */ + private fun recordChoreographerInterval(intervalNs: Long) { + if (intervalNs <= 0L) return + frameCount++ + totalFrameDurationNs += intervalNs + if (intervalNs > maxFrameDurationNs) { + maxFrameDurationNs = intervalNs + } + if (refreshPeriodNs > 0L) { + val periods = (intervalNs.toDouble() / refreshPeriodNs.toDouble()).roundToInt() + missedFrames += max(0, periods - 1) + } + } + + private fun resetCaptureCounters() { + frameCount = 0 + maxFrameDurationNs = 0L + totalFrameDurationNs = 0L + missedFrames = 0 + lastChoreographerNs = 0L + captureSource = "none" + captureWindowRef = null + } + + private fun updateRefreshPeriod(activity: android.app.Activity) { + val display = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + activity.display + } else { + @Suppress("DEPRECATION") + activity.windowManager?.defaultDisplay + } + val hz = display?.refreshRate?.toDouble() ?: 60.0 + refreshRateHz = if (hz > 0) hz else 60.0 + refreshPeriodNs = (1_000_000_000.0 / refreshRateHz).toLong() + } + + private fun isHermesEnabled(): Boolean { + return try { + BuildConfig::class.java.getField("IS_HERMES_ENABLED").getBoolean(null) + } catch (_: Throwable) { + true + } + } + + private fun putExtraString(map: WritableMap, extras: Bundle?, key: String) { + val value = extras?.getString(key) + if (value != null) { + map.putString(key, value) + } + } + + companion object { + const val NAME = "BenchNative" + const val LOG_TAG = "BenchNative" + const val REPORT_FILE = "gc-report.json" + const val BUILD_MANIFEST_ASSET = "build-manifest.json" + } +} diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativePackage.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativePackage.kt new file mode 100644 index 000000000000..44afba0725ef --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativePackage.kt @@ -0,0 +1,17 @@ +package com.dataclient.benchmarknative + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class BenchNativePackage : ReactPackage { + @Deprecated("ReactPackage.createNativeModules is deprecated") + override fun createNativeModules( + reactContext: ReactApplicationContext, + ): List = listOf(BenchNativeModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List> = emptyList() +} diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainActivity.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainActivity.kt new file mode 100644 index 000000000000..d3fbafe41304 --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainActivity.kt @@ -0,0 +1,30 @@ +package com.dataclient.benchmarknative + +import android.os.Bundle +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +class MainActivity : ReactActivity() { + + override fun getMainComponentName(): String = "BenchmarkNative" + + override fun onCreate(savedInstanceState: Bundle?) { + launchExtras = intent?.extras + super.onCreate(savedInstanceState) + } + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + launchExtras = intent.extras + } + + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) + + companion object { + @JvmField var launchExtras: Bundle? = null + } +} diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainApplication.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainApplication.kt new file mode 100644 index 000000000000..b5ba9ae76bcb --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/MainApplication.kt @@ -0,0 +1,26 @@ +package com.dataclient.benchmarknative + +import android.app.Application +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + add(BenchNativePackage()) + }, + ) + } + + override fun onCreate() { + super.onCreate() + loadReactNative(this) + } +} diff --git a/examples/benchmark-native/android/app/src/main/res/drawable/rn_edit_text_material.xml b/examples/benchmark-native/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 000000000000..5c25e728ea2c --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/benchmark-native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..a2f5908281d070150700378b64a84c7db1f97aa1 GIT binary patch literal 3056 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 axPAxe%7rDusX+w*00006jj zwslyNbxW4-gAj;v!J{u#G1>?8h`uw{1?o<0nB+tYjKOW@kQM}bUbgE7^CRD4K zgurXDRXWsX-Q$uVZ0o5KpKdOl5?!YGV|1Cict&~YiG*r%TU43m2Hf99&})mPEvepe z0_$L1e8*kL@h2~YPCajw6Kkw%Bh1Pp)6B|t06|1rR3xRYjBxjSEUmZk@7wX+2&-~! z!V&EdUw!o7hqZI=T4a)^N1D|a=2scW6oZU|Q=}_)gz4pu#43{muRW1cW2WC&m-ik? zskL0dHaVZ5X4PN*v4ZEAB9m;^6r-#eJH?TnU#SN&MO`Aj%)ybFYE+Pf8Vg^T3ybTl zu50EU=3Q60vA7xg@YQ$UKD-7(jf%}8gWS$_9%)wD1O2xB!_VxzcJdN!_qQ9j8#o^Kb$2+XTKxM8p>Ve{O8LcI(e2O zeg{tPSvIFaM+_Ivk&^FEk!WiV^;s?v8fmLglKG<7EO3ezShZ_0J-`(fM;C#i5~B@w zzx;4Hu{-SKq1{ftxbjc(dX3rj46zWzu02-kR>tAoFYDaylWMJ`>FO2QR%cfi+*^9A z54;@nFhVJEQ{88Q7n&mUvLn33icX`a355bQ=TDRS4Uud|cnpZ?a5X|cXgeBhYN7btgj zfrwP+iKdz4?L7PUDFA_HqCI~GMy`trF@g!KZ#+y6U%p5#-nm5{bUh>vhr^77p~ zq~UTK6@uhDVAQcL4g#8p-`vS4CnD9M_USvfi(M-;7nXjlk)~pr>zOI`{;$VXt;?VTNcCePv4 zgZm`^)VCx8{D=H2c!%Y*Sj3qbx z3Bcvv7qRAl|BGZCts{+>FZrE;#w(Yo2zD#>s3a*Bm!6{}vF_;i)6sl_+)pUj?b%BL!T1ELx|Q*Gi=7{Z_>n0I(uv>N^kh|~nJfab z-B6Q6i-x>YYa_42Hv&m>NNuPj31wOaHZ2`_8f~BtbXc@`9CZpHzaE@9sme%_D-HH! z_+C&VZ5tjE65?}X&u-D4AHRJ|7M{hR!}PYPpANP?7wnur`Z(&LFwzUmDz}m6%m#_` zN1ihq8f|zZ&zTL92M2b-hMpPyjp;j(qwgP9x)qI?EZx@<$g#>i7(MC}@*J1VGXm6J ztz1=RK@?%Qz^vmWNydd0K7oyrXw`TLb`z;fP6eV|NZ@9kKH zIyMqzZ9Y_)PZnC#UgW6&o7RiGXSCtSQvnrvJ07P9WCuE5TE27za*L6r1qX7pIDFiP znSaHYJF8sl^n0|3j!i{?fD%?fpQ8-}VX4%STy1t@8)G-8??Fy}j}~2_iJ79Y<9BW~ z!~)T{3Y|lwcVD5s4z^GP5M=~t`V?*Wng7gTvC9%p>ErZpM)pQVx57>AIcf1j4QFg^w>YYB%MypIj2syoXw9$K!N8%s=iPIw!LE-+6v6*Rm zvCqdN&kwI+@pEX0FTb&P)ujD9Td-sLBVV=A$;?RiFOROnT^LC^+PZR*u<3yl z7b%>viF-e48L=c`4Yhgb^U=+w7snP$R-gzx379%&q-0#fsMgvQlo>14~`1YOv{?^ z*^VYyiSJO8fE65P0FORgqSz#mi#9@40VO@TaPOT7pJq3WTK9*n;Niogu+4zte1FUa zyN7rIFbaQxeK{^RC3Iu@_J~ii&CvyWn^W}4wpexHwV9>GKO$zR3a&*L9&AgL=QfA$ z+G-YMq;1D{;N38`jTdN}Pw77sDCR|$2s+->;9gh-ObE_muwxq>sEpX)ywtgCHKIATY}p&%F4bRV>R9rYpeWbT(xnE7}?(HDXFgNDdC^@gUdK& zk=MolYT3>rpR*$Ell2!`c zjrIZftl&PUxlH2EgV+3VfQy&FjhL&5*Zg&R8xrSx?WgB?YuLO-JDaP3jr*I~qiywy z`-52AwB_6L#X ztms{{yRkRfQLbsb#Ov%`)acN(OCewI3Ex__xed17hg#g4c1blx?sK}UQg%PM@N;5d zsg{y6(|`H1Xfbz@5x{1688tu7TGkzFEBhOPDdFK(H_NQIFf|(>)ltFd!WdnkrY&mp z0y@5yU2;u1_enx%+U9tyY-LNWrd4^Wi?x<^r`QbaLBngWL`HzX@G550 zrdyNjhPTknrrJn#jT0WD0Z)WJRi&3FKJ#Sa&|883%QxM-?S%4niK{~k81<(c11sLk|!_7%s zH>c$`*nP-wA8Dx-K(HE~JG_@Yxxa;J+2yr+*iVlh;2Eiw?e`D1vu6*qY1+XTe8RVu z?RV%L|Mk!wO}j^S)p4H%?G37StD0Rx{_Y00%3a+V^SyOkfV@ZuFlEc;vR9r-D>cYU&plUkXL|M%1AYBQ3DI;;hF%_X@m*cTQAMZ4+FO74@AQB{A*_HtoXT@}l=8awaa7{RHC>07s?E%G{iSeRbh z?h#NM)bP`z`zdp5lij!N*df;4+sgz&U_JEr?N9#1{+UG3^11oQUOvU4W%tD1Cie3; z4zcz0SIrK-PG0(mp9gTYr(4ngx;ieH{NLq{* z;Pd=vS6KZYPV?DLbo^)~2dTpiKVBOh?|v2XNA)li)4V6B6PA!iq#XV5eO{{vL%OmU z0z3ZE2kcEkZ`kK(g^#s)#&#Zn5zw!R93cW^4+g0D=ydf&j4o_ti<@2WbzC>{(QhCL z(=%Zb;Ax8U=sdec9pkk|cW)1Ko;gK{-575HsDZ!w@WOQ^Up)GGorc38cGxe<$8O!6 zmQ`=@;TG{FjWq(s0eBn5I~vVgoE}un8+#YuR$Asq?lobvVAO-`SBs3!&;QEKT>gZ0T)jG^Foo~J2YkV&mi-axlvC}-(J4S2 z;opuO)+FIV#}&4;wwisb>{XU+FJ~tyK7UaG@ZD^C1^brazu7Xkh5Od}&P)GufW=u# zMxOwfWJ3a^MZha>9OmQ)@!Y;v*4@+dg~s~NQ;q@hV~l>lw`P)d`4XF9rE?aEFe(JV zI>11}Ny%^CkO=VN>wCV?P!-?VdT3vWe4zBLV*?6XPqsC%n93bQXvydh0Mo+tXHO4^ zxQ{x0?CG{fmToCyYny7>*-tNh;Sh9=THLzkS~lBiV9)IKa^C~_p8MVZWAUb)Btjt< zVZ;l7?_KnLHelj>)M1|Q_%pk5b?Bod_&86o-#36xIEag%b+8JqlDy@B^*YS*1; zGYT`@5nPgt)S^6Ap@b160C4d9do0iE;wYdn_Tr(vY{MS!ja!t*Z7G=Vz-=j5Z⁣ zwiG+x#%j}{0gU~J8;<|!B1@-XaB@{KORFwrYg_8rOv({b0EO#DbeQRm;B6_9=mXGf z-x|VL{zd`)#@yN}HkCSJbjbNlE|zL3Wm9Q8HY`sV)}3%pgN>cL^67{Z;PPL(*wT8N zUjXU{@|*hvm}({wsAC=x0^ok0%UAz0;sogW{B!nDqk|JJ5x~4NfTDgP49^zeu`csl?5mY@JdQdISc zFs!E{^grmkLnUk9 zny~m)1vws@5BFI<-0Tuo2JWX(0v`W|t(wg;s--L47WTvTMz-8l#TL^=OJNRS2?_Qj z3AKT+gvbyBi#H*-tJ%tWD|>EV3wy|8qxfzS!5RW;Jpl5*zo&^UBU=fG#2}UvRyNkK zA06Dy9;K1ca@r2T>yThYgI!ont$(G{6q#2QT+00r_x0(b)gsE`lBB?2gr55gq^D3Fi&p%E(p9>U%bv zkg1Jco(RbyTX7FDHOnl7-O@ zI$AaIl?9NJKPm(WiBP`1-#CB1QzU>&hKm)fpa5DKE{2$X0hGz-0uZ?cyTk(YC!Y&| zL=1VrNERSA5NA2jq7FACfX4JfPyj5XXl1yv0>~s;eF7L2$>&oMqeTFT2m$y7FlkON z_yurD1yIOvA;5C6016pyxBznGUt0kJ&k5r#;&>Jow`r)sp9R~PmK~lz$3xH%LT*1U zJdOyABZ3!FvNoR*vN$5ykHS8f`jA4zV+|L}i1C4`B2c{R0;UdYxaU|H)2avz@ z=mEYc|2S<+(B2Tj+FkX+2D+yFI!k9lWMA61DJ{)e;lum$(;O87?vGJJe!KtK04+N_ zI*P~t@dUb>9Xh{dbyl{-ZQ(UMgz7$|QfL5XSPkskt^NgctYC#;4WcZB1@%@wy@2t3 z2z0DI7&%b$*Aw~abe?GxE`ez@+6hOh-6*8fHRV{1os$EL@}uUZeG4h1&Be`98q*7j z=3-v+lhIjfWVo12!<>%V^a6lTgW3+_#W6n|p*~==zOH7z$0{LSZk(Tpd7EaD04hnA zL;#fxS0aD{`5^&D`}>0Uq?byDD-l2=!wm_bLcUl4gc(% za1p|itVANvFF>hghAS07Im1;IK;|b*W)}VDyI;BIp2=K*yu2a)j?B|f<44NI$NbmJ z#dE0>jI$fMr&@>4kN8MLFb4&2O9fEKaQg%(QO$4_1rVQywG^CmBLh#}_7gKW3vd?| z2?1^&KWq8}8I^_S0|)MowU_pw$q@nl@Nkn$z>BQq_KA^9yaR`(R3u{{Ig;cwt z@AJ^{ODQCm^neroM9nKNUAXi9RCK`OsP_LuR0PUR(YZCCX5dNF6VzcoK&=b^r`W?ltt|*F zpkoae%ZT{C1h~EcFui~b7fF`vb<<~j_VquuUA$}QqIKYELPp#;{u?q8Dz}WAG-(3; zjrm$i%7UbyZMM(Y{>!uJ#vNB?R~B{6Htp=>e*<{fQQ5W7V(1coCWlOON!MzZxhum| ztZBQpGR z;~#ur^&PockKdV{Q6R>o`Pl{0x!DEbpZ7y9Y;*ZvE!*gU`V1W3znva{f=?WO5I&>B z&hw6}tjECtaghm5z|C#%M;Yf_*pI^};h}Vl=^r9EN=tVDj86D;C$jIJ?K7VP+00000NkvXXu0mjf D5i!M* literal 0 HcmV?d00001 diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..459ca609d3ae0d3943ab44cdc27feef9256dc6d7 GIT binary patch literal 7098 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 literal 0 HcmV?d00001 diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..8ca12fe024be86e868d14e91120a6902f8e88ac6 GIT binary patch literal 6464 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 B0*L?s literal 0 HcmV?d00001 diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..8e19b410a1b15ff180f3dacac19395fe3046cdec GIT binary patch literal 10676 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 literal 0 HcmV?d00001 diff --git a/examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/examples/benchmark-native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..4c19a13c239cb67b8a2134ddd5f325db1d2d5bee GIT binary patch literal 15523 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 BIP(Ai literal 0 HcmV?d00001 diff --git a/examples/benchmark-native/android/app/src/main/res/values/strings.xml b/examples/benchmark-native/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000000..a3a26959f049 --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + BenchmarkNative + diff --git a/examples/benchmark-native/android/app/src/main/res/values/styles.xml b/examples/benchmark-native/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000000..7ba83a2ad5a2 --- /dev/null +++ b/examples/benchmark-native/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/examples/benchmark-native/android/build.gradle b/examples/benchmark-native/android/build.gradle new file mode 100644 index 000000000000..dad99b022ac9 --- /dev/null +++ b/examples/benchmark-native/android/build.gradle @@ -0,0 +1,21 @@ +buildscript { + ext { + buildToolsVersion = "36.0.0" + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + ndkVersion = "27.1.12297006" + kotlinVersion = "2.1.20" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle") + classpath("com.facebook.react:react-native-gradle-plugin") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + } +} + +apply plugin: "com.facebook.react.rootproject" diff --git a/examples/benchmark-native/android/gradle.properties b/examples/benchmark-native/android/gradle.properties new file mode 100644 index 000000000000..9afe61598fc5 --- /dev/null +++ b/examples/benchmark-native/android/gradle.properties @@ -0,0 +1,44 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=false diff --git a/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.jar b/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 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

pVX@HRij*jw$g4?}Pn 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 literal 0 HcmV?d00001 diff --git a/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.properties b/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..37f78a6af837 --- /dev/null +++ b/examples/benchmark-native/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/benchmark-native/android/gradlew b/examples/benchmark-native/android/gradlew new file mode 100644 index 000000000000..adff685a0348 --- /dev/null +++ b/examples/benchmark-native/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/benchmark-native/android/gradlew.bat b/examples/benchmark-native/android/gradlew.bat new file mode 100644 index 000000000000..39baf4d68461 --- /dev/null +++ b/examples/benchmark-native/android/gradlew.bat @@ -0,0 +1,98 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/benchmark-native/android/settings.gradle b/examples/benchmark-native/android/settings.gradle new file mode 100644 index 000000000000..dbaef8911829 --- /dev/null +++ b/examples/benchmark-native/android/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +rootProject.name = 'BenchmarkNative' +include ':app' +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/examples/benchmark-native/app.json b/examples/benchmark-native/app.json new file mode 100644 index 000000000000..70edac4556d2 --- /dev/null +++ b/examples/benchmark-native/app.json @@ -0,0 +1,4 @@ +{ + "name": "BenchmarkNative", + "displayName": "BenchmarkNative" +} diff --git a/examples/benchmark-native/babel.config.js b/examples/benchmark-native/babel.config.js new file mode 100644 index 000000000000..f7b3da3b33d1 --- /dev/null +++ b/examples/benchmark-native/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +}; diff --git a/examples/benchmark-native/index.js b/examples/benchmark-native/index.js new file mode 100644 index 000000000000..9b7393291400 --- /dev/null +++ b/examples/benchmark-native/index.js @@ -0,0 +1,9 @@ +/** + * @format + */ + +import { AppRegistry } from 'react-native'; +import App from './App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); diff --git a/examples/benchmark-native/jest.config.js b/examples/benchmark-native/jest.config.js new file mode 100644 index 000000000000..1c2b0e3c866c --- /dev/null +++ b/examples/benchmark-native/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'react-native', + testPathIgnorePatterns: ['/node_modules/', '/android/'], + modulePathIgnorePatterns: ['/android/'], +}; diff --git a/examples/benchmark-native/metro.config.js b/examples/benchmark-native/metro.config.js new file mode 100644 index 000000000000..fdf64ff51740 --- /dev/null +++ b/examples/benchmark-native/metro.config.js @@ -0,0 +1,23 @@ +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const path = require('path'); + +const projectRoot = __dirname; +const monorepoRoot = path.resolve(projectRoot, '../..'); + +/** + * Metro configuration for yarn workspace monorepo. + * https://reactnative.dev/docs/metro + * + * @type {import('@react-native/metro-config').MetroConfig} + */ +const config = { + watchFolders: [monorepoRoot], + resolver: { + nodeModulesPaths: [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(monorepoRoot, 'node_modules'), + ], + }, +}; + +module.exports = mergeConfig(getDefaultConfig(projectRoot), config); diff --git a/examples/benchmark-native/package.json b/examples/benchmark-native/package.json new file mode 100644 index 000000000000..802bf9fe6d13 --- /dev/null +++ b/examples/benchmark-native/package.json @@ -0,0 +1,44 @@ +{ + "name": "example-benchmark-native", + "version": "0.0.1", + "private": true, + "scripts": { + "android": "react-native run-android --mode=release", + "android:debug": "react-native run-android", + "build:android:release": "bash scripts/build-android-release.sh", + "build:android:release:gradle": "cd android && ./gradlew assembleRelease", + "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --config .eslintrc.js --ignore-pattern android --ignore-pattern artifacts .", + "start": "react-native start", + "test": "jest", + "typecheck": "tsc --noEmit", + "collect": "bash scripts/collect-report.sh", + "matrix": "bash scripts/run-matrix.sh" + }, + "dependencies": { + "@data-client/core": "workspace:*", + "react": "19.2.3", + "react-native": "0.86.0" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/preset-env": "^7.25.3", + "@babel/runtime": "^7.25.0", + "@react-native-community/cli": "20.1.0", + "@react-native-community/cli-platform-android": "20.1.0", + "@react-native/babel-preset": "0.86.0", + "@react-native/eslint-config": "0.86.0", + "@react-native/metro-config": "0.86.0", + "@react-native/typescript-config": "0.86.0", + "@types/jest": "^29.5.13", + "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.1.0", + "eslint": "^8.19.0", + "jest": "^29.6.3", + "prettier": "2.8.8", + "react-test-renderer": "19.2.3", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">= 22.11.0" + } +} diff --git a/examples/benchmark-native/scripts/build-android-release.sh b/examples/benchmark-native/scripts/build-android-release.sh new file mode 100644 index 000000000000..07d4692f5f82 --- /dev/null +++ b/examples/benchmark-native/scripts/build-android-release.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Authoritative release build: prepare BuildManifest → Gradle assembleRelease → sidecar. +# Split APKs are unsupported; requires single app-release.apk. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APK="${ROOT}/android/app/build/outputs/apk/release/app-release.apk" + +echo "== prepare BuildManifest v1 ==" +node "${ROOT}/scripts/build-manifest.cjs" prepare + +echo "== gradle assembleRelease ==" +( + cd "${ROOT}/android" + ./gradlew assembleRelease +) + +if [[ ! -f "${APK}" ]]; then + echo "error: expected single release APK at ${APK}" >&2 + echo "split / multi-APK installs are unsupported" >&2 + exit 1 +fi + +echo "== finalize sidecar ==" +node "${ROOT}/scripts/build-manifest.cjs" finalize "${APK}" + +echo "Release build complete: ${APK}" +echo "Sidecar: ${ROOT}/artifacts/build-sidecar.json" diff --git a/examples/benchmark-native/scripts/build-identity.cjs b/examples/benchmark-native/scripts/build-identity.cjs new file mode 100644 index 000000000000..c11d24178906 --- /dev/null +++ b/examples/benchmark-native/scripts/build-identity.cjs @@ -0,0 +1,101 @@ +/** + * Deterministic BuildManifest / sidecar identity helpers (pure). + * + * buildId — source-build identity: sha256 over canonical v1 inputs + * (schemaVersion, gitCommit, gitDirty, sourceDigest). + * Does NOT include createdAt or artifact hashes. + * sidecarId — artifact-aware identity: sha256(buildId || apkSha256). + */ +const crypto = require('crypto'); + +/** + * @param {{ schemaVersion: number, gitCommit: string, gitDirty: boolean, sourceDigest: string }} inputs + * @returns {string} hex sha256 + */ +function computeBuildId(inputs) { + if (inputs.schemaVersion !== 1) { + throw new Error(`unsupported schemaVersion=${inputs.schemaVersion}`); + } + if (typeof inputs.gitCommit !== 'string' || !inputs.gitCommit) { + throw new Error('gitCommit required'); + } + if (typeof inputs.gitDirty !== 'boolean') { + throw new Error('gitDirty must be boolean'); + } + if (typeof inputs.sourceDigest !== 'string' || !/^[0-9a-f]{64}$/.test(inputs.sourceDigest)) { + throw new Error('sourceDigest must be 64-char hex sha256'); + } + // Canonical encoding: fixed field order, no timestamps/artifacts. + const payload = [ + String(inputs.schemaVersion), + inputs.gitCommit, + inputs.gitDirty ? '1' : '0', + inputs.sourceDigest, + ].join('\0'); + return crypto.createHash('sha256').update(payload, 'utf8').digest('hex'); +} + +/** + * @param {{ buildId: string, apkSha256: string }} inputs + * @returns {string} hex sha256 + */ +function computeSidecarId(inputs) { + if (typeof inputs.buildId !== 'string' || !/^[0-9a-f]{64}$/.test(inputs.buildId)) { + throw new Error('buildId must be 64-char hex sha256'); + } + if (typeof inputs.apkSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(inputs.apkSha256)) { + throw new Error('apkSha256 must be 64-char hex sha256'); + } + return crypto + .createHash('sha256') + .update(`${inputs.buildId}\0${inputs.apkSha256}`, 'utf8') + .digest('hex'); +} + +/** + * Recompute buildId from manifest fields and ensure it matches. + * Catches tampered metadata or a forged buildId. + * @param {{ schemaVersion: number, buildId: string, gitCommit: string, gitDirty: boolean, sourceDigest: string }} manifest + */ +function verifyManifestBuildId(manifest) { + const expected = computeBuildId({ + schemaVersion: manifest.schemaVersion, + gitCommit: manifest.gitCommit, + gitDirty: manifest.gitDirty, + sourceDigest: manifest.sourceDigest, + }); + if (manifest.buildId !== expected) { + throw new Error( + `buildId mismatch: embedded=${manifest.buildId} expected=${expected} (tampered metadata or forged buildId)`, + ); + } + return expected; +} + +/** + * Verify sidecar buildId + sidecarId against fields + apkSha256. + * @param {{ schemaVersion: number, buildId: string, gitCommit: string, gitDirty: boolean, sourceDigest: string, apkSha256: string, sidecarId?: string }} sidecar + */ +function verifySidecarIdentity(sidecar) { + verifyManifestBuildId(sidecar); + const expectedSidecarId = computeSidecarId({ + buildId: sidecar.buildId, + apkSha256: sidecar.apkSha256, + }); + if (sidecar.sidecarId == null || sidecar.sidecarId === '') { + throw new Error('sidecarId required (artifact-aware identity)'); + } + if (sidecar.sidecarId !== expectedSidecarId) { + throw new Error( + `sidecarId mismatch: embedded=${sidecar.sidecarId} expected=${expectedSidecarId}`, + ); + } + return expectedSidecarId; +} + +module.exports = { + computeBuildId, + computeSidecarId, + verifyManifestBuildId, + verifySidecarIdentity, +}; diff --git a/examples/benchmark-native/scripts/build-manifest.cjs b/examples/benchmark-native/scripts/build-manifest.cjs new file mode 100644 index 000000000000..dc6087ed9450 --- /dev/null +++ b/examples/benchmark-native/scripts/build-manifest.cjs @@ -0,0 +1,245 @@ +#!/usr/bin/env node +/** + * BuildManifest v1 prepare / finalize for release-Hermes Android GC bench. + * + * prepare — write android/app/src/main/assets/build-manifest.json + * buildId = deterministic sha256(schemaVersion, gitCommit, gitDirty, sourceDigest) + * finalize — verify embedded buildId, write artifacts/build-sidecar.json with + * apkSha256 (artifact identity) + sidecarId (buildId∥apkSha256) + * digest — print current sourceDigest (for stale checks) + * verify — verify manifest and/or sidecar identity + * + * Inputs: sorted paths under this app (tracked+untracked contents) plus + * packages/core/src, excluding build/node_modules/artifacts/.jdk/generated. + */ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { + computeBuildId, + computeSidecarId, + verifyManifestBuildId, + verifySidecarIdentity, +} = require('./build-identity.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const REPO = path.resolve(ROOT, '../..'); +const ASSET_DIR = path.join(ROOT, 'android/app/src/main/assets'); +const MANIFEST_PATH = path.join(ASSET_DIR, 'build-manifest.json'); +const SIDECAR_PATH = path.join(ROOT, 'artifacts/build-sidecar.json'); +const DEFAULT_APK = path.join( + ROOT, + 'android/app/build/outputs/apk/release/app-release.apk', +); + +const IGNORE_DIR_NAMES = new Set([ + 'node_modules', + 'build', + 'artifacts', + '.jdk', + '.git', + '.gradle', + '.idea', + 'coverage', + 'Pods', +]); + +function shouldSkipDir(name) { + return IGNORE_DIR_NAMES.has(name) || name.startsWith('.'); +} + +function walkFiles(dir, out) { + if (!fs.existsSync(dir)) return; + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.name === 'build-manifest.json') continue; // generated + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (shouldSkipDir(ent.name)) continue; + walkFiles(full, out); + } else if (ent.isFile()) { + out.push(full); + } + } +} + +function collectInputFiles() { + const files = []; + walkFiles(ROOT, files); + walkFiles(path.join(REPO, 'packages/core/src'), files); + return files + .map(f => path.resolve(f)) + .filter( + f => + !f.includes(`${path.sep}android${path.sep}app${path.sep}build${path.sep}`), + ) + .sort((a, b) => a.localeCompare(b)); +} + +function sha256File(filePath) { + const h = crypto.createHash('sha256'); + h.update(fs.readFileSync(filePath)); + return h.digest('hex'); +} + +function sourceDigest() { + const h = crypto.createHash('sha256'); + for (const file of collectInputFiles()) { + const rel = path.relative(REPO, file).split(path.sep).join('/'); + h.update(rel); + h.update('\0'); + h.update(fs.readFileSync(file)); + h.update('\0'); + } + return h.digest('hex'); +} + +function gitMeta() { + try { + const commit = execSync('git rev-parse HEAD', { + cwd: REPO, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + const dirty = + execSync('git status --porcelain', { + cwd: REPO, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim().length > 0; + return { gitCommit: commit, gitDirty: dirty }; + } catch { + return { gitCommit: 'unknown', gitDirty: true }; + } +} + +function prepare() { + const digest = sourceDigest(); + const { gitCommit, gitDirty } = gitMeta(); + const schemaVersion = 1; + const buildId = computeBuildId({ + schemaVersion, + gitCommit, + gitDirty, + sourceDigest: digest, + }); + const manifest = { + schemaVersion, + buildId, + gitCommit, + gitDirty, + sourceDigest: digest, + createdAt: new Date().toISOString(), + }; + verifyManifestBuildId(manifest); + fs.mkdirSync(ASSET_DIR, { recursive: true }); + fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n'); + console.log(`Wrote ${MANIFEST_PATH}`); + console.log(`buildId=${buildId} (source-build identity)`); + console.log(`sourceDigest=${digest}`); + return manifest; +} + +function finalize(apkPath = DEFAULT_APK) { + if (!fs.existsSync(MANIFEST_PATH)) { + throw new Error('missing build-manifest.json — run prepare first'); + } + if (!fs.existsSync(apkPath)) { + throw new Error(`APK not found: ${apkPath}`); + } + const splitsDir = path.join( + ROOT, + 'android/app/build/outputs/apk/release/splits', + ); + if (fs.existsSync(splitsDir)) { + throw new Error( + 'split APKs unsupported; use a single release APK (assembleRelease app-release.apk)', + ); + } + if (path.basename(apkPath) !== 'app-release.apk') { + console.warn( + `warning: expected app-release.apk, got ${path.basename(apkPath)}`, + ); + } + + const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); + verifyManifestBuildId(manifest); + + // Stale prepare: source tree changed after prepare but before finalize. + const currentDigest = sourceDigest(); + if (currentDigest !== manifest.sourceDigest) { + throw new Error( + `stale BuildManifest: sourceDigest ${manifest.sourceDigest} != current ${currentDigest}; re-run prepare`, + ); + } + + const apkSha256 = sha256File(apkPath); + const apkSizeBytes = fs.statSync(apkPath).size; + const sidecarId = computeSidecarId({ + buildId: manifest.buildId, + apkSha256, + }); + const sidecar = { + schemaVersion: 1, + buildId: manifest.buildId, + sourceDigest: manifest.sourceDigest, + gitCommit: manifest.gitCommit, + gitDirty: manifest.gitDirty, + apkPath: path.resolve(apkPath), + apkSha256, + apkSizeBytes, + sidecarId, + builtAt: new Date().toISOString(), + }; + verifySidecarIdentity(sidecar); + fs.mkdirSync(path.dirname(SIDECAR_PATH), { recursive: true }); + fs.writeFileSync(SIDECAR_PATH, JSON.stringify(sidecar, null, 2) + '\n'); + console.log(`Wrote ${SIDECAR_PATH}`); + console.log(`apkSha256=${apkSha256} (artifact identity)`); + console.log(`sidecarId=${sidecarId}`); + return sidecar; +} + +function verify() { + if (fs.existsSync(MANIFEST_PATH)) { + const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); + verifyManifestBuildId(manifest); + console.log(`manifest buildId ok: ${manifest.buildId}`); + } else { + console.log('no manifest present'); + } + if (fs.existsSync(SIDECAR_PATH)) { + const sidecar = JSON.parse(fs.readFileSync(SIDECAR_PATH, 'utf8')); + verifySidecarIdentity(sidecar); + console.log(`sidecar identity ok: sidecarId=${sidecar.sidecarId}`); + } else { + console.log('no sidecar present'); + } +} + +function main() { + const cmd = process.argv[2] || 'digest'; + if (cmd === 'prepare') { + prepare(); + } else if (cmd === 'finalize') { + finalize(process.argv[3] || DEFAULT_APK); + } else if (cmd === 'digest') { + console.log(sourceDigest()); + } else if (cmd === 'hash') { + const filePath = process.argv[3]; + if (!filePath) { + console.error('usage: build-manifest.cjs hash '); + process.exit(1); + } + console.log(sha256File(filePath)); + } else if (cmd === 'verify') { + verify(); + } else { + console.error( + 'usage: build-manifest.cjs prepare|finalize [apk]|digest|hash |verify', + ); + process.exit(1); + } +} + +main(); diff --git a/examples/benchmark-native/scripts/collect-report.sh b/examples/benchmark-native/scripts/collect-report.sh new file mode 100644 index 000000000000..7c182641697a --- /dev/null +++ b/examples/benchmark-native/scripts/collect-report.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# Install/start a single release APK, pass scenario axes, pull report, verify provenance. +# +# Requires exactly one adb device unless ANDROID_SERIAL is set and valid. +# Split APKs unsupported. Sidecar (artifacts/build-sidecar.json) is authority — +# never the live checkout. Intent label is optional; commit is not authority. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=validate-config.sh +source "${ROOT}/scripts/validate-config.sh" + +APP_ID="${APP_ID:-com.dataclient.benchmarknative}" +ACTIVITY="${ACTIVITY:-${APP_ID}/.MainActivity}" +APK="${APK:-${ROOT}/android/app/build/outputs/apk/release/app-release.apk}" +SIDECAR="${SIDECAR:-${ROOT}/artifacts/build-sidecar.json}" +OUT="${OUT:-${ROOT}/artifacts/gc-report.json}" +TIMEOUT_SEC="${TIMEOUT_SEC:-600}" + +CANDIDATE_KIND="${CANDIDATE_KIND:-entity}" +PATTERN="${PATTERN:-unique}" +COUNT="${COUNT:-1000}" +CONTROL="${CONTROL:-gc}" +SAMPLES="${SAMPLES:-1}" +LABEL="${LABEL:-}" +INSTALL="${INSTALL:-1}" + +# Validate axes before any install/device work. +validate_host_config "${CANDIDATE_KIND}" "${PATTERN}" "${COUNT}" "${CONTROL}" "${SAMPLES}" + +mkdir -p "$(dirname "${OUT}")" + +if ! command -v adb >/dev/null 2>&1; then + echo "error: adb not found on PATH" >&2 + exit 1 +fi + +if [[ ! -f "${SIDECAR}" ]]; then + echo "error: missing sidecar ${SIDECAR}" >&2 + echo "Build with: yarn workspace example-benchmark-native build:android:release" >&2 + exit 1 +fi + +if [[ ! -f "${APK}" ]]; then + echo "error: APK not found at ${APK}" >&2 + echo "Split APKs are unsupported; need single app-release.apk" >&2 + exit 1 +fi + +if [[ "$(basename "${APK}")" != "app-release.apk" ]]; then + echo "error: only single release APK app-release.apk is supported (got $(basename "${APK}"))" >&2 + exit 1 +fi + +# --- device selection --- +mapfile -t _DEVICES < <(adb devices | awk 'NR>1 && $2=="device" {print $1}') +if [[ -n "${ANDROID_SERIAL:-}" ]]; then + FOUND=0 + for d in "${_DEVICES[@]:-}"; do + if [[ "${d}" == "${ANDROID_SERIAL}" ]]; then + FOUND=1 + break + fi + done + if [[ "${FOUND}" != "1" ]]; then + echo "error: ANDROID_SERIAL=${ANDROID_SERIAL} is not an eligible 'device'" >&2 + adb devices >&2 || true + exit 1 + fi + SERIAL="${ANDROID_SERIAL}" +else + if [[ "${#_DEVICES[@]}" -ne 1 ]]; then + echo "error: need exactly one eligible adb device (found ${#_DEVICES[@]}); set ANDROID_SERIAL" >&2 + adb devices >&2 || true + exit 1 + fi + SERIAL="${_DEVICES[0]}" +fi +ADB=(adb -s "${SERIAL}") +echo "Using device ${SERIAL}" + +# --- provenance: local APK must match sidecar (authority) --- +node -e ' +const fs=require("fs"); +const { verifySidecarIdentity } = require(process.argv[2]); +const sidecar=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); +if (sidecar.schemaVersion!==1) { console.error("bad sidecar schema"); process.exit(2); } +verifySidecarIdentity(sidecar); +console.log("sidecar identity verified buildId="+sidecar.buildId+" sidecarId="+sidecar.sidecarId); +' "${SIDECAR}" "${ROOT}/scripts/build-identity.cjs" + +SIDECAR_BUILD_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).buildId)' "${SIDECAR}")" +SIDECAR_DIGEST="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).sourceDigest)' "${SIDECAR}")" +SIDECAR_APK_SHA="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).apkSha256)' "${SIDECAR}")" +SIDECAR_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).sidecarId)' "${SIDECAR}")" + +LOCAL_APK_SHA="$(node "${ROOT}/scripts/build-manifest.cjs" hash "${APK}")" +if [[ "${LOCAL_APK_SHA}" != "${SIDECAR_APK_SHA}" ]]; then + echo "error: local APK sha256 ${LOCAL_APK_SHA} != sidecar ${SIDECAR_APK_SHA} (stale artifact)" >&2 + exit 1 +fi + +CURRENT_DIGEST="$(node "${ROOT}/scripts/build-manifest.cjs" digest)" +if [[ "${CURRENT_DIGEST}" != "${SIDECAR_DIGEST}" ]]; then + echo "error: current sourceDigest ${CURRENT_DIGEST} != sidecar ${SIDECAR_DIGEST}" >&2 + echo "Rebuild with yarn workspace example-benchmark-native build:android:release" >&2 + exit 1 +fi + +if [[ "${INSTALL}" == "1" ]]; then + echo "Installing single APK ${APK}…" + "${ADB[@]}" install -r "${APK}" +fi + +# Hash the installed base APK (stream via adb exec-out / pull path). +TMP_INSTALLED="$(mktemp)" +cleanup() { rm -f "${TMP_INSTALLED}"; } +trap cleanup EXIT + +# Prefer `pm path` → pull the single base APK. +INSTALLED_PATH="$("${ADB[@]}" shell pm path "${APP_ID}" | tr -d '\r' | awk -F: '/^package:/{print $2; exit}')" +if [[ -z "${INSTALLED_PATH}" ]]; then + echo "error: could not resolve installed package path for ${APP_ID}" >&2 + exit 1 +fi +PATH_COUNT="$("${ADB[@]}" shell pm path "${APP_ID}" | tr -d '\r' | grep -c '^package:' || true)" +if [[ "${PATH_COUNT}" -ne 1 ]]; then + echo "error: expected exactly one installed APK path (split APKs unsupported); got ${PATH_COUNT}" >&2 + "${ADB[@]}" shell pm path "${APP_ID}" >&2 || true + exit 1 +fi + +"${ADB[@]}" pull "${INSTALLED_PATH}" "${TMP_INSTALLED}" >/dev/null +INSTALLED_SHA="$(node "${ROOT}/scripts/build-manifest.cjs" hash "${TMP_INSTALLED}")" +if [[ "${INSTALLED_SHA}" != "${SIDECAR_APK_SHA}" ]]; then + echo "error: installed APK sha256 ${INSTALLED_SHA} != sidecar ${SIDECAR_APK_SHA}" >&2 + exit 1 +fi +echo "installedApkSha256=${INSTALLED_SHA}" + +# Clear previous report +"${ADB[@]}" shell "run-as ${APP_ID} rm -f files/gc-report.json" 2>/dev/null || true + +echo "Starting ${ACTIVITY} with axes ${CANDIDATE_KIND}/${PATTERN}/${COUNT}/interaction/${CONTROL}…" +"${ADB[@]}" logcat -c || true +"${ADB[@]}" shell am force-stop "${APP_ID}" || true + +START_ARGS=( + am start -n "${ACTIVITY}" + --ez autoRun true + --es candidateKind "${CANDIDATE_KIND}" + --es pattern "${PATTERN}" + --ei count "${COUNT}" + --es control "${CONTROL}" + --ei samples "${SAMPLES}" +) +if [[ -n "${LABEL}" ]]; then + START_ARGS+=(--es label "${LABEL}") +fi +"${ADB[@]}" shell "${START_ARGS[@]}" + +echo "Waiting for REPORT_READY (timeout ${TIMEOUT_SEC}s)…" +deadline=$((SECONDS + TIMEOUT_SEC)) +found=0 +while (( SECONDS < deadline )); do + if "${ADB[@]}" logcat -d -s BenchNative:I | grep -q 'REPORT_READY'; then + found=1 + break + fi + if "${ADB[@]}" shell "run-as ${APP_ID} ls files/gc-report.json" >/dev/null 2>&1; then + found=1 + break + fi + sleep 2 +done + +if [[ "${found}" != "1" ]]; then + echo "error: timed out waiting for report" >&2 + "${ADB[@]}" logcat -d -s BenchNative:I ReactNativeJS:E AndroidRuntime:E | tail -n 80 >&2 || true + exit 1 +fi + +"${ADB[@]}" shell "run-as ${APP_ID} cat files/gc-report.json" > "${OUT}" +echo "Wrote ${OUT}" + +# Verify embedded buildId matches sidecar; attach sidecar provenance + installed hash. +node -e ' +const fs=require("fs"); +const out=process.argv[1], sidecarPath=process.argv[2], installedSha=process.argv[3]; +const report=JSON.parse(fs.readFileSync(out,"utf8")); +const sidecar=JSON.parse(fs.readFileSync(sidecarPath,"utf8")); +const embedded=report.build && report.build.buildId; +if (!embedded || embedded !== sidecar.buildId) { + console.error("error: report buildId", embedded, "!= sidecar", sidecar.buildId); + process.exit(1); +} +report.build = report.build || {}; +report.build.sidecar = { + buildId: sidecar.buildId, + sourceDigest: sidecar.sourceDigest, + apkSha256: sidecar.apkSha256, + apkPath: sidecar.apkPath, + sidecarId: sidecar.sidecarId, +}; +report.build.installedApkSha256 = installedSha; +report.build.apkSizeBytes = sidecar.apkSizeBytes; +fs.writeFileSync(out, JSON.stringify(report, null, 2)); +console.log("provenance ok buildId="+sidecar.buildId+" sidecarId="+sidecar.sidecarId); +' "${OUT}" "${SIDECAR}" "${INSTALLED_SHA}" + +echo "Done." diff --git a/examples/benchmark-native/scripts/run-matrix.sh b/examples/benchmark-native/scripts/run-matrix.sh new file mode 100644 index 000000000000..c55d1d831963 --- /dev/null +++ b/examples/benchmark-native/scripts/run-matrix.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Matrix runner over the full canonical android interaction axes. +# +# Generates every supported scenario: +# entity|endpoint|mixed × unique × {1000,10000,100000} × {gc,no-gc} +# entity × duplicate × {1000,10000,100000} × {gc,no-gc} +# +# Safety: count=100000 is skipped unless FULL=1 or an explicit filter selects it. +# +# Usage: +# bash scripts/run-matrix.sh +# bash scripts/run-matrix.sh entity/unique/1000 +# bash scripts/run-matrix.sh entity/unique/100000 # 100k via filter +# FULL=1 SAMPLES=5 bash scripts/run-matrix.sh # entire matrix incl. 100k +# FULL=1 bash scripts/run-matrix.sh /100000/ # only 100k rows +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=validate-config.sh +source "${ROOT}/scripts/validate-config.sh" + +FILTER="${1:-}" +OUT_DIR="${OUT_DIR:-${ROOT}/artifacts/matrix}" +SAMPLES="${SAMPLES:-1}" +INSTALL_FIRST="${INSTALL_FIRST:-1}" +FULL="${FULL:-0}" + +mkdir -p "${OUT_DIR}" + +KINDS=(entity endpoint mixed) +COUNTS=(1000 10000 100000) +CONTROLS=(gc no-gc) + +SCENARIOS=() + +for kind in "${KINDS[@]}"; do + for count in "${COUNTS[@]}"; do + for control in "${CONTROLS[@]}"; do + SCENARIOS+=("${kind}/unique/${count}/${control}") + done + done +done + +for count in "${COUNTS[@]}"; do + for control in "${CONTROLS[@]}"; do + SCENARIOS+=("entity/duplicate/${count}/${control}") + done +done + +install_flag=1 +if [[ "${INSTALL_FIRST}" != "1" ]]; then + install_flag=0 +fi + +ran=0 +skipped_100k=0 +for spec in "${SCENARIOS[@]}"; do + if [[ -n "${FILTER}" && "${spec}" != *"${FILTER}"* ]]; then + continue + fi + IFS='/' read -r kind pattern count control <<<"${spec}" + + # 100k safety: require FULL=1, or a non-empty filter that already matched this row. + if [[ "${count}" == "100000" && "${FULL}" != "1" && -z "${FILTER}" ]]; then + skipped_100k=$((skipped_100k + 1)) + continue + fi + + validate_host_config "${kind}" "${pattern}" "${count}" "${control}" "${SAMPLES}" + + echo "=== matrix ${kind}/${pattern}/${count}/interaction/${control} ===" + OUT="${OUT_DIR}/android-${kind}-${pattern}-${count}-interaction-${control}.json" \ + CANDIDATE_KIND="${kind}" \ + PATTERN="${pattern}" \ + COUNT="${count}" \ + CONTROL="${control}" \ + SAMPLES="${SAMPLES}" \ + INSTALL="${install_flag}" \ + bash "${ROOT}/scripts/collect-report.sh" + install_flag=0 + ran=$((ran + 1)) +done + +if [[ "${ran}" -eq 0 ]]; then + echo "error: no scenarios matched (filter=${FILTER:-} FULL=${FULL})" >&2 + echo "hint: FULL=1 includes all 100k rows; or filter e.g. entity/unique/100000" >&2 + exit 1 +fi + +if [[ "${skipped_100k}" -gt 0 ]]; then + echo "note: skipped ${skipped_100k} × 100k scenarios (set FULL=1 or pass a 100k filter)" +fi + +echo "Matrix complete (${ran} scenarios) → ${OUT_DIR}" diff --git a/examples/benchmark-native/scripts/validate-config.sh b/examples/benchmark-native/scripts/validate-config.sh new file mode 100644 index 000000000000..50c5be1df891 --- /dev/null +++ b/examples/benchmark-native/scripts/validate-config.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Shared host-side validation for collect-report / run-matrix. +# Usage: source scripts/validate-config.sh && validate_host_config KIND PATTERN COUNT CONTROL SAMPLES + +validate_host_config() { + local kind="${1:-}" + local pattern="${2:-}" + local count="${3:-}" + local control="${4:-}" + local samples="${5:-1}" + + case "${kind}" in + entity|endpoint|mixed) ;; + *) + echo "error: invalid CANDIDATE_KIND=${kind}; expected entity|endpoint|mixed" >&2 + return 1 + ;; + esac + + case "${pattern}" in + unique|duplicate) ;; + *) + echo "error: invalid PATTERN=${pattern}; expected unique|duplicate" >&2 + return 1 + ;; + esac + + case "${control}" in + gc|no-gc) ;; + *) + echo "error: invalid CONTROL=${control}; expected gc|no-gc" >&2 + return 1 + ;; + esac + + case "${count}" in + 1000|10000|100000) ;; + *) + echo "error: invalid COUNT=${count}; expected 1000|10000|100000" >&2 + return 1 + ;; + esac + + if [[ "${pattern}" == "duplicate" && "${kind}" != "entity" ]]; then + echo "error: duplicate pattern only supports CANDIDATE_KIND=entity (got ${kind})" >&2 + return 1 + fi + + if ! [[ "${samples}" =~ ^[0-9]+$ ]] || [[ "${samples}" -lt 1 ]] || [[ "${samples}" -gt 50 ]]; then + echo "error: invalid SAMPLES=${samples}; expected integer 1..50" >&2 + return 1 + fi + + return 0 +} diff --git a/examples/benchmark-native/src/BenchNative.ts b/examples/benchmark-native/src/BenchNative.ts new file mode 100644 index 000000000000..9923ec8471c9 --- /dev/null +++ b/examples/benchmark-native/src/BenchNative.ts @@ -0,0 +1,77 @@ +import { NativeModules, Platform } from 'react-native'; + +import type { UiCaptureSource } from './types'; + +export interface MemorySnapshot { + totalPssKb: number; + totalPrivateDirtyKb?: number; + rssKb?: number; +} + +export interface UiFrameCaptureResult { + source: UiCaptureSource; + frameCount: number; + maxFrameDurationMs: number; + totalFrameDurationMs: number; + missedFrames: number; + refreshPeriodMs: number; + refreshRateHz: number; + wasCapturing?: boolean; +} + +export interface NativeEnvironment { + apiLevel: number; + release: string; + manufacturer: string; + model: string; + device: string; + brand: string; + buildType: string; + applicationId: string; + hermesEnabled: boolean; + hermesRuntimeProperties?: Record; + refreshRateHz: number; + refreshPeriodMs: number; +} + +export interface NativeLaunchConfig { + autoRun: boolean; + candidateKind?: string; + pattern?: string; + count?: number; + control?: string; + samples?: number; + label?: string; +} + +interface BenchNativeNativeModule { + getLaunchConfig(): Promise; + getBuildManifest(): Promise<{ json: string }>; + getEnvironment(): Promise; + getMemorySnapshot(): Promise; + startUiFrameCapture(): Promise<{ started: boolean; source: string }>; + stopUiFrameCapture(): Promise; + writeReport(json: string): Promise<{ path: string }>; +} + +const LINKING_ERROR = `BenchNative native module is not linked. Rebuild the Android app.`; + +const BenchNative: BenchNativeNativeModule = + Platform.OS === 'android' && NativeModules.BenchNative != null + ? NativeModules.BenchNative + : new Proxy({} as BenchNativeNativeModule, { + get() { + throw new Error(LINKING_ERROR); + }, + }); + +export default BenchNative; + +/** Optional JS heap when Hermes/RN exposes performance.memory. */ +export function readJsHeapBytes(): number | undefined { + if (typeof performance === 'undefined') return undefined; + const mem = performance.memory; + if (mem == null) return undefined; + const used = mem.usedJSHeapSize; + return typeof used === 'number' && Number.isFinite(used) ? used : undefined; +} diff --git a/examples/benchmark-native/src/frames.ts b/examples/benchmark-native/src/frames.ts new file mode 100644 index 000000000000..c68527c5c0d7 --- /dev/null +++ b/examples/benchmark-native/src/frames.ts @@ -0,0 +1,167 @@ +/** + * JS frame / responsiveness helpers (rAF + timers; not pointer latency). + * + * Native UI missed-frame math differs by capture source: + * - FrameMetrics TOTAL_DURATION is a *duration* → ceil(duration/period) − 1 + * - Choreographer deltas are *intervals* → round(interval/period) − 1 (same as JS rAF) + */ +import type { UiCaptureSource } from './types'; + +export function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Adjacent differences of rAF timestamps (ms). */ +export function frameIntervalsFromTimestamps(timestampsMs: number[]): number[] { + const intervals: number[] = []; + for (let i = 1; i < timestampsMs.length; i++) { + intervals.push(timestampsMs[i] - timestampsMs[i - 1]); + } + return intervals; +} + +/** + * FrameMetrics TOTAL_DURATION → excess missed frames. + * Conservative: ceil so a duration just over one period counts as a miss. + */ +export function missedFramesFromDurationMs( + durationMs: number, + refreshPeriodMs: number, +): number { + if (!(refreshPeriodMs > 0) || !(durationMs > 0)) return 0; + return Math.max(0, Math.ceil(durationMs / refreshPeriodMs) - 1); +} + +/** + * Choreographer / JS rAF *interval* → excess missed frames (nearest-period). + */ +export function missedFramesFromIntervalMs( + intervalMs: number, + refreshPeriodMs: number, +): number { + if (!(refreshPeriodMs > 0) || !(intervalMs > 0)) return 0; + return Math.max(0, Math.round(intervalMs / refreshPeriodMs) - 1); +} + +/** Sum FrameMetrics-style duration misses. */ +export function excessMissedFramesFromDurations( + durationsMs: number[], + refreshPeriodMs: number, +): number { + let missed = 0; + for (const d of durationsMs) { + missed += missedFramesFromDurationMs(d, refreshPeriodMs); + } + return missed; +} + +/** + * Sum interval-style misses (JS rAF / Choreographer). + */ +export function excessMissedFramesFromIntervals( + intervalsMs: number[], + displayPeriodMs: number, +): number { + let missed = 0; + for (const interval of intervalsMs) { + missed += missedFramesFromIntervalMs(interval, displayPeriodMs); + } + return missed; +} + +export function missedFramesFromTimestamps( + timestampsMs: number[], + displayPeriodMs: number, +): number { + return excessMissedFramesFromIntervals( + frameIntervalsFromTimestamps(timestampsMs), + displayPeriodMs, + ); +} + +export function computeMaxInputDelayMs( + timerDelayMs: number, + frameIntervalsMs: number[], + displayPeriodMs: number, +): number { + let maxExcessFrame = 0; + if (displayPeriodMs > 0) { + for (const interval of frameIntervalsMs) { + maxExcessFrame = Math.max( + maxExcessFrame, + Math.max(0, interval - displayPeriodMs), + ); + } + } + return Math.max(timerDelayMs, maxExcessFrame); +} + +export async function measureDisplayPeriodMs(samples = 8): Promise { + const intervals: number[] = []; + await new Promise(resolve => { + let last = 0; + let n = 0; + const frame = (now: number) => { + if (n > 0) intervals.push(now - last); + last = now; + n++; + if (n <= samples) requestAnimationFrame(frame); + else resolve(); + }; + requestAnimationFrame(frame); + }); + return median(intervals); +} + +export interface UiFrameCaptureAggregate { + source: UiCaptureSource; + frameCount: number; + maxFrameDurationMs: number; + totalFrameDurationMs: number; + missedFrames: number; + refreshPeriodMs: number; + refreshRateHz: number; +} + +/** + * Reject invalid refresh period or zero/insufficient native frames. + * Missed-frame math must match capture source semantics. + */ +export function validateUiFrameCapture( + result: UiFrameCaptureAggregate, + options: { minFrames?: number } = {}, +): void { + const minFrames = options.minFrames ?? 1; + if ( + !(result.refreshPeriodMs > 0) || + !Number.isFinite(result.refreshPeriodMs) + ) { + throw new Error( + `invalid ui refreshPeriodMs=${String(result.refreshPeriodMs)}`, + ); + } + if (!(result.refreshRateHz > 0) || !Number.isFinite(result.refreshRateHz)) { + throw new Error(`invalid ui refreshRateHz=${String(result.refreshRateHz)}`); + } + if (!Number.isInteger(result.frameCount) || result.frameCount < minFrames) { + throw new Error( + `insufficient ui frames: frameCount=${result.frameCount} (need >= ${minFrames}); source=${result.source}`, + ); + } + if ( + !Number.isFinite(result.maxFrameDurationMs) || + result.maxFrameDurationMs < 0 || + !Number.isFinite(result.totalFrameDurationMs) || + result.totalFrameDurationMs < 0 + ) { + throw new Error('invalid ui frame duration aggregates'); + } + if (result.source !== 'FrameMetrics' && result.source !== 'Choreographer') { + throw new Error(`unknown ui capture source: ${String(result.source)}`); + } +} diff --git a/examples/benchmark-native/src/gcHarness.ts b/examples/benchmark-native/src/gcHarness.ts new file mode 100644 index 000000000000..4ddae735c469 --- /dev/null +++ b/examples/benchmark-native/src/gcHarness.ts @@ -0,0 +1,382 @@ +/** + * Isolated Android GC harness — independent of DataProvider. + * + * Builds a benchmark-only Controller + createReducer + timerless GCPolicy with + * raw deterministic state, queues via createCountRef, mode=end-to-end. + * Setup/queue outside timing. Same axes as Node/browser. + */ +import { + Controller, + GCPolicy, + actionTypes, + createReducer, + initialState, +} from '@data-client/core'; +import type { State } from '@data-client/core'; + +import { splitMixedCount } from './scenario'; +import type { GCAndroidMeasurement, GCScenarioConfig } from './types'; + +const { GC } = actionTypes; + +type EntityPath = { key: string; pk: string }; + +export const ENTITY_KEY = 'BenchEntity'; +export const ZERO_META = Object.freeze({ + date: 0, + fetchedAt: 0, + expiresAt: 0, +}); + +/** Timerless explicit policy: no intervals; public sweep → protected runSweep. */ +class AndroidBenchmarkGCPolicy extends GCPolicy { + constructor() { + super({ expiresAt: () => 0 }); + } + + init(controller: Controller) { + this.controller = controller; + } + + cleanup(): void { + // Timerless policy — nothing to clear. + } + + sweep() { + this.runSweep(); + } + + get entityQueueLength() { + return this.entitiesQ.length; + } + + get endpointQueueSize() { + return this.endpointsQ.size; + } + + get queueEntries() { + return this.entityQueueLength + this.endpointQueueSize; + } +} + +export interface ExpectedScalars { + queueEntries: number; + uniqueTargets: number; + expectedEntitiesInAction: number; + expectedEndpointsInAction: number; + expectedUniqueEntityDeletions: number; + expectedEndpointDeletions: number; +} + +export interface Harness { + policy: AndroidBenchmarkGCPolicy; + expected: ExpectedScalars; + getState: () => State; + getCapturedAction: () => { + type: string; + entities: EntityPath[]; + endpoints: string[]; + } | null; + clearCapturedAction: () => void; + dispose: () => void; +} + +function entityPath(pk: number): EntityPath { + return { key: ENTITY_KEY, pk: String(pk) }; +} + +function endpointKey(i: number) { + return `bench-endpoint-${i}`; +} + +function buildEntityState(count: number): State { + const entities = { [ENTITY_KEY]: {} as Record }; + const entitiesMeta = { + [ENTITY_KEY]: {} as Record, + }; + for (let i = 0; i < count; i++) { + const pk = String(i); + entities[ENTITY_KEY][pk] = { id: pk }; + entitiesMeta[ENTITY_KEY][pk] = { ...ZERO_META }; + } + return { ...initialState, entities, entitiesMeta }; +} + +function buildEndpointState(count: number): State { + const endpoints: Record = {}; + const meta: Record = {}; + for (let i = 0; i < count; i++) { + const key = endpointKey(i); + endpoints[key] = String(i); + meta[key] = { ...ZERO_META }; + } + return { ...initialState, endpoints, meta }; +} + +function buildMixedState( + entityCount: number, + endpointCount: number, +): State { + const entities = buildEntityState(entityCount); + const endpoints = buildEndpointState(endpointCount); + return { + ...entities, + endpoints: endpoints.endpoints, + meta: endpoints.meta, + }; +} + +function buildStateForSpec(config: GCScenarioConfig): State { + const { candidateKind, pattern, count } = config; + if (pattern === 'duplicate') return buildEntityState(1); + if (candidateKind === 'entity') return buildEntityState(count); + if (candidateKind === 'endpoint') return buildEndpointState(count); + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + return buildMixedState(entities, endpoints); + } + throw new Error(`unknown candidateKind: ${candidateKind}`); +} + +export function queueCandidates( + policy: AndroidBenchmarkGCPolicy, + config: GCScenarioConfig, +): ExpectedScalars { + const { candidateKind, pattern, count } = config; + + if (pattern === 'duplicate') { + if (candidateKind !== 'entity') { + throw new Error( + `duplicate pattern only supports candidateKind=entity (got ${candidateKind})`, + ); + } + const path = entityPath(0); + const countRef = policy.createCountRef({ paths: [path] }); + for (let i = 0; i < count; i++) { + const release = countRef(); + release(); + } + return { + queueEntries: count, + uniqueTargets: 1, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: 1, + expectedEndpointDeletions: 0, + }; + } + + if (candidateKind === 'entity') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + return { + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: count, + expectedEndpointDeletions: 0, + }; + } + + if (candidateKind === 'endpoint') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return { + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: 0, + expectedEndpointsInAction: count, + expectedUniqueEntityDeletions: 0, + expectedEndpointDeletions: count, + }; + } + + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + for (let i = 0; i < entities; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + for (let i = 0; i < endpoints; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return { + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: entities, + expectedEndpointsInAction: endpoints, + expectedUniqueEntityDeletions: entities, + expectedEndpointDeletions: endpoints, + }; + } + + throw new Error(`unknown candidateKind: ${candidateKind}`); +} + +/** End-to-end harness: sweep dispatches and reduces synchronously. */ +export function createHarness(config: GCScenarioConfig): Harness { + const state = buildStateForSpec(config); + const policy = new AndroidBenchmarkGCPolicy(); + const controller = new Controller({ gcPolicy: policy }); + const reducer = createReducer(controller); + + let capturedAction: { + type: string; + entities: EntityPath[]; + endpoints: string[]; + } | null = null; + let workingState: State = state; + + controller.getState = () => workingState; + controller.dispatch = ((action: any) => { + capturedAction = action; + workingState = reducer(workingState, action); + }) as typeof controller.dispatch; + + policy.init(controller); + const expected = queueCandidates(policy, config); + + if (policy.queueEntries !== expected.queueEntries) { + throw new Error( + `fixture queue cardinality ${policy.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + + return { + policy, + expected, + getState: () => workingState, + getCapturedAction: () => capturedAction, + clearCapturedAction() { + capturedAction = null; + }, + dispose() { + capturedAction = null; + controller.getState = () => initialState; + controller.dispatch = (() => + Promise.resolve()) as typeof controller.dispatch; + policy.cleanup(); + }, + }; +} + +export function countRemaining( + state: State, + config: GCScenarioConfig, + expected: ExpectedScalars, +) { + const bucket = state.entities?.[ENTITY_KEY]; + const entityRemaining = bucket ? Object.keys(bucket).length : 0; + const endpointRemaining = state.endpoints + ? Object.keys(state.endpoints).length + : 0; + + const startedEntities = + config.pattern === 'duplicate' ? 1 : expected.expectedUniqueEntityDeletions; + const startedEndpoints = expected.expectedEndpointDeletions; + + return { + entityDeleted: startedEntities - entityRemaining, + endpointDeleted: startedEndpoints - endpointRemaining, + entityRemaining, + endpointRemaining, + }; +} + +export function validateMeasurement( + config: GCScenarioConfig, + harness: Harness, + sample: Pick< + GCAndroidMeasurement, + | 'actionCount' + | 'deletionCount' + | 'queueEntries' + | 'uniqueTargets' + | 'actionTargetCount' + >, +) { + const { expected, policy, getCapturedAction, getState } = harness; + + if (config.control === 'no-gc') { + if (sample.actionCount !== 0) { + throw new Error( + `no-gc control expected actionCount 0, got ${sample.actionCount}`, + ); + } + if (sample.deletionCount !== 0) { + throw new Error( + `no-gc control expected deletionCount 0, got ${sample.deletionCount}`, + ); + } + return; + } + + if (sample.queueEntries !== expected.queueEntries) { + throw new Error( + `queueEntries ${sample.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + if (sample.uniqueTargets !== expected.uniqueTargets) { + throw new Error( + `uniqueTargets ${sample.uniqueTargets} !== expected ${expected.uniqueTargets}`, + ); + } + + const actionTargetCount = + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; + if (sample.actionTargetCount !== actionTargetCount) { + throw new Error( + `actionTargetCount ${sample.actionTargetCount} !== expected ${actionTargetCount}`, + ); + } + + const action = getCapturedAction(); + if (!action || action.type !== GC) { + throw new Error('expected a GC action to be dispatched'); + } + if (action.entities.length !== expected.expectedEntitiesInAction) { + throw new Error( + `action.entities.length ${action.entities.length} !== ${expected.expectedEntitiesInAction}`, + ); + } + if (action.endpoints.length !== expected.expectedEndpointsInAction) { + throw new Error( + `action.endpoints.length ${action.endpoints.length} !== ${expected.expectedEndpointsInAction}`, + ); + } + if (config.pattern === 'duplicate') { + const unique = new Set(action.entities.map(p => `${p.key}:${p.pk}`)); + if (unique.size !== 1) { + throw new Error( + `duplicate pattern expected 1 unique entity path in action, got ${unique.size}`, + ); + } + } + + const expectedDeletions = + expected.expectedUniqueEntityDeletions + expected.expectedEndpointDeletions; + if (sample.deletionCount !== expectedDeletions) { + throw new Error( + `deletionCount ${sample.deletionCount} !== expected ${expectedDeletions}`, + ); + } + const remaining = countRemaining(getState(), config, expected); + if (remaining.entityRemaining !== 0 || remaining.endpointRemaining !== 0) { + throw new Error( + `expected empty GC targets after deletion; remaining entities=${remaining.entityRemaining} endpoints=${remaining.endpointRemaining}`, + ); + } + if (policy.queueEntries !== 0) { + throw new Error( + `expected empty queues after sweep, got ${policy.queueEntries}`, + ); + } +} + +export { GC }; diff --git a/examples/benchmark-native/src/globals.d.ts b/examples/benchmark-native/src/globals.d.ts new file mode 100644 index 000000000000..98863b0ee40e --- /dev/null +++ b/examples/benchmark-native/src/globals.d.ts @@ -0,0 +1,17 @@ +/** React Native / Hermes globals used by the benchmark harness. */ +interface PerformanceMemory { + usedJSHeapSize?: number; +} + +interface BenchPerformance { + now(): number; + memory?: PerformanceMemory; +} + +declare const performance: BenchPerformance; + +declare namespace NodeJS { + interface Global { + performance: BenchPerformance; + } +} diff --git a/examples/benchmark-native/src/measure.ts b/examples/benchmark-native/src/measure.ts new file mode 100644 index 000000000000..f0d37848e933 --- /dev/null +++ b/examples/benchmark-native/src/measure.ts @@ -0,0 +1,226 @@ +/** + * Interaction measurement: quiet display period (prepare), UI frame capture + + * sustained JS animation, explicit cache GC inside an rAF, post frames, timer + * probe. Aggregate only — no per-candidate logs or bridge calls per frame. + * + * Engine GC must not run inside interaction timing. + * Captured GC action is released before after-memory; live store retained until dispose. + * UI frame capture / JS animation always torn down in finally (including rAF failures). + */ +import BenchNative, { readJsHeapBytes } from './BenchNative'; +import type { MemorySnapshot, UiFrameCaptureResult } from './BenchNative'; +import { + computeMaxInputDelayMs, + excessMissedFramesFromIntervals, + frameIntervalsFromTimestamps, + measureDisplayPeriodMs, + validateUiFrameCapture, +} from './frames'; +import { + countRemaining, + createHarness, + validateMeasurement, + GC, + type Harness, +} from './gcHarness'; +import { runRafCollectSequence } from './rafCollectSequence'; +import type { + GCAndroidMeasurement, + GCPreparedSummary, + GCScenarioConfig, +} from './types'; + +interface Session { + config: GCScenarioConfig; + harness: Harness; + displayPeriodMs: number; +} + +let session: Session | null = null; +let animationHandle: number | null = null; +let onAnimationTick: ((t: number) => void) | null = null; + +export function setAnimationTickListener( + listener: ((t: number) => void) | null, +) { + onAnimationTick = listener; +} + +function startSustainedAnimation() { + stopSustainedAnimation(); + const step = () => { + onAnimationTick?.(performance.now()); + animationHandle = requestAnimationFrame(step); + }; + animationHandle = requestAnimationFrame(step); +} + +function stopSustainedAnimation() { + if (animationHandle != null) { + cancelAnimationFrame(animationHandle); + animationHandle = null; + } +} + +async function stopUiCaptureSafe(): Promise { + try { + return await BenchNative.stopUiFrameCapture(); + } catch { + return null; + } +} + +export async function prepareGCScenario( + config: GCScenarioConfig, +): Promise { + if (session) { + session.harness.dispose(); + session = null; + } + + const harness = createHarness(config); + const displayPeriodMs = await measureDisplayPeriodMs(); + + session = { config, harness, displayPeriodMs }; + + return { + queueEntries: harness.expected.queueEntries, + uniqueTargets: harness.expected.uniqueTargets, + }; +} + +export async function runGCScenario(): Promise { + if (!session) { + throw new Error( + 'prepareGCScenario() must be called before runGCScenario()', + ); + } + const { config, harness, displayPeriodMs } = session; + const { policy, expected } = harness; + + harness.clearCapturedAction(); + + const memBefore: MemorySnapshot = await BenchNative.getMemorySnapshot(); + const jsHeapBeforeBytes = readJsHeapBytes(); + + let uiFrames: UiFrameCaptureResult | null = null; + + try { + await BenchNative.startUiFrameCapture(); + startSustainedAnimation(); + + const sequence = await runRafCollectSequence({ + collectWork: () => { + const t0 = performance.now(); + let actionCount = 0; + if (config.control === 'gc') { + policy.sweep(); + const action = harness.getCapturedAction(); + actionCount = action && action.type === GC ? 1 : 0; + } + return { + totalMs: performance.now() - t0, + actionCount, + }; + }, + }); + + // Deletion accounting outside timed collect work (matches Node/browser) + let deletionCount = 0; + if (config.control === 'gc') { + const remaining = countRemaining(harness.getState(), config, expected); + deletionCount = remaining.entityDeleted + remaining.endpointDeleted; + } + + uiFrames = await BenchNative.stopUiFrameCapture(); + validateUiFrameCapture(uiFrames); + + const frameIntervalsMs = frameIntervalsFromTimestamps( + sequence.frameTimestamps, + ); + + const actionTargetCount = + config.control === 'gc' + ? expected.expectedEntitiesInAction + expected.expectedEndpointsInAction + : 0; + + const missedFrames = excessMissedFramesFromIntervals( + frameIntervalsMs, + displayPeriodMs, + ); + const maxInputDelayMs = computeMaxInputDelayMs( + sequence.timerDelayMs, + frameIntervalsMs, + displayPeriodMs, + ); + + const measurement: GCAndroidMeasurement = { + schemaVersion: 1, + totalMs: sequence.totalMs, + sliceDurationsMs: config.control === 'gc' ? [sequence.totalMs] : [], + actionCount: sequence.actionCount, + queueEntries: expected.queueEntries, + uniqueTargets: expected.uniqueTargets, + actionTargetCount, + deletionCount, + timerDelayMs: sequence.timerDelayMs, + frameIntervalsMs, + displayPeriodMs, + missedFrames, + maxInputDelayMs, + uiCaptureSource: uiFrames.source, + uiFrameCount: uiFrames.frameCount, + uiMaxFrameDurationMs: uiFrames.maxFrameDurationMs, + uiTotalFrameDurationMs: uiFrames.totalFrameDurationMs, + uiMissedFrames: uiFrames.missedFrames, + uiRefreshPeriodMs: uiFrames.refreshPeriodMs, + uiRefreshRateHz: uiFrames.refreshRateHz, + }; + + validateMeasurement(config, harness, measurement); + + // Drop captured GC action arrays before heap snapshot; keep the live store. + harness.clearCapturedAction(); + + const memAfter: MemorySnapshot = await BenchNative.getMemorySnapshot(); + const jsHeapAfterBytes = readJsHeapBytes(); + + measurement.processPssBeforeKb = memBefore.totalPssKb; + measurement.processPssAfterKb = memAfter.totalPssKb; + measurement.processPssDeltaKb = memAfter.totalPssKb - memBefore.totalPssKb; + if (memBefore.rssKb != null) { + measurement.processRssBeforeKb = memBefore.rssKb; + } + if (memAfter.rssKb != null) { + measurement.processRssAfterKb = memAfter.rssKb; + } + if (memBefore.rssKb != null && memAfter.rssKb != null) { + measurement.processRssDeltaKb = memAfter.rssKb - memBefore.rssKb; + } + if (jsHeapBeforeBytes != null) { + measurement.jsHeapBeforeBytes = jsHeapBeforeBytes; + } + if (jsHeapAfterBytes != null) { + measurement.jsHeapAfterBytes = jsHeapAfterBytes; + } + if (jsHeapBeforeBytes != null && jsHeapAfterBytes != null) { + measurement.jsHeapDeltaBytes = jsHeapAfterBytes - jsHeapBeforeBytes; + } + + return measurement; + } finally { + // Always stop animation; stop native capture only if the success path + // did not already take the snapshot (collectWork may reject mid-sequence). + stopSustainedAnimation(); + if (uiFrames == null) { + await stopUiCaptureSafe(); + } + } +} + +export function disposeGCScenario(): void { + stopSustainedAnimation(); + if (!session) return; + session.harness.dispose(); + session = null; +} diff --git a/examples/benchmark-native/src/rafCollectSequence.ts b/examples/benchmark-native/src/rafCollectSequence.ts new file mode 100644 index 000000000000..d2841b74e770 --- /dev/null +++ b/examples/benchmark-native/src/rafCollectSequence.ts @@ -0,0 +1,169 @@ +/** + * Timed collect sequence inside rAF callbacks with fail-closed termination. + * + * If collect work (e.g. policy.sweep) throws, the Promise rejects once and any + * already-queued frame callbacks no-op so the outer finally can tear down + * animation / native capture. + */ + +export type FrameScheduler = (cb: (nowMs: number) => void) => void; + +export interface CollectWorkResult { + totalMs: number; + actionCount: number; +} + +export interface RafCollectSequenceResult { + frameTimestamps: number[]; + timerDelayMs: number; + totalMs: number; + actionCount: number; +} + +export interface RafCollectSequenceOptions { + /** Schedule a frame callback (defaults to requestAnimationFrame). */ + scheduleFrame?: FrameScheduler; + /** performance.now (or test double). */ + now?: () => number; + /** setTimeout(0) probe (or test double). */ + scheduleTimeout0?: (cb: () => void) => void; + postFrames?: number; + /** + * Synchronous collect work invoked inside the collect frame *after* the next + * rAF is queued. May throw — that rejects the sequence promptly. + */ + collectWork: () => CollectWorkResult; + /** Invoked exactly once when the sequence terminates due to error. */ + onTerminateError?: (error: unknown) => void; +} + +/** + * Manual scheduler for deterministic tests: callbacks are queued and flushed + * with synthetic timestamps. + */ +export function createManualFrameScheduler(periodMs = 16.67): { + scheduleFrame: FrameScheduler; + flush: (count?: number) => void; + pending: () => number; +} { + const queue: Array<(nowMs: number) => void> = []; + let nowMs = 0; + return { + scheduleFrame(cb) { + queue.push(cb); + }, + flush(count = 1) { + for (let i = 0; i < count; i++) { + const cb = queue.shift(); + if (!cb) return; + nowMs += periodMs; + cb(nowMs); + } + }, + pending() { + return queue.length; + }, + }; +} + +function defaultScheduleFrame(cb: (nowMs: number) => void) { + requestAnimationFrame(cb); +} + +function defaultTimeout0(cb: () => void) { + setTimeout(cb, 0); +} + +/** + * Run pre → collect → post rAF sequence. Queues the post frame *before* + * collectWork so stalls are visible; wraps work in try/catch with reject-once. + */ +export function runRafCollectSequence( + options: RafCollectSequenceOptions, +): Promise { + const scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + const now = options.now ?? (() => performance.now()); + const scheduleTimeout0 = options.scheduleTimeout0 ?? defaultTimeout0; + const POST_FRAMES = options.postFrames ?? 6; + + return new Promise((resolve, reject) => { + /** Once true, no callback may mutate probe state or settle again. */ + let closed = false; + let phase: 'pre' | 'collect' | 'post' = 'pre'; + let postRemaining = POST_FRAMES; + const frameTimestamps: number[] = []; + let timerDelayMs = 0; + let totalMs = 0; + let actionCount = 0; + + const settleReject = (error: unknown) => { + if (closed) return; + closed = true; + try { + options.onTerminateError?.(error); + } catch { + // ignore cleanup listener failures + } + reject(error); + }; + + const settleResolve = () => { + if (closed) return; + closed = true; + resolve({ + frameTimestamps, + timerDelayMs, + totalMs, + actionCount, + }); + }; + + const frame = (frameNow: number) => { + if (closed) return; + + try { + frameTimestamps.push(frameNow); + + if (phase === 'pre') { + phase = 'collect'; + scheduleFrame(frame); + return; + } + + if (phase === 'collect') { + // Queue next rAF BEFORE collect work so a stall is observable. + phase = 'post'; + postRemaining = POST_FRAMES; + scheduleFrame(frame); + + const beforeCollection = now(); + scheduleTimeout0(() => { + if (closed) return; + timerDelayMs = now() - beforeCollection; + }); + + const result = options.collectWork(); + totalMs = result.totalMs; + actionCount = result.actionCount; + return; + } + + // post + postRemaining--; + if (postRemaining > 0) { + scheduleFrame(frame); + return; + } + + scheduleTimeout0(() => { + if (closed) return; + settleResolve(); + }); + } catch (error) { + settleReject(error); + } + }; + + scheduleFrame(frame); + }); +} diff --git a/examples/benchmark-native/src/report.ts b/examples/benchmark-native/src/report.ts new file mode 100644 index 000000000000..8fde0d627113 --- /dev/null +++ b/examples/benchmark-native/src/report.ts @@ -0,0 +1,246 @@ +import { scenarioId } from './scenario'; +import type { + AndroidEnvironment, + BuildManifestV1, + BuildSidecarV1, + GCAndroidMeasurement, + GCMeasurementReport, + GCScenarioConfig, + GCScenarioReport, + NumberSummary, +} from './types'; + +/** Caller must pass a non-empty sorted array. */ +function percentile(sorted: number[], p: number): number { + if (sorted.length === 1) return sorted[0]!; + const idx = (p / 100) * (sorted.length - 1); + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + if (lo === hi) return sorted[lo]!; + const w = idx - lo; + return sorted[lo]! * (1 - w) + sorted[hi]! * w; +} + +export function summarizeNumbers(values: number[]): NumberSummary | null { + if (values.length === 0) return null; + const sorted = values.slice().sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 + ? (sorted[mid - 1]! + sorted[mid]!) / 2 + : sorted[mid]!; + return { + median, + min: sorted[0]!, + max: sorted[sorted.length - 1]!, + p95: percentile(sorted, 95), + p99: percentile(sorted, 99), + }; +} + +function optionalField( + samples: GCAndroidMeasurement[], + key: keyof GCAndroidMeasurement, +): number[] { + return samples + .map(s => s[key]) + .filter((v): v is number => typeof v === 'number' && Number.isFinite(v)); +} + +function putOptionalSummary( + summary: Record, + key: string, + values: number[], +) { + if (values.length) { + summary[key] = summarizeNumbers(values); + } +} + +export function summarizeGCSamples( + samples: GCAndroidMeasurement[], +): Record { + const summary: Record = { + totalMs: summarizeNumbers(samples.map(s => s.totalMs)), + actionCount: summarizeNumbers(samples.map(s => s.actionCount)), + queueEntries: summarizeNumbers(samples.map(s => s.queueEntries)), + uniqueTargets: summarizeNumbers(samples.map(s => s.uniqueTargets)), + actionTargetCount: summarizeNumbers(samples.map(s => s.actionTargetCount)), + deletionCount: summarizeNumbers(samples.map(s => s.deletionCount)), + timerDelayMs: summarizeNumbers(samples.map(s => s.timerDelayMs)), + displayPeriodMs: summarizeNumbers(samples.map(s => s.displayPeriodMs)), + missedFrames: summarizeNumbers(samples.map(s => s.missedFrames)), + maxInputDelayMs: summarizeNumbers(samples.map(s => s.maxInputDelayMs)), + uiFrameCount: summarizeNumbers(samples.map(s => s.uiFrameCount)), + uiMaxFrameDurationMs: summarizeNumbers( + samples.map(s => s.uiMaxFrameDurationMs), + ), + uiTotalFrameDurationMs: summarizeNumbers( + samples.map(s => s.uiTotalFrameDurationMs), + ), + uiMissedFrames: summarizeNumbers(samples.map(s => s.uiMissedFrames)), + uiRefreshPeriodMs: summarizeNumbers(samples.map(s => s.uiRefreshPeriodMs)), + }; + + const slices = samples.flatMap(s => s.sliceDurationsMs ?? []); + if (slices.length) { + summary.sliceDurationsMs = summarizeNumbers(slices); + } + const frameIntervals = samples.flatMap(s => s.frameIntervalsMs ?? []); + if (frameIntervals.length) { + summary.frameIntervalsMs = summarizeNumbers(frameIntervals); + } + + putOptionalSummary( + summary, + 'processPssBeforeKb', + optionalField(samples, 'processPssBeforeKb'), + ); + putOptionalSummary( + summary, + 'processPssAfterKb', + optionalField(samples, 'processPssAfterKb'), + ); + putOptionalSummary( + summary, + 'processPssDeltaKb', + optionalField(samples, 'processPssDeltaKb'), + ); + putOptionalSummary( + summary, + 'processRssBeforeKb', + optionalField(samples, 'processRssBeforeKb'), + ); + putOptionalSummary( + summary, + 'processRssAfterKb', + optionalField(samples, 'processRssAfterKb'), + ); + putOptionalSummary( + summary, + 'processRssDeltaKb', + optionalField(samples, 'processRssDeltaKb'), + ); + putOptionalSummary( + summary, + 'jsHeapBeforeBytes', + optionalField(samples, 'jsHeapBeforeBytes'), + ); + putOptionalSummary( + summary, + 'jsHeapAfterBytes', + optionalField(samples, 'jsHeapAfterBytes'), + ); + putOptionalSummary( + summary, + 'jsHeapDeltaBytes', + optionalField(samples, 'jsHeapDeltaBytes'), + ); + + return summary; +} + +export const REPORT_UNITS: Record = { + totalMs: 'milliseconds', + sliceDurationsMs: 'milliseconds', + timerDelayMs: 'milliseconds', + frameIntervalsMs: 'milliseconds', + displayPeriodMs: 'milliseconds', + maxInputDelayMs: 'milliseconds (proxy; not pointer latency)', + missedFrames: 'count (JS rAF interval nearest-period excess)', + uiCaptureSource: + 'FrameMetrics (duration/ceil) | Choreographer (interval/round)', + uiFrameCount: 'count', + uiMaxFrameDurationMs: + 'milliseconds (FrameMetrics duration or Choreographer interval)', + uiTotalFrameDurationMs: + 'milliseconds (sum of FrameMetrics durations or Choreographer intervals)', + uiMissedFrames: + 'count (FrameMetrics: ceil(duration/period)−1; Choreographer: round(interval/period)−1)', + uiRefreshPeriodMs: 'milliseconds', + uiRefreshRateHz: 'hertz', + processPssBeforeKb: 'kilobytes (Android Debug.MemoryInfo totalPss)', + processPssAfterKb: 'kilobytes (Android Debug.MemoryInfo totalPss)', + processPssDeltaKb: 'kilobytes (after − before)', + processRssBeforeKb: 'kilobytes (when API exposes RSS)', + processRssAfterKb: 'kilobytes (when API exposes RSS)', + processRssDeltaKb: 'kilobytes (after − before; when RSS available)', + jsHeapBeforeBytes: 'bytes (performance.memory.usedJSHeapSize when exposed)', + jsHeapAfterBytes: 'bytes (performance.memory.usedJSHeapSize when exposed)', + jsHeapDeltaBytes: 'bytes (after − before; when JS heap available)', + actionCount: 'count', + queueEntries: 'count', + uniqueTargets: 'count', + actionTargetCount: 'count', + deletionCount: 'count', +}; + +export const MEMORY_SEMANTICS_DESCRIPTION = + 'After interaction timing, release captured GC action arrays, keep the live store until dispose, then snapshot process/JS memory. System.gc() is not used — it does not force Hermes collection. Engine GC must not run inside interaction timing. Without a forced Hermes GC, before/after JS heap and process PSS/RSS are observational and noisy: they are not sufficient alone for a memory gate. Compare repeated gc vs no-gc controls on the same device/build series; treat deltas as supporting evidence beside interaction/frame metrics.'; + +export function buildScenarioReport( + config: GCScenarioConfig, + samples: GCAndroidMeasurement[], +): GCScenarioReport { + return { + id: scenarioId(config), + platform: 'android', + candidateKind: config.candidateKind, + pattern: config.pattern, + count: config.count, + mode: 'interaction', + control: config.control, + samples, + summary: summarizeGCSamples(samples), + }; +} + +export function buildMeasurementReport(args: { + environment: AndroidEnvironment; + config: GCScenarioConfig; + samples: GCAndroidMeasurement[]; + manifest: BuildManifestV1; + label?: string; + apkSizeBytes?: number; + hermesBytecodeBytes?: number; + hermesAssetsBytes?: number; + sidecar?: BuildSidecarV1; + installedApkSha256?: string; +}): GCMeasurementReport { + const scenario = buildScenarioReport(args.config, args.samples); + return { + schemaVersion: 1, + units: { ...REPORT_UNITS }, + memorySemantics: { + model: 'keep-store-drop-observer', + description: MEMORY_SEMANTICS_DESCRIPTION, + }, + build: { + buildId: args.manifest.buildId, + sourceDigest: args.manifest.sourceDigest, + gitCommit: args.manifest.gitCommit, + gitDirty: args.manifest.gitDirty, + label: args.label, + apkSizeBytes: args.apkSizeBytes, + hermesBytecodeBytes: args.hermesBytecodeBytes, + hermesAssetsBytes: args.hermesAssetsBytes, + sidecar: args.sidecar + ? { + buildId: args.sidecar.buildId, + sourceDigest: args.sidecar.sourceDigest, + apkSha256: args.sidecar.apkSha256, + apkPath: args.sidecar.apkPath, + sidecarId: args.sidecar.sidecarId, + } + : undefined, + installedApkSha256: args.installedApkSha256, + }, + environment: args.environment, + config: { + samplesPerScenario: args.samples.length, + filter: null, + scenarioId: scenario.id, + }, + scenarios: [scenario], + }; +} diff --git a/examples/benchmark-native/src/runOrchestration.ts b/examples/benchmark-native/src/runOrchestration.ts new file mode 100644 index 000000000000..3f28490f0521 --- /dev/null +++ b/examples/benchmark-native/src/runOrchestration.ts @@ -0,0 +1,94 @@ +/** + * Shared measurement orchestration for manual Run and intent auto-run. + */ +import BenchNative from './BenchNative'; +import { disposeGCScenario, prepareGCScenario, runGCScenario } from './measure'; +import { buildMeasurementReport } from './report'; +import { scenarioId } from './scenario'; +import type { + AndroidEnvironment, + BuildManifestV1, + GCAndroidMeasurement, + GCMeasurementReport, + GCScenarioConfig, +} from './types'; +import { validateSampleCount, validateScenarioConfig } from './validateConfig'; + +export interface ExecuteMeasurementArgs { + config: GCScenarioConfig; + samples: number; + label?: string; + onStatus?: (message: string) => void; +} + +export interface ExecuteMeasurementResult { + report: GCMeasurementReport; + path: string; + scenarioId: string; + samples: GCAndroidMeasurement[]; +} + +async function readEnvironment(): Promise { + const envRaw = await BenchNative.getEnvironment(); + return { platform: 'android', ...envRaw }; +} + +export async function readEmbeddedBuildManifest(): Promise { + const raw = await BenchNative.getBuildManifest(); + const text = raw.json; + if (!text) { + throw new Error('embedded build-manifest.json empty'); + } + const parsed = JSON.parse(text) as BuildManifestV1; + if (parsed.schemaVersion !== 1 || !parsed.buildId || !parsed.sourceDigest) { + throw new Error('invalid embedded BuildManifest v1'); + } + return parsed; +} + +export async function executeMeasurement( + args: ExecuteMeasurementArgs, +): Promise { + validateScenarioConfig(args.config); + validateSampleCount(args.samples); + + const id = scenarioId(args.config); + args.onStatus?.(`Running ${id}…`); + + const environment = await readEnvironment(); + const manifest = await readEmbeddedBuildManifest(); + const collected: GCAndroidMeasurement[] = []; + + try { + for (let i = 0; i < args.samples; i++) { + args.onStatus?.(`Sample ${i + 1}/${args.samples}: prepare…`); + await prepareGCScenario(args.config); + // Let the React status commit paint before capture so it does not + // contaminate UI frame / interaction measurement (outside timing). + args.onStatus?.(`Sample ${i + 1}/${args.samples}: measure…`); + await new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + collected.push(await runGCScenario()); + disposeGCScenario(); + } + + const report = buildMeasurementReport({ + environment, + config: args.config, + samples: collected, + manifest, + label: args.label, + }); + + const { path } = await BenchNative.writeReport(JSON.stringify(report)); + args.onStatus?.( + `Done — wrote ${path} (buildId=${manifest.buildId.slice(0, 8)}… totalMs median≈${report.scenarios[0].summary.totalMs?.median?.toFixed?.(2) ?? 'n/a'})`, + ); + + return { report, path, scenarioId: id, samples: collected }; + } catch (e) { + disposeGCScenario(); + throw e; + } +} diff --git a/examples/benchmark-native/src/scenario.ts b/examples/benchmark-native/src/scenario.ts new file mode 100644 index 000000000000..7546e440c254 --- /dev/null +++ b/examples/benchmark-native/src/scenario.ts @@ -0,0 +1,106 @@ +import type { + CandidateKind, + Control, + GCScenarioConfig, + Pattern, +} from './types'; +import { CANONICAL_COUNTS } from './types'; +import { validateScenarioConfig } from './validateConfig'; + +export interface ScenarioAxes { + platform: 'android'; + candidateKind: CandidateKind; + pattern: Pattern; + count: number; + mode: 'interaction'; + control: Control; +} + +/** Stable scenario ID: `android/{kind}/{pattern}/{count}/interaction/{control}`. */ +export function scenarioId(axes: ScenarioAxes | GCScenarioConfig): string { + return [ + 'android', + axes.candidateKind, + axes.pattern, + String(axes.count), + 'interaction', + axes.control, + ].join('/'); +} + +/** + * Split mixed total into entity + endpoint counts (entities get the remainder + * when odd so entityCount + endpointCount === total). + */ +export function splitMixedCount(total: number): { + entities: number; + endpoints: number; +} { + const endpoints = Math.floor(total / 2); + return { entities: total - endpoints, endpoints }; +} + +export function parseScenarioId(id: string): ScenarioAxes { + const parts = id.split('/'); + if ( + parts.length !== 6 || + parts[0] !== 'android' || + parts[4] !== 'interaction' + ) { + throw new Error(`invalid android scenario id: ${id}`); + } + const config: GCScenarioConfig = { + candidateKind: parts[1] as CandidateKind, + pattern: parts[2] as Pattern, + count: Number(parts[3]), + control: parts[5] as Control, + }; + validateScenarioConfig(config); + return { + platform: 'android', + ...config, + mode: 'interaction', + }; +} + +export function listScenarios(filter?: string): ScenarioAxes[] { + const kinds: CandidateKind[] = ['entity', 'endpoint', 'mixed']; + const controls: Control[] = ['gc', 'no-gc']; + const out: ScenarioAxes[] = []; + + for (const candidateKind of kinds) { + for (const count of CANONICAL_COUNTS) { + for (const control of controls) { + out.push({ + platform: 'android', + candidateKind, + pattern: 'unique', + count, + mode: 'interaction', + control, + }); + } + } + } + + for (const count of CANONICAL_COUNTS) { + for (const control of controls) { + out.push({ + platform: 'android', + candidateKind: 'entity', + pattern: 'duplicate', + count, + mode: 'interaction', + control, + }); + } + } + + if (!filter) return out; + const prefix = filter.startsWith('^'); + const needle = prefix ? filter.slice(1) : filter; + return out.filter(s => { + const id = scenarioId(s); + return prefix ? id.startsWith(needle) : id.includes(needle); + }); +} diff --git a/examples/benchmark-native/src/types.ts b/examples/benchmark-native/src/types.ts new file mode 100644 index 000000000000..f8d64e4867ee --- /dev/null +++ b/examples/benchmark-native/src/types.ts @@ -0,0 +1,189 @@ +/** + * Local schemaVersion 1 types mirroring the documented GC measurement protocol. + * No shared runtime package — semantic compatibility only. + */ + +export type CandidateKind = 'entity' | 'endpoint' | 'mixed'; +export type Pattern = 'unique' | 'duplicate'; +export type Control = 'gc' | 'no-gc'; +export type Mode = 'interaction'; +export type UiCaptureSource = 'FrameMetrics' | 'Choreographer'; + +/** Canonical scenario counts. */ +export const CANONICAL_COUNTS = [1_000, 10_000, 100_000] as const; +export type CanonicalCount = (typeof CANONICAL_COUNTS)[number]; + +/** Axes for Android GC scenarios. */ +export interface GCScenarioConfig { + candidateKind: CandidateKind; + /** `duplicate` is entity-only (one path released `count` times). */ + pattern: Pattern; + count: number; + control: Control; +} + +export interface GCPreparedSummary { + queueEntries: number; + uniqueTargets: number; +} + +/** + * JS-side interaction measurement (schemaVersion 1). + * Native UI-frame and process-memory fields are attached by the runner. + */ +export interface GCAndroidMeasurement { + schemaVersion: 1; + totalMs: number; + /** Monolithic baseline: exactly `[totalMs]` when control is `gc`; empty for `no-gc`. */ + sliceDurationsMs: number[]; + actionCount: number; + queueEntries: number; + uniqueTargets: number; + actionTargetCount: number; + deletionCount: number; + timerDelayMs: number; + frameIntervalsMs: number[]; + displayPeriodMs: number; + missedFrames: number; + maxInputDelayMs: number; + /** Native UI capture source — required when UI metrics are present. */ + uiCaptureSource: UiCaptureSource; + uiFrameCount: number; + /** Aggregate max single-frame duration/interval (ms). */ + uiMaxFrameDurationMs: number; + /** Aggregate sum of frame durations/intervals (ms). */ + uiTotalFrameDurationMs: number; + uiMissedFrames: number; + uiRefreshPeriodMs: number; + uiRefreshRateHz: number; + processPssBeforeKb?: number; + processPssAfterKb?: number; + processPssDeltaKb?: number; + processRssBeforeKb?: number; + processRssAfterKb?: number; + processRssDeltaKb?: number; + jsHeapBeforeBytes?: number; + jsHeapAfterBytes?: number; + jsHeapDeltaBytes?: number; +} + +export interface NumberSummary { + median: number; + min: number; + max: number; + p95: number; + p99: number; +} + +export interface GCScenarioReport { + id: string; + platform: 'android'; + candidateKind: CandidateKind; + pattern: Pattern; + count: number; + mode: Mode; + control: Control; + samples: GCAndroidMeasurement[]; + summary: Record; +} + +export interface AndroidEnvironment { + platform: 'android'; + apiLevel: number; + release: string; + manufacturer: string; + model: string; + device: string; + brand: string; + buildType: string; + applicationId: string; + hermesEnabled: boolean; + hermesRuntimeProperties?: Record; + refreshRateHz: number; + refreshPeriodMs: number; +} + +/** Embedded BuildManifest v1 (baked into APK assets at prepare time). */ +export interface BuildManifestV1 { + schemaVersion: 1; + /** + * Source-build identity: deterministic sha256 over + * schemaVersion, gitCommit, gitDirty, sourceDigest (not APK bytes). + */ + buildId: string; + gitCommit: string; + gitDirty: boolean; + /** Content digest of sorted app + packages/core/src inputs. */ + sourceDigest: string; + createdAt: string; +} + +/** Host sidecar written after APK assemble (authority for collection). */ +export interface BuildSidecarV1 { + schemaVersion: 1; + /** Same source-build identity as the embedded BuildManifest. */ + buildId: string; + sourceDigest: string; + gitCommit: string; + gitDirty: boolean; + apkPath: string; + /** Artifact identity: sha256 of the single release APK bytes. */ + apkSha256: string; + apkSizeBytes: number; + /** Artifact-aware id: sha256(buildId ∥ apkSha256). */ + sidecarId: string; + builtAt: string; +} + +export interface GCMeasurementReport { + schemaVersion: 1; + units: Record; + memorySemantics: { + model: 'keep-store-drop-observer'; + description: string; + }; + build: { + /** + * Source-build identity from the embedded BuildManifest + * (deterministic over schemaVersion/gitCommit/gitDirty/sourceDigest). + * Matched to sidecar.buildId at collection — not live checkout authority. + */ + buildId: string; + sourceDigest: string; + gitCommit: string; + gitDirty: boolean; + label?: string; + apkSizeBytes?: number; + hermesBytecodeBytes?: number; + hermesAssetsBytes?: number; + /** Host sidecar fields attached at collection — not live checkout. */ + sidecar?: { + buildId: string; + sourceDigest: string; + /** Artifact identity of the release APK. */ + apkSha256: string; + apkPath: string; + sidecarId: string; + }; + /** sha256 of the APK actually installed on device (must equal sidecar.apkSha256). */ + installedApkSha256?: string; + }; + environment: AndroidEnvironment; + config: { + samplesPerScenario: number; + filter: string | null; + scenarioId: string; + }; + scenarios: GCScenarioReport[]; +} + +export interface LaunchConfig { + autoRun: boolean; + candidateKind: CandidateKind; + pattern: Pattern; + count: number; + control: Control; + samples: number; + /** Optional human label only — not provenance authority. */ + label?: string; +} diff --git a/examples/benchmark-native/src/validateConfig.ts b/examples/benchmark-native/src/validateConfig.ts new file mode 100644 index 000000000000..9d58a66420cf --- /dev/null +++ b/examples/benchmark-native/src/validateConfig.ts @@ -0,0 +1,182 @@ +import { + CANONICAL_COUNTS, + type CandidateKind, + type Control, + type GCScenarioConfig, + type LaunchConfig, + type Pattern, +} from './types'; + +export const CANDIDATE_KINDS: readonly CandidateKind[] = [ + 'entity', + 'endpoint', + 'mixed', +] as const; + +export const PATTERNS: readonly Pattern[] = ['unique', 'duplicate'] as const; + +export const CONTROLS: readonly Control[] = ['gc', 'no-gc'] as const; + +/** Positive sample counts capped to keep accidental huge runs from launching. */ +export const MAX_SAMPLES = 50; + +export class ConfigValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigValidationError'; + } +} + +function isCanonicalCount(count: number): boolean { + return (CANONICAL_COUNTS as readonly number[]).includes(count); +} + +/** + * Validate scenario axes. Throws ConfigValidationError on invalid input. + * Does not coerce — callers must pass already-parsed values or use parseLaunchConfig. + */ +export function validateScenarioConfig(config: GCScenarioConfig): void { + if (!CANDIDATE_KINDS.includes(config.candidateKind)) { + throw new ConfigValidationError( + `invalid candidateKind=${String(config.candidateKind)}; expected one of ${CANDIDATE_KINDS.join('|')}`, + ); + } + if (!PATTERNS.includes(config.pattern)) { + throw new ConfigValidationError( + `invalid pattern=${String(config.pattern)}; expected one of ${PATTERNS.join('|')}`, + ); + } + if (!CONTROLS.includes(config.control)) { + throw new ConfigValidationError( + `invalid control=${String(config.control)}; expected one of ${CONTROLS.join('|')}`, + ); + } + if (!Number.isInteger(config.count) || !isCanonicalCount(config.count)) { + throw new ConfigValidationError( + `invalid count=${String(config.count)}; expected one of ${CANONICAL_COUNTS.join('|')}`, + ); + } + if (config.pattern === 'duplicate' && config.candidateKind !== 'entity') { + throw new ConfigValidationError( + `duplicate pattern only supports candidateKind=entity (got ${config.candidateKind})`, + ); + } +} + +export function validateSampleCount(samples: number): void { + if ( + !Number.isInteger(samples) || + samples < 1 || + samples > MAX_SAMPLES || + !Number.isFinite(samples) + ) { + throw new ConfigValidationError( + `invalid samples=${String(samples)}; expected integer 1..${MAX_SAMPLES}`, + ); + } +} + +export function validateLaunchConfig(config: LaunchConfig): void { + validateScenarioConfig({ + candidateKind: config.candidateKind, + pattern: config.pattern, + count: config.count, + control: config.control, + }); + validateSampleCount(config.samples); +} + +/** + * Parse raw launch/intent/env fields into a LaunchConfig. + * Missing optional fields get safe defaults; invalid values throw. + */ +export function parseLaunchConfig(raw: { + autoRun?: unknown; + candidateKind?: unknown; + pattern?: unknown; + count?: unknown; + control?: unknown; + samples?: unknown; + label?: unknown; +}): LaunchConfig { + const autoRun = Boolean(raw.autoRun); + + const candidateKindRaw = + raw.candidateKind === undefined || raw.candidateKind === null + ? 'entity' + : String(raw.candidateKind); + const patternRaw = + raw.pattern === undefined || raw.pattern === null + ? 'unique' + : String(raw.pattern); + const controlRaw = + raw.control === undefined || raw.control === null + ? 'gc' + : String(raw.control); + + if (raw.count !== undefined && raw.count !== null && raw.count !== '') { + const countNum = Number(raw.count); + if (!Number.isInteger(countNum)) { + throw new ConfigValidationError( + `invalid count=${String(raw.count)}; expected integer canonical count`, + ); + } + } + const count = + raw.count === undefined || raw.count === null || raw.count === '' + ? 1000 + : Number(raw.count); + + const samplesRaw = + raw.samples === undefined || raw.samples === null || raw.samples === '' + ? 1 + : Number(raw.samples); + if (!Number.isFinite(samplesRaw)) { + throw new ConfigValidationError( + `invalid samples=${String(raw.samples)}; expected integer 1..${MAX_SAMPLES}`, + ); + } + const samples = Math.floor(samplesRaw); + + const label = + typeof raw.label === 'string' && raw.label.length > 0 + ? raw.label + : undefined; + + const config: LaunchConfig = { + autoRun, + candidateKind: candidateKindRaw as CandidateKind, + pattern: patternRaw as Pattern, + count, + control: controlRaw as Control, + samples, + label, + }; + validateLaunchConfig(config); + return config; +} + +/** Host-env shape used by collect-report / matrix (strings from the shell). */ +export function parseHostEnvConfig(env: { + candidateKind?: string; + pattern?: string; + count?: string | number; + control?: string; + samples?: string | number; +}): GCScenarioConfig & { samples: number } { + const parsed = parseLaunchConfig({ + autoRun: false, + candidateKind: env.candidateKind, + pattern: env.pattern, + count: env.count, + control: env.control, + samples: env.samples, + }); + return { + candidateKind: parsed.candidateKind, + pattern: parsed.pattern, + count: parsed.count, + control: parsed.control, + samples: parsed.samples, + }; +} diff --git a/examples/benchmark-native/tsconfig.json b/examples/benchmark-native/tsconfig.json new file mode 100644 index 000000000000..66a2c0a97212 --- /dev/null +++ b/examples/benchmark-native/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@react-native/typescript-config", + "compilerOptions": { + "types": ["jest"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", "src/globals.d.ts"], + "exclude": ["**/node_modules", "**/Pods", "android", "artifacts"] +} diff --git a/examples/benchmark-react/.gitignore b/examples/benchmark-react/.gitignore new file mode 100644 index 000000000000..faf1b1e9bc7f --- /dev/null +++ b/examples/benchmark-react/.gitignore @@ -0,0 +1,2 @@ +# Generated GC detailed report (default path; override with BENCH_GC_OUTPUT) +gc-measurement-output.json diff --git a/examples/benchmark-react/AGENTS.md b/examples/benchmark-react/AGENTS.md index bbce217dcc65..62d305e7784c 100644 --- a/examples/benchmark-react/AGENTS.md +++ b/examples/benchmark-react/AGENTS.md @@ -32,7 +32,7 @@ Filtering: `yarn bench --lib data-client --size small --action update` ## Scenario System -`BASE_SCENARIOS` in `bench/scenarios.ts` × `LIBRARIES` via `flatMap`. `onlyLibs` restricts to specific libs. CI runs data-client hot-path only (no memory/startup/deterministic). Memory is opt-in locally (`--action memory`). Convergent timing uses single page load with adaptive iterations and early stopping on statistical convergence. Ref-stability scenarios run once (deterministic count, not ops/s). +`BASE_SCENARIOS` in `bench/scenarios.ts` × `LIBRARIES` via `flatMap`. `onlyLibs` restricts to specific libs. CI runs data-client hot-path only (no memory/startup/deterministic/gc). Memory and GC are opt-in locally (`--action memory` / `--action gc` / `yarn bench:gc`). Convergent timing uses single page load with adaptive iterations and early stopping on statistical convergence. Ref-stability scenarios run once (deterministic count, not ops/s). GC uses a dedicated fixed-sample phase with an isolated Controller harness (`src/data-client/gcBrowserHarness.ts`), not the DataProvider `benchGC` singleton. ## Update Data Flow @@ -75,3 +75,7 @@ Filtering: `yarn bench --lib data-client --size small --action update` | `BENCH_TRACE=true` | Chrome tracing for duration scenarios | | `BENCH_V8_TRACE=true` | Launch Chromium with `--trace-opt --trace-deopt`; output to `v8-trace.log` | | `BENCH_V8_DEOPT=true` | Launch Chromium with `--prof`; V8 logs to `v8-logs/` | +| `BENCH_GC_SAMPLES` | Samples per GC scenario (default 5; also `--samples`) | +| `BENCH_GC_OUTPUT` | Override path for detailed GC JSON (default `gc-measurement-output.json`, gitignored) | + +BuildManifest v1 is written to `dist/gc-build-manifest.json` after webpack (`yarn build`). `buildId` digests schemaVersion, commit, dirty, sourceDigest, and sorted artifact hashes. GC runs require a matching served manifest. diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 6cb1d7d29a47..e81081b8db5b 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -30,6 +30,7 @@ All implementations share presentational components, fixture data, fetch functio - **With network (local)** — Same shared-author update but with simulated network delay (consistent ms per "request"). Normalized caches propagate via a single store update; query-keyed caches invalidate and refetch affected queries. **Not run in CI** — run locally with `yarn bench` (no `CI` env) to include these. - **Memory (local only)** — Heap delta after repeated mount/unmount cycles. - **Startup (local only)** — FCP and task duration via CDP `Performance.getMetrics`. +- **GC (local only)** — Isolated data-client cache GC interaction + heap deltas on Chromium. Excluded by default and from CI; opt in with `--action gc`. ## Scenarios @@ -52,6 +53,29 @@ All implementations share presentational components, fixture data, fetch functio - **Memory mount/unmount cycle** (`memory-mount-unmount-cycle`) — Mount 500 issues, unmount, repeat 10 times; report JS heap delta (bytes) via CDP. Surfaces leaks or unbounded growth. +**GC (local only, data-client)** + +Isolated monolithic cache-GC baseline (not V8/engine GC). Builds a separate Controller + timerless GCPolicy with raw deterministic state — no 100k React rows, no network. Mode is always end-to-end (sweep → reduce). + +- **Axes:** `candidateKind` entity|endpoint|mixed × `pattern` unique|duplicate (duplicate is entity-only) × `count` 1k/10k/100k × `control` gc|no-gc +- **Timing boundaries:** prepare (fixtures + queue + quiet `displayPeriodMs` + dynamic harness import) is untimed; `runGCScenario` measures only sweep/no-op inside a Chromium-calibrated probe (rAF registers next rAF, then `setTimeout(0)` runs collection post-paint while a frame is pending — Chromium-specific calibration, not a web guarantee); engine GC for heap snapshots is outside interaction timing +- **Probes:** wall-clock frame timestamps, `setTimeout(0)` `timerDelayMs`, `missedFrames` via nearest-period excess over measured `displayPeriodMs`, long-task overlap filter + `takeRecords`. `maxInputDelayMs` is a responsiveness proxy (not pointer input). Validation runs a synthetic 40–50 ms block calibration confirming the probe spans pending frames +- **Stable IDs:** detailed report scenario ids are `browser/{kind}/{pattern}/{count}/end-to-end/{control}` (not display names) +- **Failures:** incomplete runs write `complete:false` with failure records and exit nonzero +- **Bundle fairness:** GC harness is a dynamic `import()` chunk loaded during prepare — not in the initial data-client bundle +- **Provenance:** `yarn build` writes `dist/gc-build-manifest.json` (BuildManifest v1). GC runner verifies local source digest + artifact hashes and matching served manifest `buildId` before measuring; report embeds verified provenance (never live HEAD) +- **Outputs:** scalar median `totalMs` on stdout. Detailed schemaVersion 1 JSON is always written to `gc-measurement-output.json` (gitignored; override path with `BENCH_GC_OUTPUT`). Failed runs still write `complete:false` reports before exiting nonzero + +```bash +yarn build # webpack + BuildManifest +yarn bench:gc # all GC scenarios, 5 samples each +yarn bench:gc --scenario unique-1000 --samples 3 +yarn bench:gc --scenario entity-unique-100000 +BENCH_GC_OUTPUT=./custom-gc.json yarn bench:gc --scenario mixed-unique-100000 +yarn test:gc-metrics +yarn test:gc-provenance # after build +``` + **Startup (local only)** - **Startup FCP** (`startup-fcp`) — First Contentful Paint time via CDP `Performance.getMetrics`. @@ -171,8 +195,10 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar |---|---|---| | `--lib ` | `BENCH_LIB` | Comma-separated library names (e.g. `data-client,swr`) | | `--size ` | `BENCH_SIZE` | Run only `small` (cheap, full rigor) or `large` (expensive, reduced runs) scenarios | - | `--action ` | `BENCH_ACTION` | Filter by action group (`mount`, `update`, `mutation`, `memory`) or exact action name. Memory is **not run by default**; use `--action memory` to include. | + | `--action ` | `BENCH_ACTION` | Filter by action group (`mount`, `update`, `mutation`, `memory`, `gc`) or exact action name. Memory and GC are **not run by default**; use `--action memory` or `--action gc` to include. | | `--scenario ` | `BENCH_SCENARIO` | Substring filter on scenario name | + | `--samples ` | `BENCH_GC_SAMPLES` | Samples per GC scenario (default 5; GC phase only) | + | (env only) | `BENCH_GC_OUTPUT` | Override detailed GC report path (default `gc-measurement-output.json`) | CLI flags take precedence over env vars. Examples: @@ -181,6 +207,7 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar yarn bench --size small # only cheap scenarios (full warmup/measurement) yarn bench --action mount # init, mountSortedView yarn bench --action memory # memory-mount-unmount-cycle (heap delta; opt-in category) + yarn bench --action gc # isolated cache GC scenarios (data-client; opt-in) yarn bench --action update --lib swr # update scenarios for swr only yarn bench --scenario sorted-view # only sorted-view scenarios ``` @@ -191,6 +218,7 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar yarn bench:small # --size small yarn bench:large # --size large yarn bench:dc # --lib data-client + yarn bench:gc # --lib data-client --action gc ``` 5. **Scenario sizes** @@ -201,6 +229,7 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar - **Small** (deterministic, single run): `ref-stability-*` - **Large** (convergent: 5 warmup + 10–50 measurement iterations): `getlist-500`, `getlist-500-sorted`, `update-user`, `update-user-10000`, `update-entity-sorted`, `update-entity-multi-view`, `list-detail-switch-10` - **Memory** (opt-in, 1 warmup + 3 measurement rounds): `memory-mount-unmount-cycle` — run with `--action memory` + - **GC** (opt-in, fixed samples default 5): `gc-*-{1000,10000,100000}-{gc,no-gc}` — run with `yarn bench:gc` or `--action gc` Timing scenarios use convergent mode (single page load, inline convergence per scenario). Each group uses its own warmup/measurement config. Use `--size` to run only one group. diff --git a/examples/benchmark-react/bench/build-manifest.ts b/examples/benchmark-react/bench/build-manifest.ts new file mode 100644 index 000000000000..ee554a63ea54 --- /dev/null +++ b/examples/benchmark-react/bench/build-manifest.ts @@ -0,0 +1,277 @@ +/** + * BuildManifest v1 — provenance for browser GC measurements. + * + * Generated after webpack build. Runner verifies local source digest + artifact + * hashes and that the served `/gc-build-manifest.json` matches before measuring. + */ +import { execSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const BENCH_ROOT = path.resolve(__dirname, '..'); +export const REPO_ROOT = path.resolve(BENCH_ROOT, '../..'); +export const DIST_DIR = path.join(BENCH_ROOT, 'dist'); +export const MANIFEST_FILENAME = 'gc-build-manifest.json'; +export const MANIFEST_PATH = path.join(DIST_DIR, MANIFEST_FILENAME); + +export interface BuildManifestV1 { + schemaVersion: 1; + buildId: string; + commit: string; + dirty: boolean; + sourceDigest: string; + /** relative path from dist/ → sha256 hex */ + artifacts: Record; +} + +function sha256Buffer(buf: Buffer): string { + return createHash('sha256').update(buf).digest('hex'); +} + +function sha256File(filePath: string): string { + return sha256Buffer(fs.readFileSync(filePath)); +} + +function git(cmd: string): string { + try { + return execSync(`git ${cmd}`, { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +/** Walk directory; return absolute file paths sorted. */ +function walkFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + const stack = [dir]; + while (stack.length) { + const cur = stack.pop()!; + for (const ent of fs.readdirSync(cur, { withFileTypes: true })) { + const p = path.join(cur, ent.name); + if (ent.isDirectory()) { + if ( + ent.name === 'node_modules' || + ent.name === 'dist' || + ent.name === '.git' + ) + continue; + stack.push(p); + } else if (ent.isFile()) { + out.push(p); + } + } + } + return out.sort(); +} + +/** + * Sorted relevant inputs for sourceDigest: + * packages/core/src + benchmark-react src/bench/config/package files. + * Hashes actual file contents (includes dirty/untracked). + */ +export function listSourceInputFiles(): string[] { + const files = new Set(); + for (const f of walkFiles(path.join(REPO_ROOT, 'packages/core/src'))) { + files.add(f); + } + const benchRoots = [ + path.join(BENCH_ROOT, 'src'), + path.join(BENCH_ROOT, 'bench'), + ]; + for (const root of benchRoots) { + for (const f of walkFiles(root)) files.add(f); + } + for (const name of [ + 'package.json', + 'tsconfig.json', + 'webpack.config.cjs', + '.babelrc.js', + 'playwright.config.ts', + ]) { + const p = path.join(BENCH_ROOT, name); + if (fs.existsSync(p)) files.add(p); + } + return [...files].sort(); +} + +export function computeSourceDigest(files = listSourceInputFiles()): string { + const h = createHash('sha256'); + for (const abs of files) { + const rel = path.relative(REPO_ROOT, abs).split(path.sep).join('/'); + h.update(rel); + h.update('\0'); + h.update(fs.readFileSync(abs)); + h.update('\0'); + } + return h.digest('hex'); +} + +export function listDistArtifacts(distDir = DIST_DIR): string[] { + return walkFiles(distDir) + .filter(f => path.basename(f) !== MANIFEST_FILENAME) + .sort(); +} + +export function hashDistArtifacts(distDir = DIST_DIR): Record { + const artifacts: Record = {}; + for (const abs of listDistArtifacts(distDir)) { + const rel = path.relative(distDir, abs).split(path.sep).join('/'); + artifacts[rel] = sha256File(abs); + } + // Stable key order + return Object.fromEntries( + Object.keys(artifacts) + .sort() + .map(k => [k, artifacts[k]]), + ); +} + +export function isGitDirty(): boolean { + const status = git('status --porcelain'); + return status.length > 0; +} + +export function gitCommitFull(): string { + return git('rev-parse HEAD') || 'unknown'; +} + +export function computeBuildId(inputs: { + schemaVersion: 1; + commit: string; + dirty: boolean; + sourceDigest: string; + artifacts: Record; +}): string { + const h = createHash('sha256'); + // Canonical BuildManifest v1 inputs (order fixed; artifacts sorted by path) + h.update(`schemaVersion:${inputs.schemaVersion}\n`); + h.update(`commit:${inputs.commit}\n`); + h.update(`dirty:${inputs.dirty ? '1' : '0'}\n`); + h.update(`sourceDigest:${inputs.sourceDigest}\n`); + for (const k of Object.keys(inputs.artifacts).sort()) { + h.update(k); + h.update(':'); + h.update(inputs.artifacts[k]); + h.update('\n'); + } + return h.digest('hex'); +} + +export function writeBuildManifest(distDir = DIST_DIR): BuildManifestV1 { + fs.mkdirSync(distDir, { recursive: true }); + const sourceDigest = computeSourceDigest(); + const artifacts = hashDistArtifacts(distDir); + const commit = gitCommitFull(); + const dirty = isGitDirty(); + const schemaVersion = 1 as const; + const buildId = computeBuildId({ + schemaVersion, + commit, + dirty, + sourceDigest, + artifacts, + }); + const manifest: BuildManifestV1 = { + schemaVersion, + buildId, + commit, + dirty, + sourceDigest, + artifacts, + }; + fs.writeFileSync( + path.join(distDir, MANIFEST_FILENAME), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + return manifest; +} + +export function readBuildManifest( + manifestPath = MANIFEST_PATH, +): BuildManifestV1 { + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (raw.schemaVersion !== 1) { + throw new Error( + `unsupported build manifest schemaVersion: ${raw.schemaVersion}`, + ); + } + return raw as BuildManifestV1; +} + +export interface ProvenanceVerification { + ok: true; + manifest: BuildManifestV1; +} + +/** + * Verify local source digest + on-disk artifact hashes match the manifest. + * Does not fetch the served URL (caller does that separately). + */ +export function verifyLocalManifest( + manifest: BuildManifestV1, + distDir = DIST_DIR, +): void { + const sourceDigest = computeSourceDigest(); + if (sourceDigest !== manifest.sourceDigest) { + throw new Error( + `stale build: sourceDigest mismatch (manifest ${manifest.sourceDigest.slice(0, 12)}… vs current ${sourceDigest.slice(0, 12)}…). Rebuild with yarn build.`, + ); + } + const artifacts = hashDistArtifacts(distDir); + const manKeys = Object.keys(manifest.artifacts).sort(); + const curKeys = Object.keys(artifacts).sort(); + if (manKeys.join('\n') !== curKeys.join('\n')) { + throw new Error( + 'stale/tampered build: dist artifact set does not match manifest', + ); + } + for (const k of manKeys) { + if (artifacts[k] !== manifest.artifacts[k]) { + throw new Error( + `tampered/stale artifact: ${k} hash mismatch (rebuild required)`, + ); + } + } + const buildId = computeBuildId({ + schemaVersion: 1, + commit: manifest.commit, + dirty: manifest.dirty, + sourceDigest, + artifacts, + }); + if (buildId !== manifest.buildId) { + throw new Error( + 'buildId mismatch after recompute (manifest fields tampered or corrupted?)', + ); + } +} + +/** CLI: write manifest after webpack. */ +async function main() { + if (!fs.existsSync(DIST_DIR)) { + throw new Error(`dist/ missing at ${DIST_DIR}; run webpack build first`); + } + const m = writeBuildManifest(); + process.stderr.write( + `BuildManifest v1 → ${MANIFEST_FILENAME} buildId=${m.buildId.slice(0, 16)}… commit=${m.commit.slice(0, 8)} dirty=${m.dirty}\n`, + ); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + main().catch(err => { + console.error(err); + process.exit(1); + }); +} diff --git a/examples/benchmark-react/bench/gc-interaction-metrics.test.ts b/examples/benchmark-react/bench/gc-interaction-metrics.test.ts new file mode 100644 index 000000000000..fe816cb5a9fa --- /dev/null +++ b/examples/benchmark-react/bench/gc-interaction-metrics.test.ts @@ -0,0 +1,291 @@ +/** + * Deterministic unit tests for GC interaction metrics, stable IDs, and report completeness. + * + * yarn test:gc-metrics + */ +import { buildGCReport } from './gc-report.ts'; +import { + browserGCScenarioId, + computeMaxInputDelayMs, + excessMissedFrames, + frameIntervalsFromTimestamps, + longTaskOverlapsWindow, + parseBrowserGCScenarioId, +} from '../src/data-client/gcInteractionMetrics.ts'; + +function assert(cond: boolean, message: string): asserts cond { + if (!cond) throw new Error(message); +} + +function assertEq(actual: unknown, expected: unknown, label: string) { + assert( + Object.is(actual, expected) || actual === expected, + `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); +} + +function assertClose( + actual: number, + expected: number, + eps: number, + label: string, +) { + assert( + Math.abs(actual - expected) <= eps, + `${label}: expected ≈${expected} (±${eps}), got ${actual}`, + ); +} + +// --- frameIntervalsFromTimestamps --- +assertEq(frameIntervalsFromTimestamps([]).length, 0, 'empty timestamps'); +assertEq(frameIntervalsFromTimestamps([10]).length, 0, 'single timestamp'); +{ + const intervals = frameIntervalsFromTimestamps([0, 16.7, 50.1]); + assertEq(intervals.length, 2, 'two intervals'); + assertClose(intervals[0], 16.7, 1e-9, 'first interval'); + assertClose(intervals[1], 33.4, 1e-9, '2-period gap interval'); +} + +// --- excessMissedFrames (nearest-period) --- +const period = 16.67; +assertEq(excessMissedFrames([period], period), 0, 'nominal 1× interval'); +assertEq( + excessMissedFrames([period * 1.98], period), + 1, + '1.98× must count as 2 periods → 1 missed (floor would wrongly yield 0)', +); +assertEq( + excessMissedFrames([32.5], 16.67), + 1, + '32.5/16.67≈1.95 → round to 2 → 1 missed', +); +assertEq( + excessMissedFrames([period * 0.9, period * 1.05], period), + 0, + 'sub-period jitter around 1× → zero excess', +); +assertEq( + excessMissedFrames([period * 2.9, period], period), + 2, + '2.9× → 3 periods → 2 missed; plus nominal frame', +); +assertEq(excessMissedFrames([period], 0), 0, 'zero displayPeriod safe'); + +// Calibration-scale gap (~45ms busy with ~16.7 period → ~2 missed) +{ + const blockedSpan = frameIntervalsFromTimestamps([ + 0, + 16.7, + 16.7 + 45, + 16.7 + 45 + 16.7, + ]); + assertEq( + excessMissedFrames(blockedSpan, 16.7), + 2, + '45ms blocked span → 2 missed frames over one expected period', + ); + assert( + Math.max(...blockedSpan) >= 16.7 * 1.5, + 'calibration-scale interval exceeds 1.5× display period', + ); +} + +// --- computeMaxInputDelayMs --- +assertEq( + computeMaxInputDelayMs(12, [16.7, 16.7], 16.7), + 12, + 'timer dominates when frames are nominal', +); +assertEq( + computeMaxInputDelayMs(5, [16.7, 40], 16.7), + 40 - 16.7, + 'frame excess dominates when larger than timer', +); +assertEq(computeMaxInputDelayMs(0, [], 16.7), 0, 'empty frames → timer only'); + +// --- longTaskOverlapsWindow --- +assert( + longTaskOverlapsWindow({ startTime: 9, duration: 20 }, 10, 40), + 'entry starting just before windowStart still overlaps', +); +assert( + !longTaskOverlapsWindow({ startTime: 0, duration: 5 }, 10, 40), + 'entry ending before windowStart does not overlap', +); +assert( + !longTaskOverlapsWindow({ startTime: 40, duration: 5 }, 10, 40), + 'entry starting at windowEnd does not overlap', +); +assert( + longTaskOverlapsWindow({ startTime: 15, duration: 10 }, 10, 40), + 'entry fully inside window overlaps', +); + +// --- stable semantic IDs --- +assertEq( + browserGCScenarioId({ + candidateKind: 'endpoint', + pattern: 'unique', + count: 100000, + control: 'gc', + }), + 'browser/endpoint/unique/100000/end-to-end/gc', + 'endpoint unique 100k gc id', +); +assertEq( + browserGCScenarioId({ + candidateKind: 'entity', + pattern: 'duplicate', + count: 1000, + control: 'no-gc', + }), + 'browser/entity/duplicate/1000/end-to-end/no-gc', + 'entity duplicate no-gc id', +); +{ + const id = 'browser/mixed/unique/10000/end-to-end/gc'; + const parsed = parseBrowserGCScenarioId(id); + assertEq(parsed.candidateKind, 'mixed', 'parse kind'); + assertEq(parsed.pattern, 'unique', 'parse pattern'); + assertEq(parsed.count, 10000, 'parse count'); + assertEq(parsed.control, 'gc', 'parse control'); + assertEq(parsed.mode, 'end-to-end', 'parse mode'); + assertEq(browserGCScenarioId(parsed), id, 'round-trip id'); +} +let threw = false; +try { + browserGCScenarioId({ + candidateKind: 'endpoint', + pattern: 'duplicate', + count: 1000, + control: 'gc', + }); +} catch { + threw = true; +} +assert(threw, 'duplicate+endpoint must throw'); + +// --- report complete flag --- +{ + const provenance = { + schemaVersion: 1 as const, + buildId: 'abc', + commit: 'deadbeef', + dirty: false, + sourceDigest: 'digest', + artifacts: {}, + servedManifestBuildId: 'abc', + }; + const completeReport = buildGCReport({ + scenarios: [], + samplesPerScenario: 5, + filter: null, + browserVersion: '1', + headless: true, + requestedScenarios: 0, + requestedSamples: 0, + completedScenarios: 0, + completedSamples: 0, + failures: [], + provenance, + }); + assert(completeReport.complete === true, 'empty requested → complete'); + + const incomplete = buildGCReport({ + scenarios: [], + samplesPerScenario: 5, + filter: null, + browserVersion: '1', + headless: true, + requestedScenarios: 1, + requestedSamples: 5, + completedScenarios: 0, + completedSamples: 2, + failures: [ + { + scenarioId: 'browser/endpoint/unique/100000/end-to-end/gc', + sampleIndex: 2, + error: 'boom', + }, + ], + provenance, + }); + assert(incomplete.complete === false, 'failures → complete:false'); + assertEq(incomplete.failures.length, 1, 'failures recorded'); + assertEq(incomplete.completedSamples, 2, 'partial samples counted'); + assertEq( + incomplete.provenance.buildId, + 'abc', + 'provenance from verified manifest not HEAD', + ); +} + +/** Throwing work must reject promptly and leave no active rAF polyfill chain. */ +async function testThrowingWorkRejectsPromptly() { + const { runChromiumInteractionProbe } = + await import('../src/data-client/gcInteractionProbe.ts'); + + const pendingRaf = new Set>(); + const g = globalThis as typeof globalThis & { + requestAnimationFrame?: (cb: (t: number) => void) => number; + cancelAnimationFrame?: (id: number) => void; + }; + const prevRaf = g.requestAnimationFrame; + const prevCancel = g.cancelAnimationFrame; + + g.requestAnimationFrame = (cb: (t: number) => void) => { + const handle = setTimeout(() => { + pendingRaf.delete(handle); + cb(performance.now()); + }, 0); + pendingRaf.add(handle); + return handle as unknown as number; + }; + g.cancelAnimationFrame = (id: number) => { + const handle = id as unknown as ReturnType; + clearTimeout(handle); + pendingRaf.delete(handle); + }; + + try { + const t0 = performance.now(); + let rejected: unknown; + try { + await runChromiumInteractionProbe({ + displayPeriodMs: 16.7, + work: () => { + throw new Error('synthetic work boom'); + }, + }); + } catch (err) { + rejected = err; + } + const elapsed = performance.now() - t0; + assert(rejected instanceof Error, 'throwing work must reject'); + assert( + (rejected as Error).message === 'synthetic work boom', + 'rejection carries work error', + ); + assert(elapsed < 500, `must reject promptly (elapsed ${elapsed}ms)`); + + await new Promise(r => setTimeout(r, 30)); + assert( + pendingRaf.size === 0, + `no active rAF chain after reject (pending=${pendingRaf.size})`, + ); + } finally { + if (prevRaf) g.requestAnimationFrame = prevRaf; + else delete g.requestAnimationFrame; + if (prevCancel) g.cancelAnimationFrame = prevCancel; + else delete g.cancelAnimationFrame; + } +} + +testThrowingWorkRejectsPromptly() + .then(() => { + process.stderr.write('gc-interaction-metrics: all assertions passed\n'); + }) + .catch(err => { + console.error(err); + process.exit(1); + }); diff --git a/examples/benchmark-react/bench/gc-provenance.test.ts b/examples/benchmark-react/bench/gc-provenance.test.ts new file mode 100644 index 000000000000..b6e33ac38608 --- /dev/null +++ b/examples/benchmark-react/bench/gc-provenance.test.ts @@ -0,0 +1,125 @@ +/** + * Provenance tamper tests: artifact / commit / dirty / buildId must fail verify. + * + * yarn test:gc-provenance + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + DIST_DIR, + MANIFEST_PATH, + computeBuildId, + readBuildManifest, + verifyLocalManifest, + writeBuildManifest, + type BuildManifestV1, +} from './build-manifest.ts'; + +function assert(cond: boolean, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +function expectVerifyFail(label: string, mutate: (m: BuildManifestV1) => void) { + const original = fs.readFileSync(MANIFEST_PATH, 'utf8'); + try { + const m = readBuildManifest(); + mutate(m); + fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(m, null, 2)}\n`); + let failed = false; + try { + verifyLocalManifest(readBuildManifest()); + } catch (err) { + failed = true; + process.stderr.write( + `provenance: ${label} rejected: ${err instanceof Error ? err.message : err}\n`, + ); + } + assert(failed, `${label}: expected verifyLocalManifest to fail`); + } finally { + fs.writeFileSync(MANIFEST_PATH, original); + } +} + +if ( + !fs.existsSync(DIST_DIR) || + !fs.existsSync(path.join(DIST_DIR, 'data-client.js')) +) { + throw new Error('dist/ incomplete — run yarn build first'); +} + +// Ensure a fresh manifest exists for the current tree +writeBuildManifest(); +const manifest = readBuildManifest(); +verifyLocalManifest(manifest); +process.stderr.write('provenance: clean verify OK\n'); + +// Canonical buildId includes schemaVersion, commit, dirty, sourceDigest, artifacts +{ + const expected = computeBuildId({ + schemaVersion: 1, + commit: manifest.commit, + dirty: manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts, + }); + assert( + expected === manifest.buildId, + 'stored buildId must match canonical digest of manifest fields', + ); + const flippedDirty = computeBuildId({ + schemaVersion: 1, + commit: manifest.commit, + dirty: !manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts, + }); + assert(flippedDirty !== manifest.buildId, 'dirty bit must affect buildId'); + const otherCommit = computeBuildId({ + schemaVersion: 1, + commit: `${manifest.commit}dead`, + dirty: manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts, + }); + assert(otherCommit !== manifest.buildId, 'commit must affect buildId'); +} + +// Artifact content tamper +const target = path.join(DIST_DIR, 'data-client.js'); +const originalArtifact = fs.readFileSync(target); +try { + fs.writeFileSync( + target, + Buffer.concat([originalArtifact, Buffer.from('\n/* tamper */\n')]), + ); + let failed = false; + try { + verifyLocalManifest(readBuildManifest()); + } catch (err) { + failed = true; + process.stderr.write( + `provenance: artifact tamper rejected: ${err instanceof Error ? err.message : err}\n`, + ); + } + assert(failed, 'tampered artifact must fail verifyLocalManifest'); +} finally { + fs.writeFileSync(target, originalArtifact); + writeBuildManifest(); + verifyLocalManifest(readBuildManifest()); +} + +// Manifest field tampers (buildId left stale → mismatch) +expectVerifyFail('commit field tamper', m => { + m.commit = `${m.commit}0`; +}); +expectVerifyFail('dirty field tamper', m => { + m.dirty = !m.dirty; +}); +expectVerifyFail('buildId field tamper', m => { + m.buildId = '0'.repeat(64); +}); + +verifyLocalManifest(readBuildManifest()); +assert(fs.existsSync(MANIFEST_PATH), 'manifest exists'); +process.stderr.write('gc-provenance: all assertions passed\n'); diff --git a/examples/benchmark-react/bench/gc-report.ts b/examples/benchmark-react/bench/gc-report.ts new file mode 100644 index 000000000000..679fae09c5e2 --- /dev/null +++ b/examples/benchmark-react/bench/gc-report.ts @@ -0,0 +1,273 @@ +/** + * Aggregate schemaVersion 1 detailed GC measurement report for the browser harness. + * Raw Chromium traces (BENCH_TRACE) remain separate diagnostic artifacts. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { BuildManifestV1 } from './build-manifest.js'; +import { browserGCScenarioId } from '../src/data-client/gcInteractionMetrics.ts'; +import type { + GCBrowserMeasurement, + GCScenarioConfig, +} from '../src/shared/types.js'; + +export interface GCSampleResult extends GCBrowserMeasurement { + heapBeforeBytes?: number; + heapAfterBytes?: number; + heapDeltaBytes?: number; +} + +export interface NumberSummary { + median: number; + min: number; + max: number; + p95: number; + p99: number; +} + +export interface GCScenarioReport { + /** Stable id: browser/{kind}/{pattern}/{count}/end-to-end/{control} */ + id: string; + platform: 'browser'; + candidateKind: GCScenarioConfig['candidateKind']; + pattern: GCScenarioConfig['pattern']; + count: number; + mode: 'end-to-end'; + control: GCScenarioConfig['control']; + samples: GCSampleResult[]; + summary: Record; +} + +export interface GCFailureRecord { + scenarioId: string; + sampleIndex?: number; + error: string; +} + +export interface GCMeasurementReport { + schemaVersion: 1; + /** false when any requested sample/scenario failed */ + complete: boolean; + requestedScenarios: number; + requestedSamples: number; + completedScenarios: number; + completedSamples: number; + failures: GCFailureRecord[]; + units: Record; + memorySemantics: { + model: 'keep-store-drop-observer'; + description: string; + }; + /** Verified BuildManifest provenance — never live HEAD */ + provenance: { + buildId: string; + commit: string; + dirty: boolean; + sourceDigest: string; + servedManifestBuildId: string; + }; + build: { + commit: string; + label: string; + }; + environment: { + runtime: 'chromium'; + browserVersion: string; + headless: boolean; + os: string; + arch: string; + cpuModel: string; + cpus: number; + platform: NodeJS.Platform; + nodeVersion: string; + }; + config: { + samplesPerScenario: number; + filter: string | null; + }; + scenarios: GCScenarioReport[]; +} + +/** Caller must pass a non-empty sorted array. */ +function percentile(sorted: number[], p: number): number { + if (sorted.length === 1) return sorted[0]!; + const idx = (p / 100) * (sorted.length - 1); + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + if (lo === hi) return sorted[lo]!; + const w = idx - lo; + return sorted[lo]! * (1 - w) + sorted[hi]! * w; +} + +export function summarizeNumbers(values: number[]): NumberSummary | null { + if (values.length === 0) return null; + const sorted = values.slice().sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 ? + (sorted[mid - 1]! + sorted[mid]!) / 2 + : sorted[mid]!; + return { + median, + min: sorted[0]!, + max: sorted[sorted.length - 1]!, + p95: percentile(sorted, 95), + p99: percentile(sorted, 99), + }; +} + +export function summarizeGCSamples( + samples: GCSampleResult[], +): Record { + const summary: Record = { + totalMs: summarizeNumbers(samples.map(s => s.totalMs)), + actionCount: summarizeNumbers(samples.map(s => s.actionCount)), + queueEntries: summarizeNumbers(samples.map(s => s.queueEntries)), + uniqueTargets: summarizeNumbers(samples.map(s => s.uniqueTargets)), + actionTargetCount: summarizeNumbers(samples.map(s => s.actionTargetCount)), + deletionCount: summarizeNumbers(samples.map(s => s.deletionCount)), + timerDelayMs: summarizeNumbers(samples.map(s => s.timerDelayMs)), + displayPeriodMs: summarizeNumbers(samples.map(s => s.displayPeriodMs)), + missedFrames: summarizeNumbers(samples.map(s => s.missedFrames)), + maxInputDelayMs: summarizeNumbers(samples.map(s => s.maxInputDelayMs)), + longTaskCount: summarizeNumbers(samples.map(s => s.longTaskCount)), + longTaskTotalMs: summarizeNumbers(samples.map(s => s.longTaskTotalMs)), + }; + const slices = samples.flatMap(s => s.sliceDurationsMs ?? []); + if (slices.length) { + summary.sliceDurationsMs = summarizeNumbers(slices); + } + const frameIntervals = samples.flatMap(s => s.frameIntervalsMs ?? []); + if (frameIntervals.length) { + summary.frameIntervalsMs = summarizeNumbers(frameIntervals); + } + const heaps = samples.filter(s => s.heapBeforeBytes != null); + if (heaps.length) { + summary.heapBeforeBytes = summarizeNumbers( + heaps.map(s => s.heapBeforeBytes!), + ); + summary.heapAfterBytes = summarizeNumbers( + heaps.map(s => s.heapAfterBytes!), + ); + summary.heapDeltaBytes = summarizeNumbers( + heaps.map(s => s.heapDeltaBytes!), + ); + } + return summary; +} + +export function cpuModel(): string { + return os.cpus()[0]?.model ?? 'unknown'; +} + +export function defaultGCReportPath(): string { + return path.resolve( + process.env.BENCH_GC_OUTPUT ?? 'gc-measurement-output.json', + ); +} + +export function writeGCReport( + report: GCMeasurementReport, + outputPath: string = defaultGCReportPath(), +): void { + fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`); + process.stderr.write( + `GC detailed report → ${outputPath} (complete=${report.complete})\n`, + ); +} + +export function scenarioReportFromConfig( + config: GCScenarioConfig, + samples: GCSampleResult[], +): GCScenarioReport { + const id = browserGCScenarioId(config); + return { + id, + platform: 'browser', + candidateKind: config.candidateKind, + pattern: config.pattern, + count: config.count, + mode: 'end-to-end', + control: config.control, + samples, + summary: summarizeGCSamples(samples), + }; +} + +export function buildGCReport(opts: { + scenarios: GCScenarioReport[]; + samplesPerScenario: number; + filter: string | null; + browserVersion: string; + headless: boolean; + requestedScenarios: number; + requestedSamples: number; + completedScenarios: number; + completedSamples: number; + failures: GCFailureRecord[]; + provenance: BuildManifestV1 & { servedManifestBuildId: string }; +}): GCMeasurementReport { + const complete = + opts.failures.length === 0 && + opts.completedScenarios === opts.requestedScenarios && + opts.completedSamples === opts.requestedSamples; + + return { + schemaVersion: 1, + complete, + requestedScenarios: opts.requestedScenarios, + requestedSamples: opts.requestedSamples, + completedScenarios: opts.completedScenarios, + completedSamples: opts.completedSamples, + failures: opts.failures, + units: { + totalMs: 'milliseconds', + sliceDurationsMs: 'milliseconds', + timerDelayMs: 'milliseconds', + frameIntervalsMs: 'milliseconds', + displayPeriodMs: 'milliseconds', + maxInputDelayMs: 'milliseconds', + longTaskTotalMs: 'milliseconds', + missedFrames: 'frames', + heapBeforeBytes: 'bytes', + heapAfterBytes: 'bytes', + heapDeltaBytes: 'bytes', + }, + memorySemantics: { + model: 'keep-store-drop-observer', + description: + 'heapBefore is taken after prepareGCScenario with forced Chromium engine GC. After runGCScenario (which validates and releases captured GC action arrays), settle + forced engine GC yields heapAfter while the live store remains. Engine GC is never inside interaction timing. Compare gc vs no-gc for retained-cache signal.', + }, + provenance: { + buildId: opts.provenance.buildId, + commit: opts.provenance.commit, + dirty: opts.provenance.dirty, + sourceDigest: opts.provenance.sourceDigest, + servedManifestBuildId: opts.provenance.servedManifestBuildId, + }, + build: { + commit: opts.provenance.commit, + label: 'examples/benchmark-react GC monolithic browser baseline', + }, + environment: { + runtime: 'chromium', + browserVersion: opts.browserVersion, + headless: opts.headless, + os: `${os.type()} ${os.release()}`, + arch: os.arch(), + cpuModel: cpuModel(), + cpus: os.cpus().length, + platform: process.platform, + nodeVersion: process.version, + }, + config: { + samplesPerScenario: opts.samplesPerScenario, + filter: opts.filter, + }, + scenarios: opts.scenarios, + }; +} + +export { browserGCScenarioId }; diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 56dec71ce177..5588213ec37b 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -10,6 +10,21 @@ import type { Page, } from 'playwright'; +import { + MANIFEST_FILENAME, + MANIFEST_PATH, + readBuildManifest, + verifyLocalManifest, + type BuildManifestV1, +} from './build-manifest.js'; +import { + buildGCReport, + scenarioReportFromConfig, + writeGCReport, + type GCFailureRecord, + type GCSampleResult, + type GCScenarioReport, +} from './gc-report.js'; import { collectMeasures, getMeasureDuration } from './measure.js'; import { collectHeapUsed } from './memory.js'; import { formatReport, type BenchmarkResult } from './report.js'; @@ -24,7 +39,13 @@ import { import type { ConvergentProfile } from './scenarios.js'; import { computeStats, isConverged } from './stats.js'; import { parseTraceDuration } from './tracing.js'; -import type { Scenario, ScenarioSize } from '../src/shared/types.js'; +import { browserGCScenarioId } from '../src/data-client/gcInteractionMetrics.ts'; +import type { + GCBrowserMeasurement, + GCScenarioConfig, + Scenario, + ScenarioSize, +} from '../src/shared/types.js'; // --------------------------------------------------------------------------- // CLI + env var parsing @@ -37,6 +58,8 @@ function parseArgs(): { scenario?: string; networkSim: boolean; opsPerRound?: number; + /** Validated samples-per-GC-scenario (≥1 integer). */ + gcSamples: number; } { const argv = process.argv.slice(2); const get = (flag: string, envVar: string): string | undefined => { @@ -51,6 +74,8 @@ function parseArgs(): { const scenarioRaw = get('--scenario', 'BENCH_SCENARIO'); const networkSimRaw = get('--network-sim', 'BENCH_NETWORK_SIM'); const opsRaw = get('--ops-per-round', 'BENCH_OPS_PER_ROUND'); + // Flag / env / default resolved once here (filterScenarios must not re-read env). + const gcSamplesRaw = get('--samples', 'BENCH_GC_SAMPLES') ?? '5'; const libs = libRaw ? libRaw.split(',').map(s => s.trim()) : undefined; const size = sizeRaw === 'small' || sizeRaw === 'large' ? sizeRaw : undefined; @@ -59,6 +84,16 @@ function parseArgs(): { const networkSim = networkSimRaw != null ? networkSimRaw !== 'false' : !process.env.CI; const opsPerRound = opsRaw ? parseInt(opsRaw, 10) : undefined; + const gcSamplesParsed = Number.parseInt(gcSamplesRaw, 10); + if ( + !Number.isFinite(gcSamplesParsed) || + !Number.isInteger(gcSamplesParsed) || + gcSamplesParsed < 1 + ) { + throw new Error( + `invalid --samples / BENCH_GC_SAMPLES=${JSON.stringify(gcSamplesRaw)}; expected integer ≥ 1`, + ); + } return { libs, @@ -67,6 +102,7 @@ function parseArgs(): { scenario: scenarioRaw, networkSim, opsPerRound, + gcSamples: gcSamplesParsed, }; } @@ -75,18 +111,32 @@ function filterScenarios(scenarios: Scenario[]): { libraries: string[]; networkSim: boolean; opsPerRound?: number; + gcSamples: number; + scenarioFilter: string | null; } { const { libs, size, actions, - scenario: scenarioFilter, + scenario: scenarioFilterArg, networkSim, opsPerRound, + gcSamples, } = parseArgs(); + const scenarioFilter = scenarioFilterArg ?? null; const libraries = libs ?? (process.env.CI ? ['data-client'] : [...LIBRARIES]); + // GC scenarios are data-client-only; default lib when selecting --action gc + const effectiveLibraries = + ( + !libs && + actions && + actions.every(a => a === 'gc' || a === 'runGCScenario') + ) ? + ['data-client'] + : libraries; + let filtered = scenarios; // In CI, restrict to data-client hot-path only (existing behavior) @@ -96,14 +146,23 @@ function filterScenarios(scenarios: Scenario[]): { s.name.startsWith('data-client:') && s.category !== 'memory' && s.category !== 'startup' && + s.category !== 'gc' && !s.deterministic, ); } else if ( !actions || - !actions.some(a => a === 'memory' || a === 'mountUnmountCycle') + !actions.some( + a => + a === 'memory' || + a === 'mountUnmountCycle' || + a === 'gc' || + a === 'runGCScenario', + ) ) { - // Locally: exclude memory by default; use --action memory to include - filtered = filtered.filter(s => s.category !== 'memory'); + // Locally: exclude memory/gc by default; use --action memory|gc to include + filtered = filtered.filter( + s => s.category !== 'memory' && s.category !== 'gc', + ); } if (libs) { @@ -133,10 +192,18 @@ function filterScenarios(scenarios: Scenario[]): { // Multi-lib runs: omit scenarios that do not apply to every selected library (e.g. invalidate-and-resolve). filtered = filtered.filter( s => - !s.onlyLibs?.length || libraries.every(lib => s.onlyLibs!.includes(lib)), + !s.onlyLibs?.length || + effectiveLibraries.every(lib => s.onlyLibs!.includes(lib)), ); - return { filtered, libraries, networkSim, opsPerRound }; + return { + filtered, + libraries: effectiveLibraries, + networkSim, + opsPerRound, + gcSamples, + scenarioFilter, + }; } // --------------------------------------------------------------------------- @@ -190,7 +257,9 @@ function isConvergentScenario(scenario: Scenario): boolean { return ( !scenario.deterministic && scenario.category !== 'memory' && + scenario.category !== 'gc' && scenario.resultMetric !== 'heapDelta' && + scenario.resultMetric !== 'totalMs' && !isRefStabilityScenario(scenario) ); } @@ -579,11 +648,15 @@ async function runScenarioConvergent( if (cdp && subIdx > 0 && subIdx % CONVERGENT_GC_INTERVAL === 0) { try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(30); try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(50); } @@ -760,6 +833,7 @@ function reportV8Logs(): void { function scenarioUnit(scenario: Scenario): string { if (isRefStabilityScenario(scenario)) return 'count'; if (scenario.resultMetric === 'heapDelta') return 'bytes'; + if (scenario.resultMetric === 'totalMs') return 'ms'; return 'ops/s'; } @@ -781,10 +855,243 @@ function recordResult( function warmupCount(scenario: Scenario): number { if (scenario.deterministic) return 0; if (scenario.category === 'memory') return MEMORY_WARMUP; + if (scenario.category === 'gc') return 0; if (isConvergentScenario(scenario)) return 0; return RUN_CONFIG[scenario.size ?? 'small'].warmup; } +async function settlePage(page: Page): Promise { + await page.evaluate( + () => + new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ), + ); + await page.waitForTimeout(50); +} + +async function verifyGCBuildProvenance( + baseUrl: string, +): Promise { + if (!fs.existsSync(MANIFEST_PATH)) { + throw new Error( + `Missing ${MANIFEST_FILENAME}. Run yarn build (writes BuildManifest v1 after webpack).`, + ); + } + const local = readBuildManifest(); + verifyLocalManifest(local); + + const servedUrl = `${baseUrl.replace(/\/$/, '')}/${MANIFEST_FILENAME}`; + const res = await fetch(servedUrl); + if (!res.ok) { + throw new Error( + `Failed to fetch served ${MANIFEST_FILENAME} from ${servedUrl}: ${res.status}`, + ); + } + const served = (await res.json()) as BuildManifestV1; + if (served.schemaVersion !== 1) { + throw new Error(`served manifest schemaVersion ${served.schemaVersion}`); + } + if (served.buildId !== local.buildId) { + throw new Error( + `served manifest buildId mismatch (local ${local.buildId.slice(0, 12)}… vs served ${served.buildId.slice(0, 12)}…). Restart preview after rebuild.`, + ); + } + process.stderr.write( + `GC provenance OK buildId=${local.buildId.slice(0, 16)}… commit=${local.commit.slice(0, 8)} dirty=${local.dirty}\n`, + ); + return { ...local, servedManifestBuildId: served.buildId }; +} + +/** + * Dedicated GC phase (not the generic convergent/update path). + * Per sample: prepare → engine GC + heapBefore → run → settle → engine GC + + * heapAfter (store live) → dispose. Never force engine GC inside interaction timing. + * Failures are accumulated; report is always written with complete true/false; + * returns { reports, complete } and caller must exit nonzero when incomplete. + */ +async function runGCScenarioSample( + page: Page, + bench: any, + config: GCScenarioConfig, + cdp: CDPSession, +): Promise { + await (bench as any).evaluate(async (api: any, cfg: GCScenarioConfig) => { + if (!api.prepareGCScenario) { + throw new Error('prepareGCScenario not available'); + } + await api.prepareGCScenario(cfg); + }, config); + + // Forced engine GC + heapBefore (outside interaction timing) + const heapBeforeBytes = await collectHeapUsed(cdp); + + const measurement: GCBrowserMeasurement = await (bench as any).evaluate( + async (api: any) => { + if (!api.runGCScenario) { + throw new Error('runGCScenario not available'); + } + return api.runGCScenario(); + }, + ); + + await settlePage(page); + + // Forced engine GC + heapAfter while store remains live + const heapAfterBytes = await collectHeapUsed(cdp); + + await (bench as any).evaluate((api: any) => { + if (api.disposeGCScenario) api.disposeGCScenario(); + }); + + return { + ...measurement, + heapBeforeBytes, + heapAfterBytes, + heapDeltaBytes: heapAfterBytes - heapBeforeBytes, + }; +} + +async function runGCPhase( + browser: Browser, + scenarios: Scenario[], + sampleCount: number, + scenarioFilter: string | null, + samples: Map, + provenance: BuildManifestV1 & { servedManifestBuildId: string }, +): Promise<{ reports: GCScenarioReport[]; complete: boolean }> { + const reports: GCScenarioReport[] = []; + const failures: GCFailureRecord[] = []; + const requestedScenarios = scenarios.length; + const requestedSamples = scenarios.length * sampleCount; + let completedScenarios = 0; + let completedSamples = 0; + + if (scenarios.length === 0) { + return { reports, complete: true }; + } + + process.stderr.write( + `\n── GC (${scenarios.length} scenarios, ${sampleCount} samples each) ──\n`, + ); + + const context = await browser.newContext(); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + try { + await cdp.send('Performance.enable'); + } catch { + // best-effort + } + + for (const scenario of scenarios) { + const config = scenario.args[0] as GCScenarioConfig; + const scenarioId = browserGCScenarioId(config); + const scenarioSamples: GCSampleResult[] = []; + let scenarioFailed = false; + + process.stderr.write(` ${scenario.name} (${scenarioId})...\n`); + + for (let i = 0; i < sampleCount; i++) { + try { + const { bench } = await setupBenchPage( + page, + 'data-client', + scenario, + false, + ); + const sample = await runGCScenarioSample(page, bench, config, cdp); + scenarioSamples.push(sample); + recordResult(samples, scenario, { value: sample.totalMs }); + completedSamples++; + await bench.dispose(); + } catch (err) { + scenarioFailed = true; + const message = err instanceof Error ? err.message : String(err); + failures.push({ + scenarioId, + sampleIndex: i, + error: message, + }); + console.error(` ${scenario.name} sample ${i} FAILED:`, message); + try { + await page.evaluate(() => { + window.__BENCH__?.disposeGCScenario?.(); + }); + } catch { + // ignore cleanup failures + } + break; + } + } + + if (!scenarioFailed && scenarioSamples.length === sampleCount) { + completedScenarios++; + const report = scenarioReportFromConfig(config, scenarioSamples); + const { summary } = report; + const medianTotal = summary.totalMs?.median ?? 0; + const maxFrame = + summary.frameIntervalsMs?.max ?? summary.frameIntervalsMs?.median; + process.stderr.write( + ` median totalMs=${medianTotal.toFixed(3)} ms` + + (summary.timerDelayMs ? + ` timerDelay=${summary.timerDelayMs.median.toFixed(2)} ms` + : '') + + (summary.displayPeriodMs ? + ` displayPeriod=${summary.displayPeriodMs.median.toFixed(2)} ms` + : '') + + (maxFrame != null ? + ` frameIntervalMax=${maxFrame.toFixed(2)} ms` + : '') + + (summary.maxInputDelayMs ? + ` maxInputDelay=${summary.maxInputDelayMs.median.toFixed(2)} ms` + : '') + + (summary.missedFrames != null ? + ` missedFrames=${summary.missedFrames.median}` + : '') + + (summary.longTaskCount ? + ` longTasks=${summary.longTaskCount.median}` + + (summary.longTaskTotalMs ? + `/${summary.longTaskTotalMs.median.toFixed(2)} ms` + : '') + : '') + + (summary.heapDeltaBytes ? + ` heapΔ=${Math.round(summary.heapDeltaBytes.median)} B` + : '') + + `\n`, + ); + reports.push(report); + } + } + + await cdp.detach().catch(() => {}); + await context.close(); + + const version = browser.version(); + const report = buildGCReport({ + scenarios: reports, + samplesPerScenario: sampleCount, + filter: scenarioFilter, + browserVersion: version, + headless: true, + requestedScenarios, + requestedSamples, + completedScenarios, + completedSamples, + failures, + provenance, + }); + writeGCReport(report); + + if (!report.complete) { + process.stderr.write( + `GC phase incomplete: ${failures.length} failure(s), completed ${completedSamples}/${requestedSamples} samples\n`, + ); + } + + return { reports, complete: report.complete }; +} + /** Run each scenario once per matching library (one browser context per lib). */ async function runRound( browser: Browser, @@ -811,11 +1118,15 @@ async function runRound( // Double-GC before each scenario to reduce variance from prior allocations try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(100); try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(400); done++; @@ -858,6 +1169,8 @@ async function main() { libraries, networkSim, opsPerRound, + gcSamples, + scenarioFilter, } = filterScenarios(SCENARIOS); if (opsPerRound != null) { @@ -875,7 +1188,10 @@ async function main() { } const memoryScenarios = SCENARIOS_TO_RUN.filter(s => s.category === 'memory'); - const mainScenarios = SCENARIOS_TO_RUN.filter(s => s.category !== 'memory'); + const gcScenarios = SCENARIOS_TO_RUN.filter(s => s.category === 'gc'); + const mainScenarios = SCENARIOS_TO_RUN.filter( + s => s.category !== 'memory' && s.category !== 'gc', + ); const bySize: Record = { small: [], large: [] }; for (const s of mainScenarios) { @@ -937,11 +1253,15 @@ async function main() { for (const scenario of libScenarios) { try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(100); try { await cdp.send('HeapProfiler.collectGarbage'); - } catch {} + } catch { + // best-effort + } await page.waitForTimeout(400); process.stderr.write(` ${scenario.name}...\n`); @@ -1043,6 +1363,21 @@ async function main() { } } + // GC: dedicated phase (opt-in via --action gc) + let gcComplete = true; + if (gcScenarios.length > 0) { + const provenance = await verifyGCBuildProvenance(BASE_URL); + const { complete } = await runGCPhase( + browser, + gcScenarios, + gcSamples, + scenarioFilter, + samples, + provenance, + ); + gcComplete = complete; + } + await closeBenchBrowser(); reportV8Logs(); @@ -1110,6 +1445,10 @@ async function main() { process.stderr.write('\n'); process.stdout.write(formatReport(report)); + + if (!gcComplete) { + process.exitCode = 1; + } } main().catch(err => { diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 9e1298142ddc..93d9be515989 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -65,6 +65,7 @@ export const ACTION_GROUPS: Record = { update: ['updateEntity', 'updateUser'], mutation: ['unshiftItem', 'deleteEntity', 'invalidateAndResolve', 'moveItem'], memory: ['mountUnmountCycle'], + gc: ['runGCScenario'], }; type BaseScenario = Omit & { @@ -220,14 +221,68 @@ export const LIBRARIES = [ 'baseline', ] as const; -export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => - BASE_SCENARIOS.filter( - base => !base.onlyLibs || base.onlyLibs.includes(lib), - ).map( - ({ nameSuffix, onlyLibs, ...rest }): Scenario => ({ +/** Canonical GC counts matching the Node GC harness / shared vocabulary. */ +export const GC_CANONICAL_COUNTS = [1_000, 10_000, 100_000] as const; + +const GC_UNIQUE_KINDS = ['entity', 'endpoint', 'mixed'] as const; +const GC_CONTROLS = ['gc', 'no-gc'] as const; + +/** data-client-only browser GC scenarios (excluded by default / CI; `--action gc`). */ +const GC_SCENARIOS: Scenario[] = (() => { + const out: Scenario[] = []; + for (const count of GC_CANONICAL_COUNTS) { + const size: ScenarioSize = count <= 1_000 ? 'small' : 'large'; + for (const candidateKind of GC_UNIQUE_KINDS) { + for (const control of GC_CONTROLS) { + out.push({ + name: `data-client: gc-${candidateKind}-unique-${count}-${control}`, + action: 'runGCScenario', + args: [ + { + candidateKind, + pattern: 'unique', + count, + control, + }, + ], + category: 'gc', + resultMetric: 'totalMs', + size, + onlyLibs: ['data-client'], + }); + } + } + for (const control of GC_CONTROLS) { + out.push({ + name: `data-client: gc-entity-duplicate-${count}-${control}`, + action: 'runGCScenario', + args: [ + { + candidateKind: 'entity', + pattern: 'duplicate', + count, + control, + }, + ], + category: 'gc', + resultMetric: 'totalMs', + size, + onlyLibs: ['data-client'], + }); + } + } + return out; +})(); + +export const SCENARIOS: Scenario[] = [ + ...LIBRARIES.flatMap(lib => + BASE_SCENARIOS.filter( + base => !base.onlyLibs || base.onlyLibs.includes(lib), + ).map(({ nameSuffix, onlyLibs, ...rest }): Scenario => ({ name: `${lib}: ${nameSuffix}`, ...rest, ...(onlyLibs ? { onlyLibs: [...onlyLibs] } : {}), - }), + })), ), -); + ...GC_SCENARIOS, +]; diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index 3006c11a896c..7d13796b0eae 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -534,6 +534,188 @@ test('listDetailSwitch completes with correct DOM transitions', async (page, lib ); }); +test( + 'GC frame probe calibration: synthetic block spans pending frames', + async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.calibrateGCFrameProbe === 'function', + )) + ) + return; + + const result = await page.evaluate(async () => { + return window.__BENCH__!.calibrateGCFrameProbe!(45); + }); + + assert( + result.blockMs >= 40 && result.blockMs <= 50, + lib, + 'gc calibrate blockMs', + `expected 40–50ms block, got ${result.blockMs}`, + ); + assert( + result.totalMs >= 35, + lib, + 'gc calibrate totalMs', + `synthetic block should be timed (~45ms), got ${result.totalMs}`, + ); + assert( + result.spannedPendingFrame === true, + lib, + 'gc calibrate spannedPendingFrame', + `expected blocking to span pending frame (missedFrames=${result.missedFrames} frameIntervalMax=${result.frameIntervalMax} displayPeriod=${result.displayPeriodMs})`, + ); + assert( + result.frameIntervalMax >= result.displayPeriodMs * 1.5 || + result.missedFrames >= 1, + lib, + 'gc calibrate frame gap', + `expected large frame gap or missedFrames≥1`, + ); + process.stderr.write( + ` calibrate: totalMs=${result.totalMs.toFixed(1)} frameIntervalMax=${result.frameIntervalMax.toFixed(1)} missedFrames=${result.missedFrames} displayPeriod=${result.displayPeriodMs.toFixed(2)}\n`, + ); + }, + { onlyLibs: ['data-client'] }, +); + +test( + 'GC scenario API: prepare/run/dispose cardinality (1k entity unique)', + async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.prepareGCScenario === 'function', + )) + ) + return; + + const prepared = await page.evaluate(async () => { + return window.__BENCH__!.prepareGCScenario!({ + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + }); + }); + assert( + prepared.queueEntries === 1000, + lib, + 'gc prepare queueEntries', + `expected 1000, got ${prepared.queueEntries}`, + ); + assert( + prepared.uniqueTargets === 1000, + lib, + 'gc prepare uniqueTargets', + `expected 1000, got ${prepared.uniqueTargets}`, + ); + + const measurement = await page.evaluate(async () => { + return window.__BENCH__!.runGCScenario!(); + }); + assert( + measurement.schemaVersion === 1, + lib, + 'gc schemaVersion', + `expected 1, got ${measurement.schemaVersion}`, + ); + assert( + measurement.actionCount === 1, + lib, + 'gc actionCount', + `expected 1, got ${measurement.actionCount}`, + ); + assert( + measurement.deletionCount === 1000, + lib, + 'gc deletionCount', + `expected 1000, got ${measurement.deletionCount}`, + ); + assert( + measurement.actionTargetCount === 1000, + lib, + 'gc actionTargetCount', + `expected 1000, got ${measurement.actionTargetCount}`, + ); + assert( + measurement.sliceDurationsMs.length === 1, + lib, + 'gc sliceDurationsMs', + `expected 1 slice, got ${measurement.sliceDurationsMs.length}`, + ); + assert( + measurement.displayPeriodMs > 0, + lib, + 'gc displayPeriodMs', + `expected positive displayPeriodMs, got ${measurement.displayPeriodMs}`, + ); + assert( + typeof measurement.maxInputDelayMs === 'number' && + measurement.maxInputDelayMs >= 0, + lib, + 'gc maxInputDelayMs', + `expected non-negative maxInputDelayMs, got ${measurement.maxInputDelayMs}`, + ); + assert( + typeof measurement.missedFrames === 'number' && + measurement.missedFrames >= 0, + lib, + 'gc missedFrames', + `expected non-negative missedFrames, got ${measurement.missedFrames}`, + ); + + // no-gc control: same prepare/run path, zero actions/deletions + await page.evaluate(() => window.__BENCH__!.disposeGCScenario!()); + await page.evaluate(async () => { + await window.__BENCH__!.prepareGCScenario!({ + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'no-gc', + }); + }); + const noGc = await page.evaluate(async () => { + return window.__BENCH__!.runGCScenario!(); + }); + assert( + noGc.actionCount === 0 && noGc.deletionCount === 0, + lib, + 'gc no-gc control', + `expected zero action/deletion, got action=${noGc.actionCount} deletion=${noGc.deletionCount}`, + ); + + // duplicate pattern: queueEntries=count, uniqueTargets=1, deletionCount=1 + await page.evaluate(() => window.__BENCH__!.disposeGCScenario!()); + const dupPrepared = await page.evaluate(async () => { + return window.__BENCH__!.prepareGCScenario!({ + candidateKind: 'entity', + pattern: 'duplicate', + count: 1000, + control: 'gc', + }); + }); + assert( + dupPrepared.queueEntries === 1000 && dupPrepared.uniqueTargets === 1, + lib, + 'gc duplicate prepare', + `expected queue=1000 unique=1, got queue=${dupPrepared.queueEntries} unique=${dupPrepared.uniqueTargets}`, + ); + const dup = await page.evaluate(async () => { + return window.__BENCH__!.runGCScenario!(); + }); + assert( + dup.deletionCount === 1 && dup.actionTargetCount === 1000, + lib, + 'gc duplicate run', + `expected deletion=1 actionTarget=1000, got deletion=${dup.deletionCount} actionTarget=${dup.actionTargetCount}`, + ); + + await page.evaluate(() => window.__BENCH__!.disposeGCScenario!()); + }, + { onlyLibs: ['data-client'] }, +); + // ── TIMING VALIDATION ──────────────────────────────────────────────── // Verify that when data-bench-complete fires (measurement ends), the DOM // already reflects the update. A 100ms network delay makes timing bugs diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index fa482a5ee468..1137d3244bda 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -4,8 +4,8 @@ "private": true, "description": "React rendering benchmark comparing @data-client/react against other data libraries", "scripts": { - "build": "BROWSERSLIST_ENV=2026 webpack --mode=production", - "build:no-compiler": "BROWSERSLIST_ENV=2026 REACT_COMPILER=false webpack --mode=production", + "build": "BROWSERSLIST_ENV=2026 webpack --mode=production && npx tsx bench/build-manifest.ts", + "build:no-compiler": "BROWSERSLIST_ENV=2026 REACT_COMPILER=false webpack --mode=production && npx tsx bench/build-manifest.ts", "preview": "serve dist -l ${BENCH_PORT:-5173} --no-request-logging", "bench": "npx tsx bench/runner.ts", "bench:no-compiler": "BENCH_LABEL=no-compiler npx tsx bench/runner.ts", @@ -14,6 +14,9 @@ "bench:dc": "npx tsx bench/runner.ts --lib data-client", "bench:trace": "BENCH_V8_TRACE=true npx tsx bench/runner.ts --lib data-client --size small", "bench:deopt": "BENCH_V8_DEOPT=true npx tsx bench/runner.ts --lib data-client --size small", + "bench:gc": "npx tsx bench/runner.ts --lib data-client --action gc", + "test:gc-metrics": "npx tsx bench/gc-interaction-metrics.test.ts", + "test:gc-provenance": "npx tsx bench/gc-provenance.test.ts", "bench:run": "yarn build && (yarn preview &) && sleep 5 && yarn bench", "bench:run:no-compiler": "yarn build:no-compiler && (yarn preview &) && sleep 5 && yarn bench:no-compiler", "validate": "npx tsx bench/validate.ts", diff --git a/examples/benchmark-react/src/data-client/gcBrowserHarness.ts b/examples/benchmark-react/src/data-client/gcBrowserHarness.ts new file mode 100644 index 000000000000..62c396995923 --- /dev/null +++ b/examples/benchmark-react/src/data-client/gcBrowserHarness.ts @@ -0,0 +1,548 @@ +/** + * Isolated browser GC harness — separate from DataProvider's benchGC singleton. + * + * Builds a benchmark-only Controller + createReducer + timerless GCPolicy with + * raw deterministic state, queues via createCountRef, mode=end-to-end. Measures + * real core cache GC on the main thread without 100k React components or network. + * + * Timing boundaries: + * prepare — fixtures, queue, quiet displayPeriodMs (untimed) + * run — interaction probes + explicit sweep/no-op (timed); resolves after probes settle + * dispose — drop store/policy (after Playwright heapAfter) + */ +import { + Controller, + GCPolicy, + actionTypes, + createReducer, + initialState, +} from '@data-client/core'; +import type { State } from '@data-client/core'; +import type { + GCBrowserMeasurement, + GCPreparedSummary, + GCScenarioConfig, +} from '@shared/types'; + +import { measureDisplayPeriodMs } from './gcInteractionMetrics'; +import { + CALIBRATION_BLOCK_MS_MAX, + CALIBRATION_BLOCK_MS_MIN, + runChromiumInteractionProbe, + syntheticBlockMs, +} from './gcInteractionProbe'; + +const { GC } = actionTypes; + +type EntityPath = { key: string; pk: string }; + +export const ENTITY_KEY = 'BenchEntity'; +export const ZERO_META = Object.freeze({ + date: 0, + fetchedAt: 0, + expiresAt: 0, +}); + +/** Timerless explicit policy: no intervals; public sweep → protected runSweep. */ +class BrowserBenchmarkGCPolicy extends GCPolicy { + constructor() { + super({ expiresAt: () => 0 }); + } + + init(controller: Controller) { + this.controller = controller; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + cleanup() {} + + sweep() { + this.runSweep(); + } + + get entityQueueLength() { + return this.entitiesQ.length; + } + + get endpointQueueSize() { + return this.endpointsQ.size; + } + + get queueEntries() { + return this.entityQueueLength + this.endpointQueueSize; + } +} + +interface ExpectedScalars { + queueEntries: number; + uniqueTargets: number; + expectedEntitiesInAction: number; + expectedEndpointsInAction: number; + expectedUniqueEntityDeletions: number; + expectedEndpointDeletions: number; +} + +interface Harness { + policy: BrowserBenchmarkGCPolicy; + expected: ExpectedScalars; + getState: () => State; + getCapturedAction: () => { + type: string; + entities: EntityPath[]; + endpoints: string[]; + } | null; + clearCapturedAction: () => void; + dispose: () => void; +} + +function splitMixedCount(total: number) { + const endpoints = Math.floor(total / 2); + return { entities: total - endpoints, endpoints }; +} + +function entityPath(pk: number): EntityPath { + return { key: ENTITY_KEY, pk: String(pk) }; +} + +function endpointKey(i: number) { + return `bench-endpoint-${i}`; +} + +function buildEntityState(count: number): State { + const entities = { [ENTITY_KEY]: {} as Record }; + const entitiesMeta = { + [ENTITY_KEY]: {} as Record, + }; + for (let i = 0; i < count; i++) { + const pk = String(i); + entities[ENTITY_KEY][pk] = { id: pk }; + entitiesMeta[ENTITY_KEY][pk] = { ...ZERO_META }; + } + return { ...initialState, entities, entitiesMeta }; +} + +function buildEndpointState(count: number): State { + const endpoints: Record = {}; + const meta: Record = {}; + for (let i = 0; i < count; i++) { + const key = endpointKey(i); + endpoints[key] = String(i); + meta[key] = { ...ZERO_META }; + } + return { ...initialState, endpoints, meta }; +} + +function buildMixedState( + entityCount: number, + endpointCount: number, +): State { + const entities = buildEntityState(entityCount); + const endpoints = buildEndpointState(endpointCount); + return { + ...entities, + endpoints: endpoints.endpoints, + meta: endpoints.meta, + }; +} + +function buildStateForSpec(config: GCScenarioConfig): State { + const { candidateKind, pattern, count } = config; + if (pattern === 'duplicate') return buildEntityState(1); + if (candidateKind === 'entity') return buildEntityState(count); + if (candidateKind === 'endpoint') return buildEndpointState(count); + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + return buildMixedState(entities, endpoints); + } + throw new Error(`unknown candidateKind: ${candidateKind}`); +} + +function queueCandidates( + policy: BrowserBenchmarkGCPolicy, + config: GCScenarioConfig, +): ExpectedScalars { + const { candidateKind, pattern, count } = config; + + if (pattern === 'duplicate') { + if (candidateKind !== 'entity') { + throw new Error( + `duplicate pattern only supports candidateKind=entity (got ${candidateKind})`, + ); + } + const path = entityPath(0); + const countRef = policy.createCountRef({ paths: [path] }); + for (let i = 0; i < count; i++) { + const release = countRef(); + release(); + } + return { + queueEntries: count, + uniqueTargets: 1, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: 1, + expectedEndpointDeletions: 0, + }; + } + + if (candidateKind === 'entity') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + return { + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: count, + expectedEndpointDeletions: 0, + }; + } + + if (candidateKind === 'endpoint') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return { + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: 0, + expectedEndpointsInAction: count, + expectedUniqueEntityDeletions: 0, + expectedEndpointDeletions: count, + }; + } + + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + for (let i = 0; i < entities; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + for (let i = 0; i < endpoints; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return { + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: entities, + expectedEndpointsInAction: endpoints, + expectedUniqueEntityDeletions: entities, + expectedEndpointDeletions: endpoints, + }; + } + + throw new Error(`unknown candidateKind: ${candidateKind}`); +} + +/** End-to-end harness: sweep dispatches and reduces synchronously. */ +function createHarness(config: GCScenarioConfig): Harness { + const state = buildStateForSpec(config); + const policy = new BrowserBenchmarkGCPolicy(); + const controller = new Controller({ gcPolicy: policy }); + const reducer = createReducer(controller); + + let capturedAction: { + type: string; + entities: EntityPath[]; + endpoints: string[]; + } | null = null; + let workingState: State = state; + + controller.getState = () => workingState; + controller.dispatch = ((action: any) => { + capturedAction = action; + workingState = reducer(workingState, action); + }) as typeof controller.dispatch; + + policy.init(controller); + const expected = queueCandidates(policy, config); + + if (policy.queueEntries !== expected.queueEntries) { + throw new Error( + `fixture queue cardinality ${policy.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + + return { + policy, + expected, + getState: () => workingState, + getCapturedAction: () => capturedAction, + clearCapturedAction() { + capturedAction = null; + }, + dispose() { + capturedAction = null; + controller.getState = () => initialState; + controller.dispatch = (() => + Promise.resolve()) as typeof controller.dispatch; + policy.cleanup(); + }, + }; +} + +function countRemaining( + state: State, + config: GCScenarioConfig, + expected: ExpectedScalars, +) { + const bucket = state.entities?.[ENTITY_KEY]; + const entityRemaining = bucket ? Object.keys(bucket).length : 0; + const endpointRemaining = + state.endpoints ? Object.keys(state.endpoints).length : 0; + + const startedEntities = + config.pattern === 'duplicate' ? 1 : expected.expectedUniqueEntityDeletions; + const startedEndpoints = expected.expectedEndpointDeletions; + + return { + entityDeleted: startedEntities - entityRemaining, + endpointDeleted: startedEndpoints - endpointRemaining, + entityRemaining, + endpointRemaining, + }; +} + +function validateMeasurement( + config: GCScenarioConfig, + harness: Harness, + sample: Pick< + GCBrowserMeasurement, + | 'actionCount' + | 'deletionCount' + | 'queueEntries' + | 'uniqueTargets' + | 'actionTargetCount' + >, +) { + const { expected, policy, getCapturedAction, getState } = harness; + + if (config.control === 'no-gc') { + if (sample.actionCount !== 0) { + throw new Error( + `no-gc control expected actionCount 0, got ${sample.actionCount}`, + ); + } + if (sample.deletionCount !== 0) { + throw new Error( + `no-gc control expected deletionCount 0, got ${sample.deletionCount}`, + ); + } + return; + } + + if (sample.queueEntries !== expected.queueEntries) { + throw new Error( + `queueEntries ${sample.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + if (sample.uniqueTargets !== expected.uniqueTargets) { + throw new Error( + `uniqueTargets ${sample.uniqueTargets} !== expected ${expected.uniqueTargets}`, + ); + } + + const actionTargetCount = + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; + if (sample.actionTargetCount !== actionTargetCount) { + throw new Error( + `actionTargetCount ${sample.actionTargetCount} !== expected ${actionTargetCount}`, + ); + } + + const action = getCapturedAction(); + if (!action || action.type !== GC) { + throw new Error('expected a GC action to be dispatched'); + } + if (action.entities.length !== expected.expectedEntitiesInAction) { + throw new Error( + `action.entities.length ${action.entities.length} !== ${expected.expectedEntitiesInAction}`, + ); + } + if (action.endpoints.length !== expected.expectedEndpointsInAction) { + throw new Error( + `action.endpoints.length ${action.endpoints.length} !== ${expected.expectedEndpointsInAction}`, + ); + } + if (config.pattern === 'duplicate') { + const unique = new Set(action.entities.map(p => `${p.key}:${p.pk}`)); + if (unique.size !== 1) { + throw new Error( + `duplicate pattern expected 1 unique entity path in action, got ${unique.size}`, + ); + } + } + + const expectedDeletions = + expected.expectedUniqueEntityDeletions + expected.expectedEndpointDeletions; + if (sample.deletionCount !== expectedDeletions) { + throw new Error( + `deletionCount ${sample.deletionCount} !== expected ${expectedDeletions}`, + ); + } + const remaining = countRemaining(getState(), config, expected); + if (remaining.entityRemaining !== 0 || remaining.endpointRemaining !== 0) { + throw new Error( + `expected empty GC targets after deletion; remaining entities=${remaining.entityRemaining} endpoints=${remaining.endpointRemaining}`, + ); + } + if (policy.queueEntries !== 0) { + throw new Error( + `expected empty queues after sweep, got ${policy.queueEntries}`, + ); + } +} + +interface Session { + config: GCScenarioConfig; + harness: Harness; + displayPeriodMs: number; +} + +let session: Session | null = null; + +export async function prepareGCScenario( + config: GCScenarioConfig, +): Promise { + if (session) { + session.harness.dispose(); + session = null; + } + + const harness = createHarness(config); + const displayPeriodMs = await measureDisplayPeriodMs(); + + session = { config, harness, displayPeriodMs }; + + return { + queueEntries: harness.expected.queueEntries, + uniqueTargets: harness.expected.uniqueTargets, + }; +} + +/** + * Interaction measurement via Chromium-calibrated probe: + * rAF registers the next rAF, then setTimeout(0) runs collection post-paint + * while a future frame is pending. See gcInteractionProbe.ts. + * `totalMs` is sweep/no-op only (scheduling excluded). + */ +export async function runGCScenario(): Promise { + if (!session) { + throw new Error( + 'prepareGCScenario() must be called before runGCScenario()', + ); + } + const { config, harness, displayPeriodMs } = session; + const { policy, expected } = harness; + + harness.clearCapturedAction(); + + let actionCount = 0; + let deletionCount = 0; + + const probe = await runChromiumInteractionProbe({ + displayPeriodMs, + work: () => { + if (config.control === 'gc') { + policy.sweep(); + const action = harness.getCapturedAction(); + actionCount = action && action.type === GC ? 1 : 0; + } + }, + }); + + // Deletion accounting outside timed work + if (config.control === 'gc') { + const remaining = countRemaining(harness.getState(), config, expected); + deletionCount = remaining.entityDeleted + remaining.endpointDeleted; + } + + const actionTargetCount = + config.control === 'gc' ? + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction + : 0; + + const measurement: GCBrowserMeasurement = { + schemaVersion: 1, + totalMs: probe.totalMs, + sliceDurationsMs: config.control === 'gc' ? [probe.totalMs] : [], + actionCount, + queueEntries: expected.queueEntries, + uniqueTargets: expected.uniqueTargets, + actionTargetCount, + deletionCount, + timerDelayMs: probe.timerDelayMs, + frameIntervalsMs: probe.frameIntervalsMs, + displayPeriodMs: probe.displayPeriodMs, + missedFrames: probe.missedFrames, + maxInputDelayMs: probe.maxInputDelayMs, + longTaskCount: probe.longTaskCount, + longTaskTotalMs: probe.longTaskTotalMs, + }; + + validateMeasurement(config, harness, measurement); + + // Drop captured GC action arrays before Playwright heap snapshot; keep live store + harness.clearCapturedAction(); + + return measurement; +} + +/** + * Validation/calibration only: synthetic 40–50ms block through the same probe. + * Confirms blocking spans pending frame boundaries (missedFrames / large interval). + * Not used by GC timing scenarios. + */ +export async function calibrateGCFrameProbe(blockMs?: number): Promise<{ + blockMs: number; + totalMs: number; + timerDelayMs: number; + displayPeriodMs: number; + frameIntervalsMs: number[]; + frameIntervalMax: number; + missedFrames: number; + maxInputDelayMs: number; + spannedPendingFrame: boolean; +}> { + const displayPeriodMs = + session?.displayPeriodMs ?? (await measureDisplayPeriodMs()); + const ms = Math.min( + CALIBRATION_BLOCK_MS_MAX, + Math.max( + CALIBRATION_BLOCK_MS_MIN, + blockMs ?? (CALIBRATION_BLOCK_MS_MIN + CALIBRATION_BLOCK_MS_MAX) / 2, + ), + ); + + const probe = await runChromiumInteractionProbe({ + displayPeriodMs, + work: () => syntheticBlockMs(ms), + }); + + const frameIntervalMax = + probe.frameIntervalsMs.length > 0 ? Math.max(...probe.frameIntervalsMs) : 0; + const spannedPendingFrame = + probe.missedFrames >= 1 || frameIntervalMax >= displayPeriodMs * 1.5; + + return { + blockMs: ms, + totalMs: probe.totalMs, + timerDelayMs: probe.timerDelayMs, + displayPeriodMs: probe.displayPeriodMs, + frameIntervalsMs: probe.frameIntervalsMs, + frameIntervalMax, + missedFrames: probe.missedFrames, + maxInputDelayMs: probe.maxInputDelayMs, + spannedPendingFrame, + }; +} + +export function disposeGCScenario(): void { + if (!session) return; + session.harness.dispose(); + session = null; +} diff --git a/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts b/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts new file mode 100644 index 000000000000..aa88019e9436 --- /dev/null +++ b/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts @@ -0,0 +1,169 @@ +/** + * Pure GC interaction metric helpers + stable scenario IDs. + * Deterministic — safe to unit-test without Playwright timing. + */ + +import type { GCScenarioConfig } from '@shared/types'; + +/** + * Count excess whole display periods beyond the single expected vsync gap. + * + * Uses nearest-period (Math.round) rather than floor: rAF timestamps jitter, so + * a true 2-period stall often measures slightly under 2×displayPeriodMs + * (e.g. 1.98×). floor() would report 0 missed frames; round() recovers the + * intended whole-period count. Sub-period noise around 1× still rounds to 1 + * (zero excess). + */ +export function excessMissedFrames( + frameIntervalsMs: number[], + displayPeriodMs: number, +): number { + if (!(displayPeriodMs > 0)) return 0; + let missed = 0; + for (const interval of frameIntervalsMs) { + const periods = Math.round(interval / displayPeriodMs); + missed += Math.max(0, periods - 1); + } + return missed; +} + +/** + * Responsiveness proxy from timer + frame probes (not pointer input). + * max(timerDelayMs, largest frame-interval excess over one display period). + */ +export function computeMaxInputDelayMs( + timerDelayMs: number, + frameIntervalsMs: number[], + displayPeriodMs: number, +): number { + let maxExcessFrame = 0; + if (displayPeriodMs > 0) { + for (const interval of frameIntervalsMs) { + maxExcessFrame = Math.max( + maxExcessFrame, + Math.max(0, interval - displayPeriodMs), + ); + } + } + return Math.max(timerDelayMs, maxExcessFrame); +} + +/** LongTask overlaps measurement window (entry may start just before windowStart). */ +export function longTaskOverlapsWindow( + entry: { startTime: number; duration: number }, + windowStart: number, + windowEnd: number, +): boolean { + return ( + entry.startTime < windowEnd && + entry.startTime + entry.duration > windowStart + ); +} + +/** Convert consecutive rAF timestamps into raw intervals. */ +export function frameIntervalsFromTimestamps(timestamps: number[]): number[] { + const intervals: number[] = []; + for (let i = 1; i < timestamps.length; i++) { + intervals.push(timestamps[i] - timestamps[i - 1]); + } + return intervals; +} + +export function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? + (sorted[mid - 1]! + sorted[mid]!) / 2 + : sorted[mid]!; +} + +/** Quiet rAF intervals → display period (never hardcode 16.67). */ +export async function measureDisplayPeriodMs(samples = 8): Promise { + const intervals: number[] = []; + await new Promise(resolve => { + let last = 0; + let n = 0; + const frame = (now: number) => { + if (n > 0) intervals.push(now - last); + last = now; + n++; + if (n <= samples) requestAnimationFrame(frame); + else resolve(); + }; + requestAnimationFrame(frame); + }); + return median(intervals); +} + +/** Canonical GC counts (must match `GC_CANONICAL_COUNTS` in bench/scenarios.ts). */ +const CANONICAL_COUNTS: readonly number[] = [1_000, 10_000, 100_000]; + +/** + * Stable detailed-report scenario ID from validated axes (not display names). + * Format: browser/{kind}/{pattern}/{count}/end-to-end/{control} + */ +export function browserGCScenarioId( + axes: Pick< + GCScenarioConfig, + 'candidateKind' | 'pattern' | 'count' | 'control' + >, +): string { + const { candidateKind, pattern, count, control } = axes; + if ( + candidateKind !== 'entity' && + candidateKind !== 'endpoint' && + candidateKind !== 'mixed' + ) { + throw new Error(`invalid candidateKind: ${candidateKind}`); + } + if (pattern !== 'unique' && pattern !== 'duplicate') { + throw new Error(`invalid pattern: ${pattern}`); + } + if (pattern === 'duplicate' && candidateKind !== 'entity') { + throw new Error('duplicate pattern requires candidateKind=entity'); + } + if (!Number.isInteger(count) || !CANONICAL_COUNTS.includes(count)) { + throw new Error( + `invalid count: ${count}; expected one of ${CANONICAL_COUNTS.join('|')}`, + ); + } + if (control !== 'gc' && control !== 'no-gc') { + throw new Error(`invalid control: ${control}`); + } + return `browser/${candidateKind}/${pattern}/${count}/end-to-end/${control}`; +} + +/** Parse a stable ID back to axes (throws on malformed). */ +export function parseBrowserGCScenarioId(id: string): { + platform: 'browser'; + candidateKind: GCScenarioConfig['candidateKind']; + pattern: GCScenarioConfig['pattern']; + count: number; + mode: 'end-to-end'; + control: GCScenarioConfig['control']; +} { + const parts = id.split('/'); + if ( + parts.length !== 6 || + parts[0] !== 'browser' || + parts[4] !== 'end-to-end' + ) { + throw new Error(`malformed browser GC scenario id: ${id}`); + } + const axes = { + candidateKind: parts[1] as GCScenarioConfig['candidateKind'], + pattern: parts[2] as GCScenarioConfig['pattern'], + count: Number(parts[3]), + control: parts[5] as GCScenarioConfig['control'], + }; + // Re-validate via builder (rejects invalid axis combos) + if (browserGCScenarioId(axes) !== id) { + throw new Error(`browser GC scenario id failed revalidation: ${id}`); + } + return { + platform: 'browser', + ...axes, + mode: 'end-to-end', + }; +} diff --git a/examples/benchmark-react/src/data-client/gcInteractionProbe.ts b/examples/benchmark-react/src/data-client/gcInteractionProbe.ts new file mode 100644 index 000000000000..c572a7ec4268 --- /dev/null +++ b/examples/benchmark-react/src/data-client/gcInteractionProbe.ts @@ -0,0 +1,235 @@ +/** + * Chromium-calibrated interaction probe for browser GC measurement. + * + * Ordering (Chromium-specific calibration — NOT a web platform guarantee): + * 1. In an rAF callback, register the *next* rAF first (future frame pending). + * 2. Then post setTimeout(0) so the collection/work task runs after paint. + * 3. Synchronous work in that timer therefore blocks while a frame is pending, + * so wall-clock frame intervals can observe the stalled vsync gap. + * + * `totalMs` times only `work()` — scheduling overhead is excluded. + * If `work()` throws, the probe rejects promptly, stops the rAF/timer chain, + * and disconnects the PerformanceObserver (no further mutations). + */ +import { + computeMaxInputDelayMs, + excessMissedFrames, + frameIntervalsFromTimestamps, + longTaskOverlapsWindow, +} from './gcInteractionMetrics'; + +export interface InteractionProbeResult { + totalMs: number; + timerDelayMs: number; + frameIntervalsMs: number[]; + displayPeriodMs: number; + missedFrames: number; + maxInputDelayMs: number; + longTaskCount: number; + longTaskTotalMs: number; +} + +const POST_FRAMES = 6; + +function drainAndDisconnectObserver( + observer: PerformanceObserver | null, + longTasks: { startTime: number; duration: number }[], +): void { + if (!observer) return; + try { + for (const entry of observer.takeRecords()) { + longTasks.push({ + startTime: entry.startTime, + duration: entry.duration, + }); + } + } catch { + // takeRecords unavailable + } + try { + observer.disconnect(); + } catch { + // ignore + } +} + +/** + * Run one Chromium-calibrated probe. `work` is the only timed body. + * Rejects if `work()` throws; settles exactly once. + */ +export async function runChromiumInteractionProbe(opts: { + displayPeriodMs: number; + work: () => void; +}): Promise { + const { displayPeriodMs, work } = opts; + + type LongTaskEntry = { startTime: number; duration: number }; + const longTasks: LongTaskEntry[] = []; + let observer: PerformanceObserver | null = null; + if (typeof PerformanceObserver !== 'undefined') { + try { + observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + longTasks.push({ + startTime: entry.startTime, + duration: entry.duration, + }); + } + }); + observer.observe({ + type: 'longtask', + buffered: false, + } as PerformanceObserverInit); + } catch { + observer = null; + } + } + + const frameTimestamps: number[] = []; + let timerDelayMs = 0; + let totalMs = 0; + let windowStart = 0; + let windowEnd = 0; + + try { + await new Promise((resolve, reject) => { + let armed = false; + let collectionDone = false; + let postRemaining = POST_FRAMES; + /** Once true, no callback may mutate probe state or schedule further work. */ + let closed = false; + + let rafHandle: number | null = null; + let collectionTimer: ReturnType | null = null; + let delayProbeTimer: ReturnType | null = null; + let settleTimer: ReturnType | null = null; + + const clearPendingTimers = () => { + if (rafHandle != null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(rafHandle); + rafHandle = null; + } + if (collectionTimer != null) { + clearTimeout(collectionTimer); + collectionTimer = null; + } + if (delayProbeTimer != null) { + clearTimeout(delayProbeTimer); + delayProbeTimer = null; + } + if (settleTimer != null) { + clearTimeout(settleTimer); + settleTimer = null; + } + }; + + const settleOnce = (err?: unknown) => { + if (closed) return; + closed = true; + clearPendingTimers(); + if (err !== undefined) + reject(err instanceof Error ? err : new Error(String(err))); + else resolve(); + }; + + const scheduleFrame = () => { + if (closed) return; + rafHandle = requestAnimationFrame(onFrame); + }; + + const onFrame = (rafNow: number) => { + rafHandle = null; + if (closed) return; + + frameTimestamps.push(rafNow); + + if (!armed) { + armed = true; + // 1) Future frame pending BEFORE collection is posted. + scheduleFrame(); + // 2) Collection as macrotask — Chromium typically runs this post-paint. + collectionTimer = setTimeout(() => { + collectionTimer = null; + if (closed) return; + + windowStart = performance.now(); + const beforeProbe = performance.now(); + // Responsiveness probe: delay from immediately before timed work. + delayProbeTimer = setTimeout(() => { + delayProbeTimer = null; + if (closed) return; + timerDelayMs = performance.now() - beforeProbe; + }, 0); + + try { + const t0 = performance.now(); + work(); + totalMs = performance.now() - t0; + collectionDone = true; + } catch (err) { + settleOnce(err); + } + }, 0); + return; + } + + if (!collectionDone) { + // Keep the chain alive until collection finishes (blocked frames). + scheduleFrame(); + return; + } + + postRemaining--; + if (postRemaining > 0) { + scheduleFrame(); + return; + } + + windowEnd = performance.now(); + // Settle the responsiveness setTimeout(0) probe. + settleTimer = setTimeout(() => { + settleTimer = null; + if (closed) return; + settleOnce(); + }, 0); + }; + + scheduleFrame(); + }); + } finally { + // Resolve and reject both land here — drain + disconnect observer. + drainAndDisconnectObserver(observer, longTasks); + observer = null; + } + + const frameIntervalsMs = frameIntervalsFromTimestamps(frameTimestamps); + const longTasksInWindow = longTasks.filter(t => + longTaskOverlapsWindow(t, windowStart, windowEnd), + ); + + return { + totalMs, + timerDelayMs, + frameIntervalsMs, + displayPeriodMs, + missedFrames: excessMissedFrames(frameIntervalsMs, displayPeriodMs), + maxInputDelayMs: computeMaxInputDelayMs( + timerDelayMs, + frameIntervalsMs, + displayPeriodMs, + ), + longTaskCount: longTasksInWindow.length, + longTaskTotalMs: longTasksInWindow.reduce((s, t) => s + t.duration, 0), + }; +} + +/** Busy-wait for calibration only (not used in GC timing scenarios). */ +export function syntheticBlockMs(ms: number): void { + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } +} + +export const CALIBRATION_BLOCK_MS_MIN = 40; +export const CALIBRATION_BLOCK_MS_MAX = 50; diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index cf1af16ad3af..8688185cce62 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -30,11 +30,24 @@ import { sortedIssuesEndpoint, } from '@shared/resources'; import { patchIssue } from '@shared/server'; -import type { Issue } from '@shared/types'; +import type { GCScenarioConfig, Issue } from '@shared/types'; import React, { useCallback, useRef } from 'react'; let mutationCounter = 0; +/** Lazily loaded GC harness — never statically imported (bundle fairness). */ +type GCHarnessModule = typeof import('./gcBrowserHarness'); +let gcHarnessModule: GCHarnessModule | null = null; + +async function loadGCHarness(): Promise { + if (!gcHarnessModule) { + gcHarnessModule = await import( + /* webpackChunkName: "gc-browser-harness" */ './gcBrowserHarness' + ); + } + return gcHarnessModule; +} + /** GCPolicy with no interval (won't fire during timing scenarios) and instant * expiry so an explicit sweep() collects all unreferenced data immediately. */ class BenchGCPolicy extends GCPolicy { @@ -300,6 +313,25 @@ function BenchmarkHarness() { moveItem, triggerGC: () => benchGC.sweep(), resetStore, + prepareGCScenario: async (config: GCScenarioConfig) => { + const mod = await loadGCHarness(); + return mod.prepareGCScenario(config); + }, + runGCScenario: () => { + if (!gcHarnessModule) { + throw new Error( + 'prepareGCScenario() must load the GC harness before runGCScenario()', + ); + } + return gcHarnessModule.runGCScenario(); + }, + disposeGCScenario: () => { + gcHarnessModule?.disposeGCScenario(); + }, + calibrateGCFrameProbe: async (blockMs?: number) => { + const mod = await loadGCHarness(); + return mod.calibrateGCFrameProbe(blockMs); + }, }); return ( diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index bdde003695d8..da646eecf5eb 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -9,6 +9,56 @@ export interface RefStabilityReport { userRefChanged: number; } +/** Axes for browser GC scenarios (local mirror of the shared GC vocabulary). */ +export interface GCScenarioConfig { + candidateKind: 'entity' | 'endpoint' | 'mixed'; + /** `duplicate` is entity-only (one path released `count` times). */ + pattern: 'unique' | 'duplicate'; + /** Canonical counts: 1000 | 10000 | 100000 */ + count: number; + control: 'gc' | 'no-gc'; +} + +/** Queue cardinality after prepare (fixtures + createCountRef outside timing). */ +export interface GCPreparedSummary { + queueEntries: number; + uniqueTargets: number; +} + +/** + * Page-side GC interaction measurement (schemaVersion 1). + * Heap fields are attached by the Playwright runner, not page code. + */ +export interface GCBrowserMeasurement { + schemaVersion: 1; + totalMs: number; + /** Monolithic baseline: exactly `[totalMs]` when control is `gc`; empty for `no-gc`. */ + sliceDurationsMs: number[]; + actionCount: number; + queueEntries: number; + uniqueTargets: number; + actionTargetCount: number; + deletionCount: number; + /** Actual setTimeout(0) callback delay from immediately before collection. */ + timerDelayMs: number; + /** Raw rAF intervals around collection. */ + frameIntervalsMs: number[]; + /** Quiet-frame period measured during prepare (never hardcode 16.67). */ + displayPeriodMs: number; + /** + * Excess whole frame periods vs displayPeriodMs (nearest-period; see harness). + */ + missedFrames: number; + /** + * Responsiveness proxy: max(timerDelayMs, max excess of any frame interval + * over one displayPeriodMs). Not pointer/input-event latency — no synthetic + * pointer events are generated. + */ + maxInputDelayMs: number; + longTaskCount: number; + longTaskTotalMs: number; +} + /** * Benchmark API interface exposed by each library app on window.__BENCH__ */ @@ -57,6 +107,34 @@ export interface BenchAPI { setRenderLimit?(n: number | undefined): void; /** Clear client-side cache/store so the next mount triggers a fresh fetch. Called between sub-iterations for mount scenarios. */ resetStore?(): void; + /** + * Prepare isolated browser GC fixtures + queues (untimed). data-client only. + * Also measures quiet displayPeriodMs. Does not start interaction timing. + * Dynamically loads the GC harness chunk during prepare (before timing). + */ + prepareGCScenario?(config: GCScenarioConfig): Promise; + /** + * Run explicit monolithic cache GC (or no-gc control) with interaction probes. + * Resolves only after timer + rAF probes settle. data-client only. + */ + runGCScenario?(): Promise; + /** Tear down the isolated GC harness (store + policy). data-client only. */ + disposeGCScenario?(): void; + /** + * Validation/calibration only: synthetic ~40–50ms block through the Chromium + * frame probe. Confirms blocking spans pending frame boundaries. + */ + calibrateGCFrameProbe?(blockMs?: number): Promise<{ + blockMs: number; + totalMs: number; + timerDelayMs: number; + displayPeriodMs: number; + frameIntervalsMs: number[]; + frameIntervalMax: number; + missedFrames: number; + maxInputDelayMs: number; + spannedPendingFrame: boolean; + }>; } declare global { @@ -126,13 +204,10 @@ export type ScenarioAction = | { action: 'moveItem'; args: [number] }; export type ResultMetric = - | 'duration' - | 'issueRefChanged' - | 'userRefChanged' - | 'heapDelta'; + 'duration' | 'issueRefChanged' | 'userRefChanged' | 'heapDelta' | 'totalMs'; -/** hotPath = JS only, included in CI. memory = heap delta, not CI. startup = page load metrics, not CI. */ -export type ScenarioCategory = 'hotPath' | 'memory' | 'startup'; +/** hotPath = JS only, included in CI. memory = heap delta, not CI. startup = page load metrics, not CI. gc = cache GC interaction, not CI. */ +export type ScenarioCategory = 'hotPath' | 'memory' | 'startup' | 'gc'; /** small = cheap scenarios (full warmup + measurement). large = expensive scenarios (reduced runs). */ export type ScenarioSize = 'small' | 'large'; @@ -141,9 +216,9 @@ export interface Scenario { name: string; action: keyof BenchAPI; args: unknown[]; - /** Which value to report; default 'duration'. Ref-stability use issueRefChanged/userRefChanged; memory use heapDelta. */ + /** Which value to report; default 'duration'. Ref-stability use issueRefChanged/userRefChanged; memory use heapDelta; gc use totalMs. */ resultMetric?: ResultMetric; - /** hotPath (default) = run in CI. memory = heap delta. startup = page load metrics. */ + /** hotPath (default) = run in CI. memory = heap delta. startup = page load metrics. gc = cache GC (opt-in). */ category?: ScenarioCategory; /** small (default) = full runs. large = reduced warmup/measurement for expensive scenarios. */ size?: ScenarioSize; diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index 760c3ed276c2..a7d612a69505 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -21,13 +21,14 @@ yarn workspace example-benchmark start [suite-name] [filter] ``` Both arguments are optional: + - **No arguments**: runs `normalizr` + `core` suites with all benchmarks - **Suite only**: `yarn start normalizr` runs all benchmarks in that suite - **Suite + filter**: `yarn start normalizr denormalize` runs only benchmarks containing "denormalize" #### Filter syntax -- `text` → substring match (contains "text") +- `text` → substring match (contains) - `^text` → starts with "text" #### Suites @@ -78,6 +79,107 @@ counts (minor/major/other) and total GC pause during the loop, and retained heap after a full GC (should be ~0; nonzero indicates actual retention). Local-only — not tracked in CI. +### GC policy (isolated Node baseline) + +Measures the current monolithic cache GC (`GCPolicy.runSweep` + GC reducer) +under a timerless `BenchmarkGCPolicy` subclass: `expiresAt` always zero, `init` +only stores the controller (no intervals), `cleanup` is a no-op, and a public +`sweep()` calls protected `runSweep` once. This is **data-client cache GC**, not +V8/engine GC — do not conflate the two. + +```bash +yarn build:benchmark +yarn workspace example-benchmark start:gc [filter] [--samples=N] [--memory] [--table|--no-table] +yarn workspace example-benchmark start:gc --verify-manifest +# or: yarn workspace example-benchmark start:gc:verify +``` + +Every `yarn build:benchmark` / workspace `build` runs webpack then writes +`dist/gc-build-manifest.json` (BuildManifest v1: `schemaVersion`, `buildId`, +`commit`, `dirty`, `sourceDigest`, `artifacts`). `sourceDigest` hashes sorted +relevant inputs on disk (`packages/core/src/**`, GC harness/runner/config/ +package files), so dirty or untracked relevant files change it. `artifacts` +maps each emitted `dist/*` file (except the manifest) to sha256. `buildId` is +the digest of those canonical fields excluding itself. + +`start:gc` **verifies** the manifest before scenarios: recomputes the current +source digest and every dist artifact hash, and rejects stale/missing/tampered +builds with a rebuild instruction. Report `build` provenance comes from the +**verified manifest**, never from live `git rev-parse` at run time. Manifest +generation and verification are outside timed cache-GC work. + +`--verify-manifest` / `start:gc:verify` self-tests that source and artifact +tampering are rejected and restores all files afterward. + +Examples: + +```bash +# All 1k scenarios (substring filter); JSON on stdout +yarn workspace example-benchmark start:gc /1000/ --samples=5 + +# Single 100k entity scan without building unrelated fixtures +yarn workspace example-benchmark start:gc node/entity/unique/100000/scan/gc --samples=3 + +# Duplicate entity queue amplification baseline +yarn workspace example-benchmark start:gc entity/duplicate/1000 + +# Capture JSON only (no stderr table) +yarn workspace example-benchmark start:gc /1000/scan/gc --no-table > gc-report.json +``` + +Filter syntax matches the other suites (`text` substring, `^text` prefix) against +stable scenario IDs: + +`node/{entity|endpoint|mixed}/{unique|duplicate}/{1000|10000|100000}/{scan|reducer|end-to-end}/{gc|no-gc}` + +| Axis | Values | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `candidateKind` | `entity`, `endpoint`, `mixed` (mixed splits total count across entity+endpoint) | +| `pattern` | `unique` (distinct keys/paths); `duplicate` (one entity path released `count` times; entity only) | +| `count` | Canonical `1000`, `10000`, `100000` | +| `mode` | `scan` (dispatch captures action, no reduce), `reducer` (prebuilt GC action through reducer), `end-to-end` (sweep dispatch reduces synchronously) | +| `control` | `gc` or `no-gc` (harness/no-op overhead) | + +**Setup / timing boundaries** + +1. **Prepare** (untimed) — build deterministic fixture state (`entities`/`entitiesMeta` or `endpoints`/`meta`), queue candidates via `createCountRef` mount/release. `reducer` mode prebuilds a GC action and clones state (seed dropped before timing/heap baseline). `scan` / `end-to-end` do **not** retain parallel 100k target arrays. +2. **Timed cache-GC** — `sweep()`, `reducer(state, action)`, or empty `no-op` body only. No per-candidate marks/logging inside this window. +3. **Optional retained-heap (`--memory`)** — after timed work + validation, drop observer refs (captured GC action, prebuilt action) while **keeping the live store**; then settle + forced V8 GC. Engine GC pauses are never folded into `totalMs`. + +Each destructive sample (`reducer` / `end-to-end`) gets a fresh fixture. Default +`--samples=11` (CLI-overridable). Fixtures for unmatched counts are never built, +so a filtered `100000` run does not construct 1k/10k stores. + +**Metrics** (JSON `schemaVersion: 1` on stdout) + +- `totalMs` — timed cache-GC wall time +- `sliceDurationsMs` — current monolith is **one slice** (honest representation; not fabricated per-candidate samples) +- `actionCount`, `queueEntries`, `uniqueTargets`, `actionTargetCount`, `deletionCount` +- For `duplicate`: queue/action may list the same entity path repeatedly; `uniqueTargets` / final `deletionCount` stay `1` +- Summary: median / min / max / p95 / p99 where meaningful; units are explicit in the report + +Every accepted sample is self-validated (queue cardinality, action target shape, +final deletions for reducer/end-to-end). Invalid fixtures/results throw. + +**`--memory` semantics (keep-store / drop-observer)** + +`heapDeltaBytes` is **not** “drop the whole harness and see what V8 frees.” That +would free the fixture for both `gc` and `no-gc` and hide cache deletion. +Instead: + +1. `heapBefore` — store + queues retained (reducer seed already dropped); forced V8 GC +2. timed cache-GC (or `no-gc` no-op) +3. validate, then release observer-only refs (captured action target arrays, prebuilt action) +4. `heapAfter` — **live store still retained**; settle + forced V8 GC + +Interpretation: compare `gc` vs `no-gc` on `reducer` / `end-to-end` — `gc` should +show a more negative (or smaller) retained heap after deleting cache entries. +`scan` does not mutate the store, so its heap delta is not a retained-cache signal. +Report includes `memorySemantics` documenting this model. Still separate from +engine GC timing. + +Local calibration harness only — not claimed as CI-integrated. + ### Profiling For opt/deopt investigation: diff --git a/examples/benchmark/gc-build-manifest.js b/examples/benchmark/gc-build-manifest.js new file mode 100644 index 000000000000..d1a9c5877d90 --- /dev/null +++ b/examples/benchmark/gc-build-manifest.js @@ -0,0 +1,502 @@ +/** + * BuildManifest v1 for the Node GC harness. + * + * Generated after webpack (`yarn build` / `yarn build:benchmark`) as + * `dist/gc-build-manifest.json`. `start:gc` verifies source + artifact digests + * before any scenario work and reports provenance from the verified manifest + * (never current HEAD alone). + * + * Usage: + * node ./gc-build-manifest.js write + * node ./gc-build-manifest.js verify + * node ./gc-build-manifest.js self-test + */ +import { Buffer } from 'node:buffer'; +import { execFileSync, execSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + readdirSync, + readFileSync, + writeFileSync, + unlinkSync, + statSync, +} from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BENCH_ROOT = __dirname; +const REPO_ROOT = path.resolve(BENCH_ROOT, '../..'); + +export const MANIFEST_SCHEMA_VERSION = 1; +export const MANIFEST_RELATIVE = 'dist/gc-build-manifest.json'; +export const MANIFEST_PATH = path.join(BENCH_ROOT, MANIFEST_RELATIVE); + +/** Benchmark harness / runner / config inputs (relative to repo root). */ +const BENCH_SOURCE_FILES = [ + 'examples/benchmark/gc-build-manifest.js', + 'examples/benchmark/gc-policy.js', + 'examples/benchmark/gc-policy-scenarios.js', + 'examples/benchmark/filter.js', + 'examples/benchmark/package.json', + 'examples/benchmark/webpack.config.cjs', + 'examples/benchmark/src/index.ts', + 'examples/benchmark/tsconfig.json', +]; + +const CORE_SRC_DIR = 'packages/core/src'; + +function sha256Buffer(buf) { + return createHash('sha256').update(buf).digest('hex'); +} + +function sha256Text(text) { + return sha256Buffer(Buffer.from(text, 'utf8')); +} + +function toPosix(p) { + return p.split(path.sep).join('/'); +} + +/** Walk a directory; return repo-relative posix paths (files only), sorted. */ +function walkFiles(absDir, repoRelDir) { + const out = []; + if (!existsSync(absDir)) return out; + const entries = readdirSync(absDir, { withFileTypes: true }); + for (const ent of entries) { + const abs = path.join(absDir, ent.name); + const rel = toPosix(path.join(repoRelDir, ent.name)); + if (ent.isDirectory()) { + out.push(...walkFiles(abs, rel)); + } else if (ent.isFile()) { + out.push(rel); + } + } + return out; +} + +/** + * Sorted list of relevant source paths (repo-relative posix). + * Includes untracked files under packages/core/src when present on disk. + */ +export function listRelevantSourcePaths() { + const coreFiles = walkFiles(path.join(REPO_ROOT, CORE_SRC_DIR), CORE_SRC_DIR); + const benchFiles = BENCH_SOURCE_FILES.filter(rel => + existsSync(path.join(REPO_ROOT, rel)), + ); + return [...new Set([...coreFiles, ...benchFiles])].sort((a, b) => + a.localeCompare(b), + ); +} + +/** + * Canonical source digest over sorted path→content (filesystem, not git blobs). + * Dirty/untracked relevant files change this digest. + */ +export function computeSourceDigest(paths = listRelevantSourcePaths()) { + const hash = createHash('sha256'); + for (const rel of paths) { + const abs = path.join(REPO_ROOT, rel); + if (!existsSync(abs) || !statSync(abs).isFile()) { + throw new Error(`relevant source missing: ${rel}`); + } + hash.update(rel); + hash.update('\0'); + hash.update(readFileSync(abs)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +/** Dist artifact paths relative to examples/benchmark, excluding the manifest. */ +export function listDistArtifactPaths() { + const distDir = path.join(BENCH_ROOT, 'dist'); + if (!existsSync(distDir)) return []; + const out = []; + for (const name of readdirSync(distDir)) { + if (name === 'gc-build-manifest.json') continue; + const abs = path.join(distDir, name); + if (statSync(abs).isFile()) { + out.push(toPosix(path.join('dist', name))); + } + } + return out.sort((a, b) => a.localeCompare(b)); +} + +export function hashDistArtifacts(paths = listDistArtifactPaths()) { + /** @type {Record} */ + const artifacts = {}; + for (const rel of paths) { + const abs = path.join(BENCH_ROOT, rel); + if (!existsSync(abs)) { + throw new Error(`dist artifact missing: ${rel}`); + } + artifacts[rel] = sha256Buffer(readFileSync(abs)); + } + return artifacts; +} + +export function gitCommitFull() { + try { + return execSync('git rev-parse HEAD', { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return 'unknown'; + } +} + +/** + * dirty=true when any relevant path is modified, added, or untracked vs git. + */ +export function computeDirty(paths = listRelevantSourcePaths()) { + try { + const pathspecs = [CORE_SRC_DIR, ...BENCH_SOURCE_FILES]; + const out = execFileSync( + 'git', + ['status', '--porcelain', '-u', '--', ...pathspecs], + { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ).trim(); + if (!out) return false; + const relevant = new Set(paths); + for (const line of out.split('\n')) { + if (!line) continue; + const rest = line.slice(3); + const filePath = rest.includes(' -> ') ? rest.split(' -> ').pop() : rest; + const norm = toPosix(filePath.replace(/^"|"$/g, '')); + if ( + relevant.has(norm) || + norm.startsWith(`${CORE_SRC_DIR}/`) || + BENCH_SOURCE_FILES.includes(norm) + ) { + return true; + } + } + return false; + } catch { + return true; + } +} + +/** + * buildId = digest of canonical manifest fields excluding buildId itself. + */ +export function computeBuildId({ + schemaVersion, + commit, + dirty, + sourceDigest, + artifacts, +}) { + const sortedArtifacts = Object.fromEntries( + Object.entries(artifacts).sort(([a], [b]) => a.localeCompare(b)), + ); + const canonical = JSON.stringify({ + schemaVersion, + commit, + dirty, + sourceDigest, + artifacts: sortedArtifacts, + }); + return sha256Text(canonical); +} + +export function buildManifest() { + const paths = listRelevantSourcePaths(); + const sourceDigest = computeSourceDigest(paths); + const artifacts = hashDistArtifacts(); + if (Object.keys(artifacts).length === 0) { + throw new Error( + 'no dist artifacts found; run webpack before writing the GC build manifest', + ); + } + const commit = gitCommitFull(); + const dirty = computeDirty(paths); + const body = { + schemaVersion: MANIFEST_SCHEMA_VERSION, + commit, + dirty, + sourceDigest, + artifacts, + }; + const buildId = computeBuildId(body); + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + buildId, + commit, + dirty, + sourceDigest, + artifacts, + }; +} + +export function writeManifest(manifest = buildManifest()) { + writeFileSync( + MANIFEST_PATH, + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', + ); + return manifest; +} + +export function readManifest(manifestPath = MANIFEST_PATH) { + if (!existsSync(manifestPath)) { + return null; + } + return JSON.parse(readFileSync(manifestPath, 'utf8')); +} + +const REBUILD_HINT = + 'Rebuild with: yarn build:benchmark (or: yarn workspace example-benchmark run build)'; + +/** + * Recompute current source digest + dist artifact hashes and compare to manifest. + * @returns {{ ok: true, manifest: object } | { ok: false, reason: string, details?: string[] }} + */ +export function verifyManifest(manifestPath = MANIFEST_PATH) { + const details = []; + if (!existsSync(manifestPath)) { + return { + ok: false, + reason: `missing build manifest at ${MANIFEST_RELATIVE}`, + details: [REBUILD_HINT], + }; + } + + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch (err) { + return { + ok: false, + reason: `unreadable build manifest: ${err.message}`, + details: [REBUILD_HINT], + }; + } + + if (manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) { + return { + ok: false, + reason: `unsupported manifest schemaVersion ${manifest.schemaVersion} (expected ${MANIFEST_SCHEMA_VERSION})`, + details: [REBUILD_HINT], + }; + } + + const expectedBuildId = computeBuildId({ + schemaVersion: manifest.schemaVersion, + commit: manifest.commit, + dirty: manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts ?? {}, + }); + if (manifest.buildId !== expectedBuildId) { + details.push( + `buildId mismatch (manifest tampered or corrupt): got ${manifest.buildId}, expected ${expectedBuildId}`, + ); + } + + let currentSourceDigest; + try { + currentSourceDigest = computeSourceDigest(); + } catch (err) { + return { + ok: false, + reason: err.message, + details: [REBUILD_HINT], + }; + } + if (currentSourceDigest !== manifest.sourceDigest) { + details.push( + `sourceDigest stale: manifest ${manifest.sourceDigest.slice(0, 12)}… vs current ${currentSourceDigest.slice(0, 12)}… (relevant sources changed since build)`, + ); + } + + const currentArtifacts = hashDistArtifacts(); + const manifestArts = manifest.artifacts ?? {}; + const allKeys = new Set([ + ...Object.keys(manifestArts), + ...Object.keys(currentArtifacts), + ]); + for (const key of [...allKeys].sort()) { + if (!(key in manifestArts)) { + details.push(`unexpected dist artifact not in manifest: ${key}`); + } else if (!(key in currentArtifacts)) { + details.push(`missing dist artifact listed in manifest: ${key}`); + } else if (manifestArts[key] !== currentArtifacts[key]) { + details.push( + `dist artifact hash mismatch: ${key} (tampered or stale webpack output)`, + ); + } + } + + if (details.length) { + return { + ok: false, + reason: 'GC build provenance verification failed', + details: [...details, REBUILD_HINT], + }; + } + + return { ok: true, manifest }; +} + +export function verifyManifestOrThrow(manifestPath = MANIFEST_PATH) { + const result = verifyManifest(manifestPath); + if (!result.ok) { + const msg = [result.reason, ...(result.details ?? [])].join('\n '); + throw new Error(msg); + } + return result.manifest; +} + +/** + * Prove source + artifact tampering rejects, restoring all files afterward. + */ +export function runSelfTest() { + const steps = []; + const baseline = verifyManifest(); + if (!baseline.ok) { + throw new Error( + `self-test requires a valid build first:\n ${baseline.reason}\n ${REBUILD_HINT}`, + ); + } + steps.push('baseline verify: ok'); + + // --- artifact tamper --- + const artifactRel = Object.keys(baseline.manifest.artifacts)[0]; + if (!artifactRel) throw new Error('self-test: no artifacts in manifest'); + const artifactAbs = path.join(BENCH_ROOT, artifactRel); + const artifactOrig = readFileSync(artifactAbs); + try { + writeFileSync( + artifactAbs, + Buffer.concat([artifactOrig, Buffer.from('\n')]), + ); + const afterArt = verifyManifest(); + if (afterArt.ok) { + throw new Error('self-test: expected artifact tampering to be rejected'); + } + steps.push( + `artifact tamper rejected: ${afterArt.details?.[0] ?? afterArt.reason}`, + ); + } finally { + writeFileSync(artifactAbs, artifactOrig); + } + const restoredArt = verifyManifest(); + if (!restoredArt.ok) { + throw new Error( + `self-test: artifact restore failed verify: ${restoredArt.reason}`, + ); + } + steps.push('artifact restore: ok'); + + // --- source tamper via temporary untracked file under packages/core/src --- + const tmpRel = `${CORE_SRC_DIR}/.__gc_manifest_selftest__.tmp`; + const tmpAbs = path.join(REPO_ROOT, tmpRel); + try { + writeFileSync(tmpAbs, '// gc-build-manifest self-test — safe to delete\n'); + const afterSrc = verifyManifest(); + if (afterSrc.ok) { + throw new Error('self-test: expected source tampering to be rejected'); + } + steps.push( + `source tamper rejected: ${afterSrc.details?.[0] ?? afterSrc.reason}`, + ); + } finally { + if (existsSync(tmpAbs)) unlinkSync(tmpAbs); + } + const restoredSrc = verifyManifest(); + if (!restoredSrc.ok) { + throw new Error( + `self-test: source restore failed verify: ${restoredSrc.reason}`, + ); + } + steps.push('source restore: ok'); + + // --- manifest buildId tamper --- + const manifestOrig = readFileSync(MANIFEST_PATH); + try { + const mangled = JSON.parse(manifestOrig.toString('utf8')); + mangled.buildId = '0'.repeat(64); + writeFileSync(MANIFEST_PATH, `${JSON.stringify(mangled, null, 2)}\n`); + const afterMan = verifyManifest(); + if (afterMan.ok) { + throw new Error( + 'self-test: expected manifest buildId tamper to be rejected', + ); + } + steps.push( + `manifest tamper rejected: ${afterMan.details?.[0] ?? afterMan.reason}`, + ); + } finally { + writeFileSync(MANIFEST_PATH, manifestOrig); + } + const final = verifyManifest(); + if (!final.ok) { + throw new Error(`self-test: final verify failed: ${final.reason}`); + } + steps.push('final verify: ok'); + + return { ok: true, steps, manifest: final.manifest }; +} + +function printVerifyFailure(result) { + console.error(result.reason); + for (const d of result.details ?? []) { + console.error(` ${d}`); + } +} + +function main(argv) { + const cmd = argv[0] ?? 'write'; + if (cmd === 'write') { + const manifest = writeManifest(); + console.error( + `wrote ${MANIFEST_RELATIVE} buildId=${manifest.buildId.slice(0, 12)}… commit=${manifest.commit.slice(0, 12)}… dirty=${manifest.dirty} artifacts=${Object.keys(manifest.artifacts).length}`, + ); + return; + } + if (cmd === 'verify') { + const result = verifyManifest(); + if (!result.ok) { + printVerifyFailure(result); + process.exitCode = 1; + return; + } + console.error( + `GC build manifest OK buildId=${result.manifest.buildId.slice(0, 12)}… commit=${result.manifest.commit.slice(0, 12)}…`, + ); + return; + } + if (cmd === 'self-test') { + try { + const result = runSelfTest(); + for (const step of result.steps) { + console.error(` ${step}`); + } + console.error('gc-build-manifest self-test passed'); + } catch (err) { + console.error(err.message ?? err); + process.exitCode = 1; + } + return; + } + console.error(`unknown command: ${cmd} (expected write|verify|self-test)`); + process.exitCode = 1; +} + +const isMain = + process.argv[1] != null && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + main(process.argv.slice(2)); +} diff --git a/examples/benchmark/gc-policy-scenarios.js b/examples/benchmark/gc-policy-scenarios.js new file mode 100644 index 000000000000..9acaca8a3af7 --- /dev/null +++ b/examples/benchmark/gc-policy-scenarios.js @@ -0,0 +1,548 @@ +/** + * Deterministic GC fixtures and scenario descriptors for the Node GC harness. + * + * Builds state + candidate queues outside timed work. Fixtures match + * entities/entitiesMeta and endpoints/meta so sweeps are eligible immediately + * under BenchmarkGCPolicy (expiresAt → 0). + */ +import { + GCPolicy, + Controller, + createReducer, + initialState, + actionTypes, +} from './dist/index.js'; +import { createMatcher } from './filter.js'; + +const { GC } = actionTypes; + +export const CANONICAL_COUNTS = [1_000, 10_000, 100_000]; +export const ENTITY_KEY = 'BenchEntity'; +export const ZERO_META = Object.freeze({ + date: 0, + fetchedAt: 0, + expiresAt: 0, +}); + +/** Timerless explicit policy: no intervals/idleness; one-shot public sweep. */ +export class BenchmarkGCPolicy extends GCPolicy { + constructor() { + super({ expiresAt: () => 0 }); + } + + init(controller) { + this.controller = controller; + } + + cleanup() {} + + /** Public one-shot entry that calls the protected monolithic runSweep. */ + sweep() { + this.runSweep(); + } + + get entityQueueLength() { + return this.entitiesQ.length; + } + + get endpointQueueSize() { + return this.endpointsQ.size; + } + + get queueEntries() { + return this.entityQueueLength + this.endpointQueueSize; + } +} + +/** + * Split mixed total into entity + endpoint counts (entities get the remainder + * when odd so entityCount + endpointCount === total). + */ +export function splitMixedCount(total) { + const endpoints = Math.floor(total / 2); + return { entities: total - endpoints, endpoints }; +} + +function entityPath(pk) { + return { key: ENTITY_KEY, pk: String(pk) }; +} + +function endpointKey(i) { + return `bench-endpoint-${i}`; +} + +/** Build store state with matching entities + entitiesMeta. */ +export function buildEntityState(count) { + const entities = { [ENTITY_KEY]: {} }; + const entitiesMeta = { [ENTITY_KEY]: {} }; + for (let i = 0; i < count; i++) { + const pk = String(i); + entities[ENTITY_KEY][pk] = { id: pk }; + entitiesMeta[ENTITY_KEY][pk] = { ...ZERO_META }; + } + return { + ...initialState, + entities, + entitiesMeta, + }; +} + +/** Build store state with matching endpoints + meta. */ +export function buildEndpointState(count) { + const endpoints = {}; + const meta = {}; + for (let i = 0; i < count; i++) { + const key = endpointKey(i); + endpoints[key] = String(i); + meta[key] = { ...ZERO_META }; + } + return { + ...initialState, + endpoints, + meta, + }; +} + +/** Mixed state: entityCount distinct entities + endpointCount distinct endpoints. */ +export function buildMixedState(entityCount, endpointCount) { + const state = buildEntityState(entityCount); + const endpoints = {}; + const meta = {}; + for (let i = 0; i < endpointCount; i++) { + const key = endpointKey(i); + endpoints[key] = String(i); + meta[key] = { ...ZERO_META }; + } + return { ...state, endpoints, meta }; +} + +/** Scalar expectations only — no retained path/key arrays (avoids observer heap). */ +function expectedScalars(overrides) { + return { + queueEntries: overrides.queueEntries, + uniqueTargets: overrides.uniqueTargets, + expectedEntitiesInAction: overrides.expectedEntitiesInAction, + expectedEndpointsInAction: overrides.expectedEndpointsInAction, + expectedUniqueEntityDeletions: overrides.expectedUniqueEntityDeletions, + expectedEndpointDeletions: overrides.expectedEndpointDeletions, + }; +} + +/** + * Queue candidates via createCountRef mount/release (outside timed work). + * Duplicate pattern repeatedly releases one entity path. + * Does not retain parallel target arrays — callers that need a prebuilt GC + * action use buildPrebuiltAction(). + */ +export function queueCandidates(policy, spec) { + const { candidateKind, pattern, count } = spec; + + if (pattern === 'duplicate') { + if (candidateKind !== 'entity') { + throw new Error( + `duplicate pattern only supports candidateKind=entity (got ${candidateKind})`, + ); + } + const path = entityPath(0); + const countRef = policy.createCountRef({ paths: [path] }); + for (let i = 0; i < count; i++) { + const release = countRef(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: 1, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: 1, + expectedEndpointDeletions: 0, + }); + } + + if (candidateKind === 'entity') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: count, + expectedEndpointDeletions: 0, + }); + } + + if (candidateKind === 'endpoint') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: 0, + expectedEndpointsInAction: count, + expectedUniqueEntityDeletions: 0, + expectedEndpointDeletions: count, + }); + } + + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + for (let i = 0; i < entities; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + for (let i = 0; i < endpoints; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: entities, + expectedEndpointsInAction: endpoints, + expectedUniqueEntityDeletions: entities, + expectedEndpointDeletions: endpoints, + }); + } + + throw new Error(`unknown candidateKind: ${candidateKind}`); +} + +/** Build a GC action for reducer mode only (scan/e2e get targets from sweep). */ +export function buildPrebuiltAction(spec) { + const { candidateKind, pattern, count } = spec; + + if (pattern === 'duplicate') { + const path = entityPath(0); + return { + type: GC, + entities: Array.from({ length: count }, () => ({ ...path })), + endpoints: [], + }; + } + + if (candidateKind === 'entity') { + return { + type: GC, + entities: Array.from({ length: count }, (_, i) => entityPath(i)), + endpoints: [], + }; + } + + if (candidateKind === 'endpoint') { + return { + type: GC, + entities: [], + endpoints: Array.from({ length: count }, (_, i) => endpointKey(i)), + }; + } + + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + return { + type: GC, + entities: Array.from({ length: entities }, (_, i) => entityPath(i)), + endpoints: Array.from({ length: endpoints }, (_, i) => endpointKey(i)), + }; + } + + throw new Error(`unknown candidateKind: ${candidateKind}`); +} + +function buildStateForSpec(spec) { + const { candidateKind, pattern, count } = spec; + if (pattern === 'duplicate') { + return buildEntityState(1); + } + if (candidateKind === 'entity') return buildEntityState(count); + if (candidateKind === 'endpoint') return buildEndpointState(count); + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + return buildMixedState(entities, endpoints); + } + throw new Error(`unknown candidateKind: ${candidateKind}`); +} + +export function scenarioId(axes) { + return [ + axes.platform, + axes.candidateKind, + axes.pattern, + String(axes.count), + axes.mode, + axes.control, + ].join('/'); +} + +/** + * Wire a Controller + BenchmarkGCPolicy for the given mode. + * - scan: dispatch captures action only; no prebuilt action arrays + * - end-to-end: dispatch reduces; no prebuilt action arrays + * - reducer: prebuilt GC action only (needed for timed reducer path) + */ +export function createHarness(spec, mode) { + const state = buildStateForSpec(spec); + const policy = new BenchmarkGCPolicy(); + const controller = new Controller({ gcPolicy: policy }); + const reducer = createReducer(controller); + + let capturedAction = null; + let workingState = state; + /** Seed retained only until reducer takes its clone (then nulled). */ + let seedState = mode === 'reducer' ? state : null; + + controller.getState = () => workingState; + + if (mode === 'scan') { + controller.dispatch = action => { + capturedAction = action; + }; + } else if (mode === 'end-to-end') { + controller.dispatch = action => { + capturedAction = action; + workingState = reducer(workingState, action); + }; + } else if (mode === 'reducer') { + // Reducer mode does not sweep; dispatch unused during timed work + controller.dispatch = action => { + capturedAction = action; + }; + } else { + throw new Error(`unknown harness mode: ${mode}`); + } + + policy.init(controller); + const expected = queueCandidates(policy, spec); + + if (policy.queueEntries !== expected.queueEntries) { + throw new Error( + `fixture queue cardinality ${policy.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + + // Only reducer needs a prebuilt action; scan/e2e would duplicate 100k target arrays + let prebuiltAction = mode === 'reducer' ? buildPrebuiltAction(spec) : null; + + const harness = { + policy, + reducer, + expected, + get prebuiltAction() { + return prebuiltAction; + }, + getState: () => workingState, + getCapturedAction: () => capturedAction, + clearCapturedAction() { + capturedAction = null; + }, + /** + * Drop observer-only refs (captured action, prebuilt action) while keeping + * the live store + policy for retained-cache heap measurement. + */ + releaseObserverRefs() { + capturedAction = null; + prebuiltAction = null; + seedState = null; + }, + /** + * Reducer: clone seed into working state and drop the full-size seed + * so heapBefore does not retain two copies of the fixture. + */ + takeReducerState() { + if (!seedState) { + throw new Error('takeReducerState() requires reducer mode with a seed'); + } + const clone = structuredClone(seedState); + seedState = null; + workingState = clone; + return clone; + }, + /** Drop store/policy/controller so a later V8 GC can reclaim everything. */ + dispose() { + capturedAction = null; + prebuiltAction = null; + seedState = null; + workingState = null; + controller.getState = () => initialState; + controller.dispatch = () => {}; + policy.cleanup(); + }, + }; + + return harness; +} + +/** Count deletions vs. fixture after a destructive GC apply. */ +export function countRemaining(state, spec, expected) { + const bucket = state.entities?.[ENTITY_KEY]; + const entityRemaining = bucket ? Object.keys(bucket).length : 0; + const endpointRemaining = + state.endpoints ? Object.keys(state.endpoints).length : 0; + + // unique/mixed start with expectedUniqueEntityDeletions entities; duplicate starts with 1 + const startedEntities = + spec.pattern === 'duplicate' ? 1 : expected.expectedUniqueEntityDeletions; + const startedEndpoints = expected.expectedEndpointDeletions; + + return { + entityDeleted: startedEntities - entityRemaining, + endpointDeleted: startedEndpoints - endpointRemaining, + entityRemaining, + endpointRemaining, + }; +} + +/** + * Validate an accepted sample against fixture expectations. + * Throws on invalid fixture/result. + */ +export function validateSample(spec, harness, sampleMetrics, mode, control) { + const { expected, policy, getCapturedAction, getState } = harness; + + if (control === 'no-gc') { + if (sampleMetrics.actionCount !== 0) { + throw new Error( + `no-gc control expected actionCount 0, got ${sampleMetrics.actionCount}`, + ); + } + if (sampleMetrics.deletionCount !== 0) { + throw new Error( + `no-gc control expected deletionCount 0, got ${sampleMetrics.deletionCount}`, + ); + } + return; + } + + if (sampleMetrics.queueEntries !== expected.queueEntries) { + throw new Error( + `queueEntries ${sampleMetrics.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + if (sampleMetrics.uniqueTargets !== expected.uniqueTargets) { + throw new Error( + `uniqueTargets ${sampleMetrics.uniqueTargets} !== expected ${expected.uniqueTargets}`, + ); + } + + const actionTargetCount = + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; + if (sampleMetrics.actionTargetCount !== actionTargetCount) { + throw new Error( + `actionTargetCount ${sampleMetrics.actionTargetCount} !== expected ${actionTargetCount}`, + ); + } + + if (mode === 'scan' || mode === 'end-to-end') { + const action = getCapturedAction(); + if (!action || action.type !== GC) { + throw new Error('expected a GC action to be dispatched'); + } + if (action.entities.length !== expected.expectedEntitiesInAction) { + throw new Error( + `action.entities.length ${action.entities.length} !== ${expected.expectedEntitiesInAction}`, + ); + } + if (action.endpoints.length !== expected.expectedEndpointsInAction) { + throw new Error( + `action.endpoints.length ${action.endpoints.length} !== ${expected.expectedEndpointsInAction}`, + ); + } + if (spec.pattern === 'duplicate') { + const unique = new Set(action.entities.map(p => `${p.key}:${p.pk}`)); + if (unique.size !== 1) { + throw new Error( + `duplicate pattern expected 1 unique entity path in action, got ${unique.size}`, + ); + } + } + } + + if (mode === 'reducer' || mode === 'end-to-end') { + const expectedDeletions = + expected.expectedUniqueEntityDeletions + + expected.expectedEndpointDeletions; + if (sampleMetrics.deletionCount !== expectedDeletions) { + throw new Error( + `deletionCount ${sampleMetrics.deletionCount} !== expected ${expectedDeletions}`, + ); + } + const remaining = countRemaining(getState(), spec, expected); + if (remaining.entityRemaining !== 0 || remaining.endpointRemaining !== 0) { + throw new Error( + `expected empty GC targets after deletion; remaining entities=${remaining.entityRemaining} endpoints=${remaining.endpointRemaining}`, + ); + } + } + + if (mode === 'scan' && sampleMetrics.deletionCount !== 0) { + throw new Error( + `scan mode must not delete; got ${sampleMetrics.deletionCount}`, + ); + } + + // After a successful sweep, queues should be drained (gc path). + if ((mode === 'scan' || mode === 'end-to-end') && policy.queueEntries !== 0) { + throw new Error( + `expected empty queues after sweep, got ${policy.queueEntries}`, + ); + } +} + +const MODES = ['scan', 'reducer', 'end-to-end']; +const CONTROLS = ['gc', 'no-gc']; +const UNIQUE_KINDS = ['entity', 'endpoint', 'mixed']; + +/** + * Scenario axis list. Fixtures are not built here — only descriptors — + * so filtering a single 100k case never constructs unrelated stores. + * + * @param {string} [filter] substring or ^prefix against scenario id + * @returns {Array} + */ +export function listScenarios(filter) { + const match = createMatcher(filter); + const scenarios = []; + + for (const count of CANONICAL_COUNTS) { + for (const candidateKind of UNIQUE_KINDS) { + for (const mode of MODES) { + for (const control of CONTROLS) { + const axes = { + platform: 'node', + candidateKind, + pattern: 'unique', + count, + mode, + control, + }; + const id = scenarioId(axes); + if (match(id)) scenarios.push({ id, ...axes }); + } + } + } + + // Duplicate baseline: one entity path released `count` times + for (const mode of MODES) { + for (const control of CONTROLS) { + const axes = { + platform: 'node', + candidateKind: 'entity', + pattern: 'duplicate', + count, + mode, + control, + }; + const id = scenarioId(axes); + if (match(id)) scenarios.push({ id, ...axes }); + } + } + } + + return scenarios; +} + +export { GC }; diff --git a/examples/benchmark/gc-policy.js b/examples/benchmark/gc-policy.js new file mode 100644 index 000000000000..b09169af0772 --- /dev/null +++ b/examples/benchmark/gc-policy.js @@ -0,0 +1,401 @@ +/** + * Isolated Node GC measurement harness (monolithic baseline). + * + * Timing windows (see plans/garbage-collection.md): + * 1. Prepare — fixture + queue outside the timed window + * 2. Timed cache-GC work — sweep / reducer / no-op control + * 3. Optional retained-heap path — validate, drop observer refs (captured + * action / prebuilt action / seed clone), keep live store, then settle + + * forced V8 GC. Engine GC is never inside the timed cache-GC section. + * + * JSON report (schemaVersion 1) → stdout + * Optional human table → stderr (--table / default when stderr is a TTY) + * + * Usage: + * yarn build:benchmark + * yarn workspace example-benchmark start:gc [filter] [--samples=N] [--memory] [--table|--no-table] + * yarn workspace example-benchmark start:gc --verify-manifest + */ +import os from 'node:os'; +import process from 'node:process'; +import { setImmediate } from 'node:timers'; +import v8 from 'node:v8'; +import vm from 'node:vm'; + +import { verifyManifestOrThrow, runSelfTest } from './gc-build-manifest.js'; +import { + listScenarios, + createHarness, + validateSample, + countRemaining, + GC, +} from './gc-policy-scenarios.js'; + +const DEFAULT_SAMPLES = 11; + +function parseArgs(argv) { + let filter; + let samples = DEFAULT_SAMPLES; + let memory = false; + let table; + let verifyManifestOnly = false; + for (const arg of argv) { + if (arg === '--memory') { + memory = true; + } else if (arg === '--table') { + table = true; + } else if (arg === '--no-table') { + table = false; + } else if (arg === '--verify-manifest') { + verifyManifestOnly = true; + } else if (arg.startsWith('--samples=')) { + samples = Number(arg.slice('--samples='.length)); + if (!Number.isFinite(samples) || samples < 1) { + throw new Error(`invalid --samples value: ${arg}`); + } + samples = Math.floor(samples); + } else if (arg.startsWith('-')) { + throw new Error(`unknown flag: ${arg}`); + } else if (filter === undefined) { + filter = arg; + } else { + throw new Error(`unexpected argument: ${arg}`); + } + } + if (table === undefined) table = Boolean(process.stderr.isTTY); + return { filter, samples, memory, table, verifyManifestOnly }; +} + +function cpuModel() { + return os.cpus()[0]?.model ?? 'unknown'; +} + +function percentile(sorted, p) { + if (sorted.length === 1) return sorted[0]; + const idx = (p / 100) * (sorted.length - 1); + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + if (lo === hi) return sorted[lo]; + const w = idx - lo; + return sorted[lo] * (1 - w) + sorted[hi] * w; +} + +function summarizeNumbers(values) { + if (values.length === 0) return null; + const sorted = values.slice().sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; + return { + median, + min: sorted[0], + max: sorted[sorted.length - 1], + p95: percentile(sorted, 95), + p99: percentile(sorted, 99), + }; +} + +function summarizeScenario(samples) { + const summary = { + totalMs: summarizeNumbers(samples.map(s => s.totalMs)), + actionCount: summarizeNumbers(samples.map(s => s.actionCount)), + queueEntries: summarizeNumbers(samples.map(s => s.queueEntries)), + uniqueTargets: summarizeNumbers(samples.map(s => s.uniqueTargets)), + actionTargetCount: summarizeNumbers(samples.map(s => s.actionTargetCount)), + deletionCount: summarizeNumbers(samples.map(s => s.deletionCount)), + }; + const slices = samples.flatMap(s => s.sliceDurationsMs ?? []); + if (slices.length) { + summary.sliceDurationsMs = summarizeNumbers(slices); + } + const heaps = samples.filter(s => s.heapBeforeBytes != null); + if (heaps.length) { + summary.heapBeforeBytes = summarizeNumbers( + heaps.map(s => s.heapBeforeBytes), + ); + summary.heapAfterBytes = summarizeNumbers(heaps.map(s => s.heapAfterBytes)); + summary.heapDeltaBytes = summarizeNumbers(heaps.map(s => s.heapDeltaBytes)); + } + return summary; +} + +function maybeExposeGc() { + try { + v8.setFlagsFromString('--expose_gc'); + return vm.runInNewContext('gc'); + } catch { + return undefined; + } +} + +function fullGC(gc) { + if (!gc) return; + gc(); + gc(); +} + +function heapUsed() { + return v8.getHeapStatistics().used_heap_size; +} + +async function settleEventLoop() { + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); +} + +/** + * Run one sample. Setup/queue are outside the timed section. + * Destructive modes rebuild a fresh harness each sample. + * + * Memory lifecycle (--memory), keep-store / drop-observer: + * prepare → (reducer: take clone, drop seed) → V8 GC → heapBefore + * → timed cache-GC → validate → releaseObserverRefs (action/seed only) + * → settle + V8 GC → heapAfter (live store still retained) + * → dispose harness for the next sample + * + * heapDelta therefore reflects cache contents still held by the store, not + * harness/action observer arrays. Compare gc vs no-gc end-to-end/reducer. + */ +async function runSample(scenario, { memory, gc }) { + const { mode, control } = scenario; + const harness = createHarness(scenario, mode); + const { policy, reducer, expected } = harness; + + const queueEntries = expected.queueEntries; + const uniqueTargets = expected.uniqueTargets; + const actionTargetCount = + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; + + // Reducer: clone into working state and drop the full-size seed before baseline + let reducerState; + if (mode === 'reducer') { + reducerState = harness.takeReducerState(); + } + + let heapBeforeBytes; + if (memory) { + fullGC(gc); + await settleEventLoop(); + heapBeforeBytes = heapUsed(); + } + + harness.clearCapturedAction(); + + const t0 = performance.now(); + let actionCount = 0; + let deletionCount = 0; + + if (control === 'no-gc') { + // Harness / no-op overhead only — intentionally empty + } else if (mode === 'scan') { + policy.sweep(); + actionCount = harness.getCapturedAction() ? 1 : 0; + } else if (mode === 'reducer') { + reducer(reducerState, harness.prebuiltAction); + actionCount = 1; + } else if (mode === 'end-to-end') { + policy.sweep(); + const action = harness.getCapturedAction(); + actionCount = action && action.type === GC ? 1 : 0; + } else { + throw new Error(`unknown mode: ${mode}`); + } + + const totalMs = performance.now() - t0; + + // Deletion accounting is outside the timed window + if (control === 'gc' && (mode === 'reducer' || mode === 'end-to-end')) { + const remaining = countRemaining(harness.getState(), scenario, expected); + deletionCount = remaining.entityDeleted + remaining.endpointDeleted; + } + + // Monolithic GC: report one slice only (honest representation of current behavior) + const sliceDurationsMs = control === 'gc' ? [totalMs] : []; + + const sample = { + totalMs, + sliceDurationsMs, + actionCount, + queueEntries, + uniqueTargets, + actionTargetCount: control === 'gc' ? actionTargetCount : 0, + deletionCount, + }; + + // Validate while harness/action refs still exist + validateSample(scenario, harness, sample, mode, control); + + if (memory) { + // Drop captured GC action (holds target arrays) and prebuilt action; keep store + harness.releaseObserverRefs(); + reducerState = undefined; + await settleEventLoop(); + fullGC(gc); + const heapAfterBytes = heapUsed(); + sample.heapBeforeBytes = heapBeforeBytes; + sample.heapAfterBytes = heapAfterBytes; + sample.heapDeltaBytes = heapAfterBytes - heapBeforeBytes; + } + + harness.dispose(); + + return sample; +} + +function formatMs(n) { + if (n == null) return '—'; + if (n < 1) return `${(n * 1000).toFixed(1)} µs`; + if (n < 1000) return `${n.toFixed(2)} ms`; + return `${(n / 1000).toFixed(2)} s`; +} + +function formatBytes(n) { + if (n == null) return '—'; + const abs = Math.abs(n); + const sign = n < 0 ? '-' : ''; + if (abs >= 1024 * 1024) + return `${sign}${(abs / (1024 * 1024)).toFixed(2)} MB`; + if (abs >= 1024) return `${sign}${(abs / 1024).toFixed(1)} KB`; + return `${n} B`; +} + +function printTable(scenarioResults) { + const rows = scenarioResults.map(({ id, summary }) => { + const row = { + scenario: id, + 'median total': formatMs(summary.totalMs?.median), + min: formatMs(summary.totalMs?.min), + max: formatMs(summary.totalMs?.max), + p95: formatMs(summary.totalMs?.p95), + actions: summary.actionCount?.median ?? '—', + queue: summary.queueEntries?.median ?? '—', + targets: summary.actionTargetCount?.median ?? '—', + deleted: summary.deletionCount?.median ?? '—', + }; + if (summary.heapDeltaBytes) { + row['heap Δ'] = formatBytes(summary.heapDeltaBytes.median); + } + return row; + }); + const err = new console.Console(process.stderr, process.stderr); + err.log('\nGC policy benchmark (cache GC — not V8 engine GC)\n'); + err.table(rows); +} + +async function main() { + const { + filter, + samples: sampleCount, + memory, + table, + verifyManifestOnly, + } = parseArgs(process.argv.slice(2)); + + // Provenance check is outside timing and runs before any scenario work + if (verifyManifestOnly) { + const result = runSelfTest(); + for (const step of result.steps) { + console.error(` ${step}`); + } + console.error('gc-build-manifest self-test passed'); + process.stdout.write( + `${JSON.stringify( + { + ok: true, + buildId: result.manifest.buildId, + commit: result.manifest.commit, + dirty: result.manifest.dirty, + sourceDigest: result.manifest.sourceDigest, + }, + null, + 2, + )}\n`, + ); + return; + } + + const buildManifest = verifyManifestOrThrow(); + + const scenarios = listScenarios(filter); + if (scenarios.length === 0) { + throw new Error(`no scenarios matched filter: ${filter ?? '(none)'}`); + } + + const gc = memory ? maybeExposeGc() : undefined; + if (memory && !gc) { + console.error( + 'warning: --memory requested but V8 gc() is unavailable; heap metrics omitted', + ); + } + + const scenarioResults = []; + for (const scenario of scenarios) { + const samples = []; + for (let i = 0; i < sampleCount; i++) { + samples.push(await runSample(scenario, { memory: Boolean(gc), gc })); + } + scenarioResults.push({ + id: scenario.id, + platform: scenario.platform, + candidateKind: scenario.candidateKind, + pattern: scenario.pattern, + count: scenario.count, + mode: scenario.mode, + control: scenario.control, + samples, + summary: summarizeScenario(samples), + }); + } + + const report = { + schemaVersion: 1, + units: { + totalMs: 'milliseconds', + sliceDurationsMs: 'milliseconds', + heapBeforeBytes: 'bytes', + heapAfterBytes: 'bytes', + heapDeltaBytes: 'bytes', + }, + memorySemantics: + memory ? + { + model: 'keep-store-drop-observer', + description: + 'heapBefore is taken after fixture/queue prepare (and reducer seed drop) with a forced V8 GC. After timed cache-GC and validation, observer refs (captured GC action target arrays, prebuilt reducer action, reducer seed) are released; the live store remains. heapAfter follows settle + forced V8 GC. heapDeltaBytes ≈ retained store cache change, not harness retention. Compare gc vs no-gc for reducer/end-to-end; scan does not delete store entries so its delta is not a retained-cache signal.', + } + : undefined, + build: { + schemaVersion: buildManifest.schemaVersion, + buildId: buildManifest.buildId, + commit: buildManifest.commit, + dirty: buildManifest.dirty, + sourceDigest: buildManifest.sourceDigest, + artifacts: buildManifest.artifacts, + label: 'examples/benchmark GC monolithic baseline', + manifest: 'dist/gc-build-manifest.json', + }, + environment: { + runtime: 'node', + nodeVersion: process.version, + os: `${os.type()} ${os.release()}`, + arch: os.arch(), + cpuModel: cpuModel(), + cpus: os.cpus().length, + platform: process.platform, + }, + config: { + samplesPerScenario: sampleCount, + filter: filter ?? null, + memoryMetrics: Boolean(gc), + }, + scenarios: scenarioResults, + }; + + if (table) printTable(scenarioResults); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +main().catch(err => { + console.error(err); + process.exitCode = 1; +}); diff --git a/examples/benchmark/package.json b/examples/benchmark/package.json index 097714f6ec53..3affce30e044 100644 --- a/examples/benchmark/package.json +++ b/examples/benchmark/package.json @@ -11,9 +11,11 @@ "node": ">=22" }, "scripts": { - "build": "webpack --mode=production --target=node --env readable", + "build": "webpack --mode=production --target=node --env readable && node ./gc-build-manifest.js write", "start": "NODE_ENV=production node --allow-natives-syntax ./index.js", "start:memory": "NODE_ENV=production node ./memory.js", + "start:gc": "NODE_ENV=production node ./gc-policy.js", + "start:gc:verify": "node ./gc-build-manifest.js self-test", "start:trace": "yarn run start --trace_opt --trace_deopt", "start:deopt": "NODE_ENV=production npx dexnode --out v8.log --redirect-code-traces-to=/tmp/codetrace --allow-natives-syntax ./index.js" }, diff --git a/package.json b/package.json index e6518e69d7c5..3da16df82b63 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "packages/*", "examples/benchmark", "examples/benchmark-react", + "examples/benchmark-native", "examples/test-bundlesize", "examples/normalizr-*", "examples/coin-app", diff --git a/plans/garbage-collection.md b/plans/garbage-collection.md index 42b4375ed7ba..d6b8fce01625 100644 --- a/plans/garbage-collection.md +++ b/plans/garbage-collection.md @@ -444,18 +444,122 @@ Also required before aggressive default changes: | Expensive / device | Heap snapshots; release-Hermes Android frame and memory | Start as manual; promote only with cost justification | | Unit tests | Jest correctness for GC policy and reducer behavior | Required for logic; **not** a substitute for idle or frame measurement | -### Planned commands and artifacts - -Commands and artifact paths are planned, not claimed as already present: +### Implemented harnesses and artifacts + +v1 measurement harnesses exist in the monorepo (shared JSON vocabulary; no shared +runtime package): + +- **Node** — `examples/benchmark` (`start:gc`): scan / reducer / end-to-end modes + over the canonical scenario axes, with `gc` / `no-gc` controls. +- **Browser** — `examples/benchmark-react` (`bench:gc`): Chromium interaction, + long-task, frame-period, and heap-delta collection for the same axes. +- **Android** — `examples/benchmark-native`: release-Hermes interaction / frame / + observational memory runs on device; host matrix/collect scripts pull reports + over adb. Build and unit validation can pass without a device; device runs are + still required for calibration. + +Aggregated schemaVersion-1 JSON reports are generated artifacts (stdout redirect, +`BENCH_GC_OUTPUT`, or the native artifacts path). Optional traces/heap snapshots +stay local. Raw machine reports and calibration notes are not committed as +universal baselines — see Baseline capture below for current host status and +rerun commands. + +### Baseline capture and gate status + +Captured **2026-07-18** on Linux WSL2 x64, Node v24.5.0, headless Chromium 149 +(~16.7 ms measured display period). Numbers below are from **full provenanced +reruns** tied to BuildManifest IDs. Raw reports remain local `/tmp` (and native +sidecar) ignored artifacts — not committed universal baselines. +`dirty=true` is expected: the measurement implementation is still uncommitted; +manifest `buildId` / source / artifact hashes still make those local reports +attributable. This measurement-phase work does **not** change GC production +defaults. + +**What ran successfully** + +- ReactDOM GC policy / `countRef` / web integration: 3 suites, 10 tests passed; + ReactNative GC integration: 1 suite/test passed. +- Provenanced Node and browser 100k matrices (manifests below). +- Core `^get` / `^set` and React browser small hot-path controls (output retained + in local artifacts; passed). +- `yarn ci:build:bundlesize` completed (`rdcClient.js` 33.8 KiB minified absolute + size — a build baseline, not a branch delta). +- Native release assemble + sidecar verification (manifest below). No adb device + attached → **no** Android frame/memory baseline. + +**BuildManifest provenance** + +| Layer | Status | Identity | +| --- | --- | --- | +| Node | complete (100k matrix) | `buildId` `5da8f199371b9c84fe516700e977386f39267009ca6eae05ed5c0c16427d7a1c`; commit `4a2faa52558eb593e6e1121e2301a6bb8410690c`; `dirty=true` | +| Browser | `complete=true` | `buildId` `1d71ad58789bb9ac78d6fd0aa59259e59ebe8915bd9102a55bfd7086c54c0bce`; same commit; `dirty=true` | +| Native | build only (no device) | `buildId` `4a744c2e08acd490161a61b6d2c69a7d718af694e5b7b9775ec27c2ef56a006d`; `sourceDigest` `fab95578c64693c89f767e7fc7378fc8c8c81168174161641f251c79685672b7`; APK sha `fbc7355b4eb168f4dbce8dc837ae697a6e0d2fc908a5479fe2897e46d5f23949`; size 52,247,736 bytes; sidecar verified | -- Node GC scenarios under the existing benchmark workspace (canonical counts, - `gc` / `no-gc` controls). -- Browser Chromium harness for interaction, long-task, and optional heap modes. -- Android release-Hermes runs on mid-range hardware for frame and retained-memory - modes. -- Aggregated JSON reports plus optional traces/heap snapshots as CI or local - artifacts. -- Calibration notes that record host fingerprint, medians, and variance before - any threshold is frozen. +**100k Node median / p95 `totalMs` (samples=5)** -Exact script names and directory layout land with the harness implementation. +| Scenario | scan | end-to-end | +| --- | --- | --- | +| entity unique | 5.247 / 6.571 | 12.991 / 14.467 | +| endpoint unique | 14.745 / 16.633 | 33.691 / 39.094 | +| mixed unique | 8.961 / 9.859 | 21.113 / 21.527 | +| entity duplicate | 2.242 / 2.280 | 5.911 / 5.992 | + +**Browser 100k `gc` medians (3 samples, `complete=true`)** + +| Scenario | `totalMs` | `maxInputDelayMs` | notes | +| --- | --- | --- | --- | +| entity unique | 9.0 | 9.1 | `heapDelta` −10,704,036 B | +| endpoint unique | 16.7 | 16.8 | `frameIntervalMax` 17.7 ms; `heapDelta` −15,031,404 B | +| mixed unique | 13.5 | 13.5 | `heapDelta` −12,755,312 B | +| entity duplicate | 6.8 | 6.8 | `heapDelta` −433,492 B | + +Matching `no-gc` `maxInputDelayMs` controls were 0–0.1 ms. All 100k runs reported +0 Long Tasks and 0 nearest-period `missedFrames`. Long Tasks only fire at ≥50 ms, +so they miss a sweep that still consumes roughly one 60 Hz frame budget — visible +in endpoint unique `maxInputDelayMs` ≈ 16.8 ms versus the ~16.7 ms measured +period. A synthetic 45 ms calibration reported ~45.1 ms max frame interval and +2 missed frames, confirming the pending-frame probe. + +**Frozen gate definitions (not unsupported universal numbers)** + +1. **Correctness** — fixture self-validation of queue cardinality and final + deletion count; Jest coverage for reacquisition, cancellation, duplicates, + index reuse, and starvation. +2. **Primary interaction comparisons** — GC **excess** over the matching `no-gc` + control for `maxInputDelayMs`, missed frames, and long tasks. Frame budget + uses measured `displayPeriodMs`, not a hardcoded 16.67 ms. +3. **Cooperative ceilings** — max-slice and total-overhead numeric limits remain + calibration outputs. One local host with 3–5 samples is insufficient to freeze + universal thresholds. +4. **CI promotion** — Node/browser may become gates only after repeated controlled + CI variance runs; not claimed yet. +5. **Bundle** — repo rule unchanged: ~1 KiB growth must justify a 5–10% relevant + measured win. +6. **Android** — uncalibrated. Any React Native **default** change stays blocked + until release-Hermes runs on a named mid-range physical device under this + protocol. Harness/APK completion is not device validation. + +**Recommended reruns** + +```bash +# Node 100k gc + no-gc +yarn build:benchmark +yarn workspace example-benchmark start:gc /100000/ --samples=5 --no-table + +# Browser 100k (preview must be serving dist/) +yarn build:benchmark-react +BENCH_GC_OUTPUT=/tmp/gc-browser.json yarn workspace example-benchmark-react \ + bench:gc --samples 3 --scenario 100000 + +# Native 100k gc + no-gc (requires adb device + release APK) +yarn workspace example-benchmark-native build:android:release +SAMPLES=5 yarn workspace example-benchmark-native matrix entity/unique/100000 + +# Foreground controls +yarn workspace example-benchmark start core '^get' +yarn workspace example-benchmark start core '^set' +yarn workspace example-benchmark-react bench:small --lib data-client + +# Bundle +yarn ci:build:bundlesize +``` diff --git a/yarn.lock b/yarn.lock index 90f07d7a42ea..23e0aec241e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -670,7 +670,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.16.0, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.0, @babel/code-frame@npm:^7.29.7, @babel/code-frame@npm:^7.8.3": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.0, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.0, @babel/code-frame@npm:^7.29.7, @babel/code-frame@npm:^7.8.3": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" dependencies: @@ -688,7 +688,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:7.29.7, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.15, @babel/core@npm:^7.23.5, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.7, @babel/core@npm:^7.25.2, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": +"@babel/core@npm:7.29.7, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.15, @babel/core@npm:^7.23.5, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.4, @babel/core@npm:^7.24.7, @babel/core@npm:^7.25.2, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -711,6 +711,20 @@ __metadata: languageName: node linkType: hard +"@babel/eslint-parser@npm:^7.25.1": + version: 7.29.9 + resolution: "@babel/eslint-parser@npm:7.29.9" + dependencies: + "@nicolo-ribaudo/eslint-scope-5-internals": "npm:5.1.1-v1" + eslint-visitor-keys: "npm:^2.1.0" + semver: "npm:^6.3.1" + peerDependencies: + "@babel/core": ^7.11.0 + eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 + checksum: 10c0/f97d9eb23ad53e9263235e9efe204908494922fb89a4cf623e174d7e62a112c52850990cc19b9ef5043af6e2ca87b1772f998175571ac8cb9967689d6e036bd8 + languageName: node + linkType: hard + "@babel/eslint-parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/eslint-parser@npm:7.29.7" @@ -750,6 +764,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.7.2": + version: 7.29.8 + resolution: "@babel/generator@npm:7.29.8" + dependencies: + "@babel/parser": "npm:^7.29.8" + "@babel/types": "npm:^7.29.8" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-annotate-as-pure@npm:7.29.7" @@ -990,6 +1017,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.24.4, @babel/parser@npm:^7.29.8": + version: 7.29.9 + resolution: "@babel/parser@npm:7.29.9" + dependencies: + "@babel/types": "npm:^7.29.8" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/171b16eb4527cdbbb72cb98710f91acb22356e2c40196ba3b7a1b84efa8c55d0b0992f399c8c675c3e17f67fd133676c5c4118e5f20db243f427c8b29b0c3f40 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.29.7" @@ -1074,7 +1112,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-proposal-export-default-from@npm:^7.29.7": +"@babel/plugin-proposal-export-default-from@npm:^7.24.7, @babel/plugin-proposal-export-default-from@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-proposal-export-default-from@npm:7.29.7" dependencies: @@ -1174,7 +1212,18 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-flow@npm:^7.29.7": +"@babel/plugin-syntax-export-default-from@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-export-default-from@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/932c0046ec3035851baa59b7b02493279f2810e8d26a4fb0ee8000334fdab6a4b035567184a7bb31db22f93a2775a01ecde31eaa4bd1b3e5cde85e2562d24b00 + languageName: node + linkType: hard + +"@babel/plugin-syntax-flow@npm:^7.12.1, @babel/plugin-syntax-flow@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-syntax-flow@npm:7.29.7" dependencies: @@ -1240,7 +1289,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7": +"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: @@ -1350,7 +1399,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7": +"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: @@ -1384,7 +1433,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-async-generator-functions@npm:^7.29.7": +"@babel/plugin-transform-async-generator-functions@npm:^7.25.4, @babel/plugin-transform-async-generator-functions@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-async-generator-functions@npm:7.29.7" dependencies: @@ -1397,7 +1446,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-async-to-generator@npm:^7.29.7": +"@babel/plugin-transform-async-to-generator@npm:^7.24.7, @babel/plugin-transform-async-to-generator@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-async-to-generator@npm:7.29.7" dependencies: @@ -1421,7 +1470,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-block-scoping@npm:^7.29.7": +"@babel/plugin-transform-block-scoping@npm:^7.25.0, @babel/plugin-transform-block-scoping@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-block-scoping@npm:7.29.7" dependencies: @@ -1432,7 +1481,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-class-properties@npm:^7.24.7, @babel/plugin-transform-class-properties@npm:^7.29.7": +"@babel/plugin-transform-class-properties@npm:^7.24.7, @babel/plugin-transform-class-properties@npm:^7.25.4, @babel/plugin-transform-class-properties@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-class-properties@npm:7.29.7" dependencies: @@ -1456,7 +1505,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-classes@npm:^7.29.7": +"@babel/plugin-transform-classes@npm:^7.25.4, @babel/plugin-transform-classes@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-classes@npm:7.29.7" dependencies: @@ -1484,7 +1533,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-destructuring@npm:^7.29.7": +"@babel/plugin-transform-destructuring@npm:^7.24.8, @babel/plugin-transform-destructuring@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-destructuring@npm:7.29.7" dependencies: @@ -1576,7 +1625,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-flow-strip-types@npm:^7.29.7": +"@babel/plugin-transform-flow-strip-types@npm:^7.25.2, @babel/plugin-transform-flow-strip-types@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-flow-strip-types@npm:7.29.7" dependencies: @@ -1588,7 +1637,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-for-of@npm:^7.29.7": +"@babel/plugin-transform-for-of@npm:^7.24.7, @babel/plugin-transform-for-of@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-for-of@npm:7.29.7" dependencies: @@ -1669,7 +1718,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-modules-commonjs@npm:^7.22.15, @babel/plugin-transform-modules-commonjs@npm:^7.23.3, @babel/plugin-transform-modules-commonjs@npm:^7.24.7, @babel/plugin-transform-modules-commonjs@npm:^7.27.1, @babel/plugin-transform-modules-commonjs@npm:^7.29.7": +"@babel/plugin-transform-modules-commonjs@npm:^7.22.15, @babel/plugin-transform-modules-commonjs@npm:^7.23.3, @babel/plugin-transform-modules-commonjs@npm:^7.24.7, @babel/plugin-transform-modules-commonjs@npm:^7.24.8, @babel/plugin-transform-modules-commonjs@npm:^7.27.1, @babel/plugin-transform-modules-commonjs@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-modules-commonjs@npm:7.29.7" dependencies: @@ -1707,7 +1756,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.29.7": +"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.24.7, @babel/plugin-transform-named-capturing-groups-regex@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.29.7" dependencies: @@ -1790,7 +1839,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-optional-catch-binding@npm:^7.29.7": +"@babel/plugin-transform-optional-catch-binding@npm:^7.24.7, @babel/plugin-transform-optional-catch-binding@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.29.7" dependencies: @@ -1801,7 +1850,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-optional-chaining@npm:^7.24.7, @babel/plugin-transform-optional-chaining@npm:^7.29.7": +"@babel/plugin-transform-optional-chaining@npm:^7.24.7, @babel/plugin-transform-optional-chaining@npm:^7.24.8, @babel/plugin-transform-optional-chaining@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-optional-chaining@npm:7.29.7" dependencies: @@ -1836,7 +1885,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-private-property-in-object@npm:^7.29.7": +"@babel/plugin-transform-private-property-in-object@npm:^7.24.7, @babel/plugin-transform-private-property-in-object@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-private-property-in-object@npm:7.29.7" dependencies: @@ -1871,7 +1920,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-react-display-name@npm:^7.29.7": +"@babel/plugin-transform-react-display-name@npm:^7.24.7, @babel/plugin-transform-react-display-name@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-react-display-name@npm:7.29.7" dependencies: @@ -1905,7 +1954,29 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-react-jsx@npm:^7.29.7": +"@babel/plugin-transform-react-jsx-self@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx-self@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/288995f0fd0d61ab740a315fb56c8255eb87dd4a4ac2ac7d0fdd4ce173c3878200141e80da2db0e598c7b2a71e74e604afdbb4c8e14ae6e0527ce0b6294c03da + languageName: node + linkType: hard + +"@babel/plugin-transform-react-jsx-source@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx-source@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/a121899631e6d99b9e1b276acf736dbb77948a31f8eeeae67b89c8a4ab0f05e51ba64544baa06c286a2b9944f227244e15aac464e2313d286d0511fe51e27975 + languageName: node + linkType: hard + +"@babel/plugin-transform-react-jsx@npm:^7.25.2, @babel/plugin-transform-react-jsx@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-react-jsx@npm:7.29.7" dependencies: @@ -1932,6 +2003,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-regenerator@npm:^7.24.7": + version: 7.29.8 + resolution: "@babel/plugin-transform-regenerator@npm:7.29.8" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/768da9bc3c8cbb0c591bc7db050215b8cea44df9df525d5cd6034aa179b091c2321a745b68139b3fc70b4493f2df1fc99a57acd4029c6d355d32a8ed8bd69f82 + languageName: node + linkType: hard + "@babel/plugin-transform-regenerator@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-regenerator@npm:7.29.7" @@ -1966,7 +2048,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-runtime@npm:^7.22.15, @babel/plugin-transform-runtime@npm:^7.25.9, @babel/plugin-transform-runtime@npm:^7.29.7": +"@babel/plugin-transform-runtime@npm:^7.22.15, @babel/plugin-transform-runtime@npm:^7.24.7, @babel/plugin-transform-runtime@npm:^7.25.9, @babel/plugin-transform-runtime@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-runtime@npm:7.29.7" dependencies: @@ -2038,6 +2120,21 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-typescript@npm:^7.25.2": + version: 7.29.9 + resolution: "@babel/plugin-transform-typescript@npm:7.29.9" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" + "@babel/plugin-syntax-typescript": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/6caf62e8359fa08b112d7762036cd3c82ec5c5fd604f5faf4446a7f7b8fa8a85e5536e8425dc61dc3846316b5fdc9adb59de0cc51e39d3354a89bb31705f36de + languageName: node + linkType: hard + "@babel/plugin-transform-typescript@npm:^7.28.5, @babel/plugin-transform-typescript@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-typescript@npm:7.29.7" @@ -2076,7 +2173,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-unicode-regex@npm:^7.29.7": +"@babel/plugin-transform-unicode-regex@npm:^7.24.7, @babel/plugin-transform-unicode-regex@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-transform-unicode-regex@npm:7.29.7" dependencies: @@ -2100,7 +2197,7 @@ __metadata: languageName: node linkType: hard -"@babel/preset-env@npm:^7.20.2, @babel/preset-env@npm:^7.22.15, @babel/preset-env@npm:^7.25.9, @babel/preset-env@npm:^7.29.7": +"@babel/preset-env@npm:^7.20.2, @babel/preset-env@npm:^7.22.15, @babel/preset-env@npm:^7.25.3, @babel/preset-env@npm:^7.25.9, @babel/preset-env@npm:^7.29.7": version: 7.29.7 resolution: "@babel/preset-env@npm:7.29.7" dependencies: @@ -2305,6 +2402,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -4372,6 +4479,17 @@ __metadata: languageName: node linkType: hard +"@eslint-community/eslint-utils@npm:^4.2.0": + version: 4.10.1 + resolution: "@eslint-community/eslint-utils@npm:4.10.1" + dependencies: + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/b514586655698bc6b74db72a496c77e813c78b63e36a83429845ac7057dd77113b0b6c31e6e590d5baaeee2abae6ea22363a41209bcafa078d895ab83ce83011 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -4383,7 +4501,7 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.1, @eslint-community/regexpp@npm:^4.12.2": +"@eslint-community/regexpp@npm:^4.12.1, @eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d @@ -4419,6 +4537,23 @@ __metadata: languageName: node linkType: hard +"@eslint/eslintrc@npm:^2.1.4": + version: 2.1.4 + resolution: "@eslint/eslintrc@npm:2.1.4" + dependencies: + ajv: "npm:^6.12.4" + debug: "npm:^4.3.2" + espree: "npm:^9.6.0" + globals: "npm:^13.19.0" + ignore: "npm:^5.2.0" + import-fresh: "npm:^3.2.1" + js-yaml: "npm:^4.1.0" + minimatch: "npm:^3.1.2" + strip-json-comments: "npm:^3.1.1" + checksum: 10c0/32f67052b81768ae876c84569ffd562491ec5a5091b0c1e1ca1e0f3c24fb42f804952fdd0a137873bc64303ba368a71ba079a6f691cee25beee9722d94cc8573 + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^3.3.6": version: 3.3.6 resolution: "@eslint/eslintrc@npm:3.3.6" @@ -4436,6 +4571,13 @@ __metadata: languageName: node linkType: hard +"@eslint/js@npm:8.57.1": + version: 8.57.1 + resolution: "@eslint/js@npm:8.57.1" + checksum: 10c0/b489c474a3b5b54381c62e82b3f7f65f4b8a5eaaed126546520bf2fede5532a8ed53212919fed1e9048dcf7f37167c8561d58d0ba4492a4244004e7793805223 + languageName: node + linkType: hard + "@eslint/js@npm:9.39.5, @eslint/js@npm:^9.39.4": version: 9.39.5 resolution: "@eslint/js@npm:9.39.5" @@ -4510,6 +4652,17 @@ __metadata: languageName: node linkType: hard +"@humanwhocodes/config-array@npm:^0.13.0": + version: 0.13.0 + resolution: "@humanwhocodes/config-array@npm:0.13.0" + dependencies: + "@humanwhocodes/object-schema": "npm:^2.0.3" + debug: "npm:^4.3.1" + minimatch: "npm:^3.0.5" + checksum: 10c0/205c99e756b759f92e1f44a3dc6292b37db199beacba8f26c2165d4051fe73a4ae52fdcfd08ffa93e7e5cb63da7c88648f0e84e197d154bbbbe137b2e0dd332e + languageName: node + linkType: hard + "@humanwhocodes/module-importer@npm:^1.0.1": version: 1.0.1 resolution: "@humanwhocodes/module-importer@npm:1.0.1" @@ -4517,6 +4670,13 @@ __metadata: languageName: node linkType: hard +"@humanwhocodes/object-schema@npm:^2.0.3": + version: 2.0.3 + resolution: "@humanwhocodes/object-schema@npm:2.0.3" + checksum: 10c0/80520eabbfc2d32fe195a93557cef50dfe8c8905de447f022675aaf66abc33ae54098f5ea78548d925aa671cd4ab7c7daa5ad704fe42358c9b5e7db60f80696c + languageName: node + linkType: hard + "@humanwhocodes/retry@npm:^0.4.0": version: 0.4.3 resolution: "@humanwhocodes/retry@npm:0.4.3" @@ -4866,6 +5026,20 @@ __metadata: languageName: node linkType: hard +"@jest/console@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/console@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10c0/7be408781d0a6f657e969cbec13b540c329671819c2f57acfad0dae9dbfe2c9be859f38fe99b35dba9ff1536937dc6ddc69fdcd2794812fa3c647a1619797f6c + languageName: node + linkType: hard + "@jest/core@npm:30.4.2": version: 30.4.2 resolution: "@jest/core@npm:30.4.2" @@ -4907,6 +5081,47 @@ __metadata: languageName: node linkType: hard +"@jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/reporters": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-changed-files: "npm:^29.7.0" + jest-config: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-resolve-dependencies: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-ansi: "npm:^6.0.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10c0/934f7bf73190f029ac0f96662c85cd276ec460d407baf6b0dbaec2872e157db4d55a7ee0b1c43b18874602f662b37cb973dda469a4e6d88b4e4845b521adeeb2 + languageName: node + linkType: hard + "@jest/create-cache-key-function@npm:^29.7.0": version: 29.7.0 resolution: "@jest/create-cache-key-function@npm:29.7.0" @@ -4956,6 +5171,18 @@ __metadata: languageName: node linkType: hard +"@jest/environment@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/environment@npm:29.7.0" + dependencies: + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + checksum: 10c0/c7b1b40c618f8baf4d00609022d2afa086d9c6acc706f303a70bb4b67275868f620ad2e1a9efc5edd418906157337cce50589a627a6400bbdf117d351b91ef86 + languageName: node + linkType: hard + "@jest/expect-utils@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect-utils@npm:30.4.1" @@ -4965,6 +5192,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect-utils@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + checksum: 10c0/60b79d23a5358dc50d9510d726443316253ecda3a7fb8072e1526b3e0d3b14f066ee112db95699b7a43ad3f0b61b750c72e28a5a1cac361d7a2bb34747fa938a + languageName: node + linkType: hard + "@jest/expect@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect@npm:30.4.1" @@ -4975,6 +5211,16 @@ __metadata: languageName: node linkType: hard +"@jest/expect@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect@npm:29.7.0" + dependencies: + expect: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + checksum: 10c0/b41f193fb697d3ced134349250aed6ccea075e48c4f803159db102b826a4e473397c68c31118259868fd69a5cba70e97e1c26d2c2ff716ca39dc73a2ccec037e + languageName: node + linkType: hard + "@jest/fake-timers@npm:30.4.1": version: 30.4.1 resolution: "@jest/fake-timers@npm:30.4.1" @@ -4989,6 +5235,20 @@ __metadata: languageName: node linkType: hard +"@jest/fake-timers@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/fake-timers@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@sinonjs/fake-timers": "npm:^10.0.2" + "@types/node": "npm:*" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10c0/cf0a8bcda801b28dc2e2b2ba36302200ee8104a45ad7a21e6c234148932f826cb3bc57c8df3b7b815aeea0861d7b6ca6f0d4778f93b9219398ef28749e03595c + languageName: node + linkType: hard + "@jest/get-type@npm:30.1.0": version: 30.1.0 resolution: "@jest/get-type@npm:30.1.0" @@ -5008,6 +5268,18 @@ __metadata: languageName: node linkType: hard +"@jest/globals@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/globals@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + jest-mock: "npm:^29.7.0" + checksum: 10c0/a385c99396878fe6e4460c43bd7bb0a5cc52befb462cc6e7f2a3810f9e7bcce7cdeb51908fd530391ee452dc856c98baa2c5f5fa8a5b30b071d31ef7f6955cea + languageName: node + linkType: hard + "@jest/pattern@npm:30.4.0": version: 30.4.0 resolution: "@jest/pattern@npm:30.4.0" @@ -5054,6 +5326,43 @@ __metadata: languageName: node linkType: hard +"@jest/reporters@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/reporters@npm:29.7.0" + dependencies: + "@bcoe/v8-coverage": "npm:^0.2.3" + "@jest/console": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + collect-v8-coverage: "npm:^1.0.0" + exit: "npm:^0.1.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + istanbul-lib-coverage: "npm:^3.0.0" + istanbul-lib-instrument: "npm:^6.0.0" + istanbul-lib-report: "npm:^3.0.0" + istanbul-lib-source-maps: "npm:^4.0.0" + istanbul-reports: "npm:^3.1.3" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + slash: "npm:^3.0.0" + string-length: "npm:^4.0.1" + strip-ansi: "npm:^6.0.0" + v8-to-istanbul: "npm:^9.0.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10c0/a754402a799541c6e5aff2c8160562525e2a47e7d568f01ebfc4da66522de39cbb809bbb0a841c7052e4270d79214e70aec3c169e4eae42a03bc1a8a20cb9fa2 + languageName: node + linkType: hard + "@jest/schemas@npm:30.4.1": version: 30.4.1 resolution: "@jest/schemas@npm:30.4.1" @@ -5095,6 +5404,17 @@ __metadata: languageName: node linkType: hard +"@jest/source-map@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/source-map@npm:29.6.3" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.18" + callsites: "npm:^3.0.0" + graceful-fs: "npm:^4.2.9" + checksum: 10c0/a2f177081830a2e8ad3f2e29e20b63bd40bade294880b595acf2fc09ec74b6a9dd98f126a2baa2bf4941acd89b13a4ade5351b3885c224107083a0059b60a219 + languageName: node + linkType: hard + "@jest/test-result@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-result@npm:30.4.1" @@ -5107,6 +5427,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-result@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-result@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/istanbul-lib-coverage": "npm:^2.0.0" + collect-v8-coverage: "npm:^1.0.0" + checksum: 10c0/7de54090e54a674ca173470b55dc1afdee994f2d70d185c80236003efd3fa2b753fff51ffcdda8e2890244c411fd2267529d42c4a50a8303755041ee493e6a04 + languageName: node + linkType: hard + "@jest/test-sequencer@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-sequencer@npm:30.4.1" @@ -5119,6 +5451,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-sequencer@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-sequencer@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10c0/593a8c4272797bb5628984486080cbf57aed09c7cfdc0a634e8c06c38c6bef329c46c0016e84555ee55d1cd1f381518cf1890990ff845524c1123720c8c1481b + languageName: node + linkType: hard + "@jest/transform@npm:30.4.1": version: 30.4.1 resolution: "@jest/transform@npm:30.4.1" @@ -5735,7 +6079,7 @@ __metadata: languageName: node linkType: hard -"@nodelib/fs.walk@npm:^1.2.3": +"@nodelib/fs.walk@npm:^1.2.3, @nodelib/fs.walk@npm:^1.2.8": version: 1.2.8 resolution: "@nodelib/fs.walk@npm:1.2.8" dependencies: @@ -6323,53 +6667,338 @@ __metadata: languageName: node linkType: hard -"@react-native/assets-registry@npm:0.86.2": - version: 0.86.2 - resolution: "@react-native/assets-registry@npm:0.86.2" - checksum: 10c0/e063219dd8548240a278138a595f758a6a140cafe1493631a3d12c3da673aaf6b7d6c2c7115000b13d93c88145ec54b9be509aa683846554273c01bc4a8759c7 +"@react-native-community/cli-clean@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-clean@npm:20.1.0" + dependencies: + "@react-native-community/cli-tools": "npm:20.1.0" + execa: "npm:^5.0.0" + fast-glob: "npm:^3.3.2" + picocolors: "npm:^1.1.1" + checksum: 10c0/5d7fce73545154035e55801e9596b634c85bbe7bb04861f3d0787f2a9588b7b6f927709bfce4e45a8d62da03e5a92789281e07dafd29e2db86ca52ca3e5ad49c languageName: node linkType: hard -"@react-native/codegen@npm:0.86.2": - version: 0.86.2 - resolution: "@react-native/codegen@npm:0.86.2" +"@react-native-community/cli-config-android@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-config-android@npm:20.1.0" dependencies: - "@babel/core": "npm:^7.25.2" - "@babel/parser": "npm:^7.29.0" - hermes-parser: "npm:0.36.0" - invariant: "npm:^2.2.4" - nullthrows: "npm:^1.1.1" - tinyglobby: "npm:^0.2.15" - yargs: "npm:^17.6.2" - peerDependencies: - "@babel/core": "*" - checksum: 10c0/3b36b5d99a525195e61c1197d88118c774654de6e644a677b29461bd2b28718a751f1c45ee25efa6b274f27d36efb096a3ec816c5fba1cda1a5f5843e4239a0a + "@react-native-community/cli-tools": "npm:20.1.0" + fast-glob: "npm:^3.3.2" + fast-xml-parser: "npm:^4.4.1" + picocolors: "npm:^1.1.1" + checksum: 10c0/66e95adf2fb9750a12592a146b034ab4b8ea15aa41efb5d497d6be2f08029a59a0be2450e97de0bf59aa6b66778b3af0bbd693a42b7e7f9f60c9d9b5f966aadc languageName: node linkType: hard -"@react-native/community-cli-plugin@npm:0.86.2": - version: 0.86.2 - resolution: "@react-native/community-cli-plugin@npm:0.86.2" +"@react-native-community/cli-config-apple@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-config-apple@npm:20.1.0" dependencies: - "@react-native/dev-middleware": "npm:0.86.2" - debug: "npm:^4.4.0" - invariant: "npm:^2.2.4" - metro: "npm:^0.84.3" - metro-config: "npm:^0.84.3" - metro-core: "npm:^0.84.3" - semver: "npm:^7.1.3" - peerDependencies: - "@react-native-community/cli": "*" - "@react-native/metro-config": 0.86.2 - peerDependenciesMeta: - "@react-native-community/cli": - optional: true + "@react-native-community/cli-tools": "npm:20.1.0" + execa: "npm:^5.0.0" + fast-glob: "npm:^3.3.2" + picocolors: "npm:^1.1.1" + checksum: 10c0/2c7b7116594c7b0035ef7e41e0219fcff664c3883471460ae48f19dd88ec9a26dd5b4bd60e33c054603b3a10efd78570490bc84599ad987e4ffb274b05512351 + languageName: node + linkType: hard + +"@react-native-community/cli-config@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-config@npm:20.1.0" + dependencies: + "@react-native-community/cli-tools": "npm:20.1.0" + cosmiconfig: "npm:^9.0.0" + deepmerge: "npm:^4.3.0" + fast-glob: "npm:^3.3.2" + joi: "npm:^17.2.1" + picocolors: "npm:^1.1.1" + checksum: 10c0/e3d863ffc5d18aa7b7406908c1360de7ea7a1bab32897e34d0afe184f4682bb5d110d4db0c6ba2c35c4f1330ca4a281b8fc3548e53a2978b8b07ca3beca5765c + languageName: node + linkType: hard + +"@react-native-community/cli-doctor@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-doctor@npm:20.1.0" + dependencies: + "@react-native-community/cli-config": "npm:20.1.0" + "@react-native-community/cli-platform-android": "npm:20.1.0" + "@react-native-community/cli-platform-apple": "npm:20.1.0" + "@react-native-community/cli-platform-ios": "npm:20.1.0" + "@react-native-community/cli-tools": "npm:20.1.0" + command-exists: "npm:^1.2.8" + deepmerge: "npm:^4.3.0" + envinfo: "npm:^7.13.0" + execa: "npm:^5.0.0" + node-stream-zip: "npm:^1.9.1" + ora: "npm:^5.4.1" + picocolors: "npm:^1.1.1" + semver: "npm:^7.5.2" + wcwidth: "npm:^1.0.1" + yaml: "npm:^2.2.1" + checksum: 10c0/79762f3e789fe98f21c6f500f6c65df399508979f05174272652a9422295f4dace099e87575776bf209b533a6667070fc355c5b510e14a8a61b31a9a8af7b3be + languageName: node + linkType: hard + +"@react-native-community/cli-platform-android@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-platform-android@npm:20.1.0" + dependencies: + "@react-native-community/cli-config-android": "npm:20.1.0" + "@react-native-community/cli-tools": "npm:20.1.0" + execa: "npm:^5.0.0" + logkitty: "npm:^0.7.1" + picocolors: "npm:^1.1.1" + checksum: 10c0/8b44178a78245085824c3fda9d6753d03cd817772fdde82b1d4f1a89f177f10339822401c0b8ef17e8353f3168c3971ae4cfb0a158cef03c22e55f5dff66179b + languageName: node + linkType: hard + +"@react-native-community/cli-platform-apple@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-platform-apple@npm:20.1.0" + dependencies: + "@react-native-community/cli-config-apple": "npm:20.1.0" + "@react-native-community/cli-tools": "npm:20.1.0" + execa: "npm:^5.0.0" + fast-xml-parser: "npm:^4.4.1" + picocolors: "npm:^1.1.1" + checksum: 10c0/2218e172bf13c0bb027f7f2a036a0515f8fe023d98fc65d22d0dab601036c5b461b1af7a5d826df2dd3e86a3894f9531d43e8861746a071ee22c0b33b8e47c29 + languageName: node + linkType: hard + +"@react-native-community/cli-platform-ios@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-platform-ios@npm:20.1.0" + dependencies: + "@react-native-community/cli-platform-apple": "npm:20.1.0" + checksum: 10c0/f5eef9dfbe3dad7dc69f4b3a37ebee9d124f3a649071e0275624f88468a7f506b622646dcbd61ba0435cc35e705badfc287690d6b7c3867c0a86c9a45d4f463e + languageName: node + linkType: hard + +"@react-native-community/cli-server-api@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-server-api@npm:20.1.0" + dependencies: + "@react-native-community/cli-tools": "npm:20.1.0" + body-parser: "npm:^1.20.3" + compression: "npm:^1.7.1" + connect: "npm:^3.6.5" + errorhandler: "npm:^1.5.1" + nocache: "npm:^3.0.1" + open: "npm:^6.2.0" + pretty-format: "npm:^29.7.0" + serve-static: "npm:^1.13.1" + ws: "npm:^6.2.3" + checksum: 10c0/d83c9bbff36fb84201478ed8efc17c80f1474cb0346a2577eee7299407e9e34892c213e2316062a2c59d47ebfe9f0c8215b82cd1abb05c85fe7702a94a52cdf5 + languageName: node + linkType: hard + +"@react-native-community/cli-tools@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-tools@npm:20.1.0" + dependencies: + "@vscode/sudo-prompt": "npm:^9.0.0" + appdirsjs: "npm:^1.2.4" + execa: "npm:^5.0.0" + find-up: "npm:^5.0.0" + launch-editor: "npm:^2.9.1" + mime: "npm:^2.4.1" + ora: "npm:^5.4.1" + picocolors: "npm:^1.1.1" + prompts: "npm:^2.4.2" + semver: "npm:^7.5.2" + checksum: 10c0/993f5dfbc1ae6301e616269896b2e5ecc29888cc8a12325881148b546b07d45572bd065e392ceaad32f86067e31b0eabedbe71fac3b1b820cd489ab86e56094d + languageName: node + linkType: hard + +"@react-native-community/cli-types@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli-types@npm:20.1.0" + dependencies: + joi: "npm:^17.2.1" + checksum: 10c0/247deebf26b435c0cbabfb7dd7de09a4e79c544ee72a2ef2817268108f7c085dab4d54c7fbc8111ad89360e13227f2041c5eac208971c251da332e4d33311a7b + languageName: node + linkType: hard + +"@react-native-community/cli@npm:20.1.0": + version: 20.1.0 + resolution: "@react-native-community/cli@npm:20.1.0" + dependencies: + "@react-native-community/cli-clean": "npm:20.1.0" + "@react-native-community/cli-config": "npm:20.1.0" + "@react-native-community/cli-doctor": "npm:20.1.0" + "@react-native-community/cli-server-api": "npm:20.1.0" + "@react-native-community/cli-tools": "npm:20.1.0" + "@react-native-community/cli-types": "npm:20.1.0" + commander: "npm:^9.4.1" + deepmerge: "npm:^4.3.0" + execa: "npm:^5.0.0" + find-up: "npm:^5.0.0" + fs-extra: "npm:^8.1.0" + graceful-fs: "npm:^4.1.3" + picocolors: "npm:^1.1.1" + prompts: "npm:^2.4.2" + semver: "npm:^7.5.2" + bin: + rnc-cli: build/bin.js + checksum: 10c0/f3fa43fc8c170ba291ae81e796f4ac35956e143bf1094f15f91061b93835ed0665749ca11da91df7fbfcdd96ee32fc16da6cab60adef1a21022195aa1947bd5d + languageName: node + linkType: hard + +"@react-native/assets-registry@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/assets-registry@npm:0.86.0" + checksum: 10c0/9cd7e96d99bb5b1da0b83ad4362966749c367081bd4272ca65c8ce4933cea9caad6bbf1d6e404832447c9b200495d707809bbb1db2fd5d9a6f7ae25a50a96bf8 + languageName: node + linkType: hard + +"@react-native/assets-registry@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/assets-registry@npm:0.86.2" + checksum: 10c0/e063219dd8548240a278138a595f758a6a140cafe1493631a3d12c3da673aaf6b7d6c2c7115000b13d93c88145ec54b9be509aa683846554273c01bc4a8759c7 + languageName: node + linkType: hard + +"@react-native/babel-plugin-codegen@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/babel-plugin-codegen@npm:0.86.0" + dependencies: + "@babel/traverse": "npm:^7.29.0" + "@react-native/codegen": "npm:0.86.0" + checksum: 10c0/b9263b7fd6a40411639e2343e17454fa9e988e651f44ec9149bd0c3a7945166863713b01d21249b81e62bd9ec67e356fbbee860e7b7f8479df26310b90d2bd4e + languageName: node + linkType: hard + +"@react-native/babel-preset@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/babel-preset@npm:0.86.0" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" + "@babel/plugin-syntax-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" + "@babel/plugin-transform-async-generator-functions": "npm:^7.25.4" + "@babel/plugin-transform-async-to-generator": "npm:^7.24.7" + "@babel/plugin-transform-block-scoping": "npm:^7.25.0" + "@babel/plugin-transform-class-properties": "npm:^7.25.4" + "@babel/plugin-transform-classes": "npm:^7.25.4" + "@babel/plugin-transform-destructuring": "npm:^7.24.8" + "@babel/plugin-transform-flow-strip-types": "npm:^7.25.2" + "@babel/plugin-transform-for-of": "npm:^7.24.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.24.8" + "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.24.7" + "@babel/plugin-transform-optional-catch-binding": "npm:^7.24.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.24.8" + "@babel/plugin-transform-private-methods": "npm:^7.24.7" + "@babel/plugin-transform-private-property-in-object": "npm:^7.24.7" + "@babel/plugin-transform-react-display-name": "npm:^7.24.7" + "@babel/plugin-transform-react-jsx": "npm:^7.25.2" + "@babel/plugin-transform-react-jsx-self": "npm:^7.24.7" + "@babel/plugin-transform-react-jsx-source": "npm:^7.24.7" + "@babel/plugin-transform-regenerator": "npm:^7.24.7" + "@babel/plugin-transform-runtime": "npm:^7.24.7" + "@babel/plugin-transform-typescript": "npm:^7.25.2" + "@babel/plugin-transform-unicode-regex": "npm:^7.24.7" + "@react-native/babel-plugin-codegen": "npm:0.86.0" + babel-plugin-syntax-hermes-parser: "npm:0.36.0" + babel-plugin-transform-flow-enums: "npm:^0.0.2" + react-refresh: "npm:^0.14.0" + peerDependencies: + "@babel/core": "*" + checksum: 10c0/cf86875fa72d9bdd0b6ac3819b7a683764f154ff8df5144cce9cc60dd734d9d100bde563acbb48104e0033650dcc28d968eb985ff59e47349dc6861169df02f6 + languageName: node + linkType: hard + +"@react-native/codegen@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/codegen@npm:0.86.0" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/parser": "npm:^7.29.0" + hermes-parser: "npm:0.36.0" + invariant: "npm:^2.2.4" + nullthrows: "npm:^1.1.1" + tinyglobby: "npm:^0.2.15" + yargs: "npm:^17.6.2" + peerDependencies: + "@babel/core": "*" + checksum: 10c0/22ca5657595438f5e464223a65b25567b3e7106995c8ae59c5853d8cdb3df59065db54c4cb74e958bbe2930b0aded8cd518c2fc1ef13a4740a05fb1faa3ae160 + languageName: node + linkType: hard + +"@react-native/codegen@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/codegen@npm:0.86.2" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/parser": "npm:^7.29.0" + hermes-parser: "npm:0.36.0" + invariant: "npm:^2.2.4" + nullthrows: "npm:^1.1.1" + tinyglobby: "npm:^0.2.15" + yargs: "npm:^17.6.2" + peerDependencies: + "@babel/core": "*" + checksum: 10c0/3b36b5d99a525195e61c1197d88118c774654de6e644a677b29461bd2b28718a751f1c45ee25efa6b274f27d36efb096a3ec816c5fba1cda1a5f5843e4239a0a + languageName: node + linkType: hard + +"@react-native/community-cli-plugin@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/community-cli-plugin@npm:0.86.0" + dependencies: + "@react-native/dev-middleware": "npm:0.86.0" + debug: "npm:^4.4.0" + invariant: "npm:^2.2.4" + metro: "npm:^0.84.3" + metro-config: "npm:^0.84.3" + metro-core: "npm:^0.84.3" + semver: "npm:^7.1.3" + peerDependencies: + "@react-native-community/cli": "*" + "@react-native/metro-config": 0.86.0 + peerDependenciesMeta: + "@react-native-community/cli": + optional: true + "@react-native/metro-config": + optional: true + checksum: 10c0/62c70f1e99eb48dad8516fed9a15415e8de85ee76018f00231f4473e9fedbfc8c9b4932fc531b9307c3a6d681278b61fa7e63ba2fa250628451662f815a3366e + languageName: node + linkType: hard + +"@react-native/community-cli-plugin@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/community-cli-plugin@npm:0.86.2" + dependencies: + "@react-native/dev-middleware": "npm:0.86.2" + debug: "npm:^4.4.0" + invariant: "npm:^2.2.4" + metro: "npm:^0.84.3" + metro-config: "npm:^0.84.3" + metro-core: "npm:^0.84.3" + semver: "npm:^7.1.3" + peerDependencies: + "@react-native-community/cli": "*" + "@react-native/metro-config": 0.86.2 + peerDependenciesMeta: + "@react-native-community/cli": + optional: true "@react-native/metro-config": optional: true checksum: 10c0/f86e5fbe759b3538538c5534e78bc262d549fcb4a6e5066b71e512c9d326e787ebc8050d977f768c39671a1e995aa9eebab642bc51cbfc96dfcfac1127e329e6 languageName: node linkType: hard +"@react-native/debugger-frontend@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/debugger-frontend@npm:0.86.0" + checksum: 10c0/bc86a765dfbcaae35f501244d5a52aa0723196fe34ba177c2e3398491df6af1aa084ace044fde668e05bfec3daf15f735c20c79a8b98bd578b38891913e337e2 + languageName: node + linkType: hard + "@react-native/debugger-frontend@npm:0.86.2": version: 0.86.2 resolution: "@react-native/debugger-frontend@npm:0.86.2" @@ -6377,6 +7006,17 @@ __metadata: languageName: node linkType: hard +"@react-native/debugger-shell@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/debugger-shell@npm:0.86.0" + dependencies: + cross-spawn: "npm:^7.0.6" + debug: "npm:^4.4.0" + fb-dotslash: "npm:0.5.8" + checksum: 10c0/4cc7626b826195403c9b9ac8ad1ed9938ad83858424aa59b980882349966aa0fa6b509066006bc7c7be2c0d20767fbc7d75c8df0007f853b57646c9157fcb3e7 + languageName: node + linkType: hard + "@react-native/debugger-shell@npm:0.86.2": version: 0.86.2 resolution: "@react-native/debugger-shell@npm:0.86.2" @@ -6388,6 +7028,26 @@ __metadata: languageName: node linkType: hard +"@react-native/dev-middleware@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/dev-middleware@npm:0.86.0" + dependencies: + "@isaacs/ttlcache": "npm:^1.4.1" + "@react-native/debugger-frontend": "npm:0.86.0" + "@react-native/debugger-shell": "npm:0.86.0" + chrome-launcher: "npm:^0.15.2" + chromium-edge-launcher: "npm:^0.3.0" + connect: "npm:^3.6.5" + debug: "npm:^4.4.0" + invariant: "npm:^2.2.4" + nullthrows: "npm:^1.1.1" + open: "npm:^7.0.3" + serve-static: "npm:^1.16.2" + ws: "npm:^7.5.10" + checksum: 10c0/14e704d723d7c3eaae94552251b50b31b3769dbfbc2b48f6cabe422b1bfc640dae20751997d8fd8bbe26a22c6865a46083e4d3621d7b200b19c79e515ef54bb9 + languageName: node + linkType: hard + "@react-native/dev-middleware@npm:0.86.2": version: 0.86.2 resolution: "@react-native/dev-middleware@npm:0.86.2" @@ -6408,6 +7068,43 @@ __metadata: languageName: node linkType: hard +"@react-native/eslint-config@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/eslint-config@npm:0.86.0" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/eslint-parser": "npm:^7.25.1" + "@react-native/eslint-plugin": "npm:0.86.0" + "@typescript-eslint/eslint-plugin": "npm:^8.36.0" + "@typescript-eslint/parser": "npm:^8.36.0" + eslint-config-prettier: "npm:^8.5.0" + eslint-plugin-eslint-comments: "npm:^3.2.0" + eslint-plugin-ft-flow: "npm:^2.0.1" + eslint-plugin-jest: "npm:^29.0.1" + eslint-plugin-react: "npm:^7.37.5" + eslint-plugin-react-hooks: "npm:^7.0.1" + eslint-plugin-react-native: "npm:^5.0.0" + peerDependencies: + eslint: ^8.0.0 || ^9.0.0 + prettier: ">=2" + checksum: 10c0/51b3ab96620c8b4ebe14887b9455074de5dbecf87c8860fa065be13a1070a2f69270ecd7eb3a44e215cb49c1eeaa89204e9511606e7c6b452dbb87a1d2bedf3d + languageName: node + linkType: hard + +"@react-native/eslint-plugin@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/eslint-plugin@npm:0.86.0" + checksum: 10c0/4d8bd7451d75dc572369ab2769647b5ec624bbb13e4a924a35cd526ee57507e3667b2eeedfd79568073dc2fa9db598788fc81b39e0b071397fc854ffd36a3ee5 + languageName: node + linkType: hard + +"@react-native/gradle-plugin@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/gradle-plugin@npm:0.86.0" + checksum: 10c0/02d8f7ae14c3163a6acf7098b1227a7ffd66cbfde96b6b151a02739bfbc27f123e023bd3411383e9a7a33f18e4d4d82100448615b8955a0ff95e8dee5d1ef4e8 + languageName: node + linkType: hard + "@react-native/gradle-plugin@npm:0.86.2": version: 0.86.2 resolution: "@react-native/gradle-plugin@npm:0.86.2" @@ -6430,6 +7127,13 @@ __metadata: languageName: node linkType: hard +"@react-native/js-polyfills@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/js-polyfills@npm:0.86.0" + checksum: 10c0/63379d40d37b09fe371cc4f8a60304479ee59667a032072dded0b1e475519ff17f27bbaa39fcd463949ea9068c65eb045d5d4639ede8236a241f6e3cd295174c + languageName: node + linkType: hard + "@react-native/js-polyfills@npm:0.86.2": version: 0.86.2 resolution: "@react-native/js-polyfills@npm:0.86.2" @@ -6437,6 +7141,39 @@ __metadata: languageName: node linkType: hard +"@react-native/metro-babel-transformer@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/metro-babel-transformer@npm:0.86.0" + dependencies: + "@babel/core": "npm:^7.25.2" + "@react-native/babel-preset": "npm:0.86.0" + hermes-parser: "npm:0.36.0" + nullthrows: "npm:^1.1.1" + peerDependencies: + "@babel/core": "*" + checksum: 10c0/8b4ab63d32f28f3663204110d37c50a8dd82eef622d57fb236f3d05b69bdfc1fe277f7572e4cf9135790921922f35fe4aea5f7c183fe17cd2ead19ed8e1db5d1 + languageName: node + linkType: hard + +"@react-native/metro-config@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/metro-config@npm:0.86.0" + dependencies: + "@react-native/js-polyfills": "npm:0.86.0" + "@react-native/metro-babel-transformer": "npm:0.86.0" + metro-config: "npm:^0.84.3" + metro-runtime: "npm:^0.84.3" + checksum: 10c0/92c043b8e4d959a3d8c0aa56940a9ff455c9de21bb6fe2a799fb5a847e45587d9783c3b55e3254f2b17fa71dfc66fff30ce0a70505e0d699dbbf2c76e0ff539c + languageName: node + linkType: hard + +"@react-native/normalize-colors@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/normalize-colors@npm:0.86.0" + checksum: 10c0/d836506a642e1c636015fca23c84ca372b10fb1457df98f95e8bc5716399292d5740b94efe13f9be08c21323ddc3c2c45205d316f0f5440f24e5919b5075f69a + languageName: node + linkType: hard + "@react-native/normalize-colors@npm:0.86.2": version: 0.86.2 resolution: "@react-native/normalize-colors@npm:0.86.2" @@ -6444,6 +7181,30 @@ __metadata: languageName: node linkType: hard +"@react-native/typescript-config@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/typescript-config@npm:0.86.0" + checksum: 10c0/66020e6d945e863847639b4932ef22082dad8ba4e345fe785b85987ae18a6cb56b31539c3502731238580ad96a9b1de32fac55e2e245debf693f039ec36fcffc + languageName: node + linkType: hard + +"@react-native/virtualized-lists@npm:0.86.0": + version: 0.86.0 + resolution: "@react-native/virtualized-lists@npm:0.86.0" + dependencies: + invariant: "npm:^2.2.4" + nullthrows: "npm:^1.1.1" + peerDependencies: + "@types/react": ^19.2.0 + react: "*" + react-native: 0.86.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/df5006e01e2f527f4fd46b93db17f3dbf8fd18c832e4ad9b1b43c544a83b4b2fca34ba72114ad9679968043e7fa6f9fe352bcee250799b9b973d0998b446ca50 + languageName: node + linkType: hard + "@react-native/virtualized-lists@npm:0.86.2": version: 0.86.2 resolution: "@react-native/virtualized-lists@npm:0.86.2" @@ -6982,7 +7743,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.1": +"@sinonjs/commons@npm:^3.0.0, @sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -6991,6 +7752,15 @@ __metadata: languageName: node linkType: hard +"@sinonjs/fake-timers@npm:^10.0.2": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" + dependencies: + "@sinonjs/commons": "npm:^3.0.0" + checksum: 10c0/2e2fb6cc57f227912814085b7b01fede050cd4746ea8d49a1e44d5a0e56a804663b0340ae2f11af7559ea9bf4d087a11f2f646197a660ea3cb04e19efc04aa63 + languageName: node + linkType: hard + "@sinonjs/fake-timers@npm:^15.4.0": version: 15.4.0 resolution: "@sinonjs/fake-timers@npm:15.4.0" @@ -7880,6 +8650,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^29.5.13": + version: 29.5.14 + resolution: "@types/jest@npm:29.5.14" + dependencies: + expect: "npm:^29.0.0" + pretty-format: "npm:^29.0.0" + checksum: 10c0/18e0712d818890db8a8dab3d91e9ea9f7f19e3f83c2e50b312f557017dc81466207a71f3ed79cf4428e813ba939954fa26ffa0a9a7f153181ba174581b1c2aed + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -8109,6 +8889,15 @@ __metadata: languageName: node linkType: hard +"@types/react-test-renderer@npm:^19.1.0": + version: 19.3.0 + resolution: "@types/react-test-renderer@npm:19.3.0" + dependencies: + "@types/react": "npm:*" + checksum: 10c0/37f5588f698a057a33c1f4d5fd28a042f3df7f29a3554ddebb7f9c8ecd3e5fba085e5b12d14b30734c30b51959ef17544650b2a9704d629d0af251f126d8ae73 + languageName: node + linkType: hard + "@types/react@npm:19.2.17": version: 19.2.17 resolution: "@types/react@npm:19.2.17" @@ -8118,6 +8907,15 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:^19.2.0": + version: 19.3.0 + resolution: "@types/react@npm:19.3.0" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/fbcabf303f935ca9f36ed2dcf81751cf84744098c5a683c8a62a0fa7edd31714b48d5c46b12fd0014f7f2dfe4d9ec6de428a850748eec719a70294e51029551b + languageName: node + linkType: hard + "@types/resolve@npm:1.20.2": version: 1.20.2 resolution: "@types/resolve@npm:1.20.2" @@ -8199,7 +8997,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c @@ -8366,6 +9164,26 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/eslint-plugin@npm:^8.36.0": + version: 8.71.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.71.0" + dependencies: + "@eslint-community/regexpp": "npm:^4.12.2" + "@typescript-eslint/scope-manager": "npm:8.71.0" + "@typescript-eslint/type-utils": "npm:8.71.0" + "@typescript-eslint/utils": "npm:8.71.0" + "@typescript-eslint/visitor-keys": "npm:8.71.0" + ignore: "npm:^7.0.5" + natural-compare: "npm:^1.4.0" + ts-api-utils: "npm:^2.5.0" + peerDependencies: + "@typescript-eslint/parser": ^8.71.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/cd93b29b572c002a45c335e1eff02522c05c25ec96ce7a88be1e42866e13ac73cf53c4f03b5aad2e6355b32004a4b57ce132a185232719bc38b906a6c38d2b74 + languageName: node + linkType: hard + "@typescript-eslint/parser@npm:8.63.0, @typescript-eslint/parser@npm:^8.60.1": version: 8.63.0 resolution: "@typescript-eslint/parser@npm:8.63.0" @@ -8382,6 +9200,22 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/parser@npm:^8.36.0": + version: 8.71.0 + resolution: "@typescript-eslint/parser@npm:8.71.0" + dependencies: + "@typescript-eslint/scope-manager": "npm:8.71.0" + "@typescript-eslint/types": "npm:8.71.0" + "@typescript-eslint/typescript-estree": "npm:8.71.0" + "@typescript-eslint/visitor-keys": "npm:8.71.0" + debug: "npm:^4.4.3" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/d83a47bfe23642365950863f3e1b2ec2fc7c31bd695f4692b49d10f35db68f7fe9762784102b712d17da5beb9a27d286013eede60ecc6540ee41d21fed3e2a10 + languageName: node + linkType: hard + "@typescript-eslint/project-service@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/project-service@npm:8.63.0" @@ -8395,6 +9229,19 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/project-service@npm:8.71.0": + version: 8.71.0 + resolution: "@typescript-eslint/project-service@npm:8.71.0" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.71.0" + "@typescript-eslint/types": "npm:^8.71.0" + debug: "npm:^4.4.3" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/6d8eef9287393b807bb6b7f3f29c44540c9722650155aa0ee8eec1c466bc7c7831353a2db3fd4ba843da34c3db26fd495b80a1809a7e053b22e4e094a8189e6c + languageName: node + linkType: hard + "@typescript-eslint/scope-manager@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/scope-manager@npm:8.63.0" @@ -8405,6 +9252,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.71.0": + version: 8.71.0 + resolution: "@typescript-eslint/scope-manager@npm:8.71.0" + dependencies: + "@typescript-eslint/types": "npm:8.71.0" + "@typescript-eslint/visitor-keys": "npm:8.71.0" + checksum: 10c0/76f4565e1ba4d64c9053f475fd5eb30f538edbe17b497d9551178705117f114ba233a63e811b61b2f57a1abb426381dac38325936b28a6cd2172d01f9420ee9d + languageName: node + linkType: hard + "@typescript-eslint/tsconfig-utils@npm:8.63.0, @typescript-eslint/tsconfig-utils@npm:^8.63.0": version: 8.63.0 resolution: "@typescript-eslint/tsconfig-utils@npm:8.63.0" @@ -8414,6 +9271,15 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/tsconfig-utils@npm:8.71.0, @typescript-eslint/tsconfig-utils@npm:^8.71.0": + version: 8.71.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.71.0" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/c35af95cad4114cef15b3d9c18fa8c31735062a0cfef41fa5ef18ead1540de314f3f426996abacbb31a44a5317e8cd5fa1e08c3416ded86e4a63393ce5683ef3 + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/type-utils@npm:8.63.0" @@ -8430,6 +9296,22 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/type-utils@npm:8.71.0": + version: 8.71.0 + resolution: "@typescript-eslint/type-utils@npm:8.71.0" + dependencies: + "@typescript-eslint/types": "npm:8.71.0" + "@typescript-eslint/typescript-estree": "npm:8.71.0" + "@typescript-eslint/utils": "npm:8.71.0" + debug: "npm:^4.4.3" + ts-api-utils: "npm:^2.5.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/06502ba733d33ab8dffa43f71440e4bc1517066c23bf9b3659e3c6733cb932e97c7c497edfe6e3f9200cd44be3cbb2a905f0d2ce71212600123c146fc7f65005 + languageName: node + linkType: hard + "@typescript-eslint/types@npm:8.63.0, @typescript-eslint/types@npm:^8.63.0": version: 8.63.0 resolution: "@typescript-eslint/types@npm:8.63.0" @@ -8437,6 +9319,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.71.0, @typescript-eslint/types@npm:^8.71.0": + version: 8.71.0 + resolution: "@typescript-eslint/types@npm:8.71.0" + checksum: 10c0/87e62f61cc2499ab7a01f49df205f914f9a8c9d25e80465cce1b0513bb7d8208682376c8e877720998abb8868ca61ede7761265405853c4a64aa0335a0d98db2 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/typescript-estree@npm:8.63.0" @@ -8456,6 +9345,25 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:8.71.0": + version: 8.71.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.71.0" + dependencies: + "@typescript-eslint/project-service": "npm:8.71.0" + "@typescript-eslint/tsconfig-utils": "npm:8.71.0" + "@typescript-eslint/types": "npm:8.71.0" + "@typescript-eslint/visitor-keys": "npm:8.71.0" + debug: "npm:^4.4.3" + minimatch: "npm:^10.2.2" + semver: "npm:^7.7.3" + tinyglobby: "npm:^0.2.15" + ts-api-utils: "npm:^2.5.0" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/3238fcb3933c06fa9f2b8488becc6815c513338a1663427122b5ce6642807a95d3aceb95b3e2c73c934523227d4f90e9a694dfe044b8240c4e4e90214f96eaa8 + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/utils@npm:8.63.0" @@ -8471,6 +9379,21 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:8.71.0, @typescript-eslint/utils@npm:^8.0.0": + version: 8.71.0 + resolution: "@typescript-eslint/utils@npm:8.71.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.9.1" + "@typescript-eslint/scope-manager": "npm:8.71.0" + "@typescript-eslint/types": "npm:8.71.0" + "@typescript-eslint/typescript-estree": "npm:8.71.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/6d682bc7d578d3266a553559c603a22cd4408b6db321653d5f179b955555c3287f0bb37e6bbcff5f1c485ba11a292e74c5fcd7da60a4b0c492b6365dad48a155 + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/visitor-keys@npm:8.63.0" @@ -8481,6 +9404,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.71.0": + version: 8.71.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.71.0" + dependencies: + "@typescript-eslint/types": "npm:8.71.0" + eslint-visitor-keys: "npm:^5.0.0" + checksum: 10c0/0797c64e03402d04bac7dabf797cd36dc5e36a7bf1f2aaad063730af8be315ab41529962b5c58d4fca17890c64b32c12e3c8e62876f43f91acd0706421815f03 + languageName: node + linkType: hard + "@typescript/native@npm:typescript@7.0.2": version: 7.0.2 resolution: "typescript@npm:7.0.2" @@ -8720,6 +9653,13 @@ __metadata: languageName: node linkType: hard +"@ungap/structured-clone@npm:^1.2.0": + version: 1.4.0 + resolution: "@ungap/structured-clone@npm:1.4.0" + checksum: 10c0/be74a389850d02901c836c07f10c1070d7604dab4070ba3c4b77c91665b90b4175d9f186189a02a399cf054fc051d3251418238564a4491d0164969e05c802a2 + languageName: node + linkType: hard + "@unrs/resolver-binding-android-arm-eabi@npm:1.9.0": version: 1.9.0 resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.9.0" @@ -8870,6 +9810,13 @@ __metadata: languageName: node linkType: hard +"@vscode/sudo-prompt@npm:^9.0.0": + version: 9.3.2 + resolution: "@vscode/sudo-prompt@npm:9.3.2" + checksum: 10c0/9cf63f7001f31ada248aefe0d289e8769d82d9eeb12845aef863faf44620cbe620897625af4e160ab1c2a684d88247a0dbaead0d9a9447a5807feb4a4fd47016 + languageName: node + linkType: hard + "@vue/compiler-core@npm:3.5.40": version: 3.5.40 resolution: "@vue/compiler-core@npm:3.5.40" @@ -9405,6 +10352,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.9.0": + version: 8.18.0 + resolution: "acorn@npm:8.18.0" + bin: + acorn: bin/acorn + checksum: 10c0/be771be2135cc07910cf76f444ad514d7dcfd6d4a8026e597e93155275abc8ef61eee12211d52146e9d962874269b634f397464942087be013c89d0c54c5f8e5 + languageName: node + linkType: hard + "address@npm:^1.0.1, address@npm:^1.1.2": version: 1.2.2 resolution: "address@npm:1.2.2" @@ -9489,15 +10445,27 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.12.2, ajv@npm:^6.12.5, ajv@npm:^6.14.0": - version: 6.14.0 - resolution: "ajv@npm:6.14.0" +"ajv@npm:^6.12.2, ajv@npm:^6.12.5, ajv@npm:^6.14.0": + version: 6.14.0 + resolution: "ajv@npm:6.14.0" + dependencies: + fast-deep-equal: "npm:^3.1.1" + fast-json-stable-stringify: "npm:^2.0.0" + json-schema-traverse: "npm:^0.4.1" + uri-js: "npm:^4.2.2" + checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 + languageName: node + linkType: hard + +"ajv@npm:^6.12.4": + version: 6.15.0 + resolution: "ajv@npm:6.15.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 + checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2 languageName: node linkType: hard @@ -9573,7 +10541,7 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^4.3.2": +"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" dependencies: @@ -9582,6 +10550,17 @@ __metadata: languageName: node linkType: hard +"ansi-fragments@npm:^0.2.1": + version: 0.2.1 + resolution: "ansi-fragments@npm:0.2.1" + dependencies: + colorette: "npm:^1.0.7" + slice-ansi: "npm:^2.0.0" + strip-ansi: "npm:^5.0.0" + checksum: 10c0/44e97e558ca2f0b2ca895bfd6ebebeb2e77d674d2e4198ac2d3a05b690193fa35fd185db6e16b92dd0ee854299ea8b4387a99e4155ea62bc8ad4c42154542fd4 + languageName: node + linkType: hard + "ansi-html-community@npm:^0.0.8": version: 0.0.8 resolution: "ansi-html-community@npm:0.0.8" @@ -9591,6 +10570,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^4.1.0": + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: 10c0/d36d34234d077e8770169d980fed7b2f3724bfa2a01da150ccd75ef9707c80e883d27cdf7a0eac2f145ac1d10a785a8a855cffd05b85f778629a0db62e7033da + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.0, ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -9605,7 +10591,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^3.2.1": +"ansi-styles@npm:^3.2.0, ansi-styles@npm:^3.2.1": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" dependencies: @@ -9676,6 +10662,13 @@ __metadata: languageName: node linkType: hard +"appdirsjs@npm:^1.2.4": + version: 1.2.8 + resolution: "appdirsjs@npm:1.2.8" + checksum: 10c0/dc16a9aa5e9fd2fc8c7a0daaa7184b74c6e4e52afe9242ae73b683af16468d55eb9325b03926721c77307de668696a2e2e935253d107babe8daed96f822d849b + languageName: node + linkType: hard + "arch@npm:^2.2.0": version: 2.2.0 resolution: "arch@npm:2.2.0" @@ -9931,6 +10924,13 @@ __metadata: languageName: node linkType: hard +"astral-regex@npm:^1.0.0": + version: 1.0.0 + resolution: "astral-regex@npm:1.0.0" + checksum: 10c0/ca460207a19d84c65671e1a85940101522d42f31a450cdb8f93b3464e6daeaf4b58a362826a6c11c57e6cd1976403d197abb0447cfc2087993a29b35c6d63b63 + languageName: node + linkType: hard + "astring@npm:^1.8.0": version: 1.8.6 resolution: "astring@npm:1.8.6" @@ -9940,6 +10940,13 @@ __metadata: languageName: node linkType: hard +"async-limiter@npm:~1.0.0": + version: 1.0.1 + resolution: "async-limiter@npm:1.0.1" + checksum: 10c0/0693d378cfe86842a70d4c849595a0bb50dc44c11649640ca982fa90cbfc74e3cc4753b5a0847e51933f2e9c65ce8e05576e75e5e1fd963a086e673735b35969 + languageName: node + linkType: hard + "async@npm:^3.2.3, async@npm:^3.2.6": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -10235,6 +11242,15 @@ __metadata: languageName: node linkType: hard +"babel-plugin-transform-flow-enums@npm:^0.0.2": + version: 0.0.2 + resolution: "babel-plugin-transform-flow-enums@npm:0.0.2" + dependencies: + "@babel/plugin-syntax-flow": "npm:^7.12.1" + checksum: 10c0/aa9d022d8d4be0e7c4f1ff7e5308fe7e0ff4d6f9099449913e3a11c1e81916623a8f36432da180a9aa3f53ea534dca4401fe33d6528f043f40357cfa790ee778 + languageName: node + linkType: hard + "babel-plugin-transform-import-meta@npm:^2.3.3": version: 2.3.3 resolution: "babel-plugin-transform-import-meta@npm:2.3.3" @@ -10426,6 +11442,17 @@ __metadata: languageName: node linkType: hard +"bl@npm:^4.1.0": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f + languageName: node + linkType: hard + "bl@npm:^5.0.0": version: 5.1.0 resolution: "bl@npm:5.1.0" @@ -10451,6 +11478,26 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^1.20.3": + version: 1.20.8 + resolution: "body-parser@npm:1.20.8" + dependencies: + bytes: "npm:~3.1.2" + content-type: "npm:~1.0.5" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:~1.2.0" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.4.24" + on-finished: "npm:~2.4.1" + qs: "npm:~6.16.0" + raw-body: "npm:~2.5.3" + type-is: "npm:~1.6.18" + unpipe: "npm:~1.0.0" + checksum: 10c0/7edc8c4762d52ff685af081b1d5ccad860edcec662c25893be452b16042fd55b9c9869a194bd282e0ad936c34eb07866b2b33f0cc02ff916597058fd229c13d2 + languageName: node + linkType: hard + "body-parser@npm:~1.20.5": version: 1.20.6 resolution: "body-parser@npm:1.20.6" @@ -10726,6 +11773,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e + languageName: node + linkType: hard + "buffer@npm:^6.0.3": version: 6.0.3 resolution: "buffer@npm:6.0.3" @@ -10881,7 +11938,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.3.1": +"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: 10c0/92ff9b443bfe8abb15f2b1513ca182d16126359ad4f955ebc83dc4ddcc4ef3fdd2c078bc223f2673dc223488e75c99b16cc4d056624374b799e6a1555cf61b23 @@ -11150,6 +12207,13 @@ __metadata: languageName: node linkType: hard +"cjs-module-lexer@npm:^1.0.0": + version: 1.4.3 + resolution: "cjs-module-lexer@npm:1.4.3" + checksum: 10c0/076b3af85adc4d65dbdab1b5b240fe5b45d44fcf0ef9d429044dd94d19be5589376805c44fb2d4b3e684e5fe6a9b7cf3e426476a6507c45283c5fc6ff95240be + languageName: node + linkType: hard + "cjs-module-lexer@npm:^2.1.0": version: 2.1.0 resolution: "cjs-module-lexer@npm:2.1.0" @@ -11205,6 +12269,15 @@ __metadata: languageName: node linkType: hard +"cli-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "cli-cursor@npm:3.1.0" + dependencies: + restore-cursor: "npm:^3.1.0" + checksum: 10c0/92a2f98ff9037d09be3dfe1f0d749664797fb674bf388375a2207a1203b69d41847abf16434203e0089212479e47a358b13a0222ab9fccfe8e2644a7ccebd111 + languageName: node + linkType: hard + "cli-cursor@npm:^4.0.0": version: 4.0.0 resolution: "cli-cursor@npm:4.0.0" @@ -11214,7 +12287,7 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:^2.6.1": +"cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.6.1": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: 10c0/907a1c227ddf0d7a101e7ab8b300affc742ead4b4ebe920a5bf1bc6d45dce2958fcd195eb28fa25275062fe6fa9b109b93b63bc8033396ed3bcb50297008b3a3 @@ -11252,6 +12325,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^6.0.0": + version: 6.0.0 + resolution: "cliui@npm:6.0.0" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.0" + wrap-ansi: "npm:^6.2.0" + checksum: 10c0/35229b1bb48647e882104cac374c9a18e34bbf0bace0e2cf03000326b6ca3050d6b59545d91e17bfe3705f4a0e2988787aa5cde6331bf5cbbf0164732cef6492 + languageName: node + linkType: hard + "cliui@npm:^7.0.2": version: 7.0.4 resolution: "cliui@npm:7.0.4" @@ -11354,6 +12438,13 @@ __metadata: languageName: node linkType: hard +"collect-v8-coverage@npm:^1.0.0": + version: 1.0.3 + resolution: "collect-v8-coverage@npm:1.0.3" + checksum: 10c0/bc62ba251bcce5e3354a8f88fa6442bee56e3e612fec08d4dfcf66179b41ea0bf544b0f78c4ebc0f8050871220af95bb5c5578a6aef346feea155640582f09dc + languageName: node + linkType: hard + "collect-v8-coverage@npm:^1.0.2": version: 1.0.2 resolution: "collect-v8-coverage@npm:1.0.2" @@ -11420,6 +12511,13 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^1.0.7": + version: 1.4.0 + resolution: "colorette@npm:1.4.0" + checksum: 10c0/4955c8f7daafca8ae7081d672e4bd89d553bd5782b5846d5a7e05effe93c2f15f7e9c0cb46f341b59f579a39fcf436241ff79594899d75d5f3460c03d607fe9e + languageName: node + linkType: hard + "colorette@npm:^2.0.10, colorette@npm:^2.0.14": version: 2.0.20 resolution: "colorette@npm:2.0.20" @@ -11448,6 +12546,13 @@ __metadata: languageName: node linkType: hard +"command-exists@npm:^1.2.8": + version: 1.2.9 + resolution: "command-exists@npm:1.2.9" + checksum: 10c0/75040240062de46cd6cd43e6b3032a8b0494525c89d3962e280dde665103f8cc304a8b313a5aa541b91da2f5a9af75c5959dc3a77893a2726407a5e9a0234c16 + languageName: node + linkType: hard + "commander@npm:7, commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -11518,6 +12623,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^9.4.1": + version: 9.5.0 + resolution: "commander@npm:9.5.0" + checksum: 10c0/5f7784fbda2aaec39e89eb46f06a999e00224b3763dc65976e05929ec486e174fe9aac2655f03ba6a5e83875bd173be5283dc19309b7c65954701c02025b3c1d + languageName: node + linkType: hard + "common-path-prefix@npm:^3.0.0": version: 3.0.0 resolution: "common-path-prefix@npm:3.0.0" @@ -11556,6 +12668,22 @@ __metadata: languageName: node linkType: hard +"compression@npm:^1.7.1": + version: 1.8.2 + resolution: "compression@npm:1.8.2" + dependencies: + bytes: "npm:3.1.2" + compressible: "npm:~2.0.18" + debug: "npm:2.6.9" + destroy: "npm:1.2.0" + negotiator: "npm:~0.6.4" + on-headers: "npm:~1.1.0" + safe-buffer: "npm:5.2.1" + vary: "npm:~1.1.2" + checksum: 10c0/f95be7122323fdffcaadc807f42da38b5227eeac7d9997c1c4314abd7ca4a4de2daa3d5bd4e142706bef4d34fa0f64cd265c6f074b221143e18da1215c0a07ab + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -11884,6 +13012,23 @@ __metadata: languageName: node linkType: hard +"create-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "create-jest@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + prompts: "npm:^2.0.1" + bin: + create-jest: bin/create-jest.js + checksum: 10c0/e7e54c280692470d3398f62a6238fd396327e01c6a0757002833f06d00afc62dd7bfe04ff2b9cd145264460e6b4d1eb8386f2925b7e567f97939843b7b0e812f + languageName: node + linkType: hard + "cross-fetch@npm:^4.1.0": version: 4.1.0 resolution: "cross-fetch@npm:4.1.0" @@ -11906,7 +13051,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -12828,6 +13973,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.8.15": + version: 1.11.23 + resolution: "dayjs@npm:1.11.23" + checksum: 10c0/69ab04bf19c676e44ab50cc2fca223d265f3dafd107151ef3b0f3ad74d360c37caa602abe62f87cb25d90bd9f6bee003698af47df5b0dbf10a998b7b5f3bb3f1 + languageName: node + linkType: hard + "debounce@npm:^1.2.1": version: 1.2.1 resolution: "debounce@npm:1.2.1" @@ -12865,6 +14017,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10c0/85c39fe8fbf0482d4a1e224ef0119db5c1897f8503bcef8b826adff7a1b11414972f6fef2d7dec2ee0b4be3863cf64ac1439137ae9e6af23a3d8dcbe26a5b4b2 + languageName: node + linkType: hard + "decimal.js@npm:^10.5.0": version: 10.5.0 resolution: "decimal.js@npm:10.5.0" @@ -12897,6 +14056,18 @@ __metadata: languageName: node linkType: hard +"dedent@npm:^1.0.0": + version: 1.7.2 + resolution: "dedent@npm:1.7.2" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 10c0/acaff07cac355b93f17b1b17ebbb84d3cc55af6ab4b7814c3f505e061903e168bc6bf9ddce331552d64dee1525f0b4c549c9ade46aebfac6f69caaed74e90751 + languageName: node + linkType: hard + "dedent@npm:^1.6.0": version: 1.6.0 resolution: "dedent@npm:1.6.0" @@ -12930,7 +14101,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.1": +"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.0, deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10c0/e53481aaf1aa2c4082b5342be6b6d8ad9dfe387bc92ce197a66dea08bd4265904a087e75e464f14d1347cf2ac8afe1e4c16b266e0561cc5df29382d3c5f80044 @@ -13075,7 +14246,7 @@ __metadata: languageName: node linkType: hard -"detect-newline@npm:^3.1.0": +"detect-newline@npm:^3.0.0, detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" checksum: 10c0/c38cfc8eeb9fda09febb44bcd85e467c970d4e3bf526095394e5a4f18bc26dd0cf6b22c69c1fa9969261521c593836db335c2795218f6d781a512aea2fb8209d @@ -13123,6 +14294,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 + languageName: node + linkType: hard + "diffie-hellman@npm:^5.0.3": version: 5.0.3 resolution: "diffie-hellman@npm:5.0.3" @@ -13161,6 +14339,15 @@ __metadata: languageName: node linkType: hard +"doctrine@npm:^3.0.0": + version: 3.0.0 + resolution: "doctrine@npm:3.0.0" + dependencies: + esutils: "npm:^2.0.2" + checksum: 10c0/c96bdccabe9d62ab6fea9399fdff04a66e6563c1d6fb3a3a063e8d53c3bb136ba63e84250bbf63d00086a769ad53aef92d2bd483f03f837fc97b71cbee6b2520 + languageName: node + linkType: hard + "dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" @@ -13569,6 +14756,15 @@ __metadata: languageName: node linkType: hard +"envinfo@npm:^7.13.0": + version: 7.21.0 + resolution: "envinfo@npm:7.21.0" + bin: + envinfo: dist/cli.js + checksum: 10c0/4170127ca72dbf85be2c114f85558bd08178e8a43b394951ba9fd72d067c6fea3374df45a7b040e39e4e7b30bdd268e5bdf8661d99ae28302c2a88dedb41b5e6 + languageName: node + linkType: hard + "envinfo@npm:^7.14.0": version: 7.14.0 resolution: "envinfo@npm:7.14.0" @@ -13603,6 +14799,16 @@ __metadata: languageName: node linkType: hard +"errorhandler@npm:^1.5.1": + version: 1.5.2 + resolution: "errorhandler@npm:1.5.2" + dependencies: + accepts: "npm:~1.3.8" + escape-html: "npm:~1.0.3" + checksum: 10c0/13fc3ba2358893f1f2da43e246105d42a78bf448bf55257b75114c757bd566dcae8b0cd76a3c8777bc451a552a9215979a5e8205bdeee066550cc4acabbfd5af + languageName: node + linkType: hard + "es-abstract@npm:^1.17.0-next.1, es-abstract@npm:^1.17.5, es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.3, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9, es-abstract@npm:^1.24.0, es-abstract@npm:^1.5.1": version: 1.24.0 resolution: "es-abstract@npm:1.24.0" @@ -13912,6 +15118,17 @@ __metadata: languageName: node linkType: hard +"eslint-config-prettier@npm:^8.5.0": + version: 8.10.2 + resolution: "eslint-config-prettier@npm:8.10.2" + peerDependencies: + eslint: ">=7.0.0" + bin: + eslint-config-prettier: bin/cli.js + checksum: 10c0/b5953cf7a86f685e1218b16707bf36643b525513d08495226a6820caccd8b7bfc6b9aa64ac7cb2415dbe2c1f7dc4995832148bdc53ad45777f75a8ded1073b29 + languageName: node + linkType: hard + "eslint-import-context@npm:^0.1.8": version: 0.1.9 resolution: "eslint-import-context@npm:0.1.9" @@ -13995,6 +15212,31 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-eslint-comments@npm:^3.2.0": + version: 3.2.0 + resolution: "eslint-plugin-eslint-comments@npm:3.2.0" + dependencies: + escape-string-regexp: "npm:^1.0.5" + ignore: "npm:^5.0.5" + peerDependencies: + eslint: ">=4.19.1" + checksum: 10c0/c71db824592dc8ea498021572a0bd33d763ef26126bdb3b84a027ca75a1adbe0894ec95024f7de39ef12308560e62cbf8af0d06ffe472be5ba8bd9169c928e96 + languageName: node + linkType: hard + +"eslint-plugin-ft-flow@npm:^2.0.1": + version: 2.0.3 + resolution: "eslint-plugin-ft-flow@npm:2.0.3" + dependencies: + lodash: "npm:^4.17.21" + string-natural-compare: "npm:^3.0.1" + peerDependencies: + "@babel/eslint-parser": ^7.12.0 + eslint: ^8.1.0 + checksum: 10c0/171f6862f7be3c66a415c2ebf14a6e29ade78b661a16f344b78fbefeaeed97fc7f2c710c0d3a2c2df2bbb614b282eaef830993c2aac83b13324cd8c2f9497ea6 + languageName: node + linkType: hard + "eslint-plugin-import@npm:^2.32.0": version: 2.32.0 resolution: "eslint-plugin-import@npm:2.32.0" @@ -14024,6 +15266,27 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-jest@npm:^29.0.1": + version: 29.16.6 + resolution: "eslint-plugin-jest@npm:29.16.6" + dependencies: + "@typescript-eslint/utils": "npm:^8.0.0" + peerDependencies: + "@typescript-eslint/eslint-plugin": ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + jest: "*" + typescript: ">=4.8.4 <8.0.0" + peerDependenciesMeta: + "@typescript-eslint/eslint-plugin": + optional: true + jest: + optional: true + typescript: + optional: true + checksum: 10c0/c483057f6b7c6500c6208eb965b61fe3a81dbd86123dd1a129584ed8c490336275a8815196922c20bd4895761a7d5b993f7b37869c69d57594b522fe318def8d + languageName: node + linkType: hard + "eslint-plugin-jsx-a11y@npm:^6.10.2": version: 6.10.2 resolution: "eslint-plugin-jsx-a11y@npm:6.10.2" @@ -14078,6 +15341,39 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-react-hooks@npm:^7.0.1": + version: 7.1.1 + resolution: "eslint-plugin-react-hooks@npm:7.1.1" + dependencies: + "@babel/core": "npm:^7.24.4" + "@babel/parser": "npm:^7.24.4" + hermes-parser: "npm:^0.25.1" + zod: "npm:^3.25.0 || ^4.0.0" + zod-validation-error: "npm:^3.5.0 || ^4.0.0" + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + checksum: 10c0/cee8454915d71ac5d70a0d8f4f260e76eaf45fcd4162747dd4282b792ee5616d187351dabe6cdcff9040c79d0cec625635c4fd0777276be119efa88ebe058525 + languageName: node + linkType: hard + +"eslint-plugin-react-native-globals@npm:^0.1.1": + version: 0.1.2 + resolution: "eslint-plugin-react-native-globals@npm:0.1.2" + checksum: 10c0/ddb4ec5e31f6e72a66d51218c8f0b558b5366d614598fbec1833ac529db2c2dc1724c7ed71c1fcf922251b8438634f704d265c9bedf51aecfe807ec4a0403c09 + languageName: node + linkType: hard + +"eslint-plugin-react-native@npm:^5.0.0": + version: 5.0.0 + resolution: "eslint-plugin-react-native@npm:5.0.0" + dependencies: + eslint-plugin-react-native-globals: "npm:^0.1.1" + peerDependencies: + eslint: ^3.17.0 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + checksum: 10c0/c7c927bc743abf0cb367cc64fea5b28b28ea0c58be2990cab858a050b4855e89d90513afa44d73012c9fd670810ad0da2ac72e3e4bdfedf0ce0cbb65e901af7f + languageName: node + linkType: hard + "eslint-plugin-react@npm:^7.37.5": version: 7.37.5 resolution: "eslint-plugin-react@npm:7.37.5" @@ -14123,6 +15419,16 @@ __metadata: languageName: node linkType: hard +"eslint-scope@npm:^7.2.2": + version: 7.2.2 + resolution: "eslint-scope@npm:7.2.2" + dependencies: + esrecurse: "npm:^4.3.0" + estraverse: "npm:^5.2.0" + checksum: 10c0/613c267aea34b5a6d6c00514e8545ef1f1433108097e857225fed40d397dd6b1809dffd11c2fde23b37ca53d7bf935fe04d2a18e6fc932b31837b6ad67e1c116 + languageName: node + linkType: hard + "eslint-scope@npm:^8.4.0": version: 8.4.0 resolution: "eslint-scope@npm:8.4.0" @@ -14140,7 +15446,7 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^3.4.3": +"eslint-visitor-keys@npm:^3.4.1, eslint-visitor-keys@npm:^3.4.3": version: 3.4.3 resolution: "eslint-visitor-keys@npm:3.4.3" checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 @@ -14210,6 +15516,54 @@ __metadata: languageName: node linkType: hard +"eslint@npm:^8.19.0": + version: 8.57.1 + resolution: "eslint@npm:8.57.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.2.0" + "@eslint-community/regexpp": "npm:^4.6.1" + "@eslint/eslintrc": "npm:^2.1.4" + "@eslint/js": "npm:8.57.1" + "@humanwhocodes/config-array": "npm:^0.13.0" + "@humanwhocodes/module-importer": "npm:^1.0.1" + "@nodelib/fs.walk": "npm:^1.2.8" + "@ungap/structured-clone": "npm:^1.2.0" + ajv: "npm:^6.12.4" + chalk: "npm:^4.0.0" + cross-spawn: "npm:^7.0.2" + debug: "npm:^4.3.2" + doctrine: "npm:^3.0.0" + escape-string-regexp: "npm:^4.0.0" + eslint-scope: "npm:^7.2.2" + eslint-visitor-keys: "npm:^3.4.3" + espree: "npm:^9.6.1" + esquery: "npm:^1.4.2" + esutils: "npm:^2.0.2" + fast-deep-equal: "npm:^3.1.3" + file-entry-cache: "npm:^6.0.1" + find-up: "npm:^5.0.0" + glob-parent: "npm:^6.0.2" + globals: "npm:^13.19.0" + graphemer: "npm:^1.4.0" + ignore: "npm:^5.2.0" + imurmurhash: "npm:^0.1.4" + is-glob: "npm:^4.0.0" + is-path-inside: "npm:^3.0.3" + js-yaml: "npm:^4.1.0" + json-stable-stringify-without-jsonify: "npm:^1.0.1" + levn: "npm:^0.4.1" + lodash.merge: "npm:^4.6.2" + minimatch: "npm:^3.1.2" + natural-compare: "npm:^1.4.0" + optionator: "npm:^0.9.3" + strip-ansi: "npm:^6.0.1" + text-table: "npm:^0.2.0" + bin: + eslint: bin/eslint.js + checksum: 10c0/1fd31533086c1b72f86770a4d9d7058ee8b4643fd1cfd10c7aac1ecb8725698e88352a87805cf4b2ce890aa35947df4b4da9655fb7fdfa60dbb448a43f6ebcf1 + languageName: node + linkType: hard + "esm-env@npm:^1.1.4": version: 1.1.4 resolution: "esm-env@npm:1.1.4" @@ -14228,6 +15582,17 @@ __metadata: languageName: node linkType: hard +"espree@npm:^9.6.0, espree@npm:^9.6.1": + version: 9.6.1 + resolution: "espree@npm:9.6.1" + dependencies: + acorn: "npm:^8.9.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^3.4.1" + checksum: 10c0/1a2e9b4699b715347f62330bcc76aee224390c28bb02b31a3752e9d07549c473f5f986720483c6469cf3cfb3c9d05df612ffc69eb1ee94b54b739e67de9bb460 + languageName: node + linkType: hard + "esprima@npm:^4.0.0, esprima@npm:~4.0.0": version: 4.0.1 resolution: "esprima@npm:4.0.1" @@ -14238,6 +15603,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.4.2": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + languageName: node + linkType: hard + "esquery@npm:^1.5.0": version: 1.6.0 resolution: "esquery@npm:1.6.0" @@ -14407,6 +15781,33 @@ __metadata: languageName: node linkType: hard +"example-benchmark-native@workspace:examples/benchmark-native": + version: 0.0.0-use.local + resolution: "example-benchmark-native@workspace:examples/benchmark-native" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/preset-env": "npm:^7.25.3" + "@babel/runtime": "npm:^7.25.0" + "@data-client/core": "workspace:*" + "@react-native-community/cli": "npm:20.1.0" + "@react-native-community/cli-platform-android": "npm:20.1.0" + "@react-native/babel-preset": "npm:0.86.0" + "@react-native/eslint-config": "npm:0.86.0" + "@react-native/metro-config": "npm:0.86.0" + "@react-native/typescript-config": "npm:0.86.0" + "@types/jest": "npm:^29.5.13" + "@types/react": "npm:^19.2.0" + "@types/react-test-renderer": "npm:^19.1.0" + eslint: "npm:^8.19.0" + jest: "npm:^29.6.3" + prettier: "npm:2.8.8" + react: "npm:19.2.3" + react-native: "npm:0.86.0" + react-test-renderer: "npm:19.2.3" + typescript: "npm:^5.8.3" + languageName: unknown + linkType: soft + "example-benchmark-react@workspace:examples/benchmark-react": version: 0.0.0-use.local resolution: "example-benchmark-react@workspace:examples/benchmark-react" @@ -14458,7 +15859,7 @@ __metadata: languageName: unknown linkType: soft -"execa@npm:^5.1.1": +"execa@npm:^5.0.0, execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" dependencies: @@ -14519,6 +15920,13 @@ __metadata: languageName: node linkType: hard +"exit@npm:^0.1.2": + version: 0.1.2 + resolution: "exit@npm:0.1.2" + checksum: 10c0/71d2ad9b36bc25bb8b104b17e830b40a08989be7f7d100b13269aaae7c3784c3e6e1e88a797e9e87523993a25ba27c8958959a554535370672cfb4d824af8989 + languageName: node + linkType: hard + "expect@npm:30.4.1, expect@npm:^30.0.0": version: 30.4.1 resolution: "expect@npm:30.4.1" @@ -14533,6 +15941,19 @@ __metadata: languageName: node linkType: hard +"expect@npm:^29.0.0, expect@npm:^29.7.0": + version: 29.7.0 + resolution: "expect@npm:29.7.0" + dependencies: + "@jest/expect-utils": "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10c0/2eddeace66e68b8d8ee5f7be57f3014b19770caaf6815c7a08d131821da527fb8c8cb7b3dcd7c883d2d3d8d184206a4268984618032d1e4b16dc8d6596475d41 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -14630,7 +16051,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0": +"fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0, fast-glob@npm:^3.3.2": version: 3.3.3 resolution: "fast-glob@npm:3.3.3" dependencies: @@ -14664,6 +16085,17 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:^4.4.1": + version: 4.5.7 + resolution: "fast-xml-parser@npm:4.5.7" + dependencies: + strnum: "npm:^1.0.5" + bin: + fxparser: src/cli/cli.js + checksum: 10c0/5fccf3f53d6b2b83143d73089f04ef5db5343422891193cf10d92d3eb856007b7337818494109e46f6fa47b31b9716be15c4b275ff87a0aeb6f7315aa2edc181 + languageName: node + linkType: hard + "fastest-levenshtein@npm:^1.0.12": version: 1.0.16 resolution: "fastest-levenshtein@npm:1.0.16" @@ -14756,6 +16188,15 @@ __metadata: languageName: node linkType: hard +"file-entry-cache@npm:^6.0.1": + version: 6.0.1 + resolution: "file-entry-cache@npm:6.0.1" + dependencies: + flat-cache: "npm:^3.0.4" + checksum: 10c0/58473e8a82794d01b38e5e435f6feaf648e3f36fdb3a56e98f417f4efae71ad1c0d4ebd8a9a7c50c3ad085820a93fc7494ad721e0e4ebc1da3573f4e1c3c7cdd + languageName: node + linkType: hard + "file-entry-cache@npm:^8.0.0": version: 8.0.0 resolution: "file-entry-cache@npm:8.0.0" @@ -14947,6 +16388,17 @@ __metadata: languageName: node linkType: hard +"flat-cache@npm:^3.0.4": + version: 3.2.0 + resolution: "flat-cache@npm:3.2.0" + dependencies: + flatted: "npm:^3.2.9" + keyv: "npm:^4.5.3" + rimraf: "npm:^3.0.2" + checksum: 10c0/b76f611bd5f5d68f7ae632e3ae503e678d205cf97a17c6ab5b12f6ca61188b5f1f7464503efae6dc18683ed8f0b41460beb48ac4b9ac63fe6201296a91ba2f75 + languageName: node + linkType: hard + "flat-cache@npm:^4.0.0": version: 4.0.1 resolution: "flat-cache@npm:4.0.1" @@ -15263,7 +16715,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde @@ -15495,6 +16947,15 @@ __metadata: languageName: node linkType: hard +"globals@npm:^13.19.0": + version: 13.24.0 + resolution: "globals@npm:13.24.0" + dependencies: + type-fest: "npm:^0.20.2" + checksum: 10c0/d3c11aeea898eb83d5ec7a99508600fbe8f83d2cf00cbb77f873dbf2bcb39428eff1b538e4915c993d8a3b3473fa71eeebfe22c9bb3a3003d1e26b1f2c8a42cd + languageName: node + linkType: hard + "globals@npm:^14.0.0": version: 14.0.0 resolution: "globals@npm:14.0.0" @@ -15599,13 +17060,20 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.3, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 languageName: node linkType: hard +"graphemer@npm:^1.4.0": + version: 1.4.0 + resolution: "graphemer@npm:1.4.0" + checksum: 10c0/e951259d8cd2e0d196c72ec711add7115d42eb9a8146c8eeda5b8d3ac91e5dd816b9cd68920726d9fd4490368e7ed86e9c423f40db87e2d8dfafa00fa17c3a31 + languageName: node + linkType: hard + "gulp-filter@npm:^9.0.1": version: 9.0.1 resolution: "gulp-filter@npm:9.0.1" @@ -15939,6 +17407,13 @@ __metadata: languageName: node linkType: hard +"hermes-compiler@npm:250829098.0.14": + version: 250829098.0.14 + resolution: "hermes-compiler@npm:250829098.0.14" + checksum: 10c0/b9f4024ac7377bebec61b91e1cee8f3909a8ecad0ea786c6b7c3863f8b65bd94f928850b341c82865a9435b8390d33f03e149e9d479c6870b6f5a754d101fde7 + languageName: node + linkType: hard + "hermes-compiler@npm:250829098.0.16": version: 250829098.0.16 resolution: "hermes-compiler@npm:250829098.0.16" @@ -15946,6 +17421,13 @@ __metadata: languageName: node linkType: hard +"hermes-estree@npm:0.25.1": + version: 0.25.1 + resolution: "hermes-estree@npm:0.25.1" + checksum: 10c0/48be3b2fa37a0cbc77a112a89096fa212f25d06de92781b163d67853d210a8a5c3784fac23d7d48335058f7ed283115c87b4332c2a2abaaccc76d0ead1a282ac + languageName: node + linkType: hard + "hermes-estree@npm:0.35.0": version: 0.35.0 resolution: "hermes-estree@npm:0.35.0" @@ -15994,6 +17476,15 @@ __metadata: languageName: node linkType: hard +"hermes-parser@npm:^0.25.1": + version: 0.25.1 + resolution: "hermes-parser@npm:0.25.1" + dependencies: + hermes-estree: "npm:0.25.1" + checksum: 10c0/3abaa4c6f1bcc25273f267297a89a4904963ea29af19b8e4f6eabe04f1c2c7e9abd7bfc4730ddb1d58f2ea04b6fee74053d8bddb5656ec6ebf6c79cc8d14202c + languageName: node + linkType: hard + "history@npm:*, history@npm:5.3.0, history@npm:^5.3.0": version: 5.3.0 resolution: "history@npm:5.3.0" @@ -16459,7 +17950,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.2.1": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -16475,6 +17966,13 @@ __metadata: languageName: node linkType: hard +"ignore@npm:^5.0.5": + version: 5.3.2 + resolution: "ignore@npm:5.3.2" + checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 + languageName: node + linkType: hard + "ignore@npm:^5.2.0, ignore@npm:^5.2.4": version: 5.3.1 resolution: "ignore@npm:5.3.1" @@ -16955,6 +18453,13 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^2.0.0": + version: 2.0.0 + resolution: "is-fullwidth-code-point@npm:2.0.0" + checksum: 10c0/e58f3e4a601fc0500d8b2677e26e9fe0cd450980e66adb29d85b6addf7969731e38f8e43ed2ec868a09c101a55ac3d8b78902209269f38c5286bc98f5bc1b4d9 + languageName: node + linkType: hard + "is-fullwidth-code-point@npm:^3.0.0": version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" @@ -16962,7 +18467,7 @@ __metadata: languageName: node linkType: hard -"is-generator-fn@npm:^2.1.0": +"is-generator-fn@npm:^2.0.0, is-generator-fn@npm:^2.1.0": version: 2.1.0 resolution: "is-generator-fn@npm:2.1.0" checksum: 10c0/2957cab387997a466cd0bf5c1b6047bd21ecb32bdcfd8996b15747aa01002c1c88731802f1b3d34ac99f4f6874b626418bd118658cf39380fe5fff32a3af9c4d @@ -17015,6 +18520,13 @@ __metadata: languageName: node linkType: hard +"is-interactive@npm:^1.0.0": + version: 1.0.0 + resolution: "is-interactive@npm:1.0.0" + checksum: 10c0/dd47904dbf286cd20aa58c5192161be1a67138485b9836d5a70433b21a45442e9611b8498b8ab1f839fc962c7620667a50535fdfb4a6bc7989b8858645c06b4d + languageName: node + linkType: hard + "is-interactive@npm:^2.0.0": version: 2.0.0 resolution: "is-interactive@npm:2.0.0" @@ -17130,7 +18642,7 @@ __metadata: languageName: node linkType: hard -"is-path-inside@npm:^3.0.2": +"is-path-inside@npm:^3.0.2, is-path-inside@npm:^3.0.3": version: 3.0.3 resolution: "is-path-inside@npm:3.0.3" checksum: 10c0/cf7d4ac35fb96bab6a1d2c3598fe5ebb29aafb52c0aaa482b5a3ed9d8ba3edc11631e3ec2637660c44b3ce0e61a08d54946e8af30dec0b60a7c27296c68ffd05 @@ -17326,6 +18838,13 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^0.1.0": + version: 0.1.0 + resolution: "is-unicode-supported@npm:0.1.0" + checksum: 10c0/00cbe3455c3756be68d2542c416cab888aebd5012781d6819749fefb15162ff23e38501fe681b3d751c73e8ff561ac09a5293eba6f58fdf0178462ce6dcb3453 + languageName: node + linkType: hard + "is-unicode-supported@npm:^1.1.0": version: 1.3.0 resolution: "is-unicode-supported@npm:1.3.0" @@ -17380,6 +18899,13 @@ __metadata: languageName: node linkType: hard +"is-wsl@npm:^1.1.0": + version: 1.1.0 + resolution: "is-wsl@npm:1.1.0" + checksum: 10c0/7ad0012f21092d6f586c7faad84755a8ef0da9b9ec295e4dc82313cce4e1a93a3da3c217265016461f9b141503fe55fa6eb1fd5457d3f05e8d1bdbb48e50c13a + languageName: node + linkType: hard + "is-wsl@npm:^2.1.1, is-wsl@npm:^2.2.0": version: 2.2.0 resolution: "is-wsl@npm:2.2.0" @@ -17505,6 +19031,17 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-source-maps@npm:^4.0.0": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" + dependencies: + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + source-map: "npm:^0.6.1" + checksum: 10c0/19e4cc405016f2c906dff271a76715b3e881fa9faeb3f09a86cb99b8512b3a5ed19cadfe0b54c17ca0e54c1142c9c6de9330d65506e35873994e06634eebeb66 + languageName: node + linkType: hard + "istanbul-lib-source-maps@npm:^5.0.0": version: 5.0.6 resolution: "istanbul-lib-source-maps@npm:5.0.6" @@ -17586,6 +19123,17 @@ __metadata: languageName: node linkType: hard +"jest-changed-files@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-changed-files@npm:29.7.0" + dependencies: + execa: "npm:^5.0.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + checksum: 10c0/e071384d9e2f6bb462231ac53f29bff86f0e12394c1b49ccafbad225ce2ab7da226279a8a94f421949920bef9be7ef574fd86aee22e8adfa149be73554ab828b + languageName: node + linkType: hard + "jest-circus@npm:30.4.2": version: 30.4.2 resolution: "jest-circus@npm:30.4.2" @@ -17614,6 +19162,34 @@ __metadata: languageName: node linkType: hard +"jest-circus@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-circus@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + co: "npm:^4.6.0" + dedent: "npm:^1.0.0" + is-generator-fn: "npm:^2.0.0" + jest-each: "npm:^29.7.0" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + pure-rand: "npm:^6.0.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10c0/8d15344cf7a9f14e926f0deed64ed190c7a4fa1ed1acfcd81e4cc094d3cc5bf7902ebb7b874edc98ada4185688f90c91e1747e0dfd7ac12463b097968ae74b5e + languageName: node + linkType: hard + "jest-cli@npm:30.4.2": version: 30.4.2 resolution: "jest-cli@npm:30.4.2" @@ -17639,6 +19215,32 @@ __metadata: languageName: node linkType: hard +"jest-cli@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-cli@npm:29.7.0" + dependencies: + "@jest/core": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + create-jest: "npm:^29.7.0" + exit: "npm:^0.1.2" + import-local: "npm:^3.0.2" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + yargs: "npm:^17.3.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10c0/a658fd55050d4075d65c1066364595962ead7661711495cfa1dfeecf3d6d0a8ffec532f3dbd8afbb3e172dd5fd2fb2e813c5e10256e7cf2fea766314942fb43a + languageName: node + linkType: hard + "jest-config@npm:30.4.2": version: 30.4.2 resolution: "jest-config@npm:30.4.2" @@ -17681,6 +19283,44 @@ __metadata: languageName: node linkType: hard +"jest-config@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-config@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@jest/test-sequencer": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-jest: "npm:^29.7.0" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + deepmerge: "npm:^4.2.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-circus: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + parse-json: "npm:^5.2.0" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-json-comments: "npm:^3.1.1" + peerDependencies: + "@types/node": "*" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + ts-node: + optional: true + checksum: 10c0/bab23c2eda1fff06e0d104b00d6adfb1d1aabb7128441899c9bff2247bd26710b050a5364281ce8d52b46b499153bf7e3ee88b19831a8f3451f1477a0246a0f1 + languageName: node + linkType: hard + "jest-diff@npm:30.4.1": version: 30.4.1 resolution: "jest-diff@npm:30.4.1" @@ -17693,6 +19333,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + diff-sequences: "npm:^29.6.3" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/89a4a7f182590f56f526443dde69acefb1f2f0c9e59253c61d319569856c4931eae66b8a3790c443f529267a0ddba5ba80431c585deed81827032b2b2a1fc999 + languageName: node + linkType: hard + "jest-docblock@npm:30.4.0": version: 30.4.0 resolution: "jest-docblock@npm:30.4.0" @@ -17702,6 +19354,15 @@ __metadata: languageName: node linkType: hard +"jest-docblock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-docblock@npm:29.7.0" + dependencies: + detect-newline: "npm:^3.0.0" + checksum: 10c0/d932a8272345cf6b6142bb70a2bb63e0856cc0093f082821577ea5bdf4643916a98744dfc992189d2b1417c38a11fa42466f6111526bc1fb81366f56410f3be9 + languageName: node + linkType: hard + "jest-each@npm:30.4.1": version: 30.4.1 resolution: "jest-each@npm:30.4.1" @@ -17715,6 +19376,19 @@ __metadata: languageName: node linkType: hard +"jest-each@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-each@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + pretty-format: "npm:^29.7.0" + checksum: 10c0/f7f9a90ebee80cc688e825feceb2613627826ac41ea76a366fa58e669c3b2403d364c7c0a74d862d469b103c843154f8456d3b1c02b487509a12afa8b59edbb4 + languageName: node + linkType: hard + "jest-environment-jsdom@npm:^30.0.0": version: 30.4.1 resolution: "jest-environment-jsdom@npm:30.4.1" @@ -17808,6 +19482,16 @@ __metadata: languageName: node linkType: hard +"jest-leak-detector@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-leak-detector@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/71bb9f77fc489acb842a5c7be030f2b9acb18574dc9fb98b3100fc57d422b1abc55f08040884bd6e6dbf455047a62f7eaff12aa4058f7cbdc11558718ca6a395 + languageName: node + linkType: hard + "jest-matcher-utils@npm:30.4.1, jest-matcher-utils@npm:^30.0.5": version: 30.4.1 resolution: "jest-matcher-utils@npm:30.4.1" @@ -17820,6 +19504,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-matcher-utils@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/0d0e70b28fa5c7d4dce701dc1f46ae0922102aadc24ed45d594dd9b7ae0a8a6ef8b216718d1ab79e451291217e05d4d49a82666e1a3cc2b428b75cd9c933244e + languageName: node + linkType: hard + "jest-message-util@npm:30.4.1": version: 30.4.1 resolution: "jest-message-util@npm:30.4.1" @@ -17838,6 +19534,23 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-message-util@npm:29.7.0" + dependencies: + "@babel/code-frame": "npm:^7.12.13" + "@jest/types": "npm:^29.6.3" + "@types/stack-utils": "npm:^2.0.0" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10c0/850ae35477f59f3e6f27efac5215f706296e2104af39232bb14e5403e067992afb5c015e87a9243ec4d9df38525ef1ca663af9f2f4766aa116f127247008bd22 + languageName: node + linkType: hard + "jest-mock@npm:30.4.1, jest-mock@npm:^30.0.0": version: 30.4.1 resolution: "jest-mock@npm:30.4.1" @@ -17849,7 +19562,18 @@ __metadata: languageName: node linkType: hard -"jest-pnp-resolver@npm:^1.2.3": +"jest-mock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-mock@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-util: "npm:^29.7.0" + checksum: 10c0/7b9f8349ee87695a309fe15c46a74ab04c853369e5c40952d68061d9dc3159a0f0ed73e215f81b07ee97a9faaf10aebe5877a9d6255068a0977eae6a9ff1d5ac + languageName: node + linkType: hard + +"jest-pnp-resolver@npm:^1.2.2, jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" peerDependencies: @@ -17885,6 +19609,16 @@ __metadata: languageName: node linkType: hard +"jest-resolve-dependencies@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve-dependencies@npm:29.7.0" + dependencies: + jest-regex-util: "npm:^29.6.3" + jest-snapshot: "npm:^29.7.0" + checksum: 10c0/b6e9ad8ae5b6049474118ea6441dfddd385b6d1fc471db0136f7c8fbcfe97137a9665e4f837a9f49f15a29a1deb95a14439b7aec812f3f99d08f228464930f0d + languageName: node + linkType: hard + "jest-resolve@npm:30.4.1": version: 30.4.1 resolution: "jest-resolve@npm:30.4.1" @@ -17901,6 +19635,23 @@ __metadata: languageName: node linkType: hard +"jest-resolve@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-pnp-resolver: "npm:^1.2.2" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + resolve: "npm:^1.20.0" + resolve.exports: "npm:^2.0.0" + slash: "npm:^3.0.0" + checksum: 10c0/59da5c9c5b50563e959a45e09e2eace783d7f9ac0b5dcc6375dea4c0db938d2ebda97124c8161310082760e8ebbeff9f6b177c15ca2f57fb424f637a5d2adb47 + languageName: node + linkType: hard + "jest-runner@npm:30.4.2": version: 30.4.2 resolution: "jest-runner@npm:30.4.2" @@ -17931,6 +19682,35 @@ __metadata: languageName: node linkType: hard +"jest-runner@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runner@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/environment": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + graceful-fs: "npm:^4.2.9" + jest-docblock: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-leak-detector: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-resolve: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 10c0/2194b4531068d939f14c8d3274fe5938b77fa73126aedf9c09ec9dec57d13f22c72a3b5af01ac04f5c1cf2e28d0ac0b4a54212a61b05f10b5d6b47f2a1097bb4 + languageName: node + linkType: hard + "jest-runtime@npm:30.4.2": version: 30.4.2 resolution: "jest-runtime@npm:30.4.2" @@ -17961,6 +19741,36 @@ __metadata: languageName: node linkType: hard +"jest-runtime@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runtime@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/globals": "npm:^29.7.0" + "@jest/source-map": "npm:^29.6.3" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + cjs-module-lexer: "npm:^1.0.0" + collect-v8-coverage: "npm:^1.0.0" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-bom: "npm:^4.0.0" + checksum: 10c0/7cd89a1deda0bda7d0941835434e44f9d6b7bd50b5c5d9b0fc9a6c990b2d4d2cab59685ab3cb2850ed4cc37059f6de903af5a50565d7f7f1192a77d3fd6dd2a6 + languageName: node + linkType: hard + "jest-snapshot@npm:30.4.1": version: 30.4.1 resolution: "jest-snapshot@npm:30.4.1" @@ -17990,6 +19800,34 @@ __metadata: languageName: node linkType: hard +"jest-snapshot@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-snapshot@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@babel/generator": "npm:^7.7.2" + "@babel/plugin-syntax-jsx": "npm:^7.7.2" + "@babel/plugin-syntax-typescript": "npm:^7.7.2" + "@babel/types": "npm:^7.3.3" + "@jest/expect-utils": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + chalk: "npm:^4.0.0" + expect: "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + natural-compare: "npm:^1.4.0" + pretty-format: "npm:^29.7.0" + semver: "npm:^7.5.3" + checksum: 10c0/6e9003c94ec58172b4a62864a91c0146513207bedf4e0a06e1e2ac70a4484088a2683e3a0538d8ea913bcfd53dc54a9b98a98cdfa562e7fe1d1339aeae1da570 + languageName: node + linkType: hard + "jest-util@npm:30.4.1": version: 30.4.1 resolution: "jest-util@npm:30.4.1" @@ -18062,6 +19900,22 @@ __metadata: languageName: node linkType: hard +"jest-watcher@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-watcher@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + jest-util: "npm:^29.7.0" + string-length: "npm:^4.0.1" + checksum: 10c0/ec6c75030562fc8f8c727cb8f3b94e75d831fc718785abfc196e1f2a2ebc9a2e38744a15147170039628a853d77a3b695561ce850375ede3a4ee6037a2574567 + languageName: node + linkType: hard + "jest-worker@npm:30.4.1, jest-worker@npm:^30.0.5": version: 30.4.1 resolution: "jest-worker@npm:30.4.1" @@ -18098,6 +19952,25 @@ __metadata: languageName: node linkType: hard +"jest@npm:^29.6.3": + version: 29.7.0 + resolution: "jest@npm:29.7.0" + dependencies: + "@jest/core": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + import-local: "npm:^3.0.2" + jest-cli: "npm:^29.7.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10c0/f40eb8171cf147c617cc6ada49d062fbb03b4da666cb8d39cdbfb739a7d75eea4c3ca150fb072d0d273dce0c753db4d0467d54906ad0293f59c54f9db4a09d8b + languageName: node + linkType: hard + "jest@npm:^30.0.0": version: 30.4.2 resolution: "jest@npm:30.4.2" @@ -18135,7 +20008,7 @@ __metadata: languageName: node linkType: hard -"joi@npm:^17.9.2": +"joi@npm:^17.2.1, joi@npm:^17.9.2": version: 17.13.8 resolution: "joi@npm:17.13.8" dependencies: @@ -18495,7 +20368,7 @@ __metadata: languageName: node linkType: hard -"launch-editor@npm:^2.14.1": +"launch-editor@npm:^2.14.1, launch-editor@npm:^2.9.1": version: 2.14.1 resolution: "launch-editor@npm:2.14.1" dependencies: @@ -18704,6 +20577,16 @@ __metadata: languageName: node linkType: hard +"log-symbols@npm:^4.1.0": + version: 4.1.0 + resolution: "log-symbols@npm:4.1.0" + dependencies: + chalk: "npm:^4.1.0" + is-unicode-supported: "npm:^0.1.0" + checksum: 10c0/67f445a9ffa76db1989d0fa98586e5bc2fd5247260dafb8ad93d9f0ccd5896d53fb830b0e54dade5ad838b9de2006c826831a3c528913093af20dff8bd24aca6 + languageName: node + linkType: hard + "log-symbols@npm:^5.1.0": version: 5.1.0 resolution: "log-symbols@npm:5.1.0" @@ -18714,6 +20597,19 @@ __metadata: languageName: node linkType: hard +"logkitty@npm:^0.7.1": + version: 0.7.1 + resolution: "logkitty@npm:0.7.1" + dependencies: + ansi-fragments: "npm:^0.2.1" + dayjs: "npm:^1.8.15" + yargs: "npm:^15.1.0" + bin: + logkitty: bin/logkitty.js + checksum: 10c0/2067fad55c0856c0608c51ab75f8ffa5a858c5f847fefa8ec0e5fd3aa0b7d732010169d187283b23583a72aa6b80bbbec4fc6801a6c47c3fac0fbb294786002a + languageName: node + linkType: hard + "longest-streak@npm:^3.0.0": version: 3.1.0 resolution: "longest-streak@npm:3.1.0" @@ -20160,6 +22056,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:^2.4.1": + version: 2.6.0 + resolution: "mime@npm:2.6.0" + bin: + mime: cli.js + checksum: 10c0/a7f2589900d9c16e3bdf7672d16a6274df903da958c1643c9c45771f0478f3846dcb1097f31eb9178452570271361e2149310931ec705c037210fc69639c8e6c + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0" @@ -20625,6 +22530,13 @@ __metadata: languageName: node linkType: hard +"nocache@npm:^3.0.1": + version: 3.0.4 + resolution: "nocache@npm:3.0.4" + checksum: 10c0/66e5db1206bee44173358c2264ae9742259273e9719535077fe27807441bad58f0deeadf3cec2aa62d4f86ccb8a0e067c9a64b6329684ddc30a57e377ec458ee + languageName: node + linkType: hard + "nock@npm:13.3.1": version: 13.3.1 resolution: "nock@npm:13.3.1" @@ -20737,6 +22649,13 @@ __metadata: languageName: node linkType: hard +"node-stream-zip@npm:^1.9.1": + version: 1.16.0 + resolution: "node-stream-zip@npm:1.16.0" + checksum: 10c0/3e201e39b42b46c2e543bd11c71821aa675a98d6cb4901840bd952281696599c9c29c630cd00e8da9bee4b02879fee2d0194a225b359d48a3bf966bdd63d8648 + languageName: node + linkType: hard + "noms@npm:0.0.0": version: 0.0.0 resolution: "noms@npm:0.0.0" @@ -21250,6 +23169,15 @@ __metadata: languageName: node linkType: hard +"open@npm:^6.2.0": + version: 6.4.0 + resolution: "open@npm:6.4.0" + dependencies: + is-wsl: "npm:^1.1.0" + checksum: 10c0/447115632b4f3939fa0d973c33e17f28538fd268fd8257fc49763f7de6e76d29d65585b15998bbd2137337cfb70a92084a0e1b183a466e53a4829f704f295823 + languageName: node + linkType: hard + "open@npm:^7.0.3": version: 7.4.2 resolution: "open@npm:7.4.2" @@ -21294,6 +23222,23 @@ __metadata: languageName: node linkType: hard +"ora@npm:^5.4.1": + version: 5.4.1 + resolution: "ora@npm:5.4.1" + dependencies: + bl: "npm:^4.1.0" + chalk: "npm:^4.1.0" + cli-cursor: "npm:^3.1.0" + cli-spinners: "npm:^2.5.0" + is-interactive: "npm:^1.0.0" + is-unicode-supported: "npm:^0.1.0" + log-symbols: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + wcwidth: "npm:^1.0.1" + checksum: 10c0/10ff14aace236d0e2f044193362b22edce4784add08b779eccc8f8ef97195cae1248db8ec1ec5f5ff076f91acbe573f5f42a98c19b78dba8c54eefff983cae85 + languageName: node + linkType: hard + "ora@npm:^6.3.1": version: 6.3.1 resolution: "ora@npm:6.3.1" @@ -23255,6 +25200,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:2.8.8, prettier@npm:^2.7.1": + version: 2.8.8 + resolution: "prettier@npm:2.8.8" + bin: + prettier: bin-prettier.js + checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a + languageName: node + linkType: hard + "prettier@npm:3.9.5, prettier@npm:^3.0.0": version: 3.9.5 resolution: "prettier@npm:3.9.5" @@ -23264,15 +25218,6 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^2.7.1": - version: 2.8.8 - resolution: "prettier@npm:2.8.8" - bin: - prettier: bin-prettier.js - checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a - languageName: node - linkType: hard - "pretty-error@npm:^4.0.0": version: 4.0.0 resolution: "pretty-error@npm:4.0.0" @@ -23306,7 +25251,7 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.7.0": +"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" dependencies: @@ -23413,7 +25358,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.4.2": +"prompts@npm:^2.0.1, prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -23514,6 +25459,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.0.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10c0/1abe217897bf74dcb3a0c9aba3555fe975023147b48db540aa2faf507aee91c03bf54f6aef0eb2bf59cc259a16d06b28eca37f0dc426d94f4692aeff02fb0e65 + languageName: node + linkType: hard + "pure-rand@npm:^7.0.0": version: 7.0.1 resolution: "pure-rand@npm:7.0.1" @@ -23972,6 +25924,57 @@ __metadata: languageName: node linkType: hard +"react-native@npm:0.86.0": + version: 0.86.0 + resolution: "react-native@npm:0.86.0" + dependencies: + "@react-native/assets-registry": "npm:0.86.0" + "@react-native/codegen": "npm:0.86.0" + "@react-native/community-cli-plugin": "npm:0.86.0" + "@react-native/gradle-plugin": "npm:0.86.0" + "@react-native/js-polyfills": "npm:0.86.0" + "@react-native/normalize-colors": "npm:0.86.0" + "@react-native/virtualized-lists": "npm:0.86.0" + abort-controller: "npm:^3.0.0" + anser: "npm:^1.4.9" + ansi-regex: "npm:^5.0.0" + babel-plugin-syntax-hermes-parser: "npm:0.36.0" + base64-js: "npm:^1.5.1" + commander: "npm:^12.0.0" + flow-enums-runtime: "npm:^0.0.6" + hermes-compiler: "npm:250829098.0.14" + invariant: "npm:^2.2.4" + memoize-one: "npm:^5.0.0" + metro-runtime: "npm:^0.84.3" + metro-source-map: "npm:^0.84.3" + nullthrows: "npm:^1.1.1" + pretty-format: "npm:^29.7.0" + promise: "npm:^8.3.0" + react-devtools-core: "npm:^6.1.5" + react-refresh: "npm:^0.14.0" + regenerator-runtime: "npm:^0.13.2" + scheduler: "npm:0.27.0" + semver: "npm:^7.1.3" + stacktrace-parser: "npm:^0.1.10" + tinyglobby: "npm:^0.2.15" + whatwg-fetch: "npm:^3.0.0" + ws: "npm:^7.5.10" + yargs: "npm:^17.6.2" + peerDependencies: + "@react-native/jest-preset": 0.86.0 + "@types/react": ^19.1.1 + react: ^19.2.3 + peerDependenciesMeta: + "@react-native/jest-preset": + optional: true + "@types/react": + optional: true + bin: + react-native: cli.js + checksum: 10c0/47a569c47a22b37526ac54e17d1f42c6a191ca7cd30230bc16ee5307ac53da1db89d27ba20c76e9756fb646866d89fedfc1852b1ddc15c47bde45c899079096a + languageName: node + linkType: hard + "react-native@npm:0.86.2": version: 0.86.2 resolution: "react-native@npm:0.86.2" @@ -24617,6 +26620,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10c0/db91467d9ead311b4111cbd73a4e67fa7820daed2989a32f7023785a2659008c6d119752d9c4ac011ae07e537eb86523adff99804c5fdb39cd3a017f9b401bb6 + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -24675,6 +26685,13 @@ __metadata: languageName: node linkType: hard +"resolve.exports@npm:^2.0.0": + version: 2.0.3 + resolution: "resolve.exports@npm:2.0.3" + checksum: 10c0/1ade1493f4642a6267d0a5e68faeac20b3d220f18c28b140343feb83694d8fed7a286852aef43689d16042c61e2ddb270be6578ad4a13990769e12065191200d + languageName: node + linkType: hard + "resolve@npm:^1.1.6, resolve@npm:^1.10.0, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.11, resolve@npm:^1.22.4, resolve@npm:^1.22.8": version: 1.22.11 resolution: "resolve@npm:1.22.11" @@ -24742,6 +26759,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "restore-cursor@npm:3.1.0" + dependencies: + onetime: "npm:^5.1.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/8051a371d6aa67ff21625fa94e2357bd81ffdc96267f3fb0fc4aaf4534028343836548ef34c240ffa8c25b280ca35eb36be00b3cb2133fa4f51896d7e73c6b4f + languageName: node + linkType: hard + "restore-cursor@npm:^4.0.0": version: 4.0.0 resolution: "restore-cursor@npm:4.0.0" @@ -24784,6 +26811,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:^3.0.2": + version: 3.0.2 + resolution: "rimraf@npm:3.0.2" + dependencies: + glob: "npm:^7.1.3" + bin: + rimraf: bin.js + checksum: 10c0/9cb7757acb489bd83757ba1a274ab545eafd75598a9d817e0c3f8b164238dd90eba50d6b848bd4dcc5f3040912e882dc7ba71653e35af660d77b25c381d402e8 + languageName: node + linkType: hard + "rimraf@npm:^6.0.0": version: 6.1.3 resolution: "rimraf@npm:6.1.3" @@ -25348,7 +27386,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4, semver@npm:^7.8.0, semver@npm:^7.8.4": +"semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4, semver@npm:^7.8.0, semver@npm:^7.8.4": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -25399,6 +27437,27 @@ __metadata: languageName: node linkType: hard +"send@npm:~0.19.1": + version: 0.19.2 + resolution: "send@npm:0.19.2" + dependencies: + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:1.2.0" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + fresh: "npm:~0.5.2" + http-errors: "npm:~2.0.1" + mime: "npm:1.6.0" + ms: "npm:2.1.3" + on-finished: "npm:~2.4.1" + range-parser: "npm:~1.2.1" + statuses: "npm:~2.0.2" + checksum: 10c0/20c2389fe0fdf3fc499938cac598bc32272287e993c4960717381a10de8550028feadfb9076f959a3a3ebdea42e1f690e116f0d16468fa56b9fd41866d3dc267 + languageName: node + linkType: hard + "serialize-error@npm:^2.1.0": version: 2.1.0 resolution: "serialize-error@npm:2.1.0" @@ -25443,6 +27502,18 @@ __metadata: languageName: node linkType: hard +"serve-static@npm:^1.13.1": + version: 1.16.3 + resolution: "serve-static@npm:1.16.3" + dependencies: + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + parseurl: "npm:~1.3.3" + send: "npm:~0.19.1" + checksum: 10c0/36320397a073c71bedf58af48a4a100fe6d93f07459af4d6f08b9a7217c04ce2a4939e0effd842dc7bece93ffcd59eb52f58c4fff2a8e002dc29ae6b219cd42b + languageName: node + linkType: hard + "serve-static@npm:^1.16.2, serve-static@npm:~1.16.2": version: 1.16.2 resolution: "serve-static@npm:1.16.2" @@ -25476,6 +27547,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10c0/9f8c1b2d800800d0b589de1477c753492de5c1548d4ade52f57f1d1f5e04af5481554d75ce5e5c43d4004b80a3eb714398d6907027dc0534177b7539119f4454 + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -25819,6 +27897,17 @@ __metadata: languageName: node linkType: hard +"slice-ansi@npm:^2.0.0": + version: 2.1.0 + resolution: "slice-ansi@npm:2.1.0" + dependencies: + ansi-styles: "npm:^3.2.0" + astral-regex: "npm:^1.0.0" + is-fullwidth-code-point: "npm:^2.0.0" + checksum: 10c0/c317b21ec9e3d3968f3d5b548cbfc2eae331f58a03f1352621020799cbe695b3611ee972726f8f32d4ca530065a5ec9c74c97fde711c1f41b4a1585876b2c191 + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -26090,7 +28179,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -26226,7 +28315,7 @@ __metadata: languageName: node linkType: hard -"string-length@npm:^4.0.2": +"string-length@npm:^4.0.1, string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2" dependencies: @@ -26236,6 +28325,13 @@ __metadata: languageName: node linkType: hard +"string-natural-compare@npm:^3.0.1": + version: 3.0.1 + resolution: "string-natural-compare@npm:3.0.1" + checksum: 10c0/85a6a9195736be500af5d817c7ea36b7e1ac278af079a807f70f79a56602359ee6743ca409af6291b94557de550ff60d1ec31b3c4fc8e7a08d0e12cdab57c149 + languageName: node + linkType: hard + "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.2, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" @@ -26403,6 +28499,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^5.0.0": + version: 5.2.0 + resolution: "strip-ansi@npm:5.2.0" + dependencies: + ansi-regex: "npm:^4.1.0" + checksum: 10c0/de4658c8a097ce3b15955bc6008f67c0790f85748bdc025b7bc8c52c7aee94bc4f9e50624516150ed173c3db72d851826cd57e7a85fe4e4bb6dbbebd5d297fdf + languageName: node + linkType: hard + "strip-ansi@npm:^7.0.1": version: 7.1.2 resolution: "strip-ansi@npm:7.1.2" @@ -26496,6 +28601,13 @@ __metadata: languageName: node linkType: hard +"strnum@npm:^1.0.5": + version: 1.1.2 + resolution: "strnum@npm:1.1.2" + checksum: 10c0/a0fce2498fa3c64ce64a40dada41beb91cabe3caefa910e467dc0518ef2ebd7e4d10f8c2202a6104f1410254cae245066c0e94e2521fb4061a5cb41831952392 + languageName: node + linkType: hard + "style-to-object@npm:^0.4.0": version: 0.4.4 resolution: "style-to-object@npm:0.4.4" @@ -27431,6 +29543,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.8.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A4.9.0-dev.20220903#optional!builtin": version: 4.9.0-dev.20220903 resolution: "typescript@patch:typescript@npm%3A4.9.0-dev.20220903#optional!builtin::version=4.9.0-dev.20220903&hash=1a91c8" @@ -27452,6 +29574,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.19.3 resolution: "uglify-js@npm:3.19.3" @@ -28524,6 +30656,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10c0/087038e7992649eaffa6c7a4f3158d5b53b14cf5b6c1f0e043dccfacb1ba179d12f17545d5b85ebd94a42ce280a6fe65d0cbcab70f4fc6daad1dfae85e0e6a3e + languageName: node + linkType: hard + "which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19, which-typed-array@npm:^1.1.2": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" @@ -28710,6 +30849,15 @@ __metadata: languageName: node linkType: hard +"ws@npm:^6.2.3": + version: 6.2.6 + resolution: "ws@npm:6.2.6" + dependencies: + async-limiter: "npm:~1.0.0" + checksum: 10c0/24245efc3c09b6175df32ddb4a06f85432c4c88abb0e75f43f2551c1d5de456218b1b6c7685c414b8e03a68a167488e4aadf04a895681c8e01502025ce505742 + languageName: node + linkType: hard + "ws@npm:^7, ws@npm:^7.3.1, ws@npm:^7.5.10": version: 7.5.13 resolution: "ws@npm:7.5.13" @@ -28788,6 +30936,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10c0/308a2efd7cc296ab2c0f3b9284fd4827be01cfeb647b3ba18230e3a416eb1bc887ac050de9f8c4fd9e7856b2e8246e05d190b53c96c5ad8d8cb56dffb6f81024 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.5 resolution: "y18n@npm:5.0.5" @@ -28823,6 +30978,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.2.1": + version: 2.9.1 + resolution: "yaml@npm:2.9.1" + bin: + yaml: bin.mjs + checksum: 10c0/9d06676ef02b89559a8b10a71882bd2cdae81e8393dc8dbc871615c3259475d9609ad973d19cb3c9e71e72d6206e3d1e7aa241c7e55f3d16fd2e77db305827d6 + languageName: node + linkType: hard + "yaml@npm:^2.6.1": version: 2.9.0 resolution: "yaml@npm:2.9.0" @@ -28832,6 +30996,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^18.1.2": + version: 18.1.3 + resolution: "yargs-parser@npm:18.1.3" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10c0/25df918833592a83f52e7e4f91ba7d7bfaa2b891ebf7fe901923c2ee797534f23a176913ff6ff7ebbc1cc1725a044cc6a6539fed8bfd4e13b5b16376875f9499 + languageName: node + linkType: hard + "yargs-parser@npm:^20.2.2": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" @@ -28846,6 +31020,25 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^15.1.0": + version: 15.4.1 + resolution: "yargs@npm:15.4.1" + dependencies: + cliui: "npm:^6.0.0" + decamelize: "npm:^1.2.0" + find-up: "npm:^4.1.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^4.2.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^18.1.2" + checksum: 10c0/f1ca680c974333a5822732825cca7e95306c5a1e7750eb7b973ce6dc4f97a6b0a8837203c8b194f461969bfe1fb1176d1d423036635285f6010b392fa498ab2d + languageName: node + linkType: hard + "yargs@npm:^16.1.0": version: 16.2.0 resolution: "yargs@npm:16.2.0" @@ -28861,6 +31054,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^17.3.1": + version: 17.7.3 + resolution: "yargs@npm:17.7.3" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10c0/7a28572f7e785a57886e34fdbddb9b28756dec552e1453d5f6e7cdd00ad8721a4e8c4321d33683f5e61cacb36ad43258adbb48396b71ec4ed14abee0fc0d0c1f + languageName: node + linkType: hard + "yargs@npm:^17.6.2, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" @@ -28935,6 +31143,22 @@ __metadata: languageName: node linkType: hard +"zod-validation-error@npm:^3.5.0 || ^4.0.0": + version: 4.0.2 + resolution: "zod-validation-error@npm:4.0.2" + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + checksum: 10c0/0ccfec48c46de1be440b719cd02044d4abb89ed0e14c13e637cd55bf29102f67ccdba373f25def0fc7130e5f15025be4d557a7edcc95d5a3811599aade689e1b + languageName: node + linkType: hard + +"zod@npm:^3.25.0 || ^4.0.0": + version: 4.6.5 + resolution: "zod@npm:4.6.5" + checksum: 10c0/32eb6c5e98becbe045675b2141c3611451c16ba567f9779231d79af41c4d651ca1c22f154aef7e6c6e77b929f23e43f1154ca05b82e119e922353725d6776af5 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4" From 194e02f8f0e8185f7ff440bb64fdf6fc5da762da Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 16:29:30 +0000 Subject: [PATCH 02/32] fix: Collect release Android GC reports without run-as Write the post-run report to app-specific external storage and a Downloads mirror so adb pull works on non-debuggable release devices. Co-authored-by: Nathaniel Tucker --- examples/benchmark-native/README.md | 2 + .../__tests__/collect-report-script.test.ts | 28 ++++++ .../benchmarknative/BenchNativeModule.kt | 70 +++++++++++++- .../scripts/collect-report.sh | 93 ++++++++++++++++++- examples/benchmark-native/src/BenchNative.ts | 2 +- 5 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 examples/benchmark-native/__tests__/collect-report-script.test.ts diff --git a/examples/benchmark-native/README.md b/examples/benchmark-native/README.md index d8745358bc50..dbf3fc3ffa2b 100644 --- a/examples/benchmark-native/README.md +++ b/examples/benchmark-native/README.md @@ -50,6 +50,8 @@ This writes: ## Host collection +The app writes `gc-report.json` to **app-specific external storage** (`Android/data//files/`, `Context.getExternalFilesDir`) so a non-debuggable release APK does not depend on `run-as` (that only works when `android:debuggable` is true). On Android 10+ user builds the shell often cannot read `/Android/data`, so the app also mirrors `dataclient-gc-report.json` to public Downloads. `scripts/collect-report.sh` `adb pull`s those paths (Downloads first, then `externalFilesDir`, then the `REPORT_READY` logcat path). Do not set `release { debuggable true }` — this APK is already debug-signed, and that would soften release-Hermes measurement semantics. + ```bash # exactly one device, or: # ANDROID_SERIAL=… CANDIDATE_KIND=entity … diff --git a/examples/benchmark-native/__tests__/collect-report-script.test.ts b/examples/benchmark-native/__tests__/collect-report-script.test.ts new file mode 100644 index 000000000000..f273c7002e81 --- /dev/null +++ b/examples/benchmark-native/__tests__/collect-report-script.test.ts @@ -0,0 +1,28 @@ +/** + * Host collect must pull an adb-visible path. Release is not debuggable, so + * run-as cannot read private filesDir on typical physical devices. + */ +const fs = require('fs'); +const path = require('path'); + +const script = fs.readFileSync( + path.join(__dirname, '../scripts/collect-report.sh'), + 'utf8', +); + +describe('collect-report.sh report I/O', () => { + it('does not use run-as to read or wait on the report', () => { + expect(script).not.toMatch(/run-as/); + }); + + it('pulls app-specific external storage and the Downloads mirror', () => { + expect(script).toMatch(/Android\/data\/\$\{APP_ID\}\/files/); + expect(script).toMatch(/Download\/\$\{PUBLIC_REPORT_NAME\}/); + expect(script).toMatch(/pull_device_report/); + expect(script).toMatch(/pull "\$\{src\}"/); + }); + + it('fails clearly if wait passes but pull cannot read the file', () => { + expect(script).toMatch(/REPORT_READY seen but adb could not pull/); + }); +}); diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt index b812926ecf72..aa6bffda8f9a 100644 --- a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt @@ -1,12 +1,15 @@ package com.dataclient.benchmarknative import android.app.ActivityManager +import android.content.ContentValues import android.content.Context import android.os.Build import android.os.Bundle import android.os.Debug +import android.os.Environment import android.os.Handler import android.os.Looper +import android.provider.MediaStore import android.util.Log import android.view.Choreographer import android.view.FrameMetrics @@ -236,17 +239,77 @@ class BenchNativeModule( @ReactMethod fun writeReport(json: String, promise: Promise) { try { - val file = File(reactContext.filesDir, REPORT_FILE) - file.writeText(json) - Log.i(LOG_TAG, "REPORT_READY path=${file.absolutePath} bytes=${json.length}") + val file = writeAppExternalReport(json) + // Q+: /Android/data is often hidden from adb shell on user builds of a + // non-debuggable release APK. Mirror to Downloads so collect can pull. + val pullFile = writePublicPullCopy(json) ?: file + Log.i( + LOG_TAG, + "REPORT_READY path=${file.absolutePath} pull=${pullFile.absolutePath} bytes=${json.length}", + ) val map = Arguments.createMap() map.putString("path", file.absolutePath) + map.putString("pullPath", pullFile.absolutePath) promise.resolve(map) } catch (e: Exception) { promise.reject("WRITE_REPORT", e) } } + /** App-specific external storage — no extra permission; preferred report file. */ + private fun writeAppExternalReport(json: String): File { + val dir = + reactContext.getExternalFilesDir(null) + ?: throw IllegalStateException( + "app-specific external storage unavailable; cannot write adb-pullable report", + ) + if (!dir.exists() && !dir.mkdirs()) { + throw IllegalStateException("failed to create report dir ${dir.absolutePath}") + } + val file = File(dir, REPORT_FILE) + file.writeText(json) + return file + } + + /** + * Public Downloads copy for `adb pull` on Android 10+ user builds. Pre-Q, + * `externalFilesDir` is already shell-visible so no extra copy is needed. + */ + private fun writePublicPullCopy(json: String): File? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + val resolver = reactContext.contentResolver + resolver.delete( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + "${MediaStore.MediaColumns.DISPLAY_NAME}=?", + arrayOf(PUBLIC_REPORT_FILE), + ) + val values = + ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, PUBLIC_REPORT_FILE) + put(MediaStore.Downloads.MIME_TYPE, "application/json") + put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/") + put(MediaStore.Downloads.IS_PENDING, 1) + } + val uri = + resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: throw IllegalStateException("MediaStore insert failed for $PUBLIC_REPORT_FILE") + try { + val out = + resolver.openOutputStream(uri, "w") + ?: throw IllegalStateException("no output stream for $uri") + out.use { it.write(json.toByteArray()) } + val done = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) } + resolver.update(uri, done, null, null) + } catch (e: Exception) { + resolver.delete(uri, null, null) + throw e + } + return File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + PUBLIC_REPORT_FILE, + ) + } + private fun stopCaptureInternal(idempotent: Boolean): WritableMap { if (capturing) { val listener = frameMetricsListener @@ -380,6 +443,7 @@ class BenchNativeModule( const val NAME = "BenchNative" const val LOG_TAG = "BenchNative" const val REPORT_FILE = "gc-report.json" + const val PUBLIC_REPORT_FILE = "dataclient-gc-report.json" const val BUILD_MANIFEST_ASSET = "build-manifest.json" } } diff --git a/examples/benchmark-native/scripts/collect-report.sh b/examples/benchmark-native/scripts/collect-report.sh index 7c182641697a..7d3df5afa43a 100644 --- a/examples/benchmark-native/scripts/collect-report.sh +++ b/examples/benchmark-native/scripts/collect-report.sh @@ -4,6 +4,10 @@ # Requires exactly one adb device unless ANDROID_SERIAL is set and valid. # Split APKs unsupported. Sidecar (artifacts/build-sidecar.json) is authority — # never the live checkout. Intent label is optional; commit is not authority. +# +# Report I/O: the app writes app-specific external storage (externalFilesDir) +# and, on API 29+, a Downloads mirror. Collect uses adb pull / exec-out cat — +# never run-as (release is not debuggable). set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -79,6 +83,82 @@ fi ADB=(adb -s "${SERIAL}") echo "Using device ${SERIAL}" +# adb-visible report paths. Release APKs are not debuggable, so run-as cannot +# read private filesDir. Prefer the Downloads mirror on Android 10+ user builds +# (shell often cannot read /Android/data//). +REPORT_NAME="gc-report.json" +PUBLIC_REPORT_NAME="dataclient-gc-report.json" +DEVICE_REPORT_APP="/sdcard/Android/data/${APP_ID}/files/${REPORT_NAME}" +DEVICE_REPORT_APP_EMU="/storage/emulated/0/Android/data/${APP_ID}/files/${REPORT_NAME}" +DEVICE_REPORT_PUBLIC="/sdcard/Download/${PUBLIC_REPORT_NAME}" +DEVICE_REPORT_PUBLIC_EMU="/storage/emulated/0/Download/${PUBLIC_REPORT_NAME}" + +clear_device_reports() { + "${ADB[@]}" shell "rm -f \ + '${DEVICE_REPORT_APP}' \ + '${DEVICE_REPORT_APP_EMU}' \ + '${DEVICE_REPORT_PUBLIC}' \ + '${DEVICE_REPORT_PUBLIC_EMU}'" >/dev/null 2>&1 || true +} + +device_report_ready() { + local p + for p in \ + "${DEVICE_REPORT_PUBLIC}" \ + "${DEVICE_REPORT_PUBLIC_EMU}" \ + "${DEVICE_REPORT_APP}" \ + "${DEVICE_REPORT_APP_EMU}"; do + if "${ADB[@]}" shell "test -s '${p}'" >/dev/null 2>&1; then + return 0 + fi + done + return 1 +} + +extract_logcat_report_path() { + local line + line="$("${ADB[@]}" logcat -d -s BenchNative:I | tr -d '\r' | grep 'REPORT_READY' | tail -n 1 || true)" + if [[ -z "${line}" ]]; then + return 1 + fi + if [[ "${line}" =~ pull=([^[:space:]]+) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + if [[ "${line}" =~ path=([^[:space:]]+) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} + +pull_device_report() { + local candidates=() + local from_log src + from_log="$(extract_logcat_report_path || true)" + if [[ -n "${from_log}" ]]; then + candidates+=("${from_log}") + fi + candidates+=( + "${DEVICE_REPORT_PUBLIC}" + "${DEVICE_REPORT_PUBLIC_EMU}" + "${DEVICE_REPORT_APP}" + "${DEVICE_REPORT_APP_EMU}" + ) + for src in "${candidates[@]}"; do + rm -f "${OUT}" + if "${ADB[@]}" pull "${src}" "${OUT}" >/dev/null 2>&1 && [[ -s "${OUT}" ]]; then + echo "Pulled ${src} → ${OUT}" + return 0 + fi + if "${ADB[@]}" exec-out cat "${src}" > "${OUT}" 2>/dev/null && [[ -s "${OUT}" ]]; then + echo "Read ${src} → ${OUT}" + return 0 + fi + done + return 1 +} + # --- provenance: local APK must match sidecar (authority) --- node -e ' const fs=require("fs"); @@ -138,8 +218,8 @@ if [[ "${INSTALLED_SHA}" != "${SIDECAR_APK_SHA}" ]]; then fi echo "installedApkSha256=${INSTALLED_SHA}" -# Clear previous report -"${ADB[@]}" shell "run-as ${APP_ID} rm -f files/gc-report.json" 2>/dev/null || true +# Clear previous report (externalFilesDir + Downloads mirror; no run-as) +clear_device_reports echo "Starting ${ACTIVITY} with axes ${CANDIDATE_KIND}/${PATTERN}/${COUNT}/interaction/${CONTROL}…" "${ADB[@]}" logcat -c || true @@ -167,7 +247,7 @@ while (( SECONDS < deadline )); do found=1 break fi - if "${ADB[@]}" shell "run-as ${APP_ID} ls files/gc-report.json" >/dev/null 2>&1; then + if device_report_ready; then found=1 break fi @@ -180,7 +260,12 @@ if [[ "${found}" != "1" ]]; then exit 1 fi -"${ADB[@]}" shell "run-as ${APP_ID} cat files/gc-report.json" > "${OUT}" +if ! pull_device_report; then + echo "error: REPORT_READY seen but adb could not pull the report" >&2 + echo "tried Downloads mirror and Android/data/${APP_ID}/files/${REPORT_NAME}" >&2 + "${ADB[@]}" logcat -d -s BenchNative:I | grep 'REPORT_READY' | tail -n 5 >&2 || true + exit 1 +fi echo "Wrote ${OUT}" # Verify embedded buildId matches sidecar; attach sidecar provenance + installed hash. diff --git a/examples/benchmark-native/src/BenchNative.ts b/examples/benchmark-native/src/BenchNative.ts index 9923ec8471c9..bb0737da4b3a 100644 --- a/examples/benchmark-native/src/BenchNative.ts +++ b/examples/benchmark-native/src/BenchNative.ts @@ -51,7 +51,7 @@ interface BenchNativeNativeModule { getMemorySnapshot(): Promise; startUiFrameCapture(): Promise<{ started: boolean; source: string }>; stopUiFrameCapture(): Promise; - writeReport(json: string): Promise<{ path: string }>; + writeReport(json: string): Promise<{ path: string; pullPath?: string }>; } const LINKING_ERROR = `BenchNative native module is not linked. Rebuild the Android app.`; From aa4891e18ec5524f62edfe63804f6b928ca5dd26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 16:31:09 +0000 Subject: [PATCH 03/32] fix: Ignore comment mentions of run-as in collect-script test Co-authored-by: Nathaniel Tucker --- .../__tests__/collect-report-script.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/benchmark-native/__tests__/collect-report-script.test.ts b/examples/benchmark-native/__tests__/collect-report-script.test.ts index f273c7002e81..50846d7c15ff 100644 --- a/examples/benchmark-native/__tests__/collect-report-script.test.ts +++ b/examples/benchmark-native/__tests__/collect-report-script.test.ts @@ -3,16 +3,16 @@ * run-as cannot read private filesDir on typical physical devices. */ const fs = require('fs'); -const path = require('path'); -const script = fs.readFileSync( - path.join(__dirname, '../scripts/collect-report.sh'), - 'utf8', -); +const script = fs.readFileSync('scripts/collect-report.sh', 'utf8'); +const executable = script + .split('\n') + .filter((line: string) => !/^\s*#/.test(line)) + .join('\n'); describe('collect-report.sh report I/O', () => { it('does not use run-as to read or wait on the report', () => { - expect(script).not.toMatch(/run-as/); + expect(executable).not.toMatch(/run-as/); }); it('pulls app-specific external storage and the Downloads mirror', () => { From 58c04ca7b49d2243e87f6917d50f07a32dff726e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 16:39:24 +0000 Subject: [PATCH 04/32] fix: Refuse stale Android GC reports after collect clear Verify Downloads and externalFilesDir copies are gone before start so a leftover file cannot complete wait for the wrong scenario. Co-authored-by: Nathaniel Tucker --- examples/benchmark-native/README.md | 2 +- .../__tests__/collect-report-script.test.ts | 8 ++++++++ .../benchmark-native/scripts/collect-report.sh | 15 ++++++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/examples/benchmark-native/README.md b/examples/benchmark-native/README.md index dbf3fc3ffa2b..8911cf52ce57 100644 --- a/examples/benchmark-native/README.md +++ b/examples/benchmark-native/README.md @@ -50,7 +50,7 @@ This writes: ## Host collection -The app writes `gc-report.json` to **app-specific external storage** (`Android/data//files/`, `Context.getExternalFilesDir`) so a non-debuggable release APK does not depend on `run-as` (that only works when `android:debuggable` is true). On Android 10+ user builds the shell often cannot read `/Android/data`, so the app also mirrors `dataclient-gc-report.json` to public Downloads. `scripts/collect-report.sh` `adb pull`s those paths (Downloads first, then `externalFilesDir`, then the `REPORT_READY` logcat path). Do not set `release { debuggable true }` — this APK is already debug-signed, and that would soften release-Hermes measurement semantics. +The app writes `gc-report.json` to **app-specific external storage** (`Android/data//files/`, `Context.getExternalFilesDir`) so a non-debuggable release APK does not depend on `run-as` (that only works when `android:debuggable` is true). On Android 10+ user builds the shell often cannot read `/Android/data`, so the app also mirrors `dataclient-gc-report.json` to public Downloads. `scripts/collect-report.sh` deletes both copies (including the MediaStore Downloads row) and refuses to start if a leftover remains, then `adb pull`s those paths (Downloads first, then `externalFilesDir`, then the `REPORT_READY` logcat path). Do not set `release { debuggable true }` — this APK is already debug-signed, and that would soften release-Hermes measurement semantics. ```bash # exactly one device, or: diff --git a/examples/benchmark-native/__tests__/collect-report-script.test.ts b/examples/benchmark-native/__tests__/collect-report-script.test.ts index 50846d7c15ff..618efbdbadd6 100644 --- a/examples/benchmark-native/__tests__/collect-report-script.test.ts +++ b/examples/benchmark-native/__tests__/collect-report-script.test.ts @@ -25,4 +25,12 @@ describe('collect-report.sh report I/O', () => { it('fails clearly if wait passes but pull cannot read the file', () => { expect(script).toMatch(/REPORT_READY seen but adb could not pull/); }); + + it('refuses to collect if a leftover report survives clear', () => { + expect(script).toMatch(/assert_device_reports_absent/); + expect(script).toMatch(/leftover report still on device after clear/); + expect(executable).toMatch( + /content delete --uri content:\/\/media\/external\/downloads/, + ); + }); }); diff --git a/examples/benchmark-native/scripts/collect-report.sh b/examples/benchmark-native/scripts/collect-report.sh index 7d3df5afa43a..0ec36c3e852d 100644 --- a/examples/benchmark-native/scripts/collect-report.sh +++ b/examples/benchmark-native/scripts/collect-report.sh @@ -99,6 +99,8 @@ clear_device_reports() { '${DEVICE_REPORT_APP_EMU}' \ '${DEVICE_REPORT_PUBLIC}' \ '${DEVICE_REPORT_PUBLIC_EMU}'" >/dev/null 2>&1 || true + # Downloads mirror is MediaStore-owned; filesystem rm can leave the row. + "${ADB[@]}" shell "content delete --uri content://media/external/downloads --where \"_display_name='${PUBLIC_REPORT_NAME}'\"" >/dev/null 2>&1 || true } device_report_ready() { @@ -115,6 +117,15 @@ device_report_ready() { return 1 } +# Fail closed: a leftover Downloads/externalFilesDir file must not count as this run. +assert_device_reports_absent() { + if device_report_ready; then + echo "error: leftover report still on device after clear (refusing to collect a stale file)" >&2 + echo "remove ${DEVICE_REPORT_PUBLIC} and ${DEVICE_REPORT_APP} then retry" >&2 + exit 1 + fi +} + extract_logcat_report_path() { local line line="$("${ADB[@]}" logcat -d -s BenchNative:I | tr -d '\r' | grep 'REPORT_READY' | tail -n 1 || true)" @@ -218,8 +229,10 @@ if [[ "${INSTALLED_SHA}" != "${SIDECAR_APK_SHA}" ]]; then fi echo "installedApkSha256=${INSTALLED_SHA}" -# Clear previous report (externalFilesDir + Downloads mirror; no run-as) +# Clear previous report (externalFilesDir + Downloads mirror; no run-as). +# Verify gone so a leftover cannot complete wait for the wrong scenario. clear_device_reports +assert_device_reports_absent echo "Starting ${ACTIVITY} with axes ${CANDIDATE_KIND}/${PATTERN}/${COUNT}/interaction/${CONTROL}…" "${ADB[@]}" logcat -c || true From 21ac5266849ab8e331449b8d8865ff045d3ea97b Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Fri, 2 Oct 2026 20:13:32 -0400 Subject: [PATCH 05/32] docs: Add a non-binding GC design handoff Record goals, verified evidence, and open decisions so the next agent can choose a GC design without inheriting the previous agent's recommendations. Co-authored-by: Cursor --- plans/gc-handoff.md | 344 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 plans/gc-handoff.md diff --git a/plans/gc-handoff.md b/plans/gc-handoff.md new file mode 100644 index 000000000000..37c4ac221d73 --- /dev/null +++ b/plans/gc-handoff.md @@ -0,0 +1,344 @@ +# Garbage collection handoff + +Status: handoff for a new agent. Design is **not** decided. +PR: https://github.com/reactive/data-client/pull/4034 +Branch: `demo/gc-benchmark-harnesses` + +The next agent should reach its own conclusion about the best GC design. +Recommendations in this file, in [garbage-collection.md](./garbage-collection.md), +and in the prior chat are **hypotheses and evidence**, not instructions. +`plans/garbage-collection.md` sections titled Cooperative collection model, +Direction, and Evaluation of scheduler alternatives are a prior agent's draft. +Do not implement them because they are written down. + +## 1. User intent and goals + +The user wants a GC policy for `@data-client` that can trade interaction +latency, bundle size, and retained memory differently on web and React Native. + +Stated priority order: + +1. **Interaction latency.** No dropped frames during critical updates. A + continuous 60 FPS experience on mid-range hardware, including while + collection runs. [GOALS.md](../GOALS.md) separately ranks networking as + the most expensive cost, then 60 FPS, then bundle size. For this GC work + the user put interaction first, and treated extra refetches as a reason + not to collect more aggressively just to save memory. +2. **Web:** bundle size, then retained memory. +3. **React Native:** retained memory, then bundle size. Bundle size barely + matters on RN. Memory matters much more than on web. +4. The two platforms should be able to choose different points on that + tradeoff. Endpoint and schema definitions should stay shared. +5. More aggressive deletion can cause refetches. Lower memory is not + automatically a win. + +Constraints the user stated while reviewing options. These are preferences to +weigh, not a finished architecture: + +- React's built-in reducer is intentional. It exists to support concurrent + rendering in React 19 and later. Do not replace it with an external store + merely to make GC easier. +- Reference counts and other GC bookkeeping should stay **outside** rendered + React state. State that drives rendering should not update on every + mount/unmount. +- If GC does not change anything a mounted consumer renders, it should not by + itself be an operation that triggers a React render. +- The user asked whether attaching GC work to a later reducer update would slow + that update, and whether an update is unchanged when nothing is reclaimable. + Those questions are open design constraints, not approvals of a piggyback + design. + +The user asked the previous agent to measure current behavior before changing +defaults, including an on-device React Native benchmark app. That measurement +harness is this PR. It is not a decision to ship cooperative slicing, calendar +queues, or any other collector. + +## 2. Verified information + +### Current production GC + +Verified by reading current source on this branch (production GC is unchanged +by the PR): + +- [packages/core/src/state/GCPolicy.ts](../packages/core/src/state/GCPolicy.ts) + reference-counts endpoint keys and entity paths. Last release queues the + candidate. Endpoint keys use a `Set`. Entity paths use an array, so the + same entity can be queued more than once. A `setInterval` (default 5 + minutes) calls `idleCallback`, which runs `runSweep` via + `requestIdleCallback` or **synchronously** if that API is missing. The + sweep scans the whole queue and dispatches **one** `GC` + action. Eligibility is unreferenced plus + `fetchedAt + max((expiresAt - fetchedAt) * expiryMultiplier, 120_000)`, + default multiplier `2`. +- [packages/core/src/state/reducer/createReducer.ts](../packages/core/src/state/reducer/createReducer.ts) + `case GC` deletes `entities`, `entitiesMeta`, `endpoints`, and `meta` + **in place** and returns the **same state object**. It does not touch + `indexes` or `optimistic`. +- React read hooks install `countRef` in `useEffect`, for example + [packages/react/src/hooks/useSuspense.ts](../packages/react/src/hooks/useSuspense.ts). + There is a render-to-passive-effect window with no reference. + `useSubscription` does not call `countRef`. A subscription alone does not + retain cache data unless a read hook does. +- React `DataProvider` defaults to `GCPolicy`. Vue and a bare `Controller` + default to `ImmortalGCPolicy` unless a policy is passed. `Controller` + construction does not call `gcPolicy.init()`; providers do that through + `initManager`. +- [packages/react/src/state/GCPolicy.native.ts](../packages/react/src/state/GCPolicy.native.ts) + still schedules with `InteractionManager`. In the repo's React Native 0.86 + install, `Libraries/Interaction/InteractionManager.js` implements + `runAfterInteractions` with `setImmediate` and `setDeadline` as a no-op, + and the module is deprecated. RN's own deprecation text says to use + `requestIdleCallback`. The user corrected an earlier claim: RIC is the + supported migration path, but it is a cooperative hint. Once a callback + starts, RN cannot preempt it. +- Fetch expiry (`dataExpiryLength`, invalidation, `expireAll`) is separate + from physical deletion. + +### React commit timing + +Verified by reading: + +- [packages/react/src/components/DataStore.tsx](../packages/react/src/components/DataStore.tsx) + keeps cache state in `useEnhancedReducer` (`useReducer` plus middleware). +- [packages/use-enhanced-reducer/src/usePromisifiedDispatch.ts](../packages/use-enhanced-reducer/src/usePromisifiedDispatch.ts) + queues the reducer update and resolves the dispatch promise in an effect + after that state commits. +- `Controller.getState` is the committed snapshot copied into a ref from that + effect, so it lags an in-flight dispatch. + +Consequence, from that control flow: a GC action can be queued while +`useReducer` has not applied it. A later `countRef` increment updates only +the external `GCPolicy` maps. The already queued action still contains the +old deletion list. A **pure** `(state, action)` reducer cannot see that +later increment. A disposable state-machine probe under +`/tmp/data-client-gc-correctness-spike/` (not in git; may be gone) reported +that side-table vetoes and epoch tickets also fail if the reducer does not +read live policy state at apply time, and that a follow-up React action does +not help because the GC action is already ahead in the queue. Treat that +probe as supporting evidence for the control-flow reading, not as a shipped +test. + +Vue's provider in +[packages/vue/src/providers/createDataClient.ts](../packages/vue/src/providers/createDataClient.ts) +applies the reducer inside `realDispatch` before the dispatch promise +resolves. The React gap is not automatically the Vue gap. + +### Normalized deletion is visible + +Verified in source: +[packages/endpoint/src/schemas/Array.ts](../packages/endpoint/src/schemas/Array.ts) +denormalizes with `.filter(filterEmpty)`. A missing entity becomes a shorter +array rather than an invalid result. + +A disposable probe against real `Controller.getResponseMeta` (same `/tmp` +spike) reported: after deleting one entity under a surviving list endpoint, +the list shrank and expiry stayed `Valid`, so Suspense was not forced to +refetch. A deleted entity behind a detail endpoint was `undefined` while +still `Valid`. Re-run this before relying on it; the probe is not in the +repo. + +### Memo and index residue + +Verified in source: + +- [packages/normalizr/src/memo/entitiesCache.ts](../packages/normalizr/src/memo/entitiesCache.ts) + keeps a strong `Map` from entity key to pk to a `WeakMap`. Store GC does + not delete those entries. Denormalized values hanging off entity-object + identity can still be collected by JS GC; the map shells are not. +- Index writes in `NormalizeDelegate.handleIndexes` store + `indexes[entityKey][indexName][fieldValue] = pk`. GC does not remove them. + A correct delete of one mapping is possible from the dying entity's field + value (`delete only if indexMap[value] === pk`). Scanning every index value + is a different, unbounded design. + +### What other caches do + +Checked against current official docs, not against their source, on +2026-07-18: + +- **RTK Query** stores a whole document per endpoint+args. It does not + collect a nested entity out from under a surviving query. Unused queries + are removed after `keepUnusedDataFor` (default 60s). Cost: duplicate + copies and tag-driven refetch instead of normalization. +- **Relay** retains queries and mark-sweeps records unreachable from retained + operations. A record referenced by a retained query stays. It needs + explicit record references and compiler-known selections. Default release + buffer is 10 queries. +- **Apollo `cache.gc()`** traces from roots and keeps reachable objects. + Explicit `cache.evict(id)` may leave dangling refs. Default list reads + filter those refs, which is the same silent-shrink behavior. Singular + dangling refs need a custom `read`. + +### Measurement evidence already collected + +Host: Linux WSL2, Node v24.5.0, headless Chromium 149, display period about +16.7 ms. Numbers are single-host and small-sample. They are **not** universal +thresholds. Raw JSON was local `/tmp` output and is not in git. Build +manifests at the time were `dirty=true` because the harness was uncommitted; +those exact IDs will not match a clean checkout of this PR. + +From the provenanced rerun recorded in +[garbage-collection.md](./garbage-collection.md): + +- Node 100k end-to-end median `totalMs`: entity unique ~13 ms, endpoint + unique ~34 ms, mixed unique ~21 ms, entity duplicate ~6 ms. One action, + one slice. +- Chromium 100k `gc` median `totalMs` / `maxInputDelayMs`: entity unique + 9.0 / 9.1; endpoint unique 16.7 / 16.8; mixed unique 13.5 / 13.5; entity + duplicate 6.8 / 6.8. Matching `no-gc` input delay was ~0–0.1 ms. +- Long Tasks stayed 0 because Chromium's threshold is about 50 ms. A + synthetic 45 ms block through the browser probe reported ~45.1 ms max + frame interval and 2 missed frames. The ~17 ms endpoint sweep therefore + sits near one 60 Hz frame without tripping Long Tasks. +- Foreground `core` `^get` / `^set` and React small hot-path benches were + run as controls and completed. They are not GC before/after comparisons. +- `yarn ci:build:bundlesize` completed. Reported `rdcClient.js` 33.8 KiB + minified is an absolute size, not a delta caused by GC work. The harness + is opt-in; production package behavior is unchanged. +- Android release assemble succeeded in that session (~50 MiB APK). **No + device was attached.** There is no RN frame or memory baseline. + +A later disposable scheduler spike +(`/tmp/data-client-gc-calendar-spike/`, not in git) compared structures at +100k candidates on one machine (7 samples, Node): + +| Workload | Flat re-scan | Calendar buckets | +| --- | ---: | ---: | +| 0% due | ~11.5 ms, examines all | ~0.01 ms, examines none | +| 10 mostly-unexpired ticks | ~110 ms, ~991k examinations | ~0.6 ms, ~1k examinations | +| 100% due | ~7.8 ms | ~25 ms | +| Insert 100k | ~5.9 ms | ~30 ms | + +`Object.keys` on ~100k meta was ~4 ms plus ~2.7 MiB. Warmed resumable +`for...in` slices of 256 were ~0.019 ms median. A min-heap was worse on both +time and memory in that spike. These numbers argue about scheduling cost +only. They do not choose a product design. + +## 3. Status, remaining work, and open decisions + +### Done + +- Current GC behavior and its main limitations are documented. +- Opt-in Node, Chromium, and Android measurement harnesses exist on this PR, + with provenance checks so a report is tied to a build manifest. +- Some local baselines exist. Android on-device numbers do not. +- Several designs were sketched and partly spiked. None is chosen. + +### Not done + +- No production `GCPolicy`, reducer, hook, or schema change. +- No committed regression test for the React dispatch-to-reducer race. +- No committed test for surviving-endpoint silent shrink. +- No CI job runs the new GC benchmarks. +- No physical Android calibration. +- No decision on ownership (per-consumer refs vs query reachability vs whole + documents), scheduling, or how a deletion becomes a React snapshot. + +### Decisions the next agent should make + +Do not treat prior rankings as votes. + +1. **What is a GC root, and what must remain intact?** + Per-consumer entity paths can delete an entity that a surviving endpoint + result still names. Whole-document eviction (RTK Query) avoids that by + giving up normalization. Reachability from retained queries (Relay / Apollo + `gc`) avoids it by keeping or tracing edges. Direct entity eviction (Apollo + `evict`, and today's `GC` action) allows dangling refs and filtered lists. +2. **When may collection change a React snapshot?** + The user does not want GC to render by itself if rendered output is + unchanged. Immutable deletion still creates a new reducer state if it is + committed alone. In-place mutation avoids the render and conflicts with + concurrent reducer replay. Piggybacking a **prebuilt** table swap on a + later real update avoids an extra render but must stay off the timed + portion of that update. Doing nothing while idle retains memory. RN may + value a standalone low-priority commit more than web does. +3. **How is "still referenced" known at the moment of deletion?** + External counts plus a pure deferred reducer cannot observe an increment + that happens after the action is queued. Options include: read live + external counts inside the GC reducer (impure, closes the race), a + synchronous commit, an urgent update that React is proven to rebase ahead + of a pending transition, or accepting a rare deletion of newly mounted + data. This needs a concurrent-rendering test, not another diagram. +4. **What runs during interaction vs idle?** + Any design that inserts into a heavier structure during unmount, walks + `Object.keys` of a large store on the critical path, or deletes inside the + reducer can miss the 60 FPS goal even if the steady-state sweep is fast. + The calendar spike says release-time bucket insertion was slower than a + flat queue. A cheap inbox drained later is one hypothesis, not a + requirement. +5. **Is the never-consumed leak in scope?** + Hydrated `initialState`, `controller.set()`, and fetches nobody mounted + never pass through decrement-to-zero, so today's queue never sees them. + A census can find them. Write-time enqueue also can, and it taxes + normalize. Whether this leak matters more than interaction cost is open. +6. **Index rows and memo map shells.** + Both retain memory after entity deletion. Cleanup is likely justified if + entity GC remains, but it should be designed with the deletion model, not + bolted on first. +7. **Platform policy vs shared mechanism.** + The user wants different web and RN tradeoffs. Shared correctness with + different cadence, retention, and whether an idle commit is allowed is + one shape. Different collectors is another. Bundle cost of the web default + matters; RN bundle cost mostly does not. + +### Suggested next evidence, not a required sequence + +- Re-run the silent-shrink case as a real Jest test and decide if it is + acceptable. +- Re-run the React deferred-dispatch race under concurrent rendering. +- If comparing schedulers, measure release/unmount cost and idle sweep cost + separately, on the browser harness, not only in Node. +- Before any RN default change, run the release app on a named mid-range + device. The harness is ready enough to do that; the numbers are not. +- Keep production GC unchanged until those results exist. + +## 4. What this PR is + +**Purpose:** let the next design be measured. It adds opt-in harnesses and +notes. It does not change `@data-client/core` GC behavior, public package +APIs, or default provider policy. + +**In the PR, and hard in the sense that the user asked for measurement:** + +- Node GC scenarios: `yarn workspace example-benchmark start:gc` +- Chromium GC scenarios: `yarn workspace example-benchmark-react bench:gc` +- Android release-Hermes app: `examples/benchmark-native` +- Build manifests so reports bind to source and artifact hashes +- CircleCI workspace trimming updated for the new workspace index + +**In the PR, but exploratory:** + +- Scenario vocabulary, slice/census ideas, and "direction" prose in + [garbage-collection.md](./garbage-collection.md) +- Baseline tables. Useful local evidence. Not pass/fail gates. +- Harness-only policies (`BenchmarkGCPolicy`, `BrowserBenchmarkGCPolicy`, + `AndroidBenchmarkGCPolicy`) that disable the interval and force expiry to + zero so a sweep can be timed. They are fixtures, not proposed production + defaults. +- Frame probes, Long Task filters, and nearest-period missed-frame math. + They are measurement tools. A later design does not have to preserve them. + +**Explicitly not decided by the user:** + +- Cooperative slicing, calendar queues, censuses, reverse edges, memo + `forget`, commit-time revalidation, private maintenance actions, or + piggyback compaction. +- Changing Vue or bare `Controller` defaults. +- Promoting these benchmarks to CI. + +### Branch state + +PR #4034 is open against `master`. Before this handoff commit the branch +was 4 commits ahead of its merge base with `origin/master` and 3 behind. +Those three are unrelated: #4099 renderDataHook suspense mount, #4094 +concurrent rendering tests, and #4093 bundle-size CI. Rebase or merge before +further implementation; do not assume the harness still passes on current +master until that is done. + +This handoff commit only adds this file. It does not change harness code. + +### How to ignore the previous agent's preferences + +Use the goals in section 1, the source facts in section 2, and the harness to +test whatever design you invent. If a prior recommendation conflicts with a +simpler design that still meets the goals, prefer the simpler design. Say so. From 94d109327b6fa5abe373bc897a741d5876e8fbb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 00:18:50 +0000 Subject: [PATCH 06/32] pkg: Align benchmark-native React Native to 0.86.2 Match root react-native and the example's @react-native/* packages to 0.86.2 so the Android harness does not keep a second 0.86.0 tree. Co-authored-by: Nathaniel Tucker --- examples/benchmark-native/README.md | 2 +- examples/benchmark-native/package.json | 10 +- yarn.lock | 259 ++++--------------------- 3 files changed, 45 insertions(+), 226 deletions(-) diff --git a/examples/benchmark-native/README.md b/examples/benchmark-native/README.md index 8911cf52ce57..614bc4682427 100644 --- a/examples/benchmark-native/README.md +++ b/examples/benchmark-native/README.md @@ -1,6 +1,6 @@ # Release-Hermes Android GC benchmark -Private monorepo workspace `example-benchmark-native`: Android-only React Native **0.86** app (React **19.2.3**, Hermes enabled). Measures `@data-client/core` cache GC interaction cost on a physical device in a **release** build. +Private monorepo workspace `example-benchmark-native`: Android-only React Native **0.86.2** app (React **19.2.3**, Hermes enabled). Measures `@data-client/core` cache GC interaction cost on a physical device in a **release** build. This phase is **manual**, not CI. Emulators are functional checks only — not authoritative. diff --git a/examples/benchmark-native/package.json b/examples/benchmark-native/package.json index 802bf9fe6d13..04196c7aebae 100644 --- a/examples/benchmark-native/package.json +++ b/examples/benchmark-native/package.json @@ -17,7 +17,7 @@ "dependencies": { "@data-client/core": "workspace:*", "react": "19.2.3", - "react-native": "0.86.0" + "react-native": "0.86.2" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -25,10 +25,10 @@ "@babel/runtime": "^7.25.0", "@react-native-community/cli": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", - "@react-native/babel-preset": "0.86.0", - "@react-native/eslint-config": "0.86.0", - "@react-native/metro-config": "0.86.0", - "@react-native/typescript-config": "0.86.0", + "@react-native/babel-preset": "0.86.2", + "@react-native/eslint-config": "0.86.2", + "@react-native/metro-config": "0.86.2", + "@react-native/typescript-config": "0.86.2", "@types/jest": "^29.5.13", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", diff --git a/yarn.lock b/yarn.lock index 23e0aec241e1..47045fca2e92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6845,13 +6845,6 @@ __metadata: languageName: node linkType: hard -"@react-native/assets-registry@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/assets-registry@npm:0.86.0" - checksum: 10c0/9cd7e96d99bb5b1da0b83ad4362966749c367081bd4272ca65c8ce4933cea9caad6bbf1d6e404832447c9b200495d707809bbb1db2fd5d9a6f7ae25a50a96bf8 - languageName: node - linkType: hard - "@react-native/assets-registry@npm:0.86.2": version: 0.86.2 resolution: "@react-native/assets-registry@npm:0.86.2" @@ -6859,19 +6852,19 @@ __metadata: languageName: node linkType: hard -"@react-native/babel-plugin-codegen@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/babel-plugin-codegen@npm:0.86.0" +"@react-native/babel-plugin-codegen@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/babel-plugin-codegen@npm:0.86.2" dependencies: "@babel/traverse": "npm:^7.29.0" - "@react-native/codegen": "npm:0.86.0" - checksum: 10c0/b9263b7fd6a40411639e2343e17454fa9e988e651f44ec9149bd0c3a7945166863713b01d21249b81e62bd9ec67e356fbbee860e7b7f8479df26310b90d2bd4e + "@react-native/codegen": "npm:0.86.2" + checksum: 10c0/ea2faab711f07578f0d0b1a62ccc731a60fdb21b0bf3924209fa055d62f597d6104f6646b5cbf416da4dc1a012f5c708e7d58694ddfafab9325dd60b0bf495f2 languageName: node linkType: hard -"@react-native/babel-preset@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/babel-preset@npm:0.86.0" +"@react-native/babel-preset@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/babel-preset@npm:0.86.2" dependencies: "@babel/core": "npm:^7.25.2" "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" @@ -6902,30 +6895,13 @@ __metadata: "@babel/plugin-transform-runtime": "npm:^7.24.7" "@babel/plugin-transform-typescript": "npm:^7.25.2" "@babel/plugin-transform-unicode-regex": "npm:^7.24.7" - "@react-native/babel-plugin-codegen": "npm:0.86.0" + "@react-native/babel-plugin-codegen": "npm:0.86.2" babel-plugin-syntax-hermes-parser: "npm:0.36.0" babel-plugin-transform-flow-enums: "npm:^0.0.2" react-refresh: "npm:^0.14.0" peerDependencies: "@babel/core": "*" - checksum: 10c0/cf86875fa72d9bdd0b6ac3819b7a683764f154ff8df5144cce9cc60dd734d9d100bde563acbb48104e0033650dcc28d968eb985ff59e47349dc6861169df02f6 - languageName: node - linkType: hard - -"@react-native/codegen@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/codegen@npm:0.86.0" - dependencies: - "@babel/core": "npm:^7.25.2" - "@babel/parser": "npm:^7.29.0" - hermes-parser: "npm:0.36.0" - invariant: "npm:^2.2.4" - nullthrows: "npm:^1.1.1" - tinyglobby: "npm:^0.2.15" - yargs: "npm:^17.6.2" - peerDependencies: - "@babel/core": "*" - checksum: 10c0/22ca5657595438f5e464223a65b25567b3e7106995c8ae59c5853d8cdb3df59065db54c4cb74e958bbe2930b0aded8cd518c2fc1ef13a4740a05fb1faa3ae160 + checksum: 10c0/12358b3898771cf7340c3fa0199e3608b9aca03fd33d6f10af59d0acef65181d9893acab51c068adc816231df9e5baff360b042963e633fc5f24a73faf6387b2 languageName: node linkType: hard @@ -6946,29 +6922,6 @@ __metadata: languageName: node linkType: hard -"@react-native/community-cli-plugin@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/community-cli-plugin@npm:0.86.0" - dependencies: - "@react-native/dev-middleware": "npm:0.86.0" - debug: "npm:^4.4.0" - invariant: "npm:^2.2.4" - metro: "npm:^0.84.3" - metro-config: "npm:^0.84.3" - metro-core: "npm:^0.84.3" - semver: "npm:^7.1.3" - peerDependencies: - "@react-native-community/cli": "*" - "@react-native/metro-config": 0.86.0 - peerDependenciesMeta: - "@react-native-community/cli": - optional: true - "@react-native/metro-config": - optional: true - checksum: 10c0/62c70f1e99eb48dad8516fed9a15415e8de85ee76018f00231f4473e9fedbfc8c9b4932fc531b9307c3a6d681278b61fa7e63ba2fa250628451662f815a3366e - languageName: node - linkType: hard - "@react-native/community-cli-plugin@npm:0.86.2": version: 0.86.2 resolution: "@react-native/community-cli-plugin@npm:0.86.2" @@ -6992,13 +6945,6 @@ __metadata: languageName: node linkType: hard -"@react-native/debugger-frontend@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/debugger-frontend@npm:0.86.0" - checksum: 10c0/bc86a765dfbcaae35f501244d5a52aa0723196fe34ba177c2e3398491df6af1aa084ace044fde668e05bfec3daf15f735c20c79a8b98bd578b38891913e337e2 - languageName: node - linkType: hard - "@react-native/debugger-frontend@npm:0.86.2": version: 0.86.2 resolution: "@react-native/debugger-frontend@npm:0.86.2" @@ -7006,17 +6952,6 @@ __metadata: languageName: node linkType: hard -"@react-native/debugger-shell@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/debugger-shell@npm:0.86.0" - dependencies: - cross-spawn: "npm:^7.0.6" - debug: "npm:^4.4.0" - fb-dotslash: "npm:0.5.8" - checksum: 10c0/4cc7626b826195403c9b9ac8ad1ed9938ad83858424aa59b980882349966aa0fa6b509066006bc7c7be2c0d20767fbc7d75c8df0007f853b57646c9157fcb3e7 - languageName: node - linkType: hard - "@react-native/debugger-shell@npm:0.86.2": version: 0.86.2 resolution: "@react-native/debugger-shell@npm:0.86.2" @@ -7028,26 +6963,6 @@ __metadata: languageName: node linkType: hard -"@react-native/dev-middleware@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/dev-middleware@npm:0.86.0" - dependencies: - "@isaacs/ttlcache": "npm:^1.4.1" - "@react-native/debugger-frontend": "npm:0.86.0" - "@react-native/debugger-shell": "npm:0.86.0" - chrome-launcher: "npm:^0.15.2" - chromium-edge-launcher: "npm:^0.3.0" - connect: "npm:^3.6.5" - debug: "npm:^4.4.0" - invariant: "npm:^2.2.4" - nullthrows: "npm:^1.1.1" - open: "npm:^7.0.3" - serve-static: "npm:^1.16.2" - ws: "npm:^7.5.10" - checksum: 10c0/14e704d723d7c3eaae94552251b50b31b3769dbfbc2b48f6cabe422b1bfc640dae20751997d8fd8bbe26a22c6865a46083e4d3621d7b200b19c79e515ef54bb9 - languageName: node - linkType: hard - "@react-native/dev-middleware@npm:0.86.2": version: 0.86.2 resolution: "@react-native/dev-middleware@npm:0.86.2" @@ -7068,13 +6983,13 @@ __metadata: languageName: node linkType: hard -"@react-native/eslint-config@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/eslint-config@npm:0.86.0" +"@react-native/eslint-config@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/eslint-config@npm:0.86.2" dependencies: "@babel/core": "npm:^7.25.2" "@babel/eslint-parser": "npm:^7.25.1" - "@react-native/eslint-plugin": "npm:0.86.0" + "@react-native/eslint-plugin": "npm:0.86.2" "@typescript-eslint/eslint-plugin": "npm:^8.36.0" "@typescript-eslint/parser": "npm:^8.36.0" eslint-config-prettier: "npm:^8.5.0" @@ -7087,21 +7002,14 @@ __metadata: peerDependencies: eslint: ^8.0.0 || ^9.0.0 prettier: ">=2" - checksum: 10c0/51b3ab96620c8b4ebe14887b9455074de5dbecf87c8860fa065be13a1070a2f69270ecd7eb3a44e215cb49c1eeaa89204e9511606e7c6b452dbb87a1d2bedf3d + checksum: 10c0/4daa5362bf99a12b60a5a8b7a2e7e00f2a81472017bf5eea2fe42de05c954371f87e75296edc66bc1192dec4af0b5c45c42f24bea5d1acf2d63742b9ff192452 languageName: node linkType: hard -"@react-native/eslint-plugin@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/eslint-plugin@npm:0.86.0" - checksum: 10c0/4d8bd7451d75dc572369ab2769647b5ec624bbb13e4a924a35cd526ee57507e3667b2eeedfd79568073dc2fa9db598788fc81b39e0b071397fc854ffd36a3ee5 - languageName: node - linkType: hard - -"@react-native/gradle-plugin@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/gradle-plugin@npm:0.86.0" - checksum: 10c0/02d8f7ae14c3163a6acf7098b1227a7ffd66cbfde96b6b151a02739bfbc27f123e023bd3411383e9a7a33f18e4d4d82100448615b8955a0ff95e8dee5d1ef4e8 +"@react-native/eslint-plugin@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/eslint-plugin@npm:0.86.2" + checksum: 10c0/b883f8666d207e2d30b67606dcdee836eb01ba7cb132f4484d6269253a5c037375e0d56d207b40ae9051166a706db48f29f57a732c3048807e0c48e5cec1533a languageName: node linkType: hard @@ -7127,13 +7035,6 @@ __metadata: languageName: node linkType: hard -"@react-native/js-polyfills@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/js-polyfills@npm:0.86.0" - checksum: 10c0/63379d40d37b09fe371cc4f8a60304479ee59667a032072dded0b1e475519ff17f27bbaa39fcd463949ea9068c65eb045d5d4639ede8236a241f6e3cd295174c - languageName: node - linkType: hard - "@react-native/js-polyfills@npm:0.86.2": version: 0.86.2 resolution: "@react-native/js-polyfills@npm:0.86.2" @@ -7141,36 +7042,29 @@ __metadata: languageName: node linkType: hard -"@react-native/metro-babel-transformer@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/metro-babel-transformer@npm:0.86.0" +"@react-native/metro-babel-transformer@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/metro-babel-transformer@npm:0.86.2" dependencies: "@babel/core": "npm:^7.25.2" - "@react-native/babel-preset": "npm:0.86.0" + "@react-native/babel-preset": "npm:0.86.2" hermes-parser: "npm:0.36.0" nullthrows: "npm:^1.1.1" peerDependencies: "@babel/core": "*" - checksum: 10c0/8b4ab63d32f28f3663204110d37c50a8dd82eef622d57fb236f3d05b69bdfc1fe277f7572e4cf9135790921922f35fe4aea5f7c183fe17cd2ead19ed8e1db5d1 + checksum: 10c0/c4841accee16c5cf0054462bc6d11d94cb327192e11934b153f28feb980ebe2079167058805621593b0dd61f0fe8f17cac3340db4b8644797a15893210c0bf7d languageName: node linkType: hard -"@react-native/metro-config@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/metro-config@npm:0.86.0" +"@react-native/metro-config@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/metro-config@npm:0.86.2" dependencies: - "@react-native/js-polyfills": "npm:0.86.0" - "@react-native/metro-babel-transformer": "npm:0.86.0" + "@react-native/js-polyfills": "npm:0.86.2" + "@react-native/metro-babel-transformer": "npm:0.86.2" metro-config: "npm:^0.84.3" metro-runtime: "npm:^0.84.3" - checksum: 10c0/92c043b8e4d959a3d8c0aa56940a9ff455c9de21bb6fe2a799fb5a847e45587d9783c3b55e3254f2b17fa71dfc66fff30ce0a70505e0d699dbbf2c76e0ff539c - languageName: node - linkType: hard - -"@react-native/normalize-colors@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/normalize-colors@npm:0.86.0" - checksum: 10c0/d836506a642e1c636015fca23c84ca372b10fb1457df98f95e8bc5716399292d5740b94efe13f9be08c21323ddc3c2c45205d316f0f5440f24e5919b5075f69a + checksum: 10c0/5e7408301b9a06f9da34598d0fd355ef5bf610721113c3e3d7ee8ff2a9a92ce4218bcda8302b7d3d722e1ae6f4e1db906703fb8ce08466b4b89bdd2fdd4fc111 languageName: node linkType: hard @@ -7181,27 +7075,10 @@ __metadata: languageName: node linkType: hard -"@react-native/typescript-config@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/typescript-config@npm:0.86.0" - checksum: 10c0/66020e6d945e863847639b4932ef22082dad8ba4e345fe785b85987ae18a6cb56b31539c3502731238580ad96a9b1de32fac55e2e245debf693f039ec36fcffc - languageName: node - linkType: hard - -"@react-native/virtualized-lists@npm:0.86.0": - version: 0.86.0 - resolution: "@react-native/virtualized-lists@npm:0.86.0" - dependencies: - invariant: "npm:^2.2.4" - nullthrows: "npm:^1.1.1" - peerDependencies: - "@types/react": ^19.2.0 - react: "*" - react-native: 0.86.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/df5006e01e2f527f4fd46b93db17f3dbf8fd18c832e4ad9b1b43c544a83b4b2fca34ba72114ad9679968043e7fa6f9fe352bcee250799b9b973d0998b446ca50 +"@react-native/typescript-config@npm:0.86.2": + version: 0.86.2 + resolution: "@react-native/typescript-config@npm:0.86.2" + checksum: 10c0/473908af62140c9fecb13ea41a6d3f9ae4296cf6d4f481964aebac56f484b56500e07462e2940585001d4dd0fadb102d4c4de871d57e8b981ffffc2d4a83fddb languageName: node linkType: hard @@ -15791,10 +15668,10 @@ __metadata: "@data-client/core": "workspace:*" "@react-native-community/cli": "npm:20.1.0" "@react-native-community/cli-platform-android": "npm:20.1.0" - "@react-native/babel-preset": "npm:0.86.0" - "@react-native/eslint-config": "npm:0.86.0" - "@react-native/metro-config": "npm:0.86.0" - "@react-native/typescript-config": "npm:0.86.0" + "@react-native/babel-preset": "npm:0.86.2" + "@react-native/eslint-config": "npm:0.86.2" + "@react-native/metro-config": "npm:0.86.2" + "@react-native/typescript-config": "npm:0.86.2" "@types/jest": "npm:^29.5.13" "@types/react": "npm:^19.2.0" "@types/react-test-renderer": "npm:^19.1.0" @@ -15802,7 +15679,7 @@ __metadata: jest: "npm:^29.6.3" prettier: "npm:2.8.8" react: "npm:19.2.3" - react-native: "npm:0.86.0" + react-native: "npm:0.86.2" react-test-renderer: "npm:19.2.3" typescript: "npm:^5.8.3" languageName: unknown @@ -17407,13 +17284,6 @@ __metadata: languageName: node linkType: hard -"hermes-compiler@npm:250829098.0.14": - version: 250829098.0.14 - resolution: "hermes-compiler@npm:250829098.0.14" - checksum: 10c0/b9f4024ac7377bebec61b91e1cee8f3909a8ecad0ea786c6b7c3863f8b65bd94f928850b341c82865a9435b8390d33f03e149e9d479c6870b6f5a754d101fde7 - languageName: node - linkType: hard - "hermes-compiler@npm:250829098.0.16": version: 250829098.0.16 resolution: "hermes-compiler@npm:250829098.0.16" @@ -25924,57 +25794,6 @@ __metadata: languageName: node linkType: hard -"react-native@npm:0.86.0": - version: 0.86.0 - resolution: "react-native@npm:0.86.0" - dependencies: - "@react-native/assets-registry": "npm:0.86.0" - "@react-native/codegen": "npm:0.86.0" - "@react-native/community-cli-plugin": "npm:0.86.0" - "@react-native/gradle-plugin": "npm:0.86.0" - "@react-native/js-polyfills": "npm:0.86.0" - "@react-native/normalize-colors": "npm:0.86.0" - "@react-native/virtualized-lists": "npm:0.86.0" - abort-controller: "npm:^3.0.0" - anser: "npm:^1.4.9" - ansi-regex: "npm:^5.0.0" - babel-plugin-syntax-hermes-parser: "npm:0.36.0" - base64-js: "npm:^1.5.1" - commander: "npm:^12.0.0" - flow-enums-runtime: "npm:^0.0.6" - hermes-compiler: "npm:250829098.0.14" - invariant: "npm:^2.2.4" - memoize-one: "npm:^5.0.0" - metro-runtime: "npm:^0.84.3" - metro-source-map: "npm:^0.84.3" - nullthrows: "npm:^1.1.1" - pretty-format: "npm:^29.7.0" - promise: "npm:^8.3.0" - react-devtools-core: "npm:^6.1.5" - react-refresh: "npm:^0.14.0" - regenerator-runtime: "npm:^0.13.2" - scheduler: "npm:0.27.0" - semver: "npm:^7.1.3" - stacktrace-parser: "npm:^0.1.10" - tinyglobby: "npm:^0.2.15" - whatwg-fetch: "npm:^3.0.0" - ws: "npm:^7.5.10" - yargs: "npm:^17.6.2" - peerDependencies: - "@react-native/jest-preset": 0.86.0 - "@types/react": ^19.1.1 - react: ^19.2.3 - peerDependenciesMeta: - "@react-native/jest-preset": - optional: true - "@types/react": - optional: true - bin: - react-native: cli.js - checksum: 10c0/47a569c47a22b37526ac54e17d1f42c6a191ca7cd30230bc16ee5307ac53da1db89d27ba20c76e9756fb646866d89fedfc1852b1ddc15c47bde45c899079096a - languageName: node - linkType: hard - "react-native@npm:0.86.2": version: 0.86.2 resolution: "react-native@npm:0.86.2" From 271e4470fb371b02a43a5bc9b2a9ac3336169523 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 00:18:50 +0000 Subject: [PATCH 07/32] docs: Record that the GC handoff branch is rebased on master Co-authored-by: Nathaniel Tucker --- plans/gc-handoff.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/plans/gc-handoff.md b/plans/gc-handoff.md index 37c4ac221d73..6ad8896b65d3 100644 --- a/plans/gc-handoff.md +++ b/plans/gc-handoff.md @@ -328,14 +328,11 @@ APIs, or default provider policy. ### Branch state -PR #4034 is open against `master`. Before this handoff commit the branch -was 4 commits ahead of its merge base with `origin/master` and 3 behind. -Those three are unrelated: #4099 renderDataHook suspense mount, #4094 -concurrent rendering tests, and #4093 bundle-size CI. Rebase or merge before -further implementation; do not assume the harness still passes on current -master until that is done. - -This handoff commit only adds this file. It does not change harness code. +PR #4034 is open against `master`. It was rebased onto the commits from +#4099, #4094, and #4093. Re-check CI on the rebased tip before treating the +harness as green on current master. + +This handoff file does not change harness code. ### How to ignore the previous agent's preferences From bf2217b56fc22482f321185ca55e911488dc28ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 00:29:44 +0000 Subject: [PATCH 08/32] fix: Match GC matrix filters on slash-bounded segments A raw substring treated 1000 as part of 10000 and 100000, and any non-empty filter disabled the 100k skip. Strip one leading and trailing slash so /100000/ still selects only those rows without FULL=1. Co-authored-by: Nathaniel Tucker --- examples/benchmark-native/README.md | 2 +- .../__tests__/run-matrix-filter.test.ts | 50 +++++++++++++++++++ .../__tests__/scenario-and-frames.test.ts | 14 ++++++ .../benchmark-native/scripts/run-matrix.sh | 19 +++++-- .../scripts/scenario-filter.sh | 19 +++++++ examples/benchmark-native/src/scenario.ts | 15 +++++- 6 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 examples/benchmark-native/__tests__/run-matrix-filter.test.ts create mode 100644 examples/benchmark-native/scripts/scenario-filter.sh diff --git a/examples/benchmark-native/README.md b/examples/benchmark-native/README.md index 614bc4682427..5aae2c0d35d1 100644 --- a/examples/benchmark-native/README.md +++ b/examples/benchmark-native/README.md @@ -70,7 +70,7 @@ CANDIDATE_KIND=entity PATTERN=unique COUNT=100000 CONTROL=no-gc SAMPLES=5 \ ### Matrix -Full canonical matrix via loops; 100k skipped unless `FULL=1` or filter selects them. +Full canonical matrix via loops; 100k skipped unless `FULL=1` or a filter selects those rows. Filters match slash-bounded segments, so `entity/unique/1000` does not select 10k or 100k. `/100000/` selects only the 100k rows and does not need `FULL=1`. ```bash SAMPLES=3 yarn workspace example-benchmark-native matrix diff --git a/examples/benchmark-native/__tests__/run-matrix-filter.test.ts b/examples/benchmark-native/__tests__/run-matrix-filter.test.ts new file mode 100644 index 000000000000..c0aa44fd1bf0 --- /dev/null +++ b/examples/benchmark-native/__tests__/run-matrix-filter.test.ts @@ -0,0 +1,50 @@ +/** + * Host matrix filter must match slash-bounded segments. A raw substring + * would treat 1000 as part of 10000 and 100000, and a non-empty filter + * disables the 100k skip. + */ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +export {}; + +function dryRun(filter: string): string[] { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-matrix-')); + const stdout = execFileSync( + 'bash', + [ + '-c', + 'DRY_RUN=1 OUT_DIR="$1" bash scripts/run-matrix.sh "$2"', + 'bash', + outDir, + filter, + ], + { cwd: '.', encoding: 'utf8' }, + ); + return stdout + .split('\n') + .map((line: string) => line.trim()) + .filter((line: string) => /^(entity|endpoint|mixed)\//.test(line)); +} + +describe('run-matrix.sh filter', () => { + it('does not select 10000 or 100000 for a 1000 filter', () => { + for (const filter of ['1000', 'entity/unique/1000']) { + const specs = dryRun(filter); + expect(specs.length).toBeGreaterThan(0); + expect(specs.every((spec: string) => spec.split('/')[2] === '1000')).toBe( + true, + ); + } + }); + + it('runs /100000/ without FULL=1 and selects only 100k rows', () => { + const specs = dryRun('/100000/'); + expect(specs.length).toBeGreaterThan(0); + expect(specs.every((spec: string) => spec.split('/')[2] === '100000')).toBe( + true, + ); + }); +}); diff --git a/examples/benchmark-native/__tests__/scenario-and-frames.test.ts b/examples/benchmark-native/__tests__/scenario-and-frames.test.ts index 0c4ef0f4657a..2e2281632f90 100644 --- a/examples/benchmark-native/__tests__/scenario-and-frames.test.ts +++ b/examples/benchmark-native/__tests__/scenario-and-frames.test.ts @@ -29,6 +29,20 @@ describe('scenarioId', () => { expect(scenarioId(parseScenarioId(id))).toBe(id); }); + it('matches slash-bounded segments so 1000 does not select 10000 or 100000', () => { + const countOf = (id: string) => id.split('/')[3]; + const counts = (filter: string) => + listScenarios(filter).map(scenarioId).map(countOf); + + expect(counts('1000')).toEqual(expect.arrayContaining(['1000'])); + expect(counts('1000').every(count => count === '1000')).toBe(true); + expect(counts('entity/unique/1000')).toEqual(['1000', '1000']); + expect(counts('/100000/').length).toBeGreaterThan(0); + expect(counts('/100000/').every(count => count === '100000')).toBe(true); + expect(counts('100000/').every(count => count === '100000')).toBe(true); + expect(counts('/100000').every(count => count === '100000')).toBe(true); + }); + it('lists duplicate only for entity', () => { const ids = listScenarios().map(scenarioId); expect(ids.some(id => id.includes('/duplicate/'))).toBe(true); diff --git a/examples/benchmark-native/scripts/run-matrix.sh b/examples/benchmark-native/scripts/run-matrix.sh index c55d1d831963..b5bcd57061fe 100644 --- a/examples/benchmark-native/scripts/run-matrix.sh +++ b/examples/benchmark-native/scripts/run-matrix.sh @@ -9,15 +9,20 @@ # # Usage: # bash scripts/run-matrix.sh -# bash scripts/run-matrix.sh entity/unique/1000 +# bash scripts/run-matrix.sh entity/unique/1000 # 1000 only, not 10k/100k # bash scripts/run-matrix.sh entity/unique/100000 # 100k via filter +# bash scripts/run-matrix.sh /100000/ # only 100k; no FULL=1 # FULL=1 SAMPLES=5 bash scripts/run-matrix.sh # entire matrix incl. 100k -# FULL=1 bash scripts/run-matrix.sh /100000/ # only 100k rows +# +# Filters match slash-bounded segments, not raw substrings. +# DRY_RUN=1 prints matched specs and does not install or collect. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" # shellcheck source=validate-config.sh source "${ROOT}/scripts/validate-config.sh" +# shellcheck source=scenario-filter.sh +source "${ROOT}/scripts/scenario-filter.sh" FILTER="${1:-}" OUT_DIR="${OUT_DIR:-${ROOT}/artifacts/matrix}" @@ -55,17 +60,23 @@ fi ran=0 skipped_100k=0 for spec in "${SCENARIOS[@]}"; do - if [[ -n "${FILTER}" && "${spec}" != *"${FILTER}"* ]]; then + if ! scenario_filter_matches "${spec}" "${FILTER}"; then continue fi IFS='/' read -r kind pattern count control <<<"${spec}" - # 100k safety: require FULL=1, or a non-empty filter that already matched this row. + # 100k safety: require FULL=1, or an explicit filter that selected this row. if [[ "${count}" == "100000" && "${FULL}" != "1" && -z "${FILTER}" ]]; then skipped_100k=$((skipped_100k + 1)) continue fi + if [[ "${DRY_RUN:-0}" == "1" ]]; then + echo "${spec}" + ran=$((ran + 1)) + continue + fi + validate_host_config "${kind}" "${pattern}" "${count}" "${control}" "${SAMPLES}" echo "=== matrix ${kind}/${pattern}/${count}/interaction/${control} ===" diff --git a/examples/benchmark-native/scripts/scenario-filter.sh b/examples/benchmark-native/scripts/scenario-filter.sh new file mode 100644 index 000000000000..9abc5b73b546 --- /dev/null +++ b/examples/benchmark-native/scripts/scenario-filter.sh @@ -0,0 +1,19 @@ +# Slash-bounded scenario filter shared by run-matrix.sh. +# +# Empty filter matches every spec. One leading slash and one trailing slash +# are removed, so /100000/ matches the 100000 segment only. +# The remaining text must be a contiguous run of spec segments: +# entity/unique/1000 matches entity/unique/1000/gc and not entity/unique/10000/gc. +scenario_filter_matches() { + local spec="$1" + local filter="${2:-}" + if [[ -z "${filter}" ]]; then + return 0 + fi + filter="${filter#/}" + filter="${filter%/}" + if [[ -z "${filter}" ]]; then + return 0 + fi + [[ "/${spec}/" == *"/${filter}/"* ]] +} diff --git a/examples/benchmark-native/src/scenario.ts b/examples/benchmark-native/src/scenario.ts index 7546e440c254..7f540b2b9c83 100644 --- a/examples/benchmark-native/src/scenario.ts +++ b/examples/benchmark-native/src/scenario.ts @@ -101,6 +101,19 @@ export function listScenarios(filter?: string): ScenarioAxes[] { const needle = prefix ? filter.slice(1) : filter; return out.filter(s => { const id = scenarioId(s); - return prefix ? id.startsWith(needle) : id.includes(needle); + return prefix ? id.startsWith(needle) : slashBoundedIncludes(id, needle); }); } + +/** + * Contiguous slash-bounded segments. One leading slash and one trailing + * slash are removed, so `/100000/` matches the 100000 segment only. + * `1000` does not match `10000` or `100000`. + */ +function slashBoundedIncludes(id: string, filter: string): boolean { + let needle = filter; + if (needle.startsWith('/')) needle = needle.slice(1); + if (needle.endsWith('/')) needle = needle.slice(0, -1); + if (needle.length === 0) return true; + return `/${id}/`.includes(`/${needle}/`); +} From 634339e851bb7efc87f88a5a8c208d785fdafeba Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Fri, 2 Oct 2026 20:19:30 -0400 Subject: [PATCH 09/32] test(core): Characterize GC deleting entities named by uncounted endpoints A list or detail result written without a mounted consumer is never queued, but an entity it names is swept when another consumer releases it. The surviving list shrinks and the detail reads undefined while both stay Valid, so nothing refetches. Co-Authored-By: Claude Opus 5.5 --- .../src/state/__tests__/GCPolicy-dangling.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/core/src/state/__tests__/GCPolicy-dangling.ts diff --git a/packages/core/src/state/__tests__/GCPolicy-dangling.ts b/packages/core/src/state/__tests__/GCPolicy-dangling.ts new file mode 100644 index 000000000000..8907a06b5a61 --- /dev/null +++ b/packages/core/src/state/__tests__/GCPolicy-dangling.ts @@ -0,0 +1,101 @@ +import { Endpoint, Entity } from '@data-client/endpoint'; +import { jest } from '@jest/globals'; + +import { ExpiryStatus } from '../..'; +import Controller from '../../controller/Controller'; +import type { ActionTypes, State } from '../../types'; +import { GCPolicy } from '../GCPolicy'; +import createReducer, { initialState } from '../reducer/createReducer'; + +/** Characterization of today's GC deletion model, not a statement of + * desired behavior. + * + * GCPolicy counts references per mounted consumer. An endpoint result that + * was written but never counted (prefetch, `controller.set*`, hydration) is + * never queued, so it survives. An entity it names can still be swept when + * a different consumer releases it. The surviving result then denormalizes + * without that entity while its expiry stays `Valid`, so nothing refetches. + * + * See plans/gc-handoff.md, decision 1 ("What is a GC root"). + */ +describe('GCPolicy deletes entities still named by uncounted endpoints', () => { + class Article extends Entity { + id = 0; + title = ''; + } + const getList = new Endpoint(() => Promise.resolve(), { + key: () => 'articles', + schema: [Article], + }); + const getDetail = new Endpoint((_: { id: number }) => Promise.resolve(), { + key: ({ id }) => `article ${id}`, + schema: Article, + }); + + let state: State; + let controller: Controller; + let gcPolicy: GCPolicy; + const now = 1_000_000; + + beforeEach(() => { + jest.useFakeTimers({ now }); + const reducer = createReducer(new Controller()); + state = initialState; + gcPolicy = new GCPolicy({ expiresAt: () => 0 }); + controller = new Controller({ + gcPolicy, + getState: () => state, + dispatch: (action: ActionTypes) => { + state = reducer(state, action); + return Promise.resolve(); + }, + }); + gcPolicy.init(controller); + + // list is written but never mounted; detail 1 is written too + controller.setResponse(getList, [ + { id: 1, title: 'one' }, + { id: 2, title: 'two' }, + ]); + controller.setResponse(getDetail, { id: 1 }, { id: 1, title: 'one' }); + }); + + afterEach(() => { + gcPolicy.cleanup(); + jest.useRealTimers(); + }); + + function mountDetailThenUnmountAndSweep() { + const meta = controller.getResponseMeta(getDetail, { id: 1 }, state); + const release = meta.countRef(); + release(); + gcPolicy['runSweep'](); + } + + it('list shrinks and stays Valid after a detail consumer releases an entity', () => { + expect(controller.getResponseMeta(getList, state).data).toHaveLength(2); + + mountDetailThenUnmountAndSweep(); + + expect(state.entities[Article.key]?.['1']).toBeUndefined(); + expect(state.endpoints[getList.key()]).toEqual(['1', '2']); + + const list = controller.getResponseMeta(getList, state); + expect(list.data).toEqual([expect.objectContaining({ id: 2 })]); + expect(list.expiryStatus).toBe(ExpiryStatus.Valid); + }); + + it('a surviving detail result is undefined and still Valid after a list consumer releases its entity', () => { + const meta = controller.getResponseMeta(getList, state); + meta.countRef()(); + gcPolicy['runSweep'](); + + expect(state.endpoints[getList.key()]).toBeUndefined(); + expect(state.entities[Article.key]?.['1']).toBeUndefined(); + expect(state.endpoints[getDetail.key({ id: 1 })]).toBe('1'); + + const detail = controller.getResponseMeta(getDetail, { id: 1 }, state); + expect(detail.data).toBeUndefined(); + expect(detail.expiryStatus).toBe(ExpiryStatus.Valid); + }); +}); From 06108aedac3cc925caaf1ec583ec3e07600611c4 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Fri, 2 Oct 2026 20:26:50 -0400 Subject: [PATCH 10/32] test(react): Characterize GC sweep between consumer commit and countRef Read hooks count GC refs in a passive effect. A sweep in the window after a consumer commits but before that effect deletes the mounted consumer's entity. GC returns the same state object, so the consumer keeps stale data until an unrelated update, then suspends and refetches. Co-Authored-By: Claude Opus 5.5 --- ...ntegration-garbage-collection-race.web.tsx | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/react/src/__tests__/integration-garbage-collection-race.web.tsx diff --git a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx new file mode 100644 index 000000000000..e4f69d8aad36 --- /dev/null +++ b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx @@ -0,0 +1,96 @@ +import { GCPolicy } from '@data-client/core'; +import { DataProvider, useController, useSuspense } from '@data-client/react'; +import { act, render } from '@testing-library/react'; +import { makeGetTodo, mockTodoState, Todo } from '__tests__/concurrentFixtures'; +import React, { Suspense, useLayoutEffect, useState } from 'react'; + +/** Characterization of today's GC timing, not a statement of desired + * behavior. + * + * Read hooks increment GC refs in a passive effect. A sweep that runs after + * a consumer commits but before that effect sees a zero count and queues + * deletion. The reducer does not consult counts, so the entity is deleted + * while the consumer is mounted. GC returns the same state object, so the + * consumer keeps rendering stale data until an unrelated update, then + * suspends and refetches. + * + * The sweep runs in a sibling layout effect to land deterministically in + * that window. In an app it would be an idle callback that fires between a + * non-sync commit and React's scheduled passive-effect flush. + * + * See plans/gc-handoff.md, decision 3 ("How is 'still referenced' known"). + */ +class SweepableGCPolicy extends GCPolicy { + sweep() { + this.runSweep(); + } +} + +describe('GC sweep between consumer commit and countRef', () => { + it('deletes a mounted consumer entity, which later suspends and refetches', async () => { + const fetchTodo = jest.fn(async ({ id }: { id: string }) => ({ + id, + title: `fetched ${id}`, + })); + const getTodo = makeGetTodo(fetchTodo); + const gcPolicy = new SweepableGCPolicy({ expiresAt: () => 0 }); + let sweepOnLayout = false; + let setShow!: (show: boolean) => void; + let controller!: ReturnType; + + function TodoTitle() { + const todo = useSuspense(getTodo, { id: '1' }); + return
{todo.title}
; + } + function SweepInLayout() { + useLayoutEffect(() => { + if (sweepOnLayout) gcPolicy.sweep(); + }, []); + return null; + } + function App() { + const [show, set] = useState(true); + setShow = set; + controller = useController(); + return show ? + <> + + + + : null; + } + + const { container } = render( + + + + + , + ); + expect(container.textContent).toBe('todo 1'); + + // unmount queues Todo 1 and its endpoint for collection + act(() => setShow(false)); + + act(() => { + sweepOnLayout = true; + setShow(true); + }); + + // consumer is mounted and counted, but its entity is gone + expect(container.textContent).toBe('todo 1'); + expect(gcPolicy['entityCount'].get(Todo.key)?.get('1')).toBe(1); + expect(controller.getState().entities[Todo.key]?.['1']).toBeUndefined(); + expect(fetchTodo).not.toHaveBeenCalled(); + + // any unrelated update exposes the deletion to the mounted consumer + await act(async () => { + controller.setResponse(getTodo, { id: '2' }, { id: '2', title: 'two' }); + }); + expect(fetchTodo).toHaveBeenCalledTimes(1); + expect(container.textContent).toBe('fetched 1'); + }); +}); From 648ec626bf9d5055c0640221512e6ac25ad6e20e Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Fri, 2 Oct 2026 20:27:25 -0400 Subject: [PATCH 11/32] docs: Record GC characterization test results in handoff Co-Authored-By: Claude Opus 5.5 --- plans/gc-handoff.md | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/plans/gc-handoff.md b/plans/gc-handoff.md index 6ad8896b65d3..f665924c1545 100644 --- a/plans/gc-handoff.md +++ b/plans/gc-handoff.md @@ -118,6 +118,25 @@ not help because the GC action is already ahead in the queue. Treat that probe as supporting evidence for the control-flow reading, not as a shipped test. +Committed tests on React 19.2 narrow that reading: + +- **Queued GC ahead of a sync mount: did not reproduce.** A sweep dispatched + at default priority followed by `flushSync` of a new consumer rendered both + updates together. The consumer saw the entity already deleted, suspended, + and refetched. That is a refetch, not deletion of counted data. Whether a + transition- or idle-priority GC dispatch would be skipped by a mount + render was not tested, nor were React 17/18 (disposable probe, not + committed). +- **Sweep between commit and passive effect: reproduces deterministically.** + [integration-garbage-collection-race.web.tsx](../packages/react/src/__tests__/integration-garbage-collection-race.web.tsx) + sweeps from a sibling layout effect, after the consumer commits and before + its `countRef` effect runs. The entity is deleted while the consumer is + mounted and counted. Because `case GC` returns the same state object, + nothing re-renders. The consumer shows stale data until an unrelated + update, then suspends and refetches. In an app this needs an idle callback + to fire between a non-sync commit and React's scheduled passive-effect + flush. How often that happens is unmeasured. + Vue's provider in [packages/vue/src/providers/createDataClient.ts](../packages/vue/src/providers/createDataClient.ts) applies the reducer inside `realDispatch` before the dispatch promise @@ -130,12 +149,12 @@ Verified in source: denormalizes with `.filter(filterEmpty)`. A missing entity becomes a shorter array rather than an invalid result. -A disposable probe against real `Controller.getResponseMeta` (same `/tmp` -spike) reported: after deleting one entity under a surviving list endpoint, -the list shrank and expiry stayed `Valid`, so Suspense was not forced to -refetch. A deleted entity behind a detail endpoint was `undefined` while -still `Valid`. Re-run this before relying on it; the probe is not in the -repo. +[GCPolicy-dangling.ts](../packages/core/src/state/__tests__/GCPolicy-dangling.ts) +drives real `GCPolicy`, `Controller`, and reducer. A result written without a +mounted consumer (`setResponse`, prefetch, hydration) is never queued, yet an +entity it names is swept when another consumer releases it. The surviving +list shrinks and the surviving detail reads `undefined`, both still `Valid`, +so nothing refetches. ### Memo and index residue @@ -223,12 +242,13 @@ only. They do not choose a product design. with provenance checks so a report is tied to a build manifest. - Some local baselines exist. Android on-device numbers do not. - Several designs were sketched and partly spiked. None is chosen. +- Characterization tests for silent shrink and the commit-to-passive-effect + race (section 2). They assert current behavior; a design that fixes either + should flip them. ### Not done - No production `GCPolicy`, reducer, hook, or schema change. -- No committed regression test for the React dispatch-to-reducer race. -- No committed test for surviving-endpoint silent shrink. - No CI job runs the new GC benchmarks. - No physical Android calibration. - No decision on ownership (per-consumer refs vs query reachability vs whole @@ -283,9 +303,10 @@ Do not treat prior rankings as votes. ### Suggested next evidence, not a required sequence -- Re-run the silent-shrink case as a real Jest test and decide if it is - acceptable. -- Re-run the React deferred-dispatch race under concurrent rendering. +- Decide whether silent shrink is acceptable (it is now a committed test). +- Decide whether the commit-to-passive-effect window matters. Counting in a + layout effect, or reading live counts when the GC reducer applies, are two + ways to close it; neither is measured. - If comparing schedulers, measure release/unmount cost and idle sweep cost separately, on the browser harness, not only in Node. - Before any RN default change, run the release app on a named mid-range From db6e2a91a2b74fb933f5085803637fa42e016098 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 10:59:53 -0400 Subject: [PATCH 12/32] ci: Trim setup workspaces by name instead of index Index deletes silently shift whenever a root workspace is added or reordered. Filter out examples/* and website by name instead; the resulting list is unchanged. Co-Authored-By: Claude Opus 5.5 --- .circleci/config.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c4ab1586b7d2..8b0129440d59 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -123,13 +123,7 @@ jobs: - run: name: Add examples/* to yarn workspace command: | - npm pkg delete workspaces[7] - npm pkg delete workspaces[6] - npm pkg delete workspaces[5] - npm pkg delete workspaces[4] - npm pkg delete workspaces[3] - npm pkg delete workspaces[2] - npm pkg delete workspaces[1] + npm pkg set workspaces="$(node -p "JSON.stringify(require('./package.json').workspaces.filter(w => !w.startsWith('examples/') && w !== 'website'))")" --json npm pkg set 'workspaces[]'='examples/todo-app' npm pkg set 'workspaces[]'='examples/github-app' npm pkg set 'workspaces[]'='examples/normalizr-relationships' From 4d3422d8c6c93078ccbdcedc73a72f8c3cc07789 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 11:03:20 -0400 Subject: [PATCH 13/32] test(react): Wait for refetch render in GC race test on legacy Suspense React 17 keeps the hidden subtree beside the fallback and has not rendered the refetch by the end of act. The deletion assertions are unchanged and pass on React 17, 18, and 19. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/integration-garbage-collection-race.web.tsx | 4 ++-- plans/gc-handoff.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx index e4f69d8aad36..d728b6ad2ea1 100644 --- a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx +++ b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx @@ -1,6 +1,6 @@ import { GCPolicy } from '@data-client/core'; import { DataProvider, useController, useSuspense } from '@data-client/react'; -import { act, render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import { makeGetTodo, mockTodoState, Todo } from '__tests__/concurrentFixtures'; import React, { Suspense, useLayoutEffect, useState } from 'react'; @@ -91,6 +91,6 @@ describe('GC sweep between consumer commit and countRef', () => { controller.setResponse(getTodo, { id: '2' }, { id: '2', title: 'two' }); }); expect(fetchTodo).toHaveBeenCalledTimes(1); - expect(container.textContent).toBe('fetched 1'); + await waitFor(() => expect(container.textContent).toBe('fetched 1')); }); }); diff --git a/plans/gc-handoff.md b/plans/gc-handoff.md index f665924c1545..71d85344860a 100644 --- a/plans/gc-handoff.md +++ b/plans/gc-handoff.md @@ -135,7 +135,8 @@ Committed tests on React 19.2 narrow that reading: nothing re-renders. The consumer shows stale data until an unrelated update, then suspends and refetches. In an app this needs an idle callback to fire between a non-sync commit and React's scheduled passive-effect - flush. How often that happens is unmeasured. + flush. How often that happens is unmeasured. The test passes on React + 17.0.2, 18.3.1, and 19.2.3. Vue's provider in [packages/vue/src/providers/createDataClient.ts](../packages/vue/src/providers/createDataClient.ts) From 560b7106ae8813efedfed7684163c5b22e05b663 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 15:08:02 +0000 Subject: [PATCH 14/32] test(react): Record the React 17 GC race text separately React 17 leaves todo 1loading after the unrelated update. React 18 and later render fetched 1. Keep the race checks that already pass on 17. Co-authored-by: Nathaniel Tucker --- .../integration-garbage-collection-race.web.tsx | 10 +++++++++- plans/gc-handoff.md | 10 ++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx index d728b6ad2ea1..204de23a77e0 100644 --- a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx +++ b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx @@ -91,6 +91,14 @@ describe('GC sweep between consumer commit and countRef', () => { controller.setResponse(getTodo, { id: '2' }, { id: '2', title: 'two' }); }); expect(fetchTodo).toHaveBeenCalledTimes(1); - await waitFor(() => expect(container.textContent).toBe('fetched 1')); + // React 17 legacy Suspense keeps the stale title and the fallback in the + // same container (`todo 1` + `loading`). React 18+ commits the refetch. + // Both are today's behavior, not a chosen design. + const reactMajor = Number(React.version.split('.')[0]); + if (reactMajor < 18) { + await waitFor(() => expect(container.textContent).toBe('todo 1loading')); + } else { + await waitFor(() => expect(container.textContent).toBe('fetched 1')); + } }); }); diff --git a/plans/gc-handoff.md b/plans/gc-handoff.md index 71d85344860a..f06cfb2c5ff7 100644 --- a/plans/gc-handoff.md +++ b/plans/gc-handoff.md @@ -133,10 +133,12 @@ Committed tests on React 19.2 narrow that reading: its `countRef` effect runs. The entity is deleted while the consumer is mounted and counted. Because `case GC` returns the same state object, nothing re-renders. The consumer shows stale data until an unrelated - update, then suspends and refetches. In an app this needs an idle callback - to fire between a non-sync commit and React's scheduled passive-effect - flush. How often that happens is unmeasured. The test passes on React - 17.0.2, 18.3.1, and 19.2.3. + update, then the fetch runs once. On React 18 and 19 that refetch renders + `fetched 1`. On React 17.0.2 the container stays `todo 1loading` (stale + title plus the Suspense fallback). The race checks before that text are + the same on 17. In an app this needs an idle callback to fire between a + non-sync commit and React's scheduled passive-effect flush. How often that + happens is unmeasured. Vue's provider in [packages/vue/src/providers/createDataClient.ts](../packages/vue/src/providers/createDataClient.ts) From 8e7c071ddd26442c9619b0b12f8c09e79b45c698 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 15:09:06 +0000 Subject: [PATCH 15/32] Revert "test(react): Record the React 17 GC race text separately" This reverts commit abc107c01e6f192e2996817753346b384a67b3bf. --- .../integration-garbage-collection-race.web.tsx | 10 +--------- plans/gc-handoff.md | 10 ++++------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx index 204de23a77e0..d728b6ad2ea1 100644 --- a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx +++ b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx @@ -91,14 +91,6 @@ describe('GC sweep between consumer commit and countRef', () => { controller.setResponse(getTodo, { id: '2' }, { id: '2', title: 'two' }); }); expect(fetchTodo).toHaveBeenCalledTimes(1); - // React 17 legacy Suspense keeps the stale title and the fallback in the - // same container (`todo 1` + `loading`). React 18+ commits the refetch. - // Both are today's behavior, not a chosen design. - const reactMajor = Number(React.version.split('.')[0]); - if (reactMajor < 18) { - await waitFor(() => expect(container.textContent).toBe('todo 1loading')); - } else { - await waitFor(() => expect(container.textContent).toBe('fetched 1')); - } + await waitFor(() => expect(container.textContent).toBe('fetched 1')); }); }); diff --git a/plans/gc-handoff.md b/plans/gc-handoff.md index f06cfb2c5ff7..71d85344860a 100644 --- a/plans/gc-handoff.md +++ b/plans/gc-handoff.md @@ -133,12 +133,10 @@ Committed tests on React 19.2 narrow that reading: its `countRef` effect runs. The entity is deleted while the consumer is mounted and counted. Because `case GC` returns the same state object, nothing re-renders. The consumer shows stale data until an unrelated - update, then the fetch runs once. On React 18 and 19 that refetch renders - `fetched 1`. On React 17.0.2 the container stays `todo 1loading` (stale - title plus the Suspense fallback). The race checks before that text are - the same on 17. In an app this needs an idle callback to fire between a - non-sync commit and React's scheduled passive-effect flush. How often that - happens is unmeasured. + update, then suspends and refetches. In an app this needs an idle callback + to fire between a non-sync commit and React's scheduled passive-effect + flush. How often that happens is unmeasured. The test passes on React + 17.0.2, 18.3.1, and 19.2.3. Vue's provider in [packages/vue/src/providers/createDataClient.ts](../packages/vue/src/providers/createDataClient.ts) From 53ae098e7db4af8800d7025e067e4756ef53d2c5 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 11:37:00 -0400 Subject: [PATCH 16/32] docs: Document local React version testing and defer Android GC calibration AGENTS.md gains the worktree recipe for running Jest against CI's other React versions, including the --no-cache trap. The workspace-trim notes now match the by-name filter. The GC handoff records Android on-device calibration as future work. Co-Authored-By: Claude Opus 5.5 --- .cursor/rules/ci-config.mdc | 2 +- AGENTS.md | 4 +++- plans/gc-handoff.md | 15 ++++++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index 84a9c7171d3c..363764c2caac 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -19,7 +19,7 @@ alwaysApply: false - `esmodule-types` also runs `examples/todo-app/tsconfig.typetest-libcheck.json` (`skipLibCheck: false`, `types: []`) so errors inside the legacy outputs fail CI, and `esmodule-types-latest` runs it with `--moduleResolution bundler` (TS 7 removed `node`) to cover `lib/`; the other typetests use `skipLibCheck: true`. - Any change to legacy types building must leave `ts*/` output byte-identical to master (diff it) unless it intentionally changes published types (then add a changeset). - Never `git fetch --depth` the base branch in the relevance check: a shallow fetch severs the merge base and the three-dot diff fails. -- Changing root `package.json` `workspaces` requires updating the `setup` job's workspace trimming step. +- The `setup` job trims root `workspaces` by name (drops `examples/*` and `website`, keeps the rest). A new workspace outside those is kept in every CircleCI job; update the filter if it should not be. ## GitHub Actions (`.github/workflows/`) diff --git a/AGENTS.md b/AGENTS.md index b73593a1a67b..c42b9011e6a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,12 +22,14 @@ Monorepo for `@data-client` high performance npm packages. **Targeted tests**: `yarn test --selectProjects ReactDOM --testPathPatterns packages/react` (project names: `ReactDOM`, `Node`, `ReactNative`) +**Other React versions** (CI runs `^17`, `^18`, `19.3`): never swap versions in the main checkout. In a throwaway `git worktree`: `yarn install`, `yarn run ci:build:setup`, then the matching `npm pkg delete resolutions.*` + `yarn up ...` lines from the `unit_tests` job in `.circleci/config.yml`, then `yarn test --no-cache ...`. Without `--no-cache`, transforms cached before types were built leave `const enum` members (e.g. `ExpiryStatus.Valid`) undefined; that looks like a version failure but isn't. React 17's legacy Suspense keeps hidden content beside the fallback in `textContent`. + ## CI - **CircleCI** (`.circleci/config.yml`) — PR validation: lint, typecheck, unit tests (React 17/18/native/latest), Node matrix, ESM type checks (TS 4.0–5.3+), browser build. - **GitHub Actions** (`.github/workflows/`) — release (`changesets`), bundle size PR comments, benchmark regression detection. -Changing root `package.json` `workspaces` requires updating `.circleci/config.yml` (`setup` job) and `.github/workflows/` install steps. +Adding a root `package.json` workspace outside `examples/` or `website` requires updating `.circleci/config.yml` (`setup` job trims by name) and `.github/workflows/` install steps. ## Changesets diff --git a/plans/gc-handoff.md b/plans/gc-handoff.md index 71d85344860a..fbca7df88bd9 100644 --- a/plans/gc-handoff.md +++ b/plans/gc-handoff.md @@ -251,7 +251,8 @@ only. They do not choose a product design. - No production `GCPolicy`, reducer, hook, or schema change. - No CI job runs the new GC benchmarks. -- No physical Android calibration. +- No physical Android calibration. **Deferred as future work**; not planned + soon. See "Future work" below. - No decision on ownership (per-consumer refs vs query reachability vs whole documents), scheduling, or how a deletion becomes a React snapshot. @@ -310,10 +311,18 @@ Do not treat prior rankings as votes. ways to close it; neither is measured. - If comparing schedulers, measure release/unmount cost and idle sweep cost separately, on the browser harness, not only in Node. -- Before any RN default change, run the release app on a named mid-range - device. The harness is ready enough to do that; the numbers are not. - Keep production GC unchanged until those results exist. +### Future work + +- **Android on-device calibration.** Not planned soon. The release-Hermes app + in `examples/benchmark-native` builds and its unit tests pass, but it has + never run on a physical device. Until it does, there is no React Native + frame or memory baseline. Do not change React Native GC defaults on the + strength of Node or Chromium numbers alone; a design may land web-first and + leave the RN policy as is. When picked up, run the release app on a named + mid-range device (see `examples/benchmark-native/README.md`). + ## 4. What this PR is **Purpose:** let the next design be measured. It adds opt-in harnesses and From 5875fd4de1982fd9eeea16b75c067928c4e220dc Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 11:55:13 -0400 Subject: [PATCH 17/32] docs: Merge GC handoff into a single concise garbage-collection plan The handoff repeated much of garbage-collection.md and carried PR status that belongs in the PR. One doc now holds goals and constraints, current behavior, confirmed gaps with their tests, prior art, open decisions, the cooperative-slicing draft (marked unchosen), the measurement protocol, baselines, and future work. Co-Authored-By: Claude Opus 5.5 --- .../src/state/__tests__/GCPolicy-dangling.ts | 2 +- ...ntegration-garbage-collection-race.web.tsx | 12 +- plans/garbage-collection.md | 857 +++++++----------- plans/gc-handoff.md | 372 -------- 4 files changed, 320 insertions(+), 923 deletions(-) delete mode 100644 plans/gc-handoff.md diff --git a/packages/core/src/state/__tests__/GCPolicy-dangling.ts b/packages/core/src/state/__tests__/GCPolicy-dangling.ts index 8907a06b5a61..b73f04400400 100644 --- a/packages/core/src/state/__tests__/GCPolicy-dangling.ts +++ b/packages/core/src/state/__tests__/GCPolicy-dangling.ts @@ -16,7 +16,7 @@ import createReducer, { initialState } from '../reducer/createReducer'; * a different consumer releases it. The surviving result then denormalizes * without that entity while its expiry stays `Valid`, so nothing refetches. * - * See plans/gc-handoff.md, decision 1 ("What is a GC root"). + * See plans/garbage-collection.md, "Open decisions" 1. */ describe('GCPolicy deletes entities still named by uncounted endpoints', () => { class Article extends Entity { diff --git a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx index d728b6ad2ea1..91fab4110ae4 100644 --- a/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx +++ b/packages/react/src/__tests__/integration-garbage-collection-race.web.tsx @@ -18,14 +18,8 @@ import React, { Suspense, useLayoutEffect, useState } from 'react'; * that window. In an app it would be an idle callback that fires between a * non-sync commit and React's scheduled passive-effect flush. * - * See plans/gc-handoff.md, decision 3 ("How is 'still referenced' known"). + * See plans/garbage-collection.md, "Open decisions" 3. */ -class SweepableGCPolicy extends GCPolicy { - sweep() { - this.runSweep(); - } -} - describe('GC sweep between consumer commit and countRef', () => { it('deletes a mounted consumer entity, which later suspends and refetches', async () => { const fetchTodo = jest.fn(async ({ id }: { id: string }) => ({ @@ -33,7 +27,7 @@ describe('GC sweep between consumer commit and countRef', () => { title: `fetched ${id}`, })); const getTodo = makeGetTodo(fetchTodo); - const gcPolicy = new SweepableGCPolicy({ expiresAt: () => 0 }); + const gcPolicy = new GCPolicy({ expiresAt: () => 0 }); let sweepOnLayout = false; let setShow!: (show: boolean) => void; let controller!: ReturnType; @@ -44,7 +38,7 @@ describe('GC sweep between consumer commit and countRef', () => { } function SweepInLayout() { useLayoutEffect(() => { - if (sweepOnLayout) gcPolicy.sweep(); + if (sweepOnLayout) gcPolicy['runSweep'](); }, []); return null; } diff --git a/plans/garbage-collection.md b/plans/garbage-collection.md index d6b8fce01625..8813b5f6ba9b 100644 --- a/plans/garbage-collection.md +++ b/plans/garbage-collection.md @@ -1,565 +1,340 @@ -# Garbage Collection Direction +# Garbage collection -Status: draft -Owner: TBD +Status: exploring. No design is chosen, and production GC is unchanged. Related: [GOALS.md](../GOALS.md), `packages/core/src/state/GCPolicy.ts` -## Priorities - -Garbage collection must follow the project-wide performance goals and the -platform priorities adopted for this work: - -1. Preserve a continuous 60 FPS experience on mid-range hardware. Collection - must not create blocking stutters during critical updates or interactions. -2. For this work, web prioritizes bundle size before retained cache memory. -3. For this work, React Native prioritizes retained memory before bundle size. -4. Continue minimizing network work. More aggressive collection can cause - refetches, so lower memory use is not automatically a net performance win. -5. Keep the default path understandable, dependency-light, and portable across - React, React Native, Vue, and imperative `Controller` use. - -These priorities imply different platform defaults or adapters over one shared -correctness model. They do not imply different endpoint or schema definitions. - -## Current system - -The current policy is reference-counted and age-gated: - -1. Read hooks obtain a `countRef` from `Controller.getResponseMeta()` or - `Controller.getQueryMeta()`. -2. Mounting increments references for the endpoint key and every entity path - used by the denormalized result. -3. Releasing the last reference queues those endpoint and entity candidates. -4. By default, every five minutes, `GCPolicy` schedules a sweep. -5. A queued candidate is eligible when it is still unreferenced and its GC - expiry has passed. -6. One `GC` action deletes all eligible endpoint results, entities, and their - metadata in place. - -The default GC expiry is: - -```ts +This is the working record for redesigning cache garbage collection (GC). It +covers the goals, how GC works today, confirmed gaps, prior art, open +decisions, one candidate design, and how to measure. Only the goals and +constraints are settled. Everything from [Candidate design](#candidate-design-cooperative-slicing) +onward is a hypothesis. If a simpler design meets the goals, prefer it. + +## Goals + +In priority order: + +1. **Interaction latency.** No dropped frames during critical updates: a + continuous 60 FPS on mid-range hardware, including while GC runs. +2. **Web:** bundle size, then retained memory. +3. **React Native:** retained memory, then bundle size. Bundle size barely + matters on React Native. +4. **Avoid refetches.** Deleting more aggressively can cause refetches. Lower + memory is not automatically a win. ([GOALS.md](../GOALS.md) ranks network + cost above frame rate in general.) + +Web and React Native may pick different points on this tradeoff. Endpoint and +schema definitions stay shared. + +## Constraints + +- **Keep React's built-in reducer.** It exists for concurrent rendering in + React 19+. Do not replace it with an external store to make GC easier. +- **Keep GC bookkeeping out of rendered state.** Reference counts must not + update render-driving state on every mount and unmount. +- **No render from GC alone.** If GC changes nothing a mounted consumer + renders, it must not by itself trigger a React render. +- **Open question, not an approval:** attaching GC work to a later reducer + update must not slow that update, and the update must be unchanged when + nothing is reclaimable. + +## How GC works today + +**Reference counting.** Read hooks get a `countRef` from +`Controller.getResponseMeta()` or `getQueryMeta()`. Mounting increments the +endpoint key and every entity path in the denormalized result. Releasing the +last reference queues those candidates: endpoints in a `Set`, entities in an +array (so one entity can be queued many times). + +**Sweeping.** Every 5 minutes (`intervalMS`), `GCPolicy` calls +`requestIdleCallback(..., { timeout: 1000 })`, or runs the sweep synchronously +if that API is missing. It ignores `timeRemaining()` and `didTimeout`. The +sweep scans the whole queue and dispatches **one** `GC` action for everything +eligible. A candidate is eligible when unreferenced and past: + +```text max((expiresAt - fetchedAt) * expiryMultiplier, 120_000) + fetchedAt ``` -`expiryMultiplier` defaults to `2`. This is deletion policy, not fetch expiry: -staleness and invalidation may cause a refetch without removing normalized data. - -The policy currently exposes: - -- `intervalMS` -- `expiryMultiplier` -- a custom `expiresAt(meta)` function -- complete replacement through `GCInterface` -- `ImmortalGCPolicy` for no automatic deletion - -React `DataProvider` installs `GCPolicy` by default. Its React Native build -substitutes `NativeGCPolicy`. A bare `Controller` and the Vue provider currently -fall back to `ImmortalGCPolicy` unless a policy is supplied. Imperative use must -also own policy lifecycle: constructing a `Controller` with a `GCPolicy` does -not call `init()` or `cleanup()`; providers do that through `initManager`. - -There is no LRU, maximum entry count, or retained-byte limit. - -## Current limitations - -### Unbounded synchronous work - -`runSweep()` scans the complete endpoint and entity candidate queues. It then -dispatches every eligible deletion in one action, whose reducer synchronously -deletes the complete batch. Both phases are unbounded. - -Idle scheduling only changes when this monolithic work begins. It does not make -the work interruptible. On web: - -- `requestIdleCallback(..., { timeout: 1000 })` may run because the timeout - elapsed even when no meaningful idle budget remains. -- The fallback runs the complete sweep synchronously. -- The callback ignores `IdleDeadline.timeRemaining()` and `didTimeout`. - -A sufficiently large queue can therefore produce a long task and dropped -frames. - -### React Native scheduling is deprecated and unbounded - -`NativeGCPolicy` uses `InteractionManager.runAfterInteractions()` and -`InteractionManager.setDeadline()`. In React Native 0.86, `InteractionManager` -is deprecated, `runAfterInteractions()` is implemented using `setImmediate()`, -and `setDeadline()` is a no-op. It defers a sweep but does not protect an active -interaction or bound the resulting task. - -React Native 0.86 officially recommends refactoring long tasks into smaller -units and scheduling them with `requestIdleCallback()`. That is the supported -migration path and should be the native host primitive. It remains a -cooperative idle hint, not a frame-safety guarantee: once a callback or one GC -unit starts, React Native cannot preempt it. The GC must therefore enforce its -own bounded units, elapsed-time/count budgets, yielding, and cancellation. - -### Candidate queue amplification - -Endpoint candidates use a `Set`, but entity candidates use an array. Repeated -mount/release cycles can queue the same entity multiple times before it becomes -eligible, increasing retained queue memory and future sweep work. - -### Incomplete deletion - -Entity GC removes `entities` and `entitiesMeta` entries but does not remove -corresponding entries from `state.indexes`. This retains stale mappings and -some memory. Correct index cleanup needs a bounded design: blindly scanning all -indexes while deleting one entity could itself create an unbounded unit. - -### Lifecycle and race gaps - -- `cleanup()` cancels the interval but not an already scheduled idle callback. -- Eligibility is checked before dispatch. A store integration with asynchronous - dispatch can reacquire a candidate before its deletion is reduced. -- Hook references are installed in effects, leaving a render-to-effect window. -- A subscription by itself does not hold a GC reference. `useLive`, which also - reads the result, normally does. +`expiryMultiplier` defaults to `2`. This is deletion, separate from fetch +expiry (`dataExpiryLength`, invalidation, `expireAll`). -These need explicit invariants and tests before making collection more -aggressive. - -### Memory cannot be bounded - -Age-based collection gives no hard upper memory bound. Active data is retained, -and unreferenced data remains until both its retention threshold and a sweep -pass. JavaScript also lacks a portable, reliable retained-byte measurement API, -so a byte-precise cross-platform policy is not currently realistic. - -### Configuration and documentation are inconsistent - -React enables GC automatically, while Vue and direct `Controller` usage do not. -The provider option and policy settings have little public documentation. The -behavior should be made deliberate and documented before adding more profiles. - -## Cooperative collection model - -JavaScript cannot suspend arbitrary synchronous work. Frame-safe collection -requires the algorithm to expose bounded, resumable units and the scheduler to -run those units within a budget. - -The reusable part is a small internal scheduling shell: - -- schedule another slice; -- decide when the current slice should yield; -- cap elapsed time and work count; -- make progress after prolonged starvation; -- cancel pending callbacks; -- provide platform-specific scheduling; -- allow deterministic test scheduling. - -The GC-specific part owns: - -- candidate traversal state; -- expiry and reference validation; -- deduplication; -- construction of bounded deletion batches; -- revalidation around dispatch; -- bounded index cleanup. - -For the current implementation, a natural scan unit is checking one endpoint -or entity candidate. Deleting one entity and its metadata is a plausible -mutation unit, but index cleanup is not yet naturally bounded: the current GC -action carries only the entity type and primary key, while `GCPolicy` has no -schema/index-name information. The index design must prove how relevant work is -identified, resumed, and bounded without scanning every index in one step. A -slice should process multiple proven-bounded units only while both time and -count budgets remain. - -This is not graph reference-counting GC. Entities do not own references to their -children, entity deletion does not decrement child references, and there is no -cascading traversal. Consumer reads independently retain all entity paths used -by a denormalized result. Any future graph ownership model would be a separate, -much larger semantic change. - -## Direction - -### 1. Bound scanning and deletion - -Make candidate traversal resumable and cap both: - -- candidates examined per slice; and -- records deleted per dispatched action. - -Time budgets adapt to device speed, while count limits protect against timer -quirks and unexpectedly expensive batches. A single unit must also remain -bounded; the scheduler cannot compensate for one deletion that scans an entire -store or synchronously notifies unbounded work. - -### 2. Use a small internal cooperative scheduler - -Prefer a small internal executor over a runtime dependency: - -- React's `scheduler` package exposes `unstable_` APIs and would couple core - data behavior to a React implementation package. -- generic coroutine or iterator dependencies do not identify domain-specific - safe yield boundaries; -- the required scheduling shell is small compared with the GC correctness - logic; -- avoiding a dependency protects bundle size and wide platform compatibility. - -The scheduler should remain internal initially. Deterministic tests should use -a private test factory or internal host adapter. Constructor injection or a new -protected hook on exported `GCPolicy` would still become observable public and -subclass surface even if `GCInterface` remained unchanged. - -Do not copy a `requestIdleCallback` loop without handling starvation. When a -callback fires with `didTimeout` and no remaining idle time, the executor must -still perform a tightly bounded amount of work before yielding again; otherwise -it can reschedule forever without progress. - -### 3. Keep the public policy seam small - -Preserve `GCInterface` and provider/controller injection. Begin with internal -scan, deletion, and time budgets selected by benchmarks. Add public -`GCOptions` controls only if concrete application needs demonstrate stable, -understandable semantics beyond platform defaults. - -Scheduling strategy, callback handles, continuation state, and platform host -APIs should not become endpoint, schema, or hook configuration. - -### 4. Use platform-specific policy defaults - -Initial target profiles: - -| Concern | Web | React Native | -| --- | --- | --- | -| Scheduling | Browser `requestIdleCallback` with yielding async fallback | React Native `requestIdleCallback` with explicit bounded units | -| Slice size | Conservative | Conservative, validated on mid-range Android | -| Sweep cadence | Current five-minute baseline | More frequent candidate processing | -| Retention | Current baseline | Start unchanged; lower only with refetch and memory evidence | -| Memory pressure | Optional/manual aggressive collection | Research optional application/native bridge | -| Bundle cost | Strictly constrained | Secondary to measured retained-memory improvement | - -An aggressive collection request may shorten retention but must never override -an active reference. Platform profiles should be thin adapters or options, not -LRU logic on every cache access. - -### 5. Do not add default LRU bookkeeping - -LRU or hard entry limits add work to hot reads/writes, increase bundle size, -and do not make eviction itself frame-safe. They also use entry count or recency -as weak proxies for actual memory. Keep such policies optional unless measured -applications demonstrate that age-based cooperative collection is insufficient. - -### 6. Fix correctness and lifecycle gaps - -Before increasing collection frequency: - -- deduplicate entity candidates; -- cancel pending scheduled slices; -- define reacquisition behavior through deletion commit; -- make index cleanup correct and bounded; -- test effect/subscription lifecycle behavior; -- reconcile and document provider defaults. - -## Evaluation of scheduler alternatives - -The cooperative-scheduling proposal contributes several useful conclusions: - -- the scheduling shell and yield boundaries are separate concerns; -- an arbitrary synchronous sweep cannot be made interruptible by wrapping it; -- a queue already provides continuation state; -- cancellation, starvation behavior, and per-slice elapsed-time caps belong in - the scheduler; -- a single step must be bounded; -- an internal executor is preferable to React's unstable scheduler dependency. - -It does not change the overall direction of bounded cooperative GC. It sharpens -the design by favoring a reusable internal scheduling shell instead of embedding -platform timing directly into `runSweep()`. - -Parts that require qualification or do not transfer directly: - -- `requestIdleCallback` is React Native's supported migration path from - `InteractionManager`, but it cannot by itself guarantee frame safety. The - supplied executor is safe only if every `task.step()` is bounded and its - continuation yields as designed. -- Its loop can starve when a timeout fires with no `timeRemaining()`, because it - performs no mandatory bounded step. -- The proposed child-reference decrements and cascading deletion describe graph - GC, not the current consumer-reference model. -- A `CooperativeTask`/`CooperativeScheduler` public interface is unnecessary at - this stage. Keeping it internal avoids premature API and bundle commitments. -- Scheduling one candidate at a time is insufficient if the eventual reducer - still receives and deletes an unbounded batch; scanning and mutation must - both be sliced. - -## Validation gates - -Before changing defaults, establish a measurement protocol and calibrate it -against the current monolithic GC. Exact time, count, cadence, and retention -defaults remain open until variance is measured. The gates below freeze *how* -we measure; they do not invent pass/fail numbers yet. - -### Measurement layers - -Measurement is layered. v1 uses three harnesses with one shared documented JSON -vocabulary and **no** shared runtime package or dependency in this phase: - -| Layer | Runtime | Role | -| --- | --- | --- | -| Node | Node.js | Stable, repeatable totals, slice summaries, action/queue cardinality | -| Browser | Chromium | Long tasks, interaction latency, heap snapshots, bundle-adjacent checks | -| Android | Release Hermes | Mid-range device frame stalls and retained memory under navigation/gestures | - -Each harness may keep a local typed mirror of the result schema. Emitted JSON -must conform **semantically** to the shared vocabulary; cross-harness type -sharing is explicitly out of scope for this phase. - -### Scenario axes - -Every run is identified by a stable, deterministic scenario ID derived from: - -| Axis | Values | -| --- | --- | -| `platform` | `node` \| `browser` \| `android` | -| `candidateKind` | `entity` \| `endpoint` \| `mixed` | -| `pattern` | `unique` \| `duplicate` | -| `count` | Canonical `1000`, `10000`, `100000` | -| `mode` | `scan` \| `reducer` \| `end-to-end` \| `interaction` \| `memory` | -| `control` | `gc` \| `no-gc` | - -IDs must be stable across machines and commits so medians can be compared. The -canonical large gate remains at least 100,000 queued candidates; smaller counts -exist for calibration and CI cost control. - -### Lifecycle boundaries - -Timing windows are explicit. Never conflate V8/Hermes **engine** GC with -data-client **cache** GC. - -1. **Prepare** — build the fixture and queue outside the timed window. -2. **Warmup** — optional; not part of reported totals when used. -3. **Timed cache-GC work** — the measured collection (or no-op control). -4. **Event-loop settling** — allow queued microtasks/macrotasks to drain before - interaction or heap observations that depend on quiescence. -5. **Forced engine GC** — only after interaction timing, and only when taking - heap snapshots. Engine GC pauses are optional metrics, not substitutes for - cache-GC slice timing. -6. **Teardown** — discard fixture state; do not include in samples. - -### Aggregated result vocabulary - -Each run emits one aggregated JSON report. Units are explicit in field names or -documented once per schema version. Do **not** log, mark, or bridge per -candidate; instrumentation cost must not dominate the work being measured. - -Core fields: - -- `schemaVersion` -- commit / build label -- environment fingerprint (runtime, OS, device class as available) -- scenario descriptor (the axes above) -- `samples` and `summary` - -Core metrics: - -- `totalMs` -- optional `sliceDurationsMs`-derived `max` / `p95` / `p99` -- `actionCount` -- targets / deletions -- queue cardinality - -Optional platform metrics (when the harness can collect them): - -- `maxInputDelayMs` -- `missedFrames` -- `displayPeriodMs` -- `longTaskCount` / `longTaskTotalMs` -- `heapBefore` / `heapAfter` / `heapDelta` -- `engineGCPauseMs` -- artifact sizes (for example heap snapshot or trace bytes) - -### Honest representation of monolithic GC - -The current implementation is one unbounded scan plus one deletion action for -eligible candidates. Reports must represent that honestly: - -- normally **one** timed slice and **one** deletion action; -- do **not** fabricate per-candidate or synthetic slice samples. - -When a future cooperative implementation lands, slice boundaries may come from -**benchmark-internal** instrumentation only. This measurement phase does not -add a new public GC API solely to expose slice markers. - -### Fixture setup - -Fixtures are deterministic: - -- fixed seed / data generation; -- `expiresAt` overridden to zero so queued candidates are immediately eligible; -- timerless explicit benchmark policy (no five-minute interval driving the run); -- **unique** pattern: distinct endpoint keys or entity `key`+`pk` pairs; -- **duplicate** pattern: repeat one candidate and report queue entries versus - unique targets. - -Self-validation after each run verifies expected queue cardinality and final -deletion count before the report is accepted. - -### Calibration - -1. Establish the current monolithic baseline **before** cooperative - implementation changes land. -2. Repeat runs on the same machine or device; compare medians for totals and - `p95` / `p99` / `max` for stalls. -3. Alternate baseline and change builds on the same host to reduce drift. -4. Always include a `no-gc` control alongside `gc`. -5. Frame judgment uses measured `displayPeriodMs` and **excess** stalls over the - control — not a hardcoded 16.67 ms assumption. -6. Raw machine reports are **artifacts**, not committed universal baselines. -7. Exact pass thresholds freeze only after measured variance is known. - -### Gate priorities - -Order of judgment when trade-offs appear: - -1. **Interaction / frame impact** is the primary hard gate. Prove that - input/timer callbacks can execute between collection slices; measure browser - long tasks, interaction latency, and React Native JS/UI frame stalls on - mid-range Android during navigation and sustained gestures. -2. **Total collection time** and **action count** may rise under cooperative - slicing within calibrated ceilings. Report worst slice duration, total - duration, and deletion action count. -3. **Web bundle delta** is next (`yarn ci:build:bundlesize`); any increase needs - a measured interaction-latency justification. -4. **React Native retained memory** is next; measure heap during mount/unmount - churn and continuous entity updates where the harness supports it. -5. **Network / refetch impact** from cadence or retention changes is deferred. - This measurement phase does not change those defaults. - -Also required before aggressive default changes: - -- Jest coverage for reacquisition, cancellation, duplicate candidates, index - reuse, and starvation behavior (logic only; see automation boundary). -- Existing core and React benchmark suites remain within noise for foreground - reads and writes. - -### Automation boundary - -| Class | Examples | Automation | -| --- | --- | --- | -| Stable | Node totals; Chromium interaction/long-task runs after calibration | Eligible as CI gates once variance and thresholds exist | -| Expensive / device | Heap snapshots; release-Hermes Android frame and memory | Start as manual; promote only with cost justification | -| Unit tests | Jest correctness for GC policy and reducer behavior | Required for logic; **not** a substitute for idle or frame measurement | - -### Implemented harnesses and artifacts - -v1 measurement harnesses exist in the monorepo (shared JSON vocabulary; no shared -runtime package): - -- **Node** — `examples/benchmark` (`start:gc`): scan / reducer / end-to-end modes - over the canonical scenario axes, with `gc` / `no-gc` controls. -- **Browser** — `examples/benchmark-react` (`bench:gc`): Chromium interaction, - long-task, frame-period, and heap-delta collection for the same axes. -- **Android** — `examples/benchmark-native`: release-Hermes interaction / frame / - observational memory runs on device; host matrix/collect scripts pull reports - over adb. Build and unit validation can pass without a device; device runs are - still required for calibration. - -Aggregated schemaVersion-1 JSON reports are generated artifacts (stdout redirect, -`BENCH_GC_OUTPUT`, or the native artifacts path). Optional traces/heap snapshots -stay local. Raw machine reports and calibration notes are not committed as -universal baselines — see Baseline capture below for current host status and -rerun commands. - -### Baseline capture and gate status - -Captured **2026-07-18** on Linux WSL2 x64, Node v24.5.0, headless Chromium 149 -(~16.7 ms measured display period). Numbers below are from **full provenanced -reruns** tied to BuildManifest IDs. Raw reports remain local `/tmp` (and native -sidecar) ignored artifacts — not committed universal baselines. -`dirty=true` is expected: the measurement implementation is still uncommitted; -manifest `buildId` / source / artifact hashes still make those local reports -attributable. This measurement-phase work does **not** change GC production -defaults. - -**What ran successfully** - -- ReactDOM GC policy / `countRef` / web integration: 3 suites, 10 tests passed; - ReactNative GC integration: 1 suite/test passed. -- Provenanced Node and browser 100k matrices (manifests below). -- Core `^get` / `^set` and React browser small hot-path controls (output retained - in local artifacts; passed). -- `yarn ci:build:bundlesize` completed (`rdcClient.js` 33.8 KiB minified absolute - size — a build baseline, not a branch delta). -- Native release assemble + sidecar verification (manifest below). No adb device - attached → **no** Android frame/memory baseline. - -**BuildManifest provenance** - -| Layer | Status | Identity | -| --- | --- | --- | -| Node | complete (100k matrix) | `buildId` `5da8f199371b9c84fe516700e977386f39267009ca6eae05ed5c0c16427d7a1c`; commit `4a2faa52558eb593e6e1121e2301a6bb8410690c`; `dirty=true` | -| Browser | `complete=true` | `buildId` `1d71ad58789bb9ac78d6fd0aa59259e59ebe8915bd9102a55bfd7086c54c0bce`; same commit; `dirty=true` | -| Native | build only (no device) | `buildId` `4a744c2e08acd490161a61b6d2c69a7d718af694e5b7b9775ec27c2ef56a006d`; `sourceDigest` `fab95578c64693c89f767e7fc7378fc8c8c81168174161641f251c79685672b7`; APK sha `fbc7355b4eb168f4dbce8dc837ae697a6e0d2fc908a5479fe2897e46d5f23949`; size 52,247,736 bytes; sidecar verified | - -**100k Node median / p95 `totalMs` (samples=5)** - -| Scenario | scan | end-to-end | -| --- | --- | --- | -| entity unique | 5.247 / 6.571 | 12.991 / 14.467 | -| endpoint unique | 14.745 / 16.633 | 33.691 / 39.094 | -| mixed unique | 8.961 / 9.859 | 21.113 / 21.527 | -| entity duplicate | 2.242 / 2.280 | 5.911 / 5.992 | - -**Browser 100k `gc` medians (3 samples, `complete=true`)** - -| Scenario | `totalMs` | `maxInputDelayMs` | notes | -| --- | --- | --- | --- | -| entity unique | 9.0 | 9.1 | `heapDelta` −10,704,036 B | -| endpoint unique | 16.7 | 16.8 | `frameIntervalMax` 17.7 ms; `heapDelta` −15,031,404 B | -| mixed unique | 13.5 | 13.5 | `heapDelta` −12,755,312 B | -| entity duplicate | 6.8 | 6.8 | `heapDelta` −433,492 B | - -Matching `no-gc` `maxInputDelayMs` controls were 0–0.1 ms. All 100k runs reported -0 Long Tasks and 0 nearest-period `missedFrames`. Long Tasks only fire at ≥50 ms, -so they miss a sweep that still consumes roughly one 60 Hz frame budget — visible -in endpoint unique `maxInputDelayMs` ≈ 16.8 ms versus the ~16.7 ms measured -period. A synthetic 45 ms calibration reported ~45.1 ms max frame interval and -2 missed frames, confirming the pending-frame probe. - -**Frozen gate definitions (not unsupported universal numbers)** - -1. **Correctness** — fixture self-validation of queue cardinality and final - deletion count; Jest coverage for reacquisition, cancellation, duplicates, - index reuse, and starvation. -2. **Primary interaction comparisons** — GC **excess** over the matching `no-gc` - control for `maxInputDelayMs`, missed frames, and long tasks. Frame budget - uses measured `displayPeriodMs`, not a hardcoded 16.67 ms. -3. **Cooperative ceilings** — max-slice and total-overhead numeric limits remain - calibration outputs. One local host with 3–5 samples is insufficient to freeze - universal thresholds. -4. **CI promotion** — Node/browser may become gates only after repeated controlled - CI variance runs; not claimed yet. -5. **Bundle** — repo rule unchanged: ~1 KiB growth must justify a 5–10% relevant - measured win. -6. **Android** — uncalibrated. Any React Native **default** change stays blocked - until release-Hermes runs on a named mid-range physical device under this - protocol. Harness/APK completion is not device validation. - -**Recommended reruns** +**Deleting.** The reducer's `case GC` deletes from `entities`, `entitiesMeta`, +`endpoints`, and `meta` **in place** and returns the **same state object**. It +does not touch `indexes` or `optimistic`. + +**Hooks.** Read hooks call `countRef` in `useEffect`, so there is no reference +between render and the passive effect. `useSubscription` alone holds no +reference; `useLive` does, because it also reads. + +**Defaults.** React `DataProvider` uses `GCPolicy`, and `NativeGCPolicy` on +React Native. Vue and a bare `Controller` use `ImmortalGCPolicy` unless one is +passed. `Controller` does not call `gcPolicy.init()`; providers do, via +`initManager`. Public knobs: `intervalMS`, `expiryMultiplier`, a custom +`expiresAt(meta)`, or a whole `GCInterface`. GC options are barely documented. + +**React Native.** `NativeGCPolicy` uses `InteractionManager`, which React +Native 0.86 deprecates: `runAfterInteractions` is `setImmediate`, and +`setDeadline` is a no-op. The supported replacement is `requestIdleCallback`. +It is only a cooperative hint; once a callback starts, React Native cannot +preempt it. + +**React commit timing.** Cache state lives in `useReducer`, through +`useEnhancedReducer` in `DataStore.tsx`. `usePromisifiedDispatch` resolves a +dispatch after its state commits, and `Controller.getState()` returns the last +committed snapshot, so it lags in-flight dispatches. Vue applies the reducer +synchronously inside `realDispatch`, so React's timing gaps do not +automatically apply to Vue. + +## Confirmed gaps + +### Synchronous, unbounded work + +One sweep scans the full queue, and one reducer call deletes the full batch. +Idle scheduling only delays the start; it cannot interrupt the work. A large +queue can therefore drop frames. See [Baselines](#baselines) for sizes. + +### Entities deleted under surviving results + +Test: [`GCPolicy-dangling.ts`](../packages/core/src/state/__tests__/GCPolicy-dangling.ts) + +A result written without a mounted consumer is never queued: `setResponse`, +prefetch, hydrated `initialState`, or a fetch nobody mounted. An entity it +names is still swept when another consumer releases that entity. List schemas +filter missing entities (`filterEmpty` in `schemas/Array.ts`), so the +surviving list silently shrinks. A surviving detail reads `undefined`. Both stay +`Valid`, so nothing refetches. + +Those never-mounted results are also never collected themselves. + +### Deleting a mounted consumer's data + +Test: [`integration-garbage-collection-race.web.tsx`](../packages/react/src/__tests__/integration-garbage-collection-race.web.tsx), +passing on React 17, 18, and 19. + +A sweep that runs after a consumer commits but before its `countRef` effect +sees a zero count and deletes the entity. Because the reducer returns the same +state object, nothing re-renders. The consumer shows stale data until an +unrelated update, then suspends and refetches. In an app this needs an idle +callback to fire between a non-sync commit and React's passive-effect flush. +How often that happens is unmeasured. + +A related hypothesis did **not** reproduce on React 19.2: a GC update queued +ahead of a synchronous mount is applied in the same render, so the new +consumer suspends and refetches instead of losing counted data. A GC +dispatched at transition or idle priority is untested; a pure +`(state, action)` reducer cannot see a count incremented after the action was +queued. + +### Leftover memory after deletion + +- `state.indexes` keeps `indexes[key][indexName][value] = pk` rows. Deleting + one row is cheap from the dying entity's field value (delete only if it + still maps to that pk). Scanning every index is unbounded. +- `normalizr/src/memo/entitiesCache.ts` keeps a strong `Map` of key → pk → + `WeakMap`. Values can be collected by JS GC, but the map entries cannot. + +### Lifecycle + +- `cleanup()` clears the interval but not an already scheduled idle callback. +- There is no memory bound, LRU, or entry limit. JavaScript has no portable + retained-byte API, so a byte-precise policy is not realistic. + +## Prior art + +Checked against official docs (not source), 2026-07-18: + +| Library | What survives | Cost | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| RTK Query | Whole document per endpoint+args; removed after `keepUnusedDataFor` (60s). Never collects an entity out from under a query. | Duplicate copies; tag-driven refetch instead of normalization | +| Relay | Retains queries and mark-sweeps records unreachable from them. A record a retained query references stays. Release buffer: 10 queries. | Needs explicit references and compiler-known selections | +| Apollo `gc()` | Traces from roots and keeps reachable objects. | Tracing cost | +| Apollo `evict(id)` | May leave dangling refs. Lists filter them, like our silent shrink; singular refs need a custom `read`. | Correctness is left to the app | + +## Open decisions + +1. **What is a GC root?** Per-consumer counts allow + [entities deleted under surviving results](#entities-deleted-under-surviving-results). + The alternatives are whole-document eviction (RTK Query, which gives up + normalization), reachability from retained queries (Relay, Apollo `gc`), or + accepting dangling refs (Apollo `evict`, today). +2. **When may GC change a React snapshot?** Immutable deletion committed + alone renders. In-place mutation avoids the render but conflicts with + concurrent reducer replay. Swapping in a prebuilt table on a later real + update avoids an extra render but must stay off that update's critical + path. Never collecting while idle retains memory. React Native may accept a + standalone low-priority commit more readily than web. +3. **How is "still referenced" known at deletion time?** Options: + - read live counts inside the GC reducer (impure, but closes the race); + - count in `useLayoutEffect` instead of `useEffect`; + - commit GC synchronously; + - accept rare deletion of newly mounted data. + + Settle this with a concurrent-rendering test. + +4. **What runs during interaction versus idle?** Unmount must stay cheap. Do + not insert into a heavy structure, walk `Object.keys` of a large store, or + delete inside the reducer on the critical path. One option is a cheap inbox + drained later. +5. **Is the never-mounted leak in scope?** A periodic census, or enqueueing + when data is written, would find never-mounted results. Enqueueing at write + time taxes normalize. +6. **Index rows and memo map entries.** Clean them up as part of whichever + deletion model is chosen, not bolted on first. +7. **Platform policy versus shared mechanism.** One option is shared + correctness with per-platform cadence, retention, and whether idle commits + are allowed. The other is a separate collector per platform. + +## Candidate design: cooperative slicing + +A prior draft. Not chosen. It mainly addresses +[synchronous, unbounded work](#synchronous-unbounded-work), and does not by +itself answer open decisions 1–3. + +JavaScript cannot suspend synchronous work, so frame-safe collection needs +bounded, resumable units and a scheduler that runs them within a budget. + +- **Bound scanning and deletion.** Make queue traversal resumable. Cap both + candidates examined per slice and records deleted per action, by elapsed + time and by count. Each single unit must also be bounded, including index + cleanup. +- **A small internal scheduler, not a dependency.** It schedules slices, + yields on time or count budgets, cancels pending callbacks, and allows + deterministic tests. Avoid React's `scheduler`: it is `unstable_` and ties + core to React. A `requestIdleCallback` loop must still do a small amount of + work when it fires with `didTimeout` and no time left, or it can reschedule + forever. +- **Keep the public surface small.** Keep `GCInterface` and provider + injection. Keep budgets internal until real apps need public knobs. +- **Per-platform defaults.** + + | Concern | Web | React Native | + | ----------- | -------------------------------------------- | --------------------------------------------- | + | Scheduling | `requestIdleCallback` plus an async fallback | `requestIdleCallback` plus bounded units | + | Cadence | Current 5 minutes | More frequent | + | Retention | Current | Unchanged until refetch and memory data exist | + | Bundle cost | Strictly limited | Secondary to memory | + +- **No default LRU.** It taxes every read and write, grows the bundle, and + does not make eviction frame-safe. +- **Prerequisites** before collecting more often: deduplicate entity + candidates, cancel pending slices, define behavior when a candidate is + re-referenced before its deletion commits, bound index cleanup, and document + provider defaults. + +A disposable Node spike compared queue structures at 100k candidates (one +machine, 7 samples): + +| Workload | Flat re-scan | Calendar buckets | +| -------------------------- | -----------: | ---------------: | +| 0% due | ~11.5 ms | ~0.01 ms | +| 10 ticks, mostly unexpired | ~110 ms | ~0.6 ms | +| 100% due | ~7.8 ms | ~25 ms | +| Insert 100k | ~5.9 ms | ~30 ms | + +Buckets win when little is due but cost more at insert time, which happens +during unmount. A min-heap was worse on time and memory. `Object.keys` over +~100k meta took ~4 ms and ~2.7 MiB. Resumable `for...in` slices of 256 took +~0.02 ms. + +## Measuring + +Three opt-in harnesses, not in CI, share one JSON report vocabulary but no +code: + +| Harness | Command | Measures | +| ------------------------------------ | -------------------- | ------------------------------------------------------------------- | +| Node, `examples/benchmark` | `start:gc` | Scan, reducer, and end-to-end totals | +| Chromium, `examples/benchmark-react` | `bench:gc` | Input delay, frames, Long Tasks, heap | +| Android, `examples/benchmark-native` | `matrix` / `collect` | Release-Hermes frames and memory over `adb` (never run on a device) | + +Each report is tied to a build manifest of source and artifact hashes; stale +or tampered reports are rejected. Harness-only policies disable the interval +and force expiry to zero so one sweep can be timed. They are fixtures, not +proposed defaults. + +### Protocol + +- **Scenario ID:** `platform` × `candidateKind` (`entity` | `endpoint` | + `mixed`) × `pattern` (`unique` | `duplicate`) × `count` (1k, 10k, 100k) × + `mode` (`scan` | `reducer` | `end-to-end` | `interaction` | `memory`) × + `control` (`gc` | `no-gc`). The 100k run is the main one. +- **Timing window:** build fixtures before timing. Time only the cache-GC + work, let the event loop settle, and force engine GC only for heap + snapshots. Never confuse JS engine GC with cache GC. +- **Reports:** one aggregated JSON per run. Fields include `totalMs`, slice + `max`/`p95`/`p99`, `actionCount`, deletions, and queue size, plus optional + `maxInputDelayMs`, `missedFrames`, `displayPeriodMs`, Long Tasks, and heap. + No per-candidate logging. Today's GC reports one slice and one action; + never fabricate slices. +- **Calibration:** repeat on one host, alternate baseline and candidate + builds, and always run `no-gc` beside `gc`. Judge frames by **excess** over + the control against the measured `displayPeriodMs`, not a hardcoded 16.67 ms. + Freeze thresholds only after variance is known. +- **Judgment order:** interaction and frame impact first; then total time and + action count, which may rise under slicing; then web bundle delta (about + 1 KiB needs a 5–10% measured win); then React Native memory. Foreground + `core` and React benchmarks must stay within noise. +- **Automation:** Jest covers logic only. Node and Chromium could become CI + gates after CI variance runs. Android stays manual. + +### Baselines + +Captured 2026-07-18 on Linux WSL2, Node 24.5, headless Chromium 149 (~16.7 ms +display period), before the harness was committed. Single host, 3–5 samples: +evidence, not thresholds. Raw reports were local and are not in git. + +Node, 100k, median / p95 `totalMs` (5 samples): + +| Scenario | Scan | End-to-end | +| ---------------- | ----------- | ----------- | +| entity unique | 5.2 / 6.6 | 13.0 / 14.5 | +| endpoint unique | 14.7 / 16.6 | 33.7 / 39.1 | +| mixed unique | 9.0 / 9.9 | 21.1 / 21.5 | +| entity duplicate | 2.2 / 2.3 | 5.9 / 6.0 | + +Chromium, 100k `gc`, medians (3 samples): + +| Scenario | `totalMs` | `maxInputDelayMs` | Heap delta | +| ---------------- | --------- | ----------------- | ---------- | +| entity unique | 9.0 | 9.1 | −10.7 MB | +| endpoint unique | 16.7 | 16.8 | −15.0 MB | +| mixed unique | 13.5 | 13.5 | −12.8 MB | +| entity duplicate | 6.8 | 6.8 | −0.4 MB | + +`no-gc` input delay was 0–0.1 ms. Long Tasks stayed at 0 because Chromium only +reports tasks of 50 ms or more. The endpoint sweep still uses about one full +60 Hz frame. A synthetic 45 ms block measured ~45.1 ms max frame interval and +2 missed frames, which confirms the frame probe works. `rdcClient.js` was +33.8 KiB minified (absolute size, not a delta). + +### Rerun ```bash -# Node 100k gc + no-gc +# Node yarn build:benchmark yarn workspace example-benchmark start:gc /100000/ --samples=5 --no-table -# Browser 100k (preview must be serving dist/) +# Chromium (preview must be serving dist/) yarn build:benchmark-react BENCH_GC_OUTPUT=/tmp/gc-browser.json yarn workspace example-benchmark-react \ bench:gc --samples 3 --scenario 100000 -# Native 100k gc + no-gc (requires adb device + release APK) +# Android (needs a device and a release APK) yarn workspace example-benchmark-native build:android:release SAMPLES=5 yarn workspace example-benchmark-native matrix entity/unique/100000 -# Foreground controls +# Foreground controls and bundle yarn workspace example-benchmark start core '^get' yarn workspace example-benchmark start core '^set' yarn workspace example-benchmark-react bench:small --lib data-client - -# Bundle yarn ci:build:bundlesize ``` + +## Future work + +- **Android on-device calibration.** Not planned soon. Without it there is no + React Native frame or memory baseline, so do not change React Native GC + defaults on Node or Chromium numbers alone. A design can land on web first. + When this is picked up, run the release app on a named mid-range device + (see `examples/benchmark-native/README.md`). +- **Benchmarks in CI.** Only after repeated CI runs show acceptable variance. diff --git a/plans/gc-handoff.md b/plans/gc-handoff.md deleted file mode 100644 index fbca7df88bd9..000000000000 --- a/plans/gc-handoff.md +++ /dev/null @@ -1,372 +0,0 @@ -# Garbage collection handoff - -Status: handoff for a new agent. Design is **not** decided. -PR: https://github.com/reactive/data-client/pull/4034 -Branch: `demo/gc-benchmark-harnesses` - -The next agent should reach its own conclusion about the best GC design. -Recommendations in this file, in [garbage-collection.md](./garbage-collection.md), -and in the prior chat are **hypotheses and evidence**, not instructions. -`plans/garbage-collection.md` sections titled Cooperative collection model, -Direction, and Evaluation of scheduler alternatives are a prior agent's draft. -Do not implement them because they are written down. - -## 1. User intent and goals - -The user wants a GC policy for `@data-client` that can trade interaction -latency, bundle size, and retained memory differently on web and React Native. - -Stated priority order: - -1. **Interaction latency.** No dropped frames during critical updates. A - continuous 60 FPS experience on mid-range hardware, including while - collection runs. [GOALS.md](../GOALS.md) separately ranks networking as - the most expensive cost, then 60 FPS, then bundle size. For this GC work - the user put interaction first, and treated extra refetches as a reason - not to collect more aggressively just to save memory. -2. **Web:** bundle size, then retained memory. -3. **React Native:** retained memory, then bundle size. Bundle size barely - matters on RN. Memory matters much more than on web. -4. The two platforms should be able to choose different points on that - tradeoff. Endpoint and schema definitions should stay shared. -5. More aggressive deletion can cause refetches. Lower memory is not - automatically a win. - -Constraints the user stated while reviewing options. These are preferences to -weigh, not a finished architecture: - -- React's built-in reducer is intentional. It exists to support concurrent - rendering in React 19 and later. Do not replace it with an external store - merely to make GC easier. -- Reference counts and other GC bookkeeping should stay **outside** rendered - React state. State that drives rendering should not update on every - mount/unmount. -- If GC does not change anything a mounted consumer renders, it should not by - itself be an operation that triggers a React render. -- The user asked whether attaching GC work to a later reducer update would slow - that update, and whether an update is unchanged when nothing is reclaimable. - Those questions are open design constraints, not approvals of a piggyback - design. - -The user asked the previous agent to measure current behavior before changing -defaults, including an on-device React Native benchmark app. That measurement -harness is this PR. It is not a decision to ship cooperative slicing, calendar -queues, or any other collector. - -## 2. Verified information - -### Current production GC - -Verified by reading current source on this branch (production GC is unchanged -by the PR): - -- [packages/core/src/state/GCPolicy.ts](../packages/core/src/state/GCPolicy.ts) - reference-counts endpoint keys and entity paths. Last release queues the - candidate. Endpoint keys use a `Set`. Entity paths use an array, so the - same entity can be queued more than once. A `setInterval` (default 5 - minutes) calls `idleCallback`, which runs `runSweep` via - `requestIdleCallback` or **synchronously** if that API is missing. The - sweep scans the whole queue and dispatches **one** `GC` - action. Eligibility is unreferenced plus - `fetchedAt + max((expiresAt - fetchedAt) * expiryMultiplier, 120_000)`, - default multiplier `2`. -- [packages/core/src/state/reducer/createReducer.ts](../packages/core/src/state/reducer/createReducer.ts) - `case GC` deletes `entities`, `entitiesMeta`, `endpoints`, and `meta` - **in place** and returns the **same state object**. It does not touch - `indexes` or `optimistic`. -- React read hooks install `countRef` in `useEffect`, for example - [packages/react/src/hooks/useSuspense.ts](../packages/react/src/hooks/useSuspense.ts). - There is a render-to-passive-effect window with no reference. - `useSubscription` does not call `countRef`. A subscription alone does not - retain cache data unless a read hook does. -- React `DataProvider` defaults to `GCPolicy`. Vue and a bare `Controller` - default to `ImmortalGCPolicy` unless a policy is passed. `Controller` - construction does not call `gcPolicy.init()`; providers do that through - `initManager`. -- [packages/react/src/state/GCPolicy.native.ts](../packages/react/src/state/GCPolicy.native.ts) - still schedules with `InteractionManager`. In the repo's React Native 0.86 - install, `Libraries/Interaction/InteractionManager.js` implements - `runAfterInteractions` with `setImmediate` and `setDeadline` as a no-op, - and the module is deprecated. RN's own deprecation text says to use - `requestIdleCallback`. The user corrected an earlier claim: RIC is the - supported migration path, but it is a cooperative hint. Once a callback - starts, RN cannot preempt it. -- Fetch expiry (`dataExpiryLength`, invalidation, `expireAll`) is separate - from physical deletion. - -### React commit timing - -Verified by reading: - -- [packages/react/src/components/DataStore.tsx](../packages/react/src/components/DataStore.tsx) - keeps cache state in `useEnhancedReducer` (`useReducer` plus middleware). -- [packages/use-enhanced-reducer/src/usePromisifiedDispatch.ts](../packages/use-enhanced-reducer/src/usePromisifiedDispatch.ts) - queues the reducer update and resolves the dispatch promise in an effect - after that state commits. -- `Controller.getState` is the committed snapshot copied into a ref from that - effect, so it lags an in-flight dispatch. - -Consequence, from that control flow: a GC action can be queued while -`useReducer` has not applied it. A later `countRef` increment updates only -the external `GCPolicy` maps. The already queued action still contains the -old deletion list. A **pure** `(state, action)` reducer cannot see that -later increment. A disposable state-machine probe under -`/tmp/data-client-gc-correctness-spike/` (not in git; may be gone) reported -that side-table vetoes and epoch tickets also fail if the reducer does not -read live policy state at apply time, and that a follow-up React action does -not help because the GC action is already ahead in the queue. Treat that -probe as supporting evidence for the control-flow reading, not as a shipped -test. - -Committed tests on React 19.2 narrow that reading: - -- **Queued GC ahead of a sync mount: did not reproduce.** A sweep dispatched - at default priority followed by `flushSync` of a new consumer rendered both - updates together. The consumer saw the entity already deleted, suspended, - and refetched. That is a refetch, not deletion of counted data. Whether a - transition- or idle-priority GC dispatch would be skipped by a mount - render was not tested, nor were React 17/18 (disposable probe, not - committed). -- **Sweep between commit and passive effect: reproduces deterministically.** - [integration-garbage-collection-race.web.tsx](../packages/react/src/__tests__/integration-garbage-collection-race.web.tsx) - sweeps from a sibling layout effect, after the consumer commits and before - its `countRef` effect runs. The entity is deleted while the consumer is - mounted and counted. Because `case GC` returns the same state object, - nothing re-renders. The consumer shows stale data until an unrelated - update, then suspends and refetches. In an app this needs an idle callback - to fire between a non-sync commit and React's scheduled passive-effect - flush. How often that happens is unmeasured. The test passes on React - 17.0.2, 18.3.1, and 19.2.3. - -Vue's provider in -[packages/vue/src/providers/createDataClient.ts](../packages/vue/src/providers/createDataClient.ts) -applies the reducer inside `realDispatch` before the dispatch promise -resolves. The React gap is not automatically the Vue gap. - -### Normalized deletion is visible - -Verified in source: -[packages/endpoint/src/schemas/Array.ts](../packages/endpoint/src/schemas/Array.ts) -denormalizes with `.filter(filterEmpty)`. A missing entity becomes a shorter -array rather than an invalid result. - -[GCPolicy-dangling.ts](../packages/core/src/state/__tests__/GCPolicy-dangling.ts) -drives real `GCPolicy`, `Controller`, and reducer. A result written without a -mounted consumer (`setResponse`, prefetch, hydration) is never queued, yet an -entity it names is swept when another consumer releases it. The surviving -list shrinks and the surviving detail reads `undefined`, both still `Valid`, -so nothing refetches. - -### Memo and index residue - -Verified in source: - -- [packages/normalizr/src/memo/entitiesCache.ts](../packages/normalizr/src/memo/entitiesCache.ts) - keeps a strong `Map` from entity key to pk to a `WeakMap`. Store GC does - not delete those entries. Denormalized values hanging off entity-object - identity can still be collected by JS GC; the map shells are not. -- Index writes in `NormalizeDelegate.handleIndexes` store - `indexes[entityKey][indexName][fieldValue] = pk`. GC does not remove them. - A correct delete of one mapping is possible from the dying entity's field - value (`delete only if indexMap[value] === pk`). Scanning every index value - is a different, unbounded design. - -### What other caches do - -Checked against current official docs, not against their source, on -2026-07-18: - -- **RTK Query** stores a whole document per endpoint+args. It does not - collect a nested entity out from under a surviving query. Unused queries - are removed after `keepUnusedDataFor` (default 60s). Cost: duplicate - copies and tag-driven refetch instead of normalization. -- **Relay** retains queries and mark-sweeps records unreachable from retained - operations. A record referenced by a retained query stays. It needs - explicit record references and compiler-known selections. Default release - buffer is 10 queries. -- **Apollo `cache.gc()`** traces from roots and keeps reachable objects. - Explicit `cache.evict(id)` may leave dangling refs. Default list reads - filter those refs, which is the same silent-shrink behavior. Singular - dangling refs need a custom `read`. - -### Measurement evidence already collected - -Host: Linux WSL2, Node v24.5.0, headless Chromium 149, display period about -16.7 ms. Numbers are single-host and small-sample. They are **not** universal -thresholds. Raw JSON was local `/tmp` output and is not in git. Build -manifests at the time were `dirty=true` because the harness was uncommitted; -those exact IDs will not match a clean checkout of this PR. - -From the provenanced rerun recorded in -[garbage-collection.md](./garbage-collection.md): - -- Node 100k end-to-end median `totalMs`: entity unique ~13 ms, endpoint - unique ~34 ms, mixed unique ~21 ms, entity duplicate ~6 ms. One action, - one slice. -- Chromium 100k `gc` median `totalMs` / `maxInputDelayMs`: entity unique - 9.0 / 9.1; endpoint unique 16.7 / 16.8; mixed unique 13.5 / 13.5; entity - duplicate 6.8 / 6.8. Matching `no-gc` input delay was ~0–0.1 ms. -- Long Tasks stayed 0 because Chromium's threshold is about 50 ms. A - synthetic 45 ms block through the browser probe reported ~45.1 ms max - frame interval and 2 missed frames. The ~17 ms endpoint sweep therefore - sits near one 60 Hz frame without tripping Long Tasks. -- Foreground `core` `^get` / `^set` and React small hot-path benches were - run as controls and completed. They are not GC before/after comparisons. -- `yarn ci:build:bundlesize` completed. Reported `rdcClient.js` 33.8 KiB - minified is an absolute size, not a delta caused by GC work. The harness - is opt-in; production package behavior is unchanged. -- Android release assemble succeeded in that session (~50 MiB APK). **No - device was attached.** There is no RN frame or memory baseline. - -A later disposable scheduler spike -(`/tmp/data-client-gc-calendar-spike/`, not in git) compared structures at -100k candidates on one machine (7 samples, Node): - -| Workload | Flat re-scan | Calendar buckets | -| --- | ---: | ---: | -| 0% due | ~11.5 ms, examines all | ~0.01 ms, examines none | -| 10 mostly-unexpired ticks | ~110 ms, ~991k examinations | ~0.6 ms, ~1k examinations | -| 100% due | ~7.8 ms | ~25 ms | -| Insert 100k | ~5.9 ms | ~30 ms | - -`Object.keys` on ~100k meta was ~4 ms plus ~2.7 MiB. Warmed resumable -`for...in` slices of 256 were ~0.019 ms median. A min-heap was worse on both -time and memory in that spike. These numbers argue about scheduling cost -only. They do not choose a product design. - -## 3. Status, remaining work, and open decisions - -### Done - -- Current GC behavior and its main limitations are documented. -- Opt-in Node, Chromium, and Android measurement harnesses exist on this PR, - with provenance checks so a report is tied to a build manifest. -- Some local baselines exist. Android on-device numbers do not. -- Several designs were sketched and partly spiked. None is chosen. -- Characterization tests for silent shrink and the commit-to-passive-effect - race (section 2). They assert current behavior; a design that fixes either - should flip them. - -### Not done - -- No production `GCPolicy`, reducer, hook, or schema change. -- No CI job runs the new GC benchmarks. -- No physical Android calibration. **Deferred as future work**; not planned - soon. See "Future work" below. -- No decision on ownership (per-consumer refs vs query reachability vs whole - documents), scheduling, or how a deletion becomes a React snapshot. - -### Decisions the next agent should make - -Do not treat prior rankings as votes. - -1. **What is a GC root, and what must remain intact?** - Per-consumer entity paths can delete an entity that a surviving endpoint - result still names. Whole-document eviction (RTK Query) avoids that by - giving up normalization. Reachability from retained queries (Relay / Apollo - `gc`) avoids it by keeping or tracing edges. Direct entity eviction (Apollo - `evict`, and today's `GC` action) allows dangling refs and filtered lists. -2. **When may collection change a React snapshot?** - The user does not want GC to render by itself if rendered output is - unchanged. Immutable deletion still creates a new reducer state if it is - committed alone. In-place mutation avoids the render and conflicts with - concurrent reducer replay. Piggybacking a **prebuilt** table swap on a - later real update avoids an extra render but must stay off the timed - portion of that update. Doing nothing while idle retains memory. RN may - value a standalone low-priority commit more than web does. -3. **How is "still referenced" known at the moment of deletion?** - External counts plus a pure deferred reducer cannot observe an increment - that happens after the action is queued. Options include: read live - external counts inside the GC reducer (impure, closes the race), a - synchronous commit, an urgent update that React is proven to rebase ahead - of a pending transition, or accepting a rare deletion of newly mounted - data. This needs a concurrent-rendering test, not another diagram. -4. **What runs during interaction vs idle?** - Any design that inserts into a heavier structure during unmount, walks - `Object.keys` of a large store on the critical path, or deletes inside the - reducer can miss the 60 FPS goal even if the steady-state sweep is fast. - The calendar spike says release-time bucket insertion was slower than a - flat queue. A cheap inbox drained later is one hypothesis, not a - requirement. -5. **Is the never-consumed leak in scope?** - Hydrated `initialState`, `controller.set()`, and fetches nobody mounted - never pass through decrement-to-zero, so today's queue never sees them. - A census can find them. Write-time enqueue also can, and it taxes - normalize. Whether this leak matters more than interaction cost is open. -6. **Index rows and memo map shells.** - Both retain memory after entity deletion. Cleanup is likely justified if - entity GC remains, but it should be designed with the deletion model, not - bolted on first. -7. **Platform policy vs shared mechanism.** - The user wants different web and RN tradeoffs. Shared correctness with - different cadence, retention, and whether an idle commit is allowed is - one shape. Different collectors is another. Bundle cost of the web default - matters; RN bundle cost mostly does not. - -### Suggested next evidence, not a required sequence - -- Decide whether silent shrink is acceptable (it is now a committed test). -- Decide whether the commit-to-passive-effect window matters. Counting in a - layout effect, or reading live counts when the GC reducer applies, are two - ways to close it; neither is measured. -- If comparing schedulers, measure release/unmount cost and idle sweep cost - separately, on the browser harness, not only in Node. -- Keep production GC unchanged until those results exist. - -### Future work - -- **Android on-device calibration.** Not planned soon. The release-Hermes app - in `examples/benchmark-native` builds and its unit tests pass, but it has - never run on a physical device. Until it does, there is no React Native - frame or memory baseline. Do not change React Native GC defaults on the - strength of Node or Chromium numbers alone; a design may land web-first and - leave the RN policy as is. When picked up, run the release app on a named - mid-range device (see `examples/benchmark-native/README.md`). - -## 4. What this PR is - -**Purpose:** let the next design be measured. It adds opt-in harnesses and -notes. It does not change `@data-client/core` GC behavior, public package -APIs, or default provider policy. - -**In the PR, and hard in the sense that the user asked for measurement:** - -- Node GC scenarios: `yarn workspace example-benchmark start:gc` -- Chromium GC scenarios: `yarn workspace example-benchmark-react bench:gc` -- Android release-Hermes app: `examples/benchmark-native` -- Build manifests so reports bind to source and artifact hashes -- CircleCI workspace trimming updated for the new workspace index - -**In the PR, but exploratory:** - -- Scenario vocabulary, slice/census ideas, and "direction" prose in - [garbage-collection.md](./garbage-collection.md) -- Baseline tables. Useful local evidence. Not pass/fail gates. -- Harness-only policies (`BenchmarkGCPolicy`, `BrowserBenchmarkGCPolicy`, - `AndroidBenchmarkGCPolicy`) that disable the interval and force expiry to - zero so a sweep can be timed. They are fixtures, not proposed production - defaults. -- Frame probes, Long Task filters, and nearest-period missed-frame math. - They are measurement tools. A later design does not have to preserve them. - -**Explicitly not decided by the user:** - -- Cooperative slicing, calendar queues, censuses, reverse edges, memo - `forget`, commit-time revalidation, private maintenance actions, or - piggyback compaction. -- Changing Vue or bare `Controller` defaults. -- Promoting these benchmarks to CI. - -### Branch state - -PR #4034 is open against `master`. It was rebased onto the commits from -#4099, #4094, and #4093. Re-check CI on the rebased tip before treating the -harness as green on current master. - -This handoff file does not change harness code. - -### How to ignore the previous agent's preferences - -Use the goals in section 1, the source facts in section 2, and the harness to -test whatever design you invent. If a prior recommendation conflicts with a -simpler design that still meets the goals, prefer the simpler design. Say so. From 079cd14f319f8dcd208ac6abcd97d0d6b1b19aa6 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 12:32:02 -0400 Subject: [PATCH 18/32] refactor: Simplify GC benchmark harnesses - Dedupe report paths, sidecar reads, and pm path calls in collect-report.sh - Remove dead frame helper and duplicate capture type in benchmark-native - Drop unreachable filters and self-verifies in native build-manifest - Single-source browser GC canonical counts; add isGCAction helper - Verify browser GC build provenance before running earlier phases - Drop duplicate --verify-manifest entry (use start:gc:verify) - Simplify Node computeDirty to the pathspec-scoped git status - Skip structuredClone of the per-harness reducer fixture - Release working state on harness dispose (browser, native) - Use gcPolicy['runSweep']() in GC race test like other GC tests Co-Authored-By: Claude Opus 5.5 --- .../scripts/build-manifest.cjs | 10 ----- .../scripts/collect-report.sh | 38 ++++++++----------- examples/benchmark-native/src/frames.ts | 26 +------------ examples/benchmark-native/src/gcHarness.ts | 2 + examples/benchmark-react/bench/gc-report.ts | 4 +- examples/benchmark-react/bench/runner.ts | 31 ++++++--------- examples/benchmark-react/bench/scenarios.ts | 4 +- .../src/data-client/gcBrowserHarness.ts | 2 + .../src/data-client/gcInteractionMetrics.ts | 5 ++- examples/benchmark/README.md | 5 +-- examples/benchmark/gc-build-manifest.js | 22 ++--------- examples/benchmark/gc-policy-scenarios.js | 12 +++--- examples/benchmark/gc-policy.js | 32 ++-------------- 13 files changed, 53 insertions(+), 140 deletions(-) diff --git a/examples/benchmark-native/scripts/build-manifest.cjs b/examples/benchmark-native/scripts/build-manifest.cjs index dc6087ed9450..ac0d175c1302 100644 --- a/examples/benchmark-native/scripts/build-manifest.cjs +++ b/examples/benchmark-native/scripts/build-manifest.cjs @@ -37,10 +37,6 @@ const IGNORE_DIR_NAMES = new Set([ 'node_modules', 'build', 'artifacts', - '.jdk', - '.git', - '.gradle', - '.idea', 'coverage', 'Pods', ]); @@ -69,10 +65,6 @@ function collectInputFiles() { walkFiles(path.join(REPO, 'packages/core/src'), files); return files .map(f => path.resolve(f)) - .filter( - f => - !f.includes(`${path.sep}android${path.sep}app${path.sep}build${path.sep}`), - ) .sort((a, b) => a.localeCompare(b)); } @@ -131,7 +123,6 @@ function prepare() { sourceDigest: digest, createdAt: new Date().toISOString(), }; - verifyManifestBuildId(manifest); fs.mkdirSync(ASSET_DIR, { recursive: true }); fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n'); console.log(`Wrote ${MANIFEST_PATH}`); @@ -191,7 +182,6 @@ function finalize(apkPath = DEFAULT_APK) { sidecarId, builtAt: new Date().toISOString(), }; - verifySidecarIdentity(sidecar); fs.mkdirSync(path.dirname(SIDECAR_PATH), { recursive: true }); fs.writeFileSync(SIDECAR_PATH, JSON.stringify(sidecar, null, 2) + '\n'); console.log(`Wrote ${SIDECAR_PATH}`); diff --git a/examples/benchmark-native/scripts/collect-report.sh b/examples/benchmark-native/scripts/collect-report.sh index 0ec36c3e852d..6c8d873d84ae 100644 --- a/examples/benchmark-native/scripts/collect-report.sh +++ b/examples/benchmark-native/scripts/collect-report.sh @@ -92,24 +92,23 @@ DEVICE_REPORT_APP="/sdcard/Android/data/${APP_ID}/files/${REPORT_NAME}" DEVICE_REPORT_APP_EMU="/storage/emulated/0/Android/data/${APP_ID}/files/${REPORT_NAME}" DEVICE_REPORT_PUBLIC="/sdcard/Download/${PUBLIC_REPORT_NAME}" DEVICE_REPORT_PUBLIC_EMU="/storage/emulated/0/Download/${PUBLIC_REPORT_NAME}" +# Pull order: public Downloads first, then app-scoped externalFilesDir. +DEVICE_REPORTS=( + "${DEVICE_REPORT_PUBLIC}" + "${DEVICE_REPORT_PUBLIC_EMU}" + "${DEVICE_REPORT_APP}" + "${DEVICE_REPORT_APP_EMU}" +) clear_device_reports() { - "${ADB[@]}" shell "rm -f \ - '${DEVICE_REPORT_APP}' \ - '${DEVICE_REPORT_APP_EMU}' \ - '${DEVICE_REPORT_PUBLIC}' \ - '${DEVICE_REPORT_PUBLIC_EMU}'" >/dev/null 2>&1 || true + "${ADB[@]}" shell "rm -f $(printf "'%s' " "${DEVICE_REPORTS[@]}")" >/dev/null 2>&1 || true # Downloads mirror is MediaStore-owned; filesystem rm can leave the row. "${ADB[@]}" shell "content delete --uri content://media/external/downloads --where \"_display_name='${PUBLIC_REPORT_NAME}'\"" >/dev/null 2>&1 || true } device_report_ready() { local p - for p in \ - "${DEVICE_REPORT_PUBLIC}" \ - "${DEVICE_REPORT_PUBLIC_EMU}" \ - "${DEVICE_REPORT_APP}" \ - "${DEVICE_REPORT_APP_EMU}"; do + for p in "${DEVICE_REPORTS[@]}"; do if "${ADB[@]}" shell "test -s '${p}'" >/dev/null 2>&1; then return 0 fi @@ -150,12 +149,7 @@ pull_device_report() { if [[ -n "${from_log}" ]]; then candidates+=("${from_log}") fi - candidates+=( - "${DEVICE_REPORT_PUBLIC}" - "${DEVICE_REPORT_PUBLIC_EMU}" - "${DEVICE_REPORT_APP}" - "${DEVICE_REPORT_APP_EMU}" - ) + candidates+=("${DEVICE_REPORTS[@]}") for src in "${candidates[@]}"; do rm -f "${OUT}" if "${ADB[@]}" pull "${src}" "${OUT}" >/dev/null 2>&1 && [[ -s "${OUT}" ]]; then @@ -180,10 +174,7 @@ verifySidecarIdentity(sidecar); console.log("sidecar identity verified buildId="+sidecar.buildId+" sidecarId="+sidecar.sidecarId); ' "${SIDECAR}" "${ROOT}/scripts/build-identity.cjs" -SIDECAR_BUILD_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).buildId)' "${SIDECAR}")" -SIDECAR_DIGEST="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).sourceDigest)' "${SIDECAR}")" -SIDECAR_APK_SHA="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).apkSha256)' "${SIDECAR}")" -SIDECAR_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).sidecarId)' "${SIDECAR}")" +read -r SIDECAR_DIGEST SIDECAR_APK_SHA < <(node -e 'const s=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(s.sourceDigest,s.apkSha256)' "${SIDECAR}") LOCAL_APK_SHA="$(node "${ROOT}/scripts/build-manifest.cjs" hash "${APK}")" if [[ "${LOCAL_APK_SHA}" != "${SIDECAR_APK_SHA}" ]]; then @@ -209,15 +200,16 @@ cleanup() { rm -f "${TMP_INSTALLED}"; } trap cleanup EXIT # Prefer `pm path` → pull the single base APK. -INSTALLED_PATH="$("${ADB[@]}" shell pm path "${APP_ID}" | tr -d '\r' | awk -F: '/^package:/{print $2; exit}')" +PM_PATH_OUT="$("${ADB[@]}" shell pm path "${APP_ID}" | tr -d '\r' || true)" +INSTALLED_PATH="$(awk -F: '/^package:/{print $2; exit}' <<<"${PM_PATH_OUT}")" if [[ -z "${INSTALLED_PATH}" ]]; then echo "error: could not resolve installed package path for ${APP_ID}" >&2 exit 1 fi -PATH_COUNT="$("${ADB[@]}" shell pm path "${APP_ID}" | tr -d '\r' | grep -c '^package:' || true)" +PATH_COUNT="$(grep -c '^package:' <<<"${PM_PATH_OUT}" || true)" if [[ "${PATH_COUNT}" -ne 1 ]]; then echo "error: expected exactly one installed APK path (split APKs unsupported); got ${PATH_COUNT}" >&2 - "${ADB[@]}" shell pm path "${APP_ID}" >&2 || true + printf '%s\n' "${PM_PATH_OUT}" >&2 exit 1 fi diff --git a/examples/benchmark-native/src/frames.ts b/examples/benchmark-native/src/frames.ts index c68527c5c0d7..fe2868578a58 100644 --- a/examples/benchmark-native/src/frames.ts +++ b/examples/benchmark-native/src/frames.ts @@ -5,7 +5,7 @@ * - FrameMetrics TOTAL_DURATION is a *duration* → ceil(duration/period) − 1 * - Choreographer deltas are *intervals* → round(interval/period) − 1 (same as JS rAF) */ -import type { UiCaptureSource } from './types'; +import type { UiFrameCaptureResult } from './BenchNative'; export function median(values: number[]): number { if (values.length === 0) return 0; @@ -48,18 +48,6 @@ export function missedFramesFromIntervalMs( return Math.max(0, Math.round(intervalMs / refreshPeriodMs) - 1); } -/** Sum FrameMetrics-style duration misses. */ -export function excessMissedFramesFromDurations( - durationsMs: number[], - refreshPeriodMs: number, -): number { - let missed = 0; - for (const d of durationsMs) { - missed += missedFramesFromDurationMs(d, refreshPeriodMs); - } - return missed; -} - /** * Sum interval-style misses (JS rAF / Choreographer). */ @@ -118,22 +106,12 @@ export async function measureDisplayPeriodMs(samples = 8): Promise { return median(intervals); } -export interface UiFrameCaptureAggregate { - source: UiCaptureSource; - frameCount: number; - maxFrameDurationMs: number; - totalFrameDurationMs: number; - missedFrames: number; - refreshPeriodMs: number; - refreshRateHz: number; -} - /** * Reject invalid refresh period or zero/insufficient native frames. * Missed-frame math must match capture source semantics. */ export function validateUiFrameCapture( - result: UiFrameCaptureAggregate, + result: UiFrameCaptureResult, options: { minFrames?: number } = {}, ): void { const minFrames = options.minFrames ?? 1; diff --git a/examples/benchmark-native/src/gcHarness.ts b/examples/benchmark-native/src/gcHarness.ts index 4ddae735c469..eb72a1840604 100644 --- a/examples/benchmark-native/src/gcHarness.ts +++ b/examples/benchmark-native/src/gcHarness.ts @@ -258,6 +258,8 @@ export function createHarness(config: GCScenarioConfig): Harness { }, dispose() { capturedAction = null; + // Release the full store held by this scope + workingState = initialState; controller.getState = () => initialState; controller.dispatch = (() => Promise.resolve()) as typeof controller.dispatch; diff --git a/examples/benchmark-react/bench/gc-report.ts b/examples/benchmark-react/bench/gc-report.ts index 679fae09c5e2..ffcf2ea41630 100644 --- a/examples/benchmark-react/bench/gc-report.ts +++ b/examples/benchmark-react/bench/gc-report.ts @@ -7,7 +7,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { BuildManifestV1 } from './build-manifest.js'; -import { browserGCScenarioId } from '../src/data-client/gcInteractionMetrics.ts'; +import { browserGCScenarioId } from '../src/data-client/gcInteractionMetrics.js'; import type { GCBrowserMeasurement, GCScenarioConfig, @@ -269,5 +269,3 @@ export function buildGCReport(opts: { scenarios: opts.scenarios, }; } - -export { browserGCScenarioId }; diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 5588213ec37b..0419ac582f6e 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -39,7 +39,7 @@ import { import type { ConvergentProfile } from './scenarios.js'; import { computeStats, isConverged } from './stats.js'; import { parseTraceDuration } from './tracing.js'; -import { browserGCScenarioId } from '../src/data-client/gcInteractionMetrics.ts'; +import { browserGCScenarioId } from '../src/data-client/gcInteractionMetrics.js'; import type { GCBrowserMeasurement, GCScenarioConfig, @@ -106,6 +106,8 @@ function parseArgs(): { }; } +const isGCAction = (a: string) => a === 'gc' || a === 'runGCScenario'; + function filterScenarios(scenarios: Scenario[]): { filtered: Scenario[]; libraries: string[]; @@ -129,13 +131,7 @@ function filterScenarios(scenarios: Scenario[]): { // GC scenarios are data-client-only; default lib when selecting --action gc const effectiveLibraries = - ( - !libs && - actions && - actions.every(a => a === 'gc' || a === 'runGCScenario') - ) ? - ['data-client'] - : libraries; + !libs && actions && actions.every(isGCAction) ? ['data-client'] : libraries; let filtered = scenarios; @@ -152,11 +148,7 @@ function filterScenarios(scenarios: Scenario[]): { } else if ( !actions || !actions.some( - a => - a === 'memory' || - a === 'mountUnmountCycle' || - a === 'gc' || - a === 'runGCScenario', + a => a === 'memory' || a === 'mountUnmountCycle' || isGCAction(a), ) ) { // Locally: exclude memory/gc by default; use --action memory|gc to include @@ -964,7 +956,6 @@ async function runGCPhase( const failures: GCFailureRecord[] = []; const requestedScenarios = scenarios.length; const requestedSamples = scenarios.length * sampleCount; - let completedScenarios = 0; let completedSamples = 0; if (scenarios.length === 0) { @@ -1026,7 +1017,6 @@ async function runGCPhase( } if (!scenarioFailed && scenarioSamples.length === sampleCount) { - completedScenarios++; const report = scenarioReportFromConfig(config, scenarioSamples); const { summary } = report; const medianTotal = summary.totalMs?.median ?? 0; @@ -1076,7 +1066,7 @@ async function runGCPhase( headless: true, requestedScenarios, requestedSamples, - completedScenarios, + completedScenarios: reports.length, completedSamples, failures, provenance, @@ -1206,6 +1196,10 @@ async function main() { samples.set(s.name, { value: [], reactCommit: [], trace: [] }); } + // Fail fast on a stale build before spending time on earlier phases + const gcProvenance = + gcScenarios.length > 0 ? await verifyGCBuildProvenance(BASE_URL) : null; + const { browser, closeBenchBrowser } = await launchBenchChromium(); if (BENCH_V8_DEOPT) { process.stderr.write(`V8 profiling logs → ${V8_LOG_DIR}/\n`); @@ -1365,15 +1359,14 @@ async function main() { // GC: dedicated phase (opt-in via --action gc) let gcComplete = true; - if (gcScenarios.length > 0) { - const provenance = await verifyGCBuildProvenance(BASE_URL); + if (gcProvenance) { const { complete } = await runGCPhase( browser, gcScenarios, gcSamples, scenarioFilter, samples, - provenance, + gcProvenance, ); gcComplete = complete; } diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 93d9be515989..7cb64092b756 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -1,3 +1,4 @@ +import { GC_CANONICAL_COUNTS } from '../src/data-client/gcInteractionMetrics.js'; import type { BenchAPI, Scenario, ScenarioSize } from '../src/shared/types.js'; /** Response-size-based network simulation used when --network-sim is enabled (default: on). @@ -221,9 +222,6 @@ export const LIBRARIES = [ 'baseline', ] as const; -/** Canonical GC counts matching the Node GC harness / shared vocabulary. */ -export const GC_CANONICAL_COUNTS = [1_000, 10_000, 100_000] as const; - const GC_UNIQUE_KINDS = ['entity', 'endpoint', 'mixed'] as const; const GC_CONTROLS = ['gc', 'no-gc'] as const; diff --git a/examples/benchmark-react/src/data-client/gcBrowserHarness.ts b/examples/benchmark-react/src/data-client/gcBrowserHarness.ts index 62c396995923..371d647b306b 100644 --- a/examples/benchmark-react/src/data-client/gcBrowserHarness.ts +++ b/examples/benchmark-react/src/data-client/gcBrowserHarness.ts @@ -277,6 +277,8 @@ function createHarness(config: GCScenarioConfig): Harness { }, dispose() { capturedAction = null; + // Release the full store held by this scope + workingState = initialState; controller.getState = () => initialState; controller.dispatch = (() => Promise.resolve()) as typeof controller.dispatch; diff --git a/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts b/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts index aa88019e9436..81aaaaee8799 100644 --- a/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts +++ b/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts @@ -96,8 +96,9 @@ export async function measureDisplayPeriodMs(samples = 8): Promise { return median(intervals); } -/** Canonical GC counts (must match `GC_CANONICAL_COUNTS` in bench/scenarios.ts). */ -const CANONICAL_COUNTS: readonly number[] = [1_000, 10_000, 100_000]; +/** Canonical GC counts matching the Node GC harness / shared vocabulary. */ +export const GC_CANONICAL_COUNTS = [1_000, 10_000, 100_000] as const; +const CANONICAL_COUNTS: readonly number[] = GC_CANONICAL_COUNTS; /** * Stable detailed-report scenario ID from validated axes (not display names). diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index a7d612a69505..3d67ec117149 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -90,8 +90,7 @@ V8/engine GC — do not conflate the two. ```bash yarn build:benchmark yarn workspace example-benchmark start:gc [filter] [--samples=N] [--memory] [--table|--no-table] -yarn workspace example-benchmark start:gc --verify-manifest -# or: yarn workspace example-benchmark start:gc:verify +yarn workspace example-benchmark start:gc:verify ``` Every `yarn build:benchmark` / workspace `build` runs webpack then writes @@ -108,7 +107,7 @@ builds with a rebuild instruction. Report `build` provenance comes from the **verified manifest**, never from live `git rev-parse` at run time. Manifest generation and verification are outside timed cache-GC work. -`--verify-manifest` / `start:gc:verify` self-tests that source and artifact +`start:gc:verify` self-tests that source and artifact tampering are rejected and restores all files afterward. Examples: diff --git a/examples/benchmark/gc-build-manifest.js b/examples/benchmark/gc-build-manifest.js index d1a9c5877d90..2a8b0380c738 100644 --- a/examples/benchmark/gc-build-manifest.js +++ b/examples/benchmark/gc-build-manifest.js @@ -153,8 +153,9 @@ export function gitCommitFull() { /** * dirty=true when any relevant path is modified, added, or untracked vs git. */ -export function computeDirty(paths = listRelevantSourcePaths()) { +export function computeDirty() { try { + // Pathspecs already scope status to relevant inputs. const pathspecs = [CORE_SRC_DIR, ...BENCH_SOURCE_FILES]; const out = execFileSync( 'git', @@ -165,22 +166,7 @@ export function computeDirty(paths = listRelevantSourcePaths()) { stdio: ['ignore', 'pipe', 'ignore'], }, ).trim(); - if (!out) return false; - const relevant = new Set(paths); - for (const line of out.split('\n')) { - if (!line) continue; - const rest = line.slice(3); - const filePath = rest.includes(' -> ') ? rest.split(' -> ').pop() : rest; - const norm = toPosix(filePath.replace(/^"|"$/g, '')); - if ( - relevant.has(norm) || - norm.startsWith(`${CORE_SRC_DIR}/`) || - BENCH_SOURCE_FILES.includes(norm) - ) { - return true; - } - } - return false; + return out.length > 0; } catch { return true; } @@ -219,7 +205,7 @@ export function buildManifest() { ); } const commit = gitCommitFull(); - const dirty = computeDirty(paths); + const dirty = computeDirty(); const body = { schemaVersion: MANIFEST_SCHEMA_VERSION, commit, diff --git a/examples/benchmark/gc-policy-scenarios.js b/examples/benchmark/gc-policy-scenarios.js index 9acaca8a3af7..e17fa922ebc8 100644 --- a/examples/benchmark/gc-policy-scenarios.js +++ b/examples/benchmark/gc-policy-scenarios.js @@ -292,7 +292,7 @@ export function createHarness(spec, mode) { let capturedAction = null; let workingState = state; - /** Seed retained only until reducer takes its clone (then nulled). */ + /** Seed retained only until reducer takes it (then nulled). */ let seedState = mode === 'reducer' ? state : null; controller.getState = () => workingState; @@ -349,17 +349,17 @@ export function createHarness(spec, mode) { seedState = null; }, /** - * Reducer: clone seed into working state and drop the full-size seed - * so heapBefore does not retain two copies of the fixture. + * Reducer: hand the freshly built seed to the reducer and drop the seed + * ref. The fixture is built per harness, so there is nothing to protect + * by cloning. */ takeReducerState() { if (!seedState) { throw new Error('takeReducerState() requires reducer mode with a seed'); } - const clone = structuredClone(seedState); + workingState = seedState; seedState = null; - workingState = clone; - return clone; + return workingState; }, /** Drop store/policy/controller so a later V8 GC can reclaim everything. */ dispose() { diff --git a/examples/benchmark/gc-policy.js b/examples/benchmark/gc-policy.js index b09169af0772..21ac8f1bf51a 100644 --- a/examples/benchmark/gc-policy.js +++ b/examples/benchmark/gc-policy.js @@ -14,7 +14,7 @@ * Usage: * yarn build:benchmark * yarn workspace example-benchmark start:gc [filter] [--samples=N] [--memory] [--table|--no-table] - * yarn workspace example-benchmark start:gc --verify-manifest + * yarn workspace example-benchmark start:gc:verify */ import os from 'node:os'; import process from 'node:process'; @@ -22,7 +22,7 @@ import { setImmediate } from 'node:timers'; import v8 from 'node:v8'; import vm from 'node:vm'; -import { verifyManifestOrThrow, runSelfTest } from './gc-build-manifest.js'; +import { verifyManifestOrThrow } from './gc-build-manifest.js'; import { listScenarios, createHarness, @@ -38,7 +38,6 @@ function parseArgs(argv) { let samples = DEFAULT_SAMPLES; let memory = false; let table; - let verifyManifestOnly = false; for (const arg of argv) { if (arg === '--memory') { memory = true; @@ -46,8 +45,6 @@ function parseArgs(argv) { table = true; } else if (arg === '--no-table') { table = false; - } else if (arg === '--verify-manifest') { - verifyManifestOnly = true; } else if (arg.startsWith('--samples=')) { samples = Number(arg.slice('--samples='.length)); if (!Number.isFinite(samples) || samples < 1) { @@ -63,7 +60,7 @@ function parseArgs(argv) { } } if (table === undefined) table = Boolean(process.stderr.isTTY); - return { filter, samples, memory, table, verifyManifestOnly }; + return { filter, samples, memory, table }; } function cpuModel() { @@ -288,32 +285,9 @@ async function main() { samples: sampleCount, memory, table, - verifyManifestOnly, } = parseArgs(process.argv.slice(2)); // Provenance check is outside timing and runs before any scenario work - if (verifyManifestOnly) { - const result = runSelfTest(); - for (const step of result.steps) { - console.error(` ${step}`); - } - console.error('gc-build-manifest self-test passed'); - process.stdout.write( - `${JSON.stringify( - { - ok: true, - buildId: result.manifest.buildId, - commit: result.manifest.commit, - dirty: result.manifest.dirty, - sourceDigest: result.manifest.sourceDigest, - }, - null, - 2, - )}\n`, - ); - return; - } - const buildManifest = verifyManifestOrThrow(); const scenarios = listScenarios(filter); From 1f29f858e40a426f1483d515688009ec0c8774f3 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 13:37:30 -0400 Subject: [PATCH 19/32] pkg(benchmark-native): Use root Jest 30, ESLint, and TypeScript toolchain Drop the nested jest 29 / eslint 8 / prettier 2 / TS 5 pins; lint through the root flat config (adds a CommonJS/Node globals block for .cjs, native configs and scripts, and examples/gc-shared). Remove unused baseUrl/paths (TS7 TS5102). Co-Authored-By: Claude Opus 5.5 --- eslint.config.mjs | 18 + examples/benchmark-native/.eslintrc.js | 4 - examples/benchmark-native/README.md | 2 + examples/benchmark-native/index.js | 1 + examples/benchmark-native/package.json | 12 +- .../scripts/build-identity.cjs | 15 +- .../scripts/build-manifest.cjs | 7 +- examples/benchmark-native/tsconfig.json | 6 +- yarn.lock | 1329 +---------------- 9 files changed, 89 insertions(+), 1305 deletions(-) delete mode 100644 examples/benchmark-native/.eslintrc.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 114716ba1156..d91e67421326 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -62,6 +62,24 @@ export default [ }, }, }, + // Node CommonJS: configs, host scripts, and the GC harness shared modules + { + files: [ + '**/*.cjs', + 'examples/gc-shared/**/*.js', + 'examples/benchmark-native/*.config.js', + 'examples/benchmark-native/.prettierrc.js', + 'examples/benchmark-native/scripts/**', + ], + languageOptions: { + sourceType: 'commonjs', + globals: globals.node, + }, + rules: { + // `const crypto = require('crypto')` shadows Node's global by design + 'no-redeclare': ['error', { builtinGlobals: false }], + }, + }, { files: ['examples/**/*.?(m|c)ts?(x)'], rules: { diff --git a/examples/benchmark-native/.eslintrc.js b/examples/benchmark-native/.eslintrc.js deleted file mode 100644 index 187894b6af25..000000000000 --- a/examples/benchmark-native/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: '@react-native', -}; diff --git a/examples/benchmark-native/README.md b/examples/benchmark-native/README.md index 5aae2c0d35d1..60b01134e291 100644 --- a/examples/benchmark-native/README.md +++ b/examples/benchmark-native/README.md @@ -27,6 +27,8 @@ yarn workspace example-benchmark-native test yarn workspace example-benchmark-native build:android:release ``` +Tooling comes from the monorepo root: Jest 30 (`preset: 'react-native'`), the root ESLint flat config (`lint` runs `yarn g:lint`), and TypeScript via `@typescript/native`. + This writes: - embedded asset `android/app/src/main/assets/build-manifest.json` (generated; gitignored) diff --git a/examples/benchmark-native/index.js b/examples/benchmark-native/index.js index 9b7393291400..016f62c2dd9d 100644 --- a/examples/benchmark-native/index.js +++ b/examples/benchmark-native/index.js @@ -3,6 +3,7 @@ */ import { AppRegistry } from 'react-native'; + import App from './App'; import { name as appName } from './app.json'; diff --git a/examples/benchmark-native/package.json b/examples/benchmark-native/package.json index 04196c7aebae..20e9713f275f 100644 --- a/examples/benchmark-native/package.json +++ b/examples/benchmark-native/package.json @@ -7,7 +7,7 @@ "android:debug": "react-native run-android", "build:android:release": "bash scripts/build-android-release.sh", "build:android:release:gradle": "cd android && ./gradlew assembleRelease", - "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --config .eslintrc.js --ignore-pattern android --ignore-pattern artifacts .", + "lint": "yarn g:lint --ignore-pattern android --ignore-pattern artifacts .", "start": "react-native start", "test": "jest", "typecheck": "tsc --noEmit", @@ -26,17 +26,15 @@ "@react-native-community/cli": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", "@react-native/babel-preset": "0.86.2", - "@react-native/eslint-config": "0.86.2", "@react-native/metro-config": "0.86.2", "@react-native/typescript-config": "0.86.2", - "@types/jest": "^29.5.13", + "@types/jest": "30.0.0", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", - "eslint": "^8.19.0", - "jest": "^29.6.3", - "prettier": "2.8.8", + "@typescript/native": "npm:typescript@7.0.2", + "jest": "^30.0.0", "react-test-renderer": "19.2.3", - "typescript": "^5.8.3" + "typescript": "npm:@typescript/typescript6@6.0.2" }, "engines": { "node": ">= 22.11.0" diff --git a/examples/benchmark-native/scripts/build-identity.cjs b/examples/benchmark-native/scripts/build-identity.cjs index c11d24178906..1153194b4cb0 100644 --- a/examples/benchmark-native/scripts/build-identity.cjs +++ b/examples/benchmark-native/scripts/build-identity.cjs @@ -22,7 +22,10 @@ function computeBuildId(inputs) { if (typeof inputs.gitDirty !== 'boolean') { throw new Error('gitDirty must be boolean'); } - if (typeof inputs.sourceDigest !== 'string' || !/^[0-9a-f]{64}$/.test(inputs.sourceDigest)) { + if ( + typeof inputs.sourceDigest !== 'string' || + !/^[0-9a-f]{64}$/.test(inputs.sourceDigest) + ) { throw new Error('sourceDigest must be 64-char hex sha256'); } // Canonical encoding: fixed field order, no timestamps/artifacts. @@ -40,10 +43,16 @@ function computeBuildId(inputs) { * @returns {string} hex sha256 */ function computeSidecarId(inputs) { - if (typeof inputs.buildId !== 'string' || !/^[0-9a-f]{64}$/.test(inputs.buildId)) { + if ( + typeof inputs.buildId !== 'string' || + !/^[0-9a-f]{64}$/.test(inputs.buildId) + ) { throw new Error('buildId must be 64-char hex sha256'); } - if (typeof inputs.apkSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(inputs.apkSha256)) { + if ( + typeof inputs.apkSha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(inputs.apkSha256) + ) { throw new Error('apkSha256 must be 64-char hex sha256'); } return crypto diff --git a/examples/benchmark-native/scripts/build-manifest.cjs b/examples/benchmark-native/scripts/build-manifest.cjs index ac0d175c1302..8e94e21bdf22 100644 --- a/examples/benchmark-native/scripts/build-manifest.cjs +++ b/examples/benchmark-native/scripts/build-manifest.cjs @@ -12,10 +12,11 @@ * Inputs: sorted paths under this app (tracked+untracked contents) plus * packages/core/src, excluding build/node_modules/artifacts/.jdk/generated. */ +const { execSync } = require('child_process'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const { execSync } = require('child_process'); + const { computeBuildId, computeSidecarId, @@ -63,9 +64,7 @@ function collectInputFiles() { const files = []; walkFiles(ROOT, files); walkFiles(path.join(REPO, 'packages/core/src'), files); - return files - .map(f => path.resolve(f)) - .sort((a, b) => a.localeCompare(b)); + return files.map(f => path.resolve(f)).sort((a, b) => a.localeCompare(b)); } function sha256File(filePath) { diff --git a/examples/benchmark-native/tsconfig.json b/examples/benchmark-native/tsconfig.json index 66a2c0a97212..8d691b165b8f 100644 --- a/examples/benchmark-native/tsconfig.json +++ b/examples/benchmark-native/tsconfig.json @@ -1,11 +1,7 @@ { "extends": "@react-native/typescript-config", "compilerOptions": { - "types": ["jest"], - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } + "types": ["jest"] }, "include": ["**/*.ts", "**/*.tsx", "src/globals.d.ts"], "exclude": ["**/node_modules", "**/Pods", "android", "artifacts"] diff --git a/yarn.lock b/yarn.lock index 47045fca2e92..94c8187c37e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -670,7 +670,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.0, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.0, @babel/code-frame@npm:^7.29.7, @babel/code-frame@npm:^7.8.3": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.16.0, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.0, @babel/code-frame@npm:^7.29.7, @babel/code-frame@npm:^7.8.3": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" dependencies: @@ -688,7 +688,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:7.29.7, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.15, @babel/core@npm:^7.23.5, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.4, @babel/core@npm:^7.24.7, @babel/core@npm:^7.25.2, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": +"@babel/core@npm:7.29.7, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.15, @babel/core@npm:^7.23.5, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.7, @babel/core@npm:^7.25.2, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -711,20 +711,6 @@ __metadata: languageName: node linkType: hard -"@babel/eslint-parser@npm:^7.25.1": - version: 7.29.9 - resolution: "@babel/eslint-parser@npm:7.29.9" - dependencies: - "@nicolo-ribaudo/eslint-scope-5-internals": "npm:5.1.1-v1" - eslint-visitor-keys: "npm:^2.1.0" - semver: "npm:^6.3.1" - peerDependencies: - "@babel/core": ^7.11.0 - eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 - checksum: 10c0/f97d9eb23ad53e9263235e9efe204908494922fb89a4cf623e174d7e62a112c52850990cc19b9ef5043af6e2ca87b1772f998175571ac8cb9967689d6e036bd8 - languageName: node - linkType: hard - "@babel/eslint-parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/eslint-parser@npm:7.29.7" @@ -764,19 +750,6 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.7.2": - version: 7.29.8 - resolution: "@babel/generator@npm:7.29.8" - dependencies: - "@babel/parser": "npm:^7.29.8" - "@babel/types": "npm:^7.29.8" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441 - languageName: node - linkType: hard - "@babel/helper-annotate-as-pure@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-annotate-as-pure@npm:7.29.7" @@ -1017,17 +990,6 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.24.4, @babel/parser@npm:^7.29.8": - version: 7.29.9 - resolution: "@babel/parser@npm:7.29.9" - dependencies: - "@babel/types": "npm:^7.29.8" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/171b16eb4527cdbbb72cb98710f91acb22356e2c40196ba3b7a1b84efa8c55d0b0992f399c8c675c3e17f67fd133676c5c4118e5f20db243f427c8b29b0c3f40 - languageName: node - linkType: hard - "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.29.7" @@ -1289,7 +1251,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": +"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: @@ -1399,7 +1361,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": +"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: @@ -2402,16 +2364,6 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.29.8": - version: 7.29.8 - resolution: "@babel/types@npm:7.29.8" - dependencies: - "@babel/helper-string-parser": "npm:^7.29.7" - "@babel/helper-validator-identifier": "npm:^7.29.7" - checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 - languageName: node - linkType: hard - "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -4479,17 +4431,6 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.2.0": - version: 4.10.1 - resolution: "@eslint-community/eslint-utils@npm:4.10.1" - dependencies: - eslint-visitor-keys: "npm:^3.4.3" - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/b514586655698bc6b74db72a496c77e813c78b63e36a83429845ac7057dd77113b0b6c31e6e590d5baaeee2abae6ea22363a41209bcafa078d895ab83ce83011 - languageName: node - linkType: hard - "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -4501,7 +4442,7 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.1, @eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": +"@eslint-community/regexpp@npm:^4.12.1, @eslint-community/regexpp@npm:^4.12.2": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d @@ -4537,23 +4478,6 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^2.1.4": - version: 2.1.4 - resolution: "@eslint/eslintrc@npm:2.1.4" - dependencies: - ajv: "npm:^6.12.4" - debug: "npm:^4.3.2" - espree: "npm:^9.6.0" - globals: "npm:^13.19.0" - ignore: "npm:^5.2.0" - import-fresh: "npm:^3.2.1" - js-yaml: "npm:^4.1.0" - minimatch: "npm:^3.1.2" - strip-json-comments: "npm:^3.1.1" - checksum: 10c0/32f67052b81768ae876c84569ffd562491ec5a5091b0c1e1ca1e0f3c24fb42f804952fdd0a137873bc64303ba368a71ba079a6f691cee25beee9722d94cc8573 - languageName: node - linkType: hard - "@eslint/eslintrc@npm:^3.3.6": version: 3.3.6 resolution: "@eslint/eslintrc@npm:3.3.6" @@ -4571,13 +4495,6 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:8.57.1": - version: 8.57.1 - resolution: "@eslint/js@npm:8.57.1" - checksum: 10c0/b489c474a3b5b54381c62e82b3f7f65f4b8a5eaaed126546520bf2fede5532a8ed53212919fed1e9048dcf7f37167c8561d58d0ba4492a4244004e7793805223 - languageName: node - linkType: hard - "@eslint/js@npm:9.39.5, @eslint/js@npm:^9.39.4": version: 9.39.5 resolution: "@eslint/js@npm:9.39.5" @@ -4652,17 +4569,6 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/config-array@npm:^0.13.0": - version: 0.13.0 - resolution: "@humanwhocodes/config-array@npm:0.13.0" - dependencies: - "@humanwhocodes/object-schema": "npm:^2.0.3" - debug: "npm:^4.3.1" - minimatch: "npm:^3.0.5" - checksum: 10c0/205c99e756b759f92e1f44a3dc6292b37db199beacba8f26c2165d4051fe73a4ae52fdcfd08ffa93e7e5cb63da7c88648f0e84e197d154bbbbe137b2e0dd332e - languageName: node - linkType: hard - "@humanwhocodes/module-importer@npm:^1.0.1": version: 1.0.1 resolution: "@humanwhocodes/module-importer@npm:1.0.1" @@ -4670,13 +4576,6 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/object-schema@npm:^2.0.3": - version: 2.0.3 - resolution: "@humanwhocodes/object-schema@npm:2.0.3" - checksum: 10c0/80520eabbfc2d32fe195a93557cef50dfe8c8905de447f022675aaf66abc33ae54098f5ea78548d925aa671cd4ab7c7daa5ad704fe42358c9b5e7db60f80696c - languageName: node - linkType: hard - "@humanwhocodes/retry@npm:^0.4.0": version: 0.4.3 resolution: "@humanwhocodes/retry@npm:0.4.3" @@ -5026,20 +4925,6 @@ __metadata: languageName: node linkType: hard -"@jest/console@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/console@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - slash: "npm:^3.0.0" - checksum: 10c0/7be408781d0a6f657e969cbec13b540c329671819c2f57acfad0dae9dbfe2c9be859f38fe99b35dba9ff1536937dc6ddc69fdcd2794812fa3c647a1619797f6c - languageName: node - linkType: hard - "@jest/core@npm:30.4.2": version: 30.4.2 resolution: "@jest/core@npm:30.4.2" @@ -5081,47 +4966,6 @@ __metadata: languageName: node linkType: hard -"@jest/core@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/core@npm:29.7.0" - dependencies: - "@jest/console": "npm:^29.7.0" - "@jest/reporters": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.2.1" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" - jest-changed-files: "npm:^29.7.0" - jest-config: "npm:^29.7.0" - jest-haste-map: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-regex-util: "npm:^29.6.3" - jest-resolve: "npm:^29.7.0" - jest-resolve-dependencies: "npm:^29.7.0" - jest-runner: "npm:^29.7.0" - jest-runtime: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - jest-watcher: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^29.7.0" - slash: "npm:^3.0.0" - strip-ansi: "npm:^6.0.0" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - checksum: 10c0/934f7bf73190f029ac0f96662c85cd276ec460d407baf6b0dbaec2872e157db4d55a7ee0b1c43b18874602f662b37cb973dda469a4e6d88b4e4845b521adeeb2 - languageName: node - linkType: hard - "@jest/create-cache-key-function@npm:^29.7.0": version: 29.7.0 resolution: "@jest/create-cache-key-function@npm:29.7.0" @@ -5171,18 +5015,6 @@ __metadata: languageName: node linkType: hard -"@jest/environment@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/environment@npm:29.7.0" - dependencies: - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - checksum: 10c0/c7b1b40c618f8baf4d00609022d2afa086d9c6acc706f303a70bb4b67275868f620ad2e1a9efc5edd418906157337cce50589a627a6400bbdf117d351b91ef86 - languageName: node - linkType: hard - "@jest/expect-utils@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect-utils@npm:30.4.1" @@ -5192,15 +5024,6 @@ __metadata: languageName: node linkType: hard -"@jest/expect-utils@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/expect-utils@npm:29.7.0" - dependencies: - jest-get-type: "npm:^29.6.3" - checksum: 10c0/60b79d23a5358dc50d9510d726443316253ecda3a7fb8072e1526b3e0d3b14f066ee112db95699b7a43ad3f0b61b750c72e28a5a1cac361d7a2bb34747fa938a - languageName: node - linkType: hard - "@jest/expect@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect@npm:30.4.1" @@ -5211,16 +5034,6 @@ __metadata: languageName: node linkType: hard -"@jest/expect@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/expect@npm:29.7.0" - dependencies: - expect: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - checksum: 10c0/b41f193fb697d3ced134349250aed6ccea075e48c4f803159db102b826a4e473397c68c31118259868fd69a5cba70e97e1c26d2c2ff716ca39dc73a2ccec037e - languageName: node - linkType: hard - "@jest/fake-timers@npm:30.4.1": version: 30.4.1 resolution: "@jest/fake-timers@npm:30.4.1" @@ -5235,20 +5048,6 @@ __metadata: languageName: node linkType: hard -"@jest/fake-timers@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/fake-timers@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@sinonjs/fake-timers": "npm:^10.0.2" - "@types/node": "npm:*" - jest-message-util: "npm:^29.7.0" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10c0/cf0a8bcda801b28dc2e2b2ba36302200ee8104a45ad7a21e6c234148932f826cb3bc57c8df3b7b815aeea0861d7b6ca6f0d4778f93b9219398ef28749e03595c - languageName: node - linkType: hard - "@jest/get-type@npm:30.1.0": version: 30.1.0 resolution: "@jest/get-type@npm:30.1.0" @@ -5268,18 +5067,6 @@ __metadata: languageName: node linkType: hard -"@jest/globals@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/globals@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/expect": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - jest-mock: "npm:^29.7.0" - checksum: 10c0/a385c99396878fe6e4460c43bd7bb0a5cc52befb462cc6e7f2a3810f9e7bcce7cdeb51908fd530391ee452dc856c98baa2c5f5fa8a5b30b071d31ef7f6955cea - languageName: node - linkType: hard - "@jest/pattern@npm:30.4.0": version: 30.4.0 resolution: "@jest/pattern@npm:30.4.0" @@ -5326,43 +5113,6 @@ __metadata: languageName: node linkType: hard -"@jest/reporters@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/reporters@npm:29.7.0" - dependencies: - "@bcoe/v8-coverage": "npm:^0.2.3" - "@jest/console": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@jridgewell/trace-mapping": "npm:^0.3.18" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - collect-v8-coverage: "npm:^1.0.0" - exit: "npm:^0.1.2" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - istanbul-lib-coverage: "npm:^3.0.0" - istanbul-lib-instrument: "npm:^6.0.0" - istanbul-lib-report: "npm:^3.0.0" - istanbul-lib-source-maps: "npm:^4.0.0" - istanbul-reports: "npm:^3.1.3" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-worker: "npm:^29.7.0" - slash: "npm:^3.0.0" - string-length: "npm:^4.0.1" - strip-ansi: "npm:^6.0.0" - v8-to-istanbul: "npm:^9.0.1" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - checksum: 10c0/a754402a799541c6e5aff2c8160562525e2a47e7d568f01ebfc4da66522de39cbb809bbb0a841c7052e4270d79214e70aec3c169e4eae42a03bc1a8a20cb9fa2 - languageName: node - linkType: hard - "@jest/schemas@npm:30.4.1": version: 30.4.1 resolution: "@jest/schemas@npm:30.4.1" @@ -5404,17 +5154,6 @@ __metadata: languageName: node linkType: hard -"@jest/source-map@npm:^29.6.3": - version: 29.6.3 - resolution: "@jest/source-map@npm:29.6.3" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.18" - callsites: "npm:^3.0.0" - graceful-fs: "npm:^4.2.9" - checksum: 10c0/a2f177081830a2e8ad3f2e29e20b63bd40bade294880b595acf2fc09ec74b6a9dd98f126a2baa2bf4941acd89b13a4ade5351b3885c224107083a0059b60a219 - languageName: node - linkType: hard - "@jest/test-result@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-result@npm:30.4.1" @@ -5427,18 +5166,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-result@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/test-result@npm:29.7.0" - dependencies: - "@jest/console": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/istanbul-lib-coverage": "npm:^2.0.0" - collect-v8-coverage: "npm:^1.0.0" - checksum: 10c0/7de54090e54a674ca173470b55dc1afdee994f2d70d185c80236003efd3fa2b753fff51ffcdda8e2890244c411fd2267529d42c4a50a8303755041ee493e6a04 - languageName: node - linkType: hard - "@jest/test-sequencer@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-sequencer@npm:30.4.1" @@ -5451,18 +5178,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-sequencer@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/test-sequencer@npm:29.7.0" - dependencies: - "@jest/test-result": "npm:^29.7.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - slash: "npm:^3.0.0" - checksum: 10c0/593a8c4272797bb5628984486080cbf57aed09c7cfdc0a634e8c06c38c6bef329c46c0016e84555ee55d1cd1f381518cf1890990ff845524c1123720c8c1481b - languageName: node - linkType: hard - "@jest/transform@npm:30.4.1": version: 30.4.1 resolution: "@jest/transform@npm:30.4.1" @@ -6079,7 +5794,7 @@ __metadata: languageName: node linkType: hard -"@nodelib/fs.walk@npm:^1.2.3, @nodelib/fs.walk@npm:^1.2.8": +"@nodelib/fs.walk@npm:^1.2.3": version: 1.2.8 resolution: "@nodelib/fs.walk@npm:1.2.8" dependencies: @@ -6983,36 +6698,6 @@ __metadata: languageName: node linkType: hard -"@react-native/eslint-config@npm:0.86.2": - version: 0.86.2 - resolution: "@react-native/eslint-config@npm:0.86.2" - dependencies: - "@babel/core": "npm:^7.25.2" - "@babel/eslint-parser": "npm:^7.25.1" - "@react-native/eslint-plugin": "npm:0.86.2" - "@typescript-eslint/eslint-plugin": "npm:^8.36.0" - "@typescript-eslint/parser": "npm:^8.36.0" - eslint-config-prettier: "npm:^8.5.0" - eslint-plugin-eslint-comments: "npm:^3.2.0" - eslint-plugin-ft-flow: "npm:^2.0.1" - eslint-plugin-jest: "npm:^29.0.1" - eslint-plugin-react: "npm:^7.37.5" - eslint-plugin-react-hooks: "npm:^7.0.1" - eslint-plugin-react-native: "npm:^5.0.0" - peerDependencies: - eslint: ^8.0.0 || ^9.0.0 - prettier: ">=2" - checksum: 10c0/4daa5362bf99a12b60a5a8b7a2e7e00f2a81472017bf5eea2fe42de05c954371f87e75296edc66bc1192dec4af0b5c45c42f24bea5d1acf2d63742b9ff192452 - languageName: node - linkType: hard - -"@react-native/eslint-plugin@npm:0.86.2": - version: 0.86.2 - resolution: "@react-native/eslint-plugin@npm:0.86.2" - checksum: 10c0/b883f8666d207e2d30b67606dcdee836eb01ba7cb132f4484d6269253a5c037375e0d56d207b40ae9051166a706db48f29f57a732c3048807e0c48e5cec1533a - languageName: node - linkType: hard - "@react-native/gradle-plugin@npm:0.86.2": version: 0.86.2 resolution: "@react-native/gradle-plugin@npm:0.86.2" @@ -7620,7 +7305,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.0, @sinonjs/commons@npm:^3.0.1": +"@sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -7629,15 +7314,6 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^10.0.2": - version: 10.3.0 - resolution: "@sinonjs/fake-timers@npm:10.3.0" - dependencies: - "@sinonjs/commons": "npm:^3.0.0" - checksum: 10c0/2e2fb6cc57f227912814085b7b01fede050cd4746ea8d49a1e44d5a0e56a804663b0340ae2f11af7559ea9bf4d087a11f2f646197a660ea3cb04e19efc04aa63 - languageName: node - linkType: hard - "@sinonjs/fake-timers@npm:^15.4.0": version: 15.4.0 resolution: "@sinonjs/fake-timers@npm:15.4.0" @@ -8527,16 +8203,6 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.5.13": - version: 29.5.14 - resolution: "@types/jest@npm:29.5.14" - dependencies: - expect: "npm:^29.0.0" - pretty-format: "npm:^29.0.0" - checksum: 10c0/18e0712d818890db8a8dab3d91e9ea9f7f19e3f83c2e50b312f557017dc81466207a71f3ed79cf4428e813ba939954fa26ffa0a9a7f153181ba174581b1c2aed - languageName: node - linkType: hard - "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -8874,7 +8540,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c @@ -9041,26 +8707,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:^8.36.0": - version: 8.71.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.71.0" - dependencies: - "@eslint-community/regexpp": "npm:^4.12.2" - "@typescript-eslint/scope-manager": "npm:8.71.0" - "@typescript-eslint/type-utils": "npm:8.71.0" - "@typescript-eslint/utils": "npm:8.71.0" - "@typescript-eslint/visitor-keys": "npm:8.71.0" - ignore: "npm:^7.0.5" - natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.5.0" - peerDependencies: - "@typescript-eslint/parser": ^8.71.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/cd93b29b572c002a45c335e1eff02522c05c25ec96ce7a88be1e42866e13ac73cf53c4f03b5aad2e6355b32004a4b57ce132a185232719bc38b906a6c38d2b74 - languageName: node - linkType: hard - "@typescript-eslint/parser@npm:8.63.0, @typescript-eslint/parser@npm:^8.60.1": version: 8.63.0 resolution: "@typescript-eslint/parser@npm:8.63.0" @@ -9077,22 +8723,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:^8.36.0": - version: 8.71.0 - resolution: "@typescript-eslint/parser@npm:8.71.0" - dependencies: - "@typescript-eslint/scope-manager": "npm:8.71.0" - "@typescript-eslint/types": "npm:8.71.0" - "@typescript-eslint/typescript-estree": "npm:8.71.0" - "@typescript-eslint/visitor-keys": "npm:8.71.0" - debug: "npm:^4.4.3" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/d83a47bfe23642365950863f3e1b2ec2fc7c31bd695f4692b49d10f35db68f7fe9762784102b712d17da5beb9a27d286013eede60ecc6540ee41d21fed3e2a10 - languageName: node - linkType: hard - "@typescript-eslint/project-service@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/project-service@npm:8.63.0" @@ -9106,19 +8736,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.71.0": - version: 8.71.0 - resolution: "@typescript-eslint/project-service@npm:8.71.0" - dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.71.0" - "@typescript-eslint/types": "npm:^8.71.0" - debug: "npm:^4.4.3" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/6d8eef9287393b807bb6b7f3f29c44540c9722650155aa0ee8eec1c466bc7c7831353a2db3fd4ba843da34c3db26fd495b80a1809a7e053b22e4e094a8189e6c - languageName: node - linkType: hard - "@typescript-eslint/scope-manager@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/scope-manager@npm:8.63.0" @@ -9129,16 +8746,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.71.0": - version: 8.71.0 - resolution: "@typescript-eslint/scope-manager@npm:8.71.0" - dependencies: - "@typescript-eslint/types": "npm:8.71.0" - "@typescript-eslint/visitor-keys": "npm:8.71.0" - checksum: 10c0/76f4565e1ba4d64c9053f475fd5eb30f538edbe17b497d9551178705117f114ba233a63e811b61b2f57a1abb426381dac38325936b28a6cd2172d01f9420ee9d - languageName: node - linkType: hard - "@typescript-eslint/tsconfig-utils@npm:8.63.0, @typescript-eslint/tsconfig-utils@npm:^8.63.0": version: 8.63.0 resolution: "@typescript-eslint/tsconfig-utils@npm:8.63.0" @@ -9148,15 +8755,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.71.0, @typescript-eslint/tsconfig-utils@npm:^8.71.0": - version: 8.71.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.71.0" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/c35af95cad4114cef15b3d9c18fa8c31735062a0cfef41fa5ef18ead1540de314f3f426996abacbb31a44a5317e8cd5fa1e08c3416ded86e4a63393ce5683ef3 - languageName: node - linkType: hard - "@typescript-eslint/type-utils@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/type-utils@npm:8.63.0" @@ -9173,22 +8771,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.71.0": - version: 8.71.0 - resolution: "@typescript-eslint/type-utils@npm:8.71.0" - dependencies: - "@typescript-eslint/types": "npm:8.71.0" - "@typescript-eslint/typescript-estree": "npm:8.71.0" - "@typescript-eslint/utils": "npm:8.71.0" - debug: "npm:^4.4.3" - ts-api-utils: "npm:^2.5.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/06502ba733d33ab8dffa43f71440e4bc1517066c23bf9b3659e3c6733cb932e97c7c497edfe6e3f9200cd44be3cbb2a905f0d2ce71212600123c146fc7f65005 - languageName: node - linkType: hard - "@typescript-eslint/types@npm:8.63.0, @typescript-eslint/types@npm:^8.63.0": version: 8.63.0 resolution: "@typescript-eslint/types@npm:8.63.0" @@ -9196,13 +8778,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.71.0, @typescript-eslint/types@npm:^8.71.0": - version: 8.71.0 - resolution: "@typescript-eslint/types@npm:8.71.0" - checksum: 10c0/87e62f61cc2499ab7a01f49df205f914f9a8c9d25e80465cce1b0513bb7d8208682376c8e877720998abb8868ca61ede7761265405853c4a64aa0335a0d98db2 - languageName: node - linkType: hard - "@typescript-eslint/typescript-estree@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/typescript-estree@npm:8.63.0" @@ -9222,25 +8797,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.71.0": - version: 8.71.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.71.0" - dependencies: - "@typescript-eslint/project-service": "npm:8.71.0" - "@typescript-eslint/tsconfig-utils": "npm:8.71.0" - "@typescript-eslint/types": "npm:8.71.0" - "@typescript-eslint/visitor-keys": "npm:8.71.0" - debug: "npm:^4.4.3" - minimatch: "npm:^10.2.2" - semver: "npm:^7.7.3" - tinyglobby: "npm:^0.2.15" - ts-api-utils: "npm:^2.5.0" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/3238fcb3933c06fa9f2b8488becc6815c513338a1663427122b5ce6642807a95d3aceb95b3e2c73c934523227d4f90e9a694dfe044b8240c4e4e90214f96eaa8 - languageName: node - linkType: hard - "@typescript-eslint/utils@npm:8.63.0": version: 8.63.0 resolution: "@typescript-eslint/utils@npm:8.63.0" @@ -9256,38 +8812,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.71.0, @typescript-eslint/utils@npm:^8.0.0": - version: 8.71.0 - resolution: "@typescript-eslint/utils@npm:8.71.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.9.1" - "@typescript-eslint/scope-manager": "npm:8.71.0" - "@typescript-eslint/types": "npm:8.71.0" - "@typescript-eslint/typescript-estree": "npm:8.71.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/6d682bc7d578d3266a553559c603a22cd4408b6db321653d5f179b955555c3287f0bb37e6bbcff5f1c485ba11a292e74c5fcd7da60a4b0c492b6365dad48a155 - languageName: node - linkType: hard - "@typescript-eslint/visitor-keys@npm:8.63.0": - version: 8.63.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.63.0" - dependencies: - "@typescript-eslint/types": "npm:8.63.0" - eslint-visitor-keys: "npm:^5.0.0" - checksum: 10c0/595b47b08efecc35d93a8ac072798f9dc2371a371f03a1a03ab134a8505fe422fc647014bd096eac75a3d487c5eecf24393048ca88ed06f759d00115c11dd8bc - languageName: node - linkType: hard - -"@typescript-eslint/visitor-keys@npm:8.71.0": - version: 8.71.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.71.0" + version: 8.63.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.63.0" dependencies: - "@typescript-eslint/types": "npm:8.71.0" + "@typescript-eslint/types": "npm:8.63.0" eslint-visitor-keys: "npm:^5.0.0" - checksum: 10c0/0797c64e03402d04bac7dabf797cd36dc5e36a7bf1f2aaad063730af8be315ab41529962b5c58d4fca17890c64b32c12e3c8e62876f43f91acd0706421815f03 + checksum: 10c0/595b47b08efecc35d93a8ac072798f9dc2371a371f03a1a03ab134a8505fe422fc647014bd096eac75a3d487c5eecf24393048ca88ed06f759d00115c11dd8bc languageName: node linkType: hard @@ -9530,13 +9061,6 @@ __metadata: languageName: node linkType: hard -"@ungap/structured-clone@npm:^1.2.0": - version: 1.4.0 - resolution: "@ungap/structured-clone@npm:1.4.0" - checksum: 10c0/be74a389850d02901c836c07f10c1070d7604dab4070ba3c4b77c91665b90b4175d9f186189a02a399cf054fc051d3251418238564a4491d0164969e05c802a2 - languageName: node - linkType: hard - "@unrs/resolver-binding-android-arm-eabi@npm:1.9.0": version: 1.9.0 resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.9.0" @@ -10229,15 +9753,6 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.9.0": - version: 8.18.0 - resolution: "acorn@npm:8.18.0" - bin: - acorn: bin/acorn - checksum: 10c0/be771be2135cc07910cf76f444ad514d7dcfd6d4a8026e597e93155275abc8ef61eee12211d52146e9d962874269b634f397464942087be013c89d0c54c5f8e5 - languageName: node - linkType: hard - "address@npm:^1.0.1, address@npm:^1.1.2": version: 1.2.2 resolution: "address@npm:1.2.2" @@ -10334,18 +9849,6 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.12.4": - version: 6.15.0 - resolution: "ajv@npm:6.15.0" - dependencies: - fast-deep-equal: "npm:^3.1.1" - fast-json-stable-stringify: "npm:^2.0.0" - json-schema-traverse: "npm:^0.4.1" - uri-js: "npm:^4.2.2" - checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2 - languageName: node - linkType: hard - "algoliasearch-helper@npm:^3.26.0": version: 3.26.0 resolution: "algoliasearch-helper@npm:3.26.0" @@ -10418,7 +9921,7 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.2": +"ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" dependencies: @@ -12084,13 +11587,6 @@ __metadata: languageName: node linkType: hard -"cjs-module-lexer@npm:^1.0.0": - version: 1.4.3 - resolution: "cjs-module-lexer@npm:1.4.3" - checksum: 10c0/076b3af85adc4d65dbdab1b5b240fe5b45d44fcf0ef9d429044dd94d19be5589376805c44fb2d4b3e684e5fe6a9b7cf3e426476a6507c45283c5fc6ff95240be - languageName: node - linkType: hard - "cjs-module-lexer@npm:^2.1.0": version: 2.1.0 resolution: "cjs-module-lexer@npm:2.1.0" @@ -12315,13 +11811,6 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.0": - version: 1.0.3 - resolution: "collect-v8-coverage@npm:1.0.3" - checksum: 10c0/bc62ba251bcce5e3354a8f88fa6442bee56e3e612fec08d4dfcf66179b41ea0bf544b0f78c4ebc0f8050871220af95bb5c5578a6aef346feea155640582f09dc - languageName: node - linkType: hard - "collect-v8-coverage@npm:^1.0.2": version: 1.0.2 resolution: "collect-v8-coverage@npm:1.0.2" @@ -12889,23 +12378,6 @@ __metadata: languageName: node linkType: hard -"create-jest@npm:^29.7.0": - version: 29.7.0 - resolution: "create-jest@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - chalk: "npm:^4.0.0" - exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" - jest-config: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - prompts: "npm:^2.0.1" - bin: - create-jest: bin/create-jest.js - checksum: 10c0/e7e54c280692470d3398f62a6238fd396327e01c6a0757002833f06d00afc62dd7bfe04ff2b9cd145264460e6b4d1eb8386f2925b7e567f97939843b7b0e812f - languageName: node - linkType: hard - "cross-fetch@npm:^4.1.0": version: 4.1.0 resolution: "cross-fetch@npm:4.1.0" @@ -12928,7 +12400,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -13933,18 +13405,6 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.0.0": - version: 1.7.2 - resolution: "dedent@npm:1.7.2" - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - checksum: 10c0/acaff07cac355b93f17b1b17ebbb84d3cc55af6ab4b7814c3f505e061903e168bc6bf9ddce331552d64dee1525f0b4c549c9ade46aebfac6f69caaed74e90751 - languageName: node - linkType: hard - "dedent@npm:^1.6.0": version: 1.6.0 resolution: "dedent@npm:1.6.0" @@ -14123,7 +13583,7 @@ __metadata: languageName: node linkType: hard -"detect-newline@npm:^3.0.0, detect-newline@npm:^3.1.0": +"detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" checksum: 10c0/c38cfc8eeb9fda09febb44bcd85e467c970d4e3bf526095394e5a4f18bc26dd0cf6b22c69c1fa9969261521c593836db335c2795218f6d781a512aea2fb8209d @@ -14171,13 +13631,6 @@ __metadata: languageName: node linkType: hard -"diff-sequences@npm:^29.6.3": - version: 29.6.3 - resolution: "diff-sequences@npm:29.6.3" - checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 - languageName: node - linkType: hard - "diffie-hellman@npm:^5.0.3": version: 5.0.3 resolution: "diffie-hellman@npm:5.0.3" @@ -14216,15 +13669,6 @@ __metadata: languageName: node linkType: hard -"doctrine@npm:^3.0.0": - version: 3.0.0 - resolution: "doctrine@npm:3.0.0" - dependencies: - esutils: "npm:^2.0.2" - checksum: 10c0/c96bdccabe9d62ab6fea9399fdff04a66e6563c1d6fb3a3a063e8d53c3bb136ba63e84250bbf63d00086a769ad53aef92d2bd483f03f837fc97b71cbee6b2520 - languageName: node - linkType: hard - "dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" @@ -14995,17 +14439,6 @@ __metadata: languageName: node linkType: hard -"eslint-config-prettier@npm:^8.5.0": - version: 8.10.2 - resolution: "eslint-config-prettier@npm:8.10.2" - peerDependencies: - eslint: ">=7.0.0" - bin: - eslint-config-prettier: bin/cli.js - checksum: 10c0/b5953cf7a86f685e1218b16707bf36643b525513d08495226a6820caccd8b7bfc6b9aa64ac7cb2415dbe2c1f7dc4995832148bdc53ad45777f75a8ded1073b29 - languageName: node - linkType: hard - "eslint-import-context@npm:^0.1.8": version: 0.1.9 resolution: "eslint-import-context@npm:0.1.9" @@ -15089,31 +14522,6 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-eslint-comments@npm:^3.2.0": - version: 3.2.0 - resolution: "eslint-plugin-eslint-comments@npm:3.2.0" - dependencies: - escape-string-regexp: "npm:^1.0.5" - ignore: "npm:^5.0.5" - peerDependencies: - eslint: ">=4.19.1" - checksum: 10c0/c71db824592dc8ea498021572a0bd33d763ef26126bdb3b84a027ca75a1adbe0894ec95024f7de39ef12308560e62cbf8af0d06ffe472be5ba8bd9169c928e96 - languageName: node - linkType: hard - -"eslint-plugin-ft-flow@npm:^2.0.1": - version: 2.0.3 - resolution: "eslint-plugin-ft-flow@npm:2.0.3" - dependencies: - lodash: "npm:^4.17.21" - string-natural-compare: "npm:^3.0.1" - peerDependencies: - "@babel/eslint-parser": ^7.12.0 - eslint: ^8.1.0 - checksum: 10c0/171f6862f7be3c66a415c2ebf14a6e29ade78b661a16f344b78fbefeaeed97fc7f2c710c0d3a2c2df2bbb614b282eaef830993c2aac83b13324cd8c2f9497ea6 - languageName: node - linkType: hard - "eslint-plugin-import@npm:^2.32.0": version: 2.32.0 resolution: "eslint-plugin-import@npm:2.32.0" @@ -15143,27 +14551,6 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jest@npm:^29.0.1": - version: 29.16.6 - resolution: "eslint-plugin-jest@npm:29.16.6" - dependencies: - "@typescript-eslint/utils": "npm:^8.0.0" - peerDependencies: - "@typescript-eslint/eslint-plugin": ^8.0.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - jest: "*" - typescript: ">=4.8.4 <8.0.0" - peerDependenciesMeta: - "@typescript-eslint/eslint-plugin": - optional: true - jest: - optional: true - typescript: - optional: true - checksum: 10c0/c483057f6b7c6500c6208eb965b61fe3a81dbd86123dd1a129584ed8c490336275a8815196922c20bd4895761a7d5b993f7b37869c69d57594b522fe318def8d - languageName: node - linkType: hard - "eslint-plugin-jsx-a11y@npm:^6.10.2": version: 6.10.2 resolution: "eslint-plugin-jsx-a11y@npm:6.10.2" @@ -15218,39 +14605,6 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:^7.0.1": - version: 7.1.1 - resolution: "eslint-plugin-react-hooks@npm:7.1.1" - dependencies: - "@babel/core": "npm:^7.24.4" - "@babel/parser": "npm:^7.24.4" - hermes-parser: "npm:^0.25.1" - zod: "npm:^3.25.0 || ^4.0.0" - zod-validation-error: "npm:^3.5.0 || ^4.0.0" - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - checksum: 10c0/cee8454915d71ac5d70a0d8f4f260e76eaf45fcd4162747dd4282b792ee5616d187351dabe6cdcff9040c79d0cec625635c4fd0777276be119efa88ebe058525 - languageName: node - linkType: hard - -"eslint-plugin-react-native-globals@npm:^0.1.1": - version: 0.1.2 - resolution: "eslint-plugin-react-native-globals@npm:0.1.2" - checksum: 10c0/ddb4ec5e31f6e72a66d51218c8f0b558b5366d614598fbec1833ac529db2c2dc1724c7ed71c1fcf922251b8438634f704d265c9bedf51aecfe807ec4a0403c09 - languageName: node - linkType: hard - -"eslint-plugin-react-native@npm:^5.0.0": - version: 5.0.0 - resolution: "eslint-plugin-react-native@npm:5.0.0" - dependencies: - eslint-plugin-react-native-globals: "npm:^0.1.1" - peerDependencies: - eslint: ^3.17.0 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - checksum: 10c0/c7c927bc743abf0cb367cc64fea5b28b28ea0c58be2990cab858a050b4855e89d90513afa44d73012c9fd670810ad0da2ac72e3e4bdfedf0ce0cbb65e901af7f - languageName: node - linkType: hard - "eslint-plugin-react@npm:^7.37.5": version: 7.37.5 resolution: "eslint-plugin-react@npm:7.37.5" @@ -15296,16 +14650,6 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^7.2.2": - version: 7.2.2 - resolution: "eslint-scope@npm:7.2.2" - dependencies: - esrecurse: "npm:^4.3.0" - estraverse: "npm:^5.2.0" - checksum: 10c0/613c267aea34b5a6d6c00514e8545ef1f1433108097e857225fed40d397dd6b1809dffd11c2fde23b37ca53d7bf935fe04d2a18e6fc932b31837b6ad67e1c116 - languageName: node - linkType: hard - "eslint-scope@npm:^8.4.0": version: 8.4.0 resolution: "eslint-scope@npm:8.4.0" @@ -15323,7 +14667,7 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^3.4.1, eslint-visitor-keys@npm:^3.4.3": +"eslint-visitor-keys@npm:^3.4.3": version: 3.4.3 resolution: "eslint-visitor-keys@npm:3.4.3" checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 @@ -15393,54 +14737,6 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^8.19.0": - version: 8.57.1 - resolution: "eslint@npm:8.57.1" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.2.0" - "@eslint-community/regexpp": "npm:^4.6.1" - "@eslint/eslintrc": "npm:^2.1.4" - "@eslint/js": "npm:8.57.1" - "@humanwhocodes/config-array": "npm:^0.13.0" - "@humanwhocodes/module-importer": "npm:^1.0.1" - "@nodelib/fs.walk": "npm:^1.2.8" - "@ungap/structured-clone": "npm:^1.2.0" - ajv: "npm:^6.12.4" - chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.2" - debug: "npm:^4.3.2" - doctrine: "npm:^3.0.0" - escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^7.2.2" - eslint-visitor-keys: "npm:^3.4.3" - espree: "npm:^9.6.1" - esquery: "npm:^1.4.2" - esutils: "npm:^2.0.2" - fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^6.0.1" - find-up: "npm:^5.0.0" - glob-parent: "npm:^6.0.2" - globals: "npm:^13.19.0" - graphemer: "npm:^1.4.0" - ignore: "npm:^5.2.0" - imurmurhash: "npm:^0.1.4" - is-glob: "npm:^4.0.0" - is-path-inside: "npm:^3.0.3" - js-yaml: "npm:^4.1.0" - json-stable-stringify-without-jsonify: "npm:^1.0.1" - levn: "npm:^0.4.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.2" - natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.3" - strip-ansi: "npm:^6.0.1" - text-table: "npm:^0.2.0" - bin: - eslint: bin/eslint.js - checksum: 10c0/1fd31533086c1b72f86770a4d9d7058ee8b4643fd1cfd10c7aac1ecb8725698e88352a87805cf4b2ce890aa35947df4b4da9655fb7fdfa60dbb448a43f6ebcf1 - languageName: node - linkType: hard - "esm-env@npm:^1.1.4": version: 1.1.4 resolution: "esm-env@npm:1.1.4" @@ -15459,17 +14755,6 @@ __metadata: languageName: node linkType: hard -"espree@npm:^9.6.0, espree@npm:^9.6.1": - version: 9.6.1 - resolution: "espree@npm:9.6.1" - dependencies: - acorn: "npm:^8.9.0" - acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^3.4.1" - checksum: 10c0/1a2e9b4699b715347f62330bcc76aee224390c28bb02b31a3752e9d07549c473f5f986720483c6469cf3cfb3c9d05df612ffc69eb1ee94b54b739e67de9bb460 - languageName: node - linkType: hard - "esprima@npm:^4.0.0, esprima@npm:~4.0.0": version: 4.0.1 resolution: "esprima@npm:4.0.1" @@ -15480,15 +14765,6 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.2": - version: 1.7.0 - resolution: "esquery@npm:1.7.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 - languageName: node - linkType: hard - "esquery@npm:^1.5.0": version: 1.6.0 resolution: "esquery@npm:1.6.0" @@ -15669,19 +14945,17 @@ __metadata: "@react-native-community/cli": "npm:20.1.0" "@react-native-community/cli-platform-android": "npm:20.1.0" "@react-native/babel-preset": "npm:0.86.2" - "@react-native/eslint-config": "npm:0.86.2" "@react-native/metro-config": "npm:0.86.2" "@react-native/typescript-config": "npm:0.86.2" - "@types/jest": "npm:^29.5.13" + "@types/jest": "npm:30.0.0" "@types/react": "npm:^19.2.0" "@types/react-test-renderer": "npm:^19.1.0" - eslint: "npm:^8.19.0" - jest: "npm:^29.6.3" - prettier: "npm:2.8.8" + "@typescript/native": "npm:typescript@7.0.2" + jest: "npm:^30.0.0" react: "npm:19.2.3" react-native: "npm:0.86.2" react-test-renderer: "npm:19.2.3" - typescript: "npm:^5.8.3" + typescript: "npm:@typescript/typescript6@6.0.2" languageName: unknown linkType: soft @@ -15797,13 +15071,6 @@ __metadata: languageName: node linkType: hard -"exit@npm:^0.1.2": - version: 0.1.2 - resolution: "exit@npm:0.1.2" - checksum: 10c0/71d2ad9b36bc25bb8b104b17e830b40a08989be7f7d100b13269aaae7c3784c3e6e1e88a797e9e87523993a25ba27c8958959a554535370672cfb4d824af8989 - languageName: node - linkType: hard - "expect@npm:30.4.1, expect@npm:^30.0.0": version: 30.4.1 resolution: "expect@npm:30.4.1" @@ -15818,19 +15085,6 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.0.0, expect@npm:^29.7.0": - version: 29.7.0 - resolution: "expect@npm:29.7.0" - dependencies: - "@jest/expect-utils": "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10c0/2eddeace66e68b8d8ee5f7be57f3014b19770caaf6815c7a08d131821da527fb8c8cb7b3dcd7c883d2d3d8d184206a4268984618032d1e4b16dc8d6596475d41 - languageName: node - linkType: hard - "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -16065,15 +15319,6 @@ __metadata: languageName: node linkType: hard -"file-entry-cache@npm:^6.0.1": - version: 6.0.1 - resolution: "file-entry-cache@npm:6.0.1" - dependencies: - flat-cache: "npm:^3.0.4" - checksum: 10c0/58473e8a82794d01b38e5e435f6feaf648e3f36fdb3a56e98f417f4efae71ad1c0d4ebd8a9a7c50c3ad085820a93fc7494ad721e0e4ebc1da3573f4e1c3c7cdd - languageName: node - linkType: hard - "file-entry-cache@npm:^8.0.0": version: 8.0.0 resolution: "file-entry-cache@npm:8.0.0" @@ -16265,17 +15510,6 @@ __metadata: languageName: node linkType: hard -"flat-cache@npm:^3.0.4": - version: 3.2.0 - resolution: "flat-cache@npm:3.2.0" - dependencies: - flatted: "npm:^3.2.9" - keyv: "npm:^4.5.3" - rimraf: "npm:^3.0.2" - checksum: 10c0/b76f611bd5f5d68f7ae632e3ae503e678d205cf97a17c6ab5b12f6ca61188b5f1f7464503efae6dc18683ed8f0b41460beb48ac4b9ac63fe6201296a91ba2f75 - languageName: node - linkType: hard - "flat-cache@npm:^4.0.0": version: 4.0.1 resolution: "flat-cache@npm:4.0.1" @@ -16824,15 +16058,6 @@ __metadata: languageName: node linkType: hard -"globals@npm:^13.19.0": - version: 13.24.0 - resolution: "globals@npm:13.24.0" - dependencies: - type-fest: "npm:^0.20.2" - checksum: 10c0/d3c11aeea898eb83d5ec7a99508600fbe8f83d2cf00cbb77f873dbf2bcb39428eff1b538e4915c993d8a3b3473fa71eeebfe22c9bb3a3003d1e26b1f2c8a42cd - languageName: node - linkType: hard - "globals@npm:^14.0.0": version: 14.0.0 resolution: "globals@npm:14.0.0" @@ -16944,13 +16169,6 @@ __metadata: languageName: node linkType: hard -"graphemer@npm:^1.4.0": - version: 1.4.0 - resolution: "graphemer@npm:1.4.0" - checksum: 10c0/e951259d8cd2e0d196c72ec711add7115d42eb9a8146c8eeda5b8d3ac91e5dd816b9cd68920726d9fd4490368e7ed86e9c423f40db87e2d8dfafa00fa17c3a31 - languageName: node - linkType: hard - "gulp-filter@npm:^9.0.1": version: 9.0.1 resolution: "gulp-filter@npm:9.0.1" @@ -17291,13 +16509,6 @@ __metadata: languageName: node linkType: hard -"hermes-estree@npm:0.25.1": - version: 0.25.1 - resolution: "hermes-estree@npm:0.25.1" - checksum: 10c0/48be3b2fa37a0cbc77a112a89096fa212f25d06de92781b163d67853d210a8a5c3784fac23d7d48335058f7ed283115c87b4332c2a2abaaccc76d0ead1a282ac - languageName: node - linkType: hard - "hermes-estree@npm:0.35.0": version: 0.35.0 resolution: "hermes-estree@npm:0.35.0" @@ -17346,15 +16557,6 @@ __metadata: languageName: node linkType: hard -"hermes-parser@npm:^0.25.1": - version: 0.25.1 - resolution: "hermes-parser@npm:0.25.1" - dependencies: - hermes-estree: "npm:0.25.1" - checksum: 10c0/3abaa4c6f1bcc25273f267297a89a4904963ea29af19b8e4f6eabe04f1c2c7e9abd7bfc4730ddb1d58f2ea04b6fee74053d8bddb5656ec6ebf6c79cc8d14202c - languageName: node - linkType: hard - "history@npm:*, history@npm:5.3.0, history@npm:^5.3.0": version: 5.3.0 resolution: "history@npm:5.3.0" @@ -17836,13 +17038,6 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.0.5": - version: 5.3.2 - resolution: "ignore@npm:5.3.2" - checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 - languageName: node - linkType: hard - "ignore@npm:^5.2.0, ignore@npm:^5.2.4": version: 5.3.1 resolution: "ignore@npm:5.3.1" @@ -18337,7 +17532,7 @@ __metadata: languageName: node linkType: hard -"is-generator-fn@npm:^2.0.0, is-generator-fn@npm:^2.1.0": +"is-generator-fn@npm:^2.1.0": version: 2.1.0 resolution: "is-generator-fn@npm:2.1.0" checksum: 10c0/2957cab387997a466cd0bf5c1b6047bd21ecb32bdcfd8996b15747aa01002c1c88731802f1b3d34ac99f4f6874b626418bd118658cf39380fe5fff32a3af9c4d @@ -18512,7 +17707,7 @@ __metadata: languageName: node linkType: hard -"is-path-inside@npm:^3.0.2, is-path-inside@npm:^3.0.3": +"is-path-inside@npm:^3.0.2": version: 3.0.3 resolution: "is-path-inside@npm:3.0.3" checksum: 10c0/cf7d4ac35fb96bab6a1d2c3598fe5ebb29aafb52c0aaa482b5a3ed9d8ba3edc11631e3ec2637660c44b3ce0e61a08d54946e8af30dec0b60a7c27296c68ffd05 @@ -18901,17 +18096,6 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^4.0.0": - version: 4.0.1 - resolution: "istanbul-lib-source-maps@npm:4.0.1" - dependencies: - debug: "npm:^4.1.1" - istanbul-lib-coverage: "npm:^3.0.0" - source-map: "npm:^0.6.1" - checksum: 10c0/19e4cc405016f2c906dff271a76715b3e881fa9faeb3f09a86cb99b8512b3a5ed19cadfe0b54c17ca0e54c1142c9c6de9330d65506e35873994e06634eebeb66 - languageName: node - linkType: hard - "istanbul-lib-source-maps@npm:^5.0.0": version: 5.0.6 resolution: "istanbul-lib-source-maps@npm:5.0.6" @@ -18993,17 +18177,6 @@ __metadata: languageName: node linkType: hard -"jest-changed-files@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-changed-files@npm:29.7.0" - dependencies: - execa: "npm:^5.0.0" - jest-util: "npm:^29.7.0" - p-limit: "npm:^3.1.0" - checksum: 10c0/e071384d9e2f6bb462231ac53f29bff86f0e12394c1b49ccafbad225ce2ab7da226279a8a94f421949920bef9be7ef574fd86aee22e8adfa149be73554ab828b - languageName: node - linkType: hard - "jest-circus@npm:30.4.2": version: 30.4.2 resolution: "jest-circus@npm:30.4.2" @@ -19032,82 +18205,28 @@ __metadata: languageName: node linkType: hard -"jest-circus@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-circus@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/expect": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - co: "npm:^4.6.0" - dedent: "npm:^1.0.0" - is-generator-fn: "npm:^2.0.0" - jest-each: "npm:^29.7.0" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-runtime: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - p-limit: "npm:^3.1.0" - pretty-format: "npm:^29.7.0" - pure-rand: "npm:^6.0.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10c0/8d15344cf7a9f14e926f0deed64ed190c7a4fa1ed1acfcd81e4cc094d3cc5bf7902ebb7b874edc98ada4185688f90c91e1747e0dfd7ac12463b097968ae74b5e - languageName: node - linkType: hard - "jest-cli@npm:30.4.2": version: 30.4.2 resolution: "jest-cli@npm:30.4.2" dependencies: "@jest/core": "npm:30.4.2" - "@jest/test-result": "npm:30.4.1" - "@jest/types": "npm:30.4.1" - chalk: "npm:^4.1.2" - exit-x: "npm:^0.2.2" - import-local: "npm:^3.2.0" - jest-config: "npm:30.4.2" - jest-util: "npm:30.4.1" - jest-validate: "npm:30.4.1" - yargs: "npm:^17.7.2" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - bin: - jest: ./bin/jest.js - checksum: 10c0/a036a1bf06ce7d5fed644a518c4a4ccf60c5fe5f3d96d143973048e6690c4a28a4f97fa3275d90ca236430a1b2a7c10544e7e190a4f2edfdf0a4e6daf1f6a384 - languageName: node - linkType: hard - -"jest-cli@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-cli@npm:29.7.0" - dependencies: - "@jest/core": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - chalk: "npm:^4.0.0" - create-jest: "npm:^29.7.0" - exit: "npm:^0.1.2" - import-local: "npm:^3.0.2" - jest-config: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - yargs: "npm:^17.3.1" + "@jest/test-result": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + chalk: "npm:^4.1.2" + exit-x: "npm:^0.2.2" + import-local: "npm:^3.2.0" + jest-config: "npm:30.4.2" + jest-util: "npm:30.4.1" + jest-validate: "npm:30.4.1" + yargs: "npm:^17.7.2" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true bin: - jest: bin/jest.js - checksum: 10c0/a658fd55050d4075d65c1066364595962ead7661711495cfa1dfeecf3d6d0a8ffec532f3dbd8afbb3e172dd5fd2fb2e813c5e10256e7cf2fea766314942fb43a + jest: ./bin/jest.js + checksum: 10c0/a036a1bf06ce7d5fed644a518c4a4ccf60c5fe5f3d96d143973048e6690c4a28a4f97fa3275d90ca236430a1b2a7c10544e7e190a4f2edfdf0a4e6daf1f6a384 languageName: node linkType: hard @@ -19153,44 +18272,6 @@ __metadata: languageName: node linkType: hard -"jest-config@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-config@npm:29.7.0" - dependencies: - "@babel/core": "npm:^7.11.6" - "@jest/test-sequencer": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - babel-jest: "npm:^29.7.0" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - deepmerge: "npm:^4.2.2" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - jest-circus: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-regex-util: "npm:^29.6.3" - jest-resolve: "npm:^29.7.0" - jest-runner: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - parse-json: "npm:^5.2.0" - pretty-format: "npm:^29.7.0" - slash: "npm:^3.0.0" - strip-json-comments: "npm:^3.1.1" - peerDependencies: - "@types/node": "*" - ts-node: ">=9.0.0" - peerDependenciesMeta: - "@types/node": - optional: true - ts-node: - optional: true - checksum: 10c0/bab23c2eda1fff06e0d104b00d6adfb1d1aabb7128441899c9bff2247bd26710b050a5364281ce8d52b46b499153bf7e3ee88b19831a8f3451f1477a0246a0f1 - languageName: node - linkType: hard - "jest-diff@npm:30.4.1": version: 30.4.1 resolution: "jest-diff@npm:30.4.1" @@ -19203,18 +18284,6 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-diff@npm:29.7.0" - dependencies: - chalk: "npm:^4.0.0" - diff-sequences: "npm:^29.6.3" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10c0/89a4a7f182590f56f526443dde69acefb1f2f0c9e59253c61d319569856c4931eae66b8a3790c443f529267a0ddba5ba80431c585deed81827032b2b2a1fc999 - languageName: node - linkType: hard - "jest-docblock@npm:30.4.0": version: 30.4.0 resolution: "jest-docblock@npm:30.4.0" @@ -19224,15 +18293,6 @@ __metadata: languageName: node linkType: hard -"jest-docblock@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-docblock@npm:29.7.0" - dependencies: - detect-newline: "npm:^3.0.0" - checksum: 10c0/d932a8272345cf6b6142bb70a2bb63e0856cc0093f082821577ea5bdf4643916a98744dfc992189d2b1417c38a11fa42466f6111526bc1fb81366f56410f3be9 - languageName: node - linkType: hard - "jest-each@npm:30.4.1": version: 30.4.1 resolution: "jest-each@npm:30.4.1" @@ -19246,19 +18306,6 @@ __metadata: languageName: node linkType: hard -"jest-each@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-each@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - chalk: "npm:^4.0.0" - jest-get-type: "npm:^29.6.3" - jest-util: "npm:^29.7.0" - pretty-format: "npm:^29.7.0" - checksum: 10c0/f7f9a90ebee80cc688e825feceb2613627826ac41ea76a366fa58e669c3b2403d364c7c0a74d862d469b103c843154f8456d3b1c02b487509a12afa8b59edbb4 - languageName: node - linkType: hard - "jest-environment-jsdom@npm:^30.0.0": version: 30.4.1 resolution: "jest-environment-jsdom@npm:30.4.1" @@ -19352,16 +18399,6 @@ __metadata: languageName: node linkType: hard -"jest-leak-detector@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-leak-detector@npm:29.7.0" - dependencies: - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10c0/71bb9f77fc489acb842a5c7be030f2b9acb18574dc9fb98b3100fc57d422b1abc55f08040884bd6e6dbf455047a62f7eaff12aa4058f7cbdc11558718ca6a395 - languageName: node - linkType: hard - "jest-matcher-utils@npm:30.4.1, jest-matcher-utils@npm:^30.0.5": version: 30.4.1 resolution: "jest-matcher-utils@npm:30.4.1" @@ -19374,18 +18411,6 @@ __metadata: languageName: node linkType: hard -"jest-matcher-utils@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-matcher-utils@npm:29.7.0" - dependencies: - chalk: "npm:^4.0.0" - jest-diff: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10c0/0d0e70b28fa5c7d4dce701dc1f46ae0922102aadc24ed45d594dd9b7ae0a8a6ef8b216718d1ab79e451291217e05d4d49a82666e1a3cc2b428b75cd9c933244e - languageName: node - linkType: hard - "jest-message-util@npm:30.4.1": version: 30.4.1 resolution: "jest-message-util@npm:30.4.1" @@ -19404,23 +18429,6 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-message-util@npm:29.7.0" - dependencies: - "@babel/code-frame": "npm:^7.12.13" - "@jest/types": "npm:^29.6.3" - "@types/stack-utils": "npm:^2.0.0" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^29.7.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10c0/850ae35477f59f3e6f27efac5215f706296e2104af39232bb14e5403e067992afb5c015e87a9243ec4d9df38525ef1ca663af9f2f4766aa116f127247008bd22 - languageName: node - linkType: hard - "jest-mock@npm:30.4.1, jest-mock@npm:^30.0.0": version: 30.4.1 resolution: "jest-mock@npm:30.4.1" @@ -19432,18 +18440,7 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-mock@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-util: "npm:^29.7.0" - checksum: 10c0/7b9f8349ee87695a309fe15c46a74ab04c853369e5c40952d68061d9dc3159a0f0ed73e215f81b07ee97a9faaf10aebe5877a9d6255068a0977eae6a9ff1d5ac - languageName: node - linkType: hard - -"jest-pnp-resolver@npm:^1.2.2, jest-pnp-resolver@npm:^1.2.3": +"jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" peerDependencies: @@ -19479,16 +18476,6 @@ __metadata: languageName: node linkType: hard -"jest-resolve-dependencies@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-resolve-dependencies@npm:29.7.0" - dependencies: - jest-regex-util: "npm:^29.6.3" - jest-snapshot: "npm:^29.7.0" - checksum: 10c0/b6e9ad8ae5b6049474118ea6441dfddd385b6d1fc471db0136f7c8fbcfe97137a9665e4f837a9f49f15a29a1deb95a14439b7aec812f3f99d08f228464930f0d - languageName: node - linkType: hard - "jest-resolve@npm:30.4.1": version: 30.4.1 resolution: "jest-resolve@npm:30.4.1" @@ -19505,23 +18492,6 @@ __metadata: languageName: node linkType: hard -"jest-resolve@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-resolve@npm:29.7.0" - dependencies: - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - jest-pnp-resolver: "npm:^1.2.2" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - resolve: "npm:^1.20.0" - resolve.exports: "npm:^2.0.0" - slash: "npm:^3.0.0" - checksum: 10c0/59da5c9c5b50563e959a45e09e2eace783d7f9ac0b5dcc6375dea4c0db938d2ebda97124c8161310082760e8ebbeff9f6b177c15ca2f57fb424f637a5d2adb47 - languageName: node - linkType: hard - "jest-runner@npm:30.4.2": version: 30.4.2 resolution: "jest-runner@npm:30.4.2" @@ -19552,35 +18522,6 @@ __metadata: languageName: node linkType: hard -"jest-runner@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-runner@npm:29.7.0" - dependencies: - "@jest/console": "npm:^29.7.0" - "@jest/environment": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - emittery: "npm:^0.13.1" - graceful-fs: "npm:^4.2.9" - jest-docblock: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" - jest-haste-map: "npm:^29.7.0" - jest-leak-detector: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-resolve: "npm:^29.7.0" - jest-runtime: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-watcher: "npm:^29.7.0" - jest-worker: "npm:^29.7.0" - p-limit: "npm:^3.1.0" - source-map-support: "npm:0.5.13" - checksum: 10c0/2194b4531068d939f14c8d3274fe5938b77fa73126aedf9c09ec9dec57d13f22c72a3b5af01ac04f5c1cf2e28d0ac0b4a54212a61b05f10b5d6b47f2a1097bb4 - languageName: node - linkType: hard - "jest-runtime@npm:30.4.2": version: 30.4.2 resolution: "jest-runtime@npm:30.4.2" @@ -19611,36 +18552,6 @@ __metadata: languageName: node linkType: hard -"jest-runtime@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-runtime@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/fake-timers": "npm:^29.7.0" - "@jest/globals": "npm:^29.7.0" - "@jest/source-map": "npm:^29.6.3" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - cjs-module-lexer: "npm:^1.0.0" - collect-v8-coverage: "npm:^1.0.0" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-mock: "npm:^29.7.0" - jest-regex-util: "npm:^29.6.3" - jest-resolve: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - slash: "npm:^3.0.0" - strip-bom: "npm:^4.0.0" - checksum: 10c0/7cd89a1deda0bda7d0941835434e44f9d6b7bd50b5c5d9b0fc9a6c990b2d4d2cab59685ab3cb2850ed4cc37059f6de903af5a50565d7f7f1192a77d3fd6dd2a6 - languageName: node - linkType: hard - "jest-snapshot@npm:30.4.1": version: 30.4.1 resolution: "jest-snapshot@npm:30.4.1" @@ -19670,34 +18581,6 @@ __metadata: languageName: node linkType: hard -"jest-snapshot@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-snapshot@npm:29.7.0" - dependencies: - "@babel/core": "npm:^7.11.6" - "@babel/generator": "npm:^7.7.2" - "@babel/plugin-syntax-jsx": "npm:^7.7.2" - "@babel/plugin-syntax-typescript": "npm:^7.7.2" - "@babel/types": "npm:^7.3.3" - "@jest/expect-utils": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - babel-preset-current-node-syntax: "npm:^1.0.0" - chalk: "npm:^4.0.0" - expect: "npm:^29.7.0" - graceful-fs: "npm:^4.2.9" - jest-diff: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - natural-compare: "npm:^1.4.0" - pretty-format: "npm:^29.7.0" - semver: "npm:^7.5.3" - checksum: 10c0/6e9003c94ec58172b4a62864a91c0146513207bedf4e0a06e1e2ac70a4484088a2683e3a0538d8ea913bcfd53dc54a9b98a98cdfa562e7fe1d1339aeae1da570 - languageName: node - linkType: hard - "jest-util@npm:30.4.1": version: 30.4.1 resolution: "jest-util@npm:30.4.1" @@ -19770,22 +18653,6 @@ __metadata: languageName: node linkType: hard -"jest-watcher@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-watcher@npm:29.7.0" - dependencies: - "@jest/test-result": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.2.1" - chalk: "npm:^4.0.0" - emittery: "npm:^0.13.1" - jest-util: "npm:^29.7.0" - string-length: "npm:^4.0.1" - checksum: 10c0/ec6c75030562fc8f8c727cb8f3b94e75d831fc718785abfc196e1f2a2ebc9a2e38744a15147170039628a853d77a3b695561ce850375ede3a4ee6037a2574567 - languageName: node - linkType: hard - "jest-worker@npm:30.4.1, jest-worker@npm:^30.0.5": version: 30.4.1 resolution: "jest-worker@npm:30.4.1" @@ -19822,25 +18689,6 @@ __metadata: languageName: node linkType: hard -"jest@npm:^29.6.3": - version: 29.7.0 - resolution: "jest@npm:29.7.0" - dependencies: - "@jest/core": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - import-local: "npm:^3.0.2" - jest-cli: "npm:^29.7.0" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - bin: - jest: bin/jest.js - checksum: 10c0/f40eb8171cf147c617cc6ada49d062fbb03b4da666cb8d39cdbfb739a7d75eea4c3ca150fb072d0d273dce0c753db4d0467d54906ad0293f59c54f9db4a09d8b - languageName: node - linkType: hard - "jest@npm:^30.0.0": version: 30.4.2 resolution: "jest@npm:30.4.2" @@ -25070,15 +23918,6 @@ __metadata: languageName: node linkType: hard -"prettier@npm:2.8.8, prettier@npm:^2.7.1": - version: 2.8.8 - resolution: "prettier@npm:2.8.8" - bin: - prettier: bin-prettier.js - checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a - languageName: node - linkType: hard - "prettier@npm:3.9.5, prettier@npm:^3.0.0": version: 3.9.5 resolution: "prettier@npm:3.9.5" @@ -25088,6 +23927,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^2.7.1": + version: 2.8.8 + resolution: "prettier@npm:2.8.8" + bin: + prettier: bin-prettier.js + checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a + languageName: node + linkType: hard + "pretty-error@npm:^4.0.0": version: 4.0.0 resolution: "pretty-error@npm:4.0.0" @@ -25121,7 +23969,7 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": +"pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" dependencies: @@ -25228,7 +24076,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.0.1, prompts@npm:^2.4.2": +"prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -25329,13 +24177,6 @@ __metadata: languageName: node linkType: hard -"pure-rand@npm:^6.0.0": - version: 6.1.0 - resolution: "pure-rand@npm:6.1.0" - checksum: 10c0/1abe217897bf74dcb3a0c9aba3555fe975023147b48db540aa2faf507aee91c03bf54f6aef0eb2bf59cc259a16d06b28eca37f0dc426d94f4692aeff02fb0e65 - languageName: node - linkType: hard - "pure-rand@npm:^7.0.0": version: 7.0.1 resolution: "pure-rand@npm:7.0.1" @@ -26504,13 +25345,6 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:^2.0.0": - version: 2.0.3 - resolution: "resolve.exports@npm:2.0.3" - checksum: 10c0/1ade1493f4642a6267d0a5e68faeac20b3d220f18c28b140343feb83694d8fed7a286852aef43689d16042c61e2ddb270be6578ad4a13990769e12065191200d - languageName: node - linkType: hard - "resolve@npm:^1.1.6, resolve@npm:^1.10.0, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.11, resolve@npm:^1.22.4, resolve@npm:^1.22.8": version: 1.22.11 resolution: "resolve@npm:1.22.11" @@ -26630,17 +25464,6 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:^3.0.2": - version: 3.0.2 - resolution: "rimraf@npm:3.0.2" - dependencies: - glob: "npm:^7.1.3" - bin: - rimraf: bin.js - checksum: 10c0/9cb7757acb489bd83757ba1a274ab545eafd75598a9d817e0c3f8b164238dd90eba50d6b848bd4dcc5f3040912e882dc7ba71653e35af660d77b25c381d402e8 - languageName: node - linkType: hard - "rimraf@npm:^6.0.0": version: 6.1.3 resolution: "rimraf@npm:6.1.3" @@ -27998,7 +26821,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -28134,7 +26957,7 @@ __metadata: languageName: node linkType: hard -"string-length@npm:^4.0.1, string-length@npm:^4.0.2": +"string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2" dependencies: @@ -28144,13 +26967,6 @@ __metadata: languageName: node linkType: hard -"string-natural-compare@npm:^3.0.1": - version: 3.0.1 - resolution: "string-natural-compare@npm:3.0.1" - checksum: 10c0/85a6a9195736be500af5d817c7ea36b7e1ac278af079a807f70f79a56602359ee6743ca409af6291b94557de550ff60d1ec31b3c4fc8e7a08d0e12cdab57c149 - languageName: node - linkType: hard - "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.2, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" @@ -29362,16 +28178,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.8.3": - version: 5.9.3 - resolution: "typescript@npm:5.9.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A4.9.0-dev.20220903#optional!builtin": version: 4.9.0-dev.20220903 resolution: "typescript@patch:typescript@npm%3A4.9.0-dev.20220903#optional!builtin::version=4.9.0-dev.20220903&hash=1a91c8" @@ -29393,16 +28199,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": - version: 5.9.3 - resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 - languageName: node - linkType: hard - "uglify-js@npm:^3.1.4": version: 3.19.3 resolution: "uglify-js@npm:3.19.3" @@ -30873,21 +29669,6 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.3.1": - version: 17.7.3 - resolution: "yargs@npm:17.7.3" - dependencies: - cliui: "npm:^8.0.1" - escalade: "npm:^3.1.1" - get-caller-file: "npm:^2.0.5" - require-directory: "npm:^2.1.1" - string-width: "npm:^4.2.3" - y18n: "npm:^5.0.5" - yargs-parser: "npm:^21.1.1" - checksum: 10c0/7a28572f7e785a57886e34fdbddb9b28756dec552e1453d5f6e7cdd00ad8721a4e8c4321d33683f5e61cacb36ad43258adbb48396b71ec4ed14abee0fc0d0c1f - languageName: node - linkType: hard - "yargs@npm:^17.6.2, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" @@ -30962,22 +29743,6 @@ __metadata: languageName: node linkType: hard -"zod-validation-error@npm:^3.5.0 || ^4.0.0": - version: 4.0.2 - resolution: "zod-validation-error@npm:4.0.2" - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - checksum: 10c0/0ccfec48c46de1be440b719cd02044d4abb89ed0e14c13e637cd55bf29102f67ccdba373f25def0fc7130e5f15025be4d557a7edcc95d5a3811599aade689e1b - languageName: node - linkType: hard - -"zod@npm:^3.25.0 || ^4.0.0": - version: 4.6.5 - resolution: "zod@npm:4.6.5" - checksum: 10c0/32eb6c5e98becbe045675b2141c3611451c16ba567f9779231d79af41c4d651ca1c22f154aef7e6c6e77b929f23e43f1154ca05b82e119e922353725d6776af5 - languageName: node - linkType: hard - "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4" From 1617e67e0bca968979867c069f9d4de0233a02f0 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 13:38:02 -0400 Subject: [PATCH 20/32] refactor(benchmark): Add examples/gc-shared GC fixture and protocol modules CommonJS + hand-written .d.ts so Node ESM, webpack, Metro, Jest, and tsx all load them without config: gc-fixture (createGCFixture over injected core pieces), protocol (axes, validation, stable ids, slash-bounded filter, per-platform scenario order), protocol-cli (host-only list/validate CLI), frames, and stats. Not wired into harnesses yet. Co-Authored-By: Claude Opus 5.5 --- examples/AGENTS.md | 8 +- examples/gc-shared/README.md | 18 + examples/gc-shared/frames.d.ts | 15 + examples/gc-shared/frames.js | 71 ++++ examples/gc-shared/gc-fixture.d.ts | 123 +++++++ examples/gc-shared/gc-fixture.js | 514 +++++++++++++++++++++++++++++ examples/gc-shared/protocol-cli.js | 63 ++++ examples/gc-shared/protocol.d.ts | 62 ++++ examples/gc-shared/protocol.js | 225 +++++++++++++ examples/gc-shared/stats.d.ts | 13 + examples/gc-shared/stats.js | 33 ++ 11 files changed, 1143 insertions(+), 2 deletions(-) create mode 100644 examples/gc-shared/README.md create mode 100644 examples/gc-shared/frames.d.ts create mode 100644 examples/gc-shared/frames.js create mode 100644 examples/gc-shared/gc-fixture.d.ts create mode 100644 examples/gc-shared/gc-fixture.js create mode 100644 examples/gc-shared/protocol-cli.js create mode 100644 examples/gc-shared/protocol.d.ts create mode 100644 examples/gc-shared/protocol.js create mode 100644 examples/gc-shared/stats.d.ts create mode 100644 examples/gc-shared/stats.js diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 94cecb86a05e..4405cffeb82d 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -4,7 +4,11 @@ ### Monorepo workspace members -`benchmark`, `benchmark-react`, `test-bundlesize`, `normalizr-*`, `coin-app` — listed in root `package.json` workspaces, managed by yarn. Most use `workspace:*` for `@data-client/*` deps. +`benchmark`, `benchmark-react`, `benchmark-native`, `test-bundlesize`, `normalizr-*`, `coin-app` — listed in root `package.json` workspaces, managed by yarn. Most use `workspace:*` for `@data-client/*` deps. `benchmark-native` (React Native Android) uses the root Jest/ESLint/TypeScript toolchain and is not installed in CI. + +### Shared (non-workspace) code + +`gc-shared` — CommonJS `.js` + hand-written `.d.ts` shared by the GC harnesses in `benchmark`, `benchmark-react`, and `benchmark-native`. No `package.json` on purpose (see its README); include it in each harness's build-provenance inputs. ### Standalone (StackBlitz demos) @@ -22,4 +26,4 @@ ## Build Tooling -Most examples use Webpack via `@anansi/webpack-config`. Exceptions: `nextjs` (Next.js), `vue-todo-app` (Vite), `normalizr-*` (Node scripts via `babel-node`). +Most examples use Webpack via `@anansi/webpack-config`. Exceptions: `nextjs` (Next.js), `vue-todo-app` (Vite), `normalizr-*` (Node scripts via `babel-node`), `benchmark-native` (Metro). diff --git a/examples/gc-shared/README.md b/examples/gc-shared/README.md new file mode 100644 index 000000000000..35f512fa5140 --- /dev/null +++ b/examples/gc-shared/README.md @@ -0,0 +1,18 @@ +# GC harness shared code + +Fixture, protocol (axes, stable ids, filters), frame helpers, and report stats +shared by the GC harnesses in `examples/benchmark` (Node), +`examples/benchmark-react` (browser), and `examples/benchmark-native` (Android). +See [plans/garbage-collection.md](../../plans/garbage-collection.md). + +- **CommonJS `.js` + hand-written `.d.ts` on purpose.** It is the one format + Node ESM (named imports via the literal `module.exports = { ... }`), webpack + (outside babel's `src/` include), Metro (no `.mjs`/`.cjs`), Jest, and tsx all + load with zero config. Keep it plain ES2020: no TS, JSX, or Node globals in + bundled files. +- **Not a workspace** (no `package.json`), so the root `package.json` (no + `"type"`) makes `.js` CommonJS. +- `protocol-cli.js` is the host-only CLI used by benchmark-native shell scripts; + never import it from app code. +- Each harness's build provenance digest includes this folder; keep it that way + when adding files. diff --git a/examples/gc-shared/frames.d.ts b/examples/gc-shared/frames.d.ts new file mode 100644 index 000000000000..26770cedf6a1 --- /dev/null +++ b/examples/gc-shared/frames.d.ts @@ -0,0 +1,15 @@ +export declare function median(values: number[]): number; +/** Adjacent differences of rAF timestamps (ms). */ +export declare function frameIntervalsFromTimestamps( + timestamps: number[], +): number[]; +/** max(timerDelayMs, largest frame-interval excess over one display period). */ +export declare function computeMaxInputDelayMs( + timerDelayMs: number, + frameIntervalsMs: number[], + displayPeriodMs: number, +): number; +/** Median of quiet rAF intervals (uses global requestAnimationFrame). */ +export declare function measureDisplayPeriodMs( + samples?: number, +): Promise; diff --git a/examples/gc-shared/frames.js b/examples/gc-shared/frames.js new file mode 100644 index 000000000000..b56e29f98daa --- /dev/null +++ b/examples/gc-shared/frames.js @@ -0,0 +1,71 @@ +'use strict'; +/** + * JS frame / responsiveness helpers shared by the browser and Android GC + * harnesses (rAF + timers; not pointer latency). Missed-frame counting stays + * platform-local because capture sources differ. + * CommonJS on purpose (see ./README.md). + */ + +function median(values) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? + (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Adjacent differences of rAF timestamps (ms). */ +function frameIntervalsFromTimestamps(timestamps) { + const intervals = []; + for (let i = 1; i < timestamps.length; i++) { + intervals.push(timestamps[i] - timestamps[i - 1]); + } + return intervals; +} + +/** + * Responsiveness proxy from timer + frame probes (not pointer input). + * max(timerDelayMs, largest frame-interval excess over one display period). + */ +function computeMaxInputDelayMs( + timerDelayMs, + frameIntervalsMs, + displayPeriodMs, +) { + let maxExcessFrame = 0; + if (displayPeriodMs > 0) { + for (const interval of frameIntervalsMs) { + maxExcessFrame = Math.max( + maxExcessFrame, + Math.max(0, interval - displayPeriodMs), + ); + } + } + return Math.max(timerDelayMs, maxExcessFrame); +} + +/** Quiet rAF intervals → display period (never hardcode 16.67). */ +async function measureDisplayPeriodMs(samples = 8) { + const intervals = []; + await new Promise(resolve => { + let last = 0; + let n = 0; + const frame = now => { + if (n > 0) intervals.push(now - last); + last = now; + n++; + if (n <= samples) requestAnimationFrame(frame); + else resolve(); + }; + requestAnimationFrame(frame); + }); + return median(intervals); +} + +module.exports = { + median, + frameIntervalsFromTimestamps, + computeMaxInputDelayMs, + measureDisplayPeriodMs, +}; diff --git a/examples/gc-shared/gc-fixture.d.ts b/examples/gc-shared/gc-fixture.d.ts new file mode 100644 index 000000000000..8c008c0bb057 --- /dev/null +++ b/examples/gc-shared/gc-fixture.d.ts @@ -0,0 +1,123 @@ +import type * as Core from '@data-client/core'; + +import type { CandidateKind, Control, Pattern } from './protocol.js'; + +/** Named pieces of one `@data-client/core` build (Node passes its dist bundle). */ +export interface CoreDeps { + GCPolicy: typeof Core.GCPolicy; + Controller: typeof Core.Controller; + createReducer: typeof Core.createReducer; + initialState: Core.State; + GC: string; +} + +export type HarnessMode = 'scan' | 'reducer' | 'end-to-end'; + +export interface FixtureSpec { + candidateKind: CandidateKind; + pattern: Pattern; + count: number; +} + +export interface EntityPath { + key: string; + pk: string; +} + +export interface GCAction { + type: string; + entities: EntityPath[]; + endpoints: string[]; +} + +export interface ExpectedScalars { + queueEntries: number; + uniqueTargets: number; + expectedEntitiesInAction: number; + expectedEndpointsInAction: number; + expectedUniqueEntityDeletions: number; + expectedEndpointDeletions: number; +} + +/** Timerless policy with a public one-shot sweep (calls protected runSweep). */ +export type BenchmarkGCPolicy = Core.GCPolicy & { + sweep(): void; + readonly entityQueueLength: number; + readonly endpointQueueSize: number; + readonly queueEntries: number; +}; + +export interface Harness { + policy: BenchmarkGCPolicy; + reducer: (state: Core.State, action: any) => Core.State; + expected: ExpectedScalars; + /** Reducer mode only; null otherwise or after releaseObserverRefs(). */ + readonly prebuiltAction: GCAction | null; + getState(): Core.State; + getCapturedAction(): GCAction | null; + clearCapturedAction(): void; + /** Drop captured/prebuilt action and seed; keep the live store. */ + releaseObserverRefs(): void; + /** Reducer mode: hand the seed state to the caller and drop the seed ref. */ + takeReducerState(): Core.State; + dispose(): void; +} + +export interface SampleCounts { + actionCount: number; + deletionCount: number; + queueEntries: number; + uniqueTargets: number; + actionTargetCount: number; +} + +export interface Remaining { + entityDeleted: number; + endpointDeleted: number; + entityRemaining: number; + endpointRemaining: number; +} + +export declare const ENTITY_KEY: 'BenchEntity'; +export declare const ZERO_META: Readonly<{ + date: 0; + fetchedAt: 0; + expiresAt: 0; +}>; +export declare function queueCandidates( + policy: Core.GCPolicy, + spec: FixtureSpec, +): ExpectedScalars; +export declare function countRemaining( + state: Core.State, + spec: FixtureSpec, + expected: ExpectedScalars, +): Remaining; + +export interface GCFixture { + ENTITY_KEY: typeof ENTITY_KEY; + ZERO_META: typeof ZERO_META; + BenchmarkGCPolicy: new () => BenchmarkGCPolicy; + buildEntityState(count: number): Core.State; + buildEndpointState(count: number): Core.State; + buildMixedState( + entityCount: number, + endpointCount: number, + ): Core.State; + buildStateForSpec(spec: FixtureSpec): Core.State; + queueCandidates: typeof queueCandidates; + buildPrebuiltAction(spec: FixtureSpec): GCAction; + createHarness(spec: FixtureSpec, mode: HarnessMode): Harness; + countRemaining: typeof countRemaining; + /** Throws when a sample disagrees with fixture expectations. */ + validateSample( + spec: FixtureSpec, + harness: Harness, + sample: SampleCounts, + mode: HarnessMode, + control: Control, + ): void; + GC: string; +} + +export declare function createGCFixture(core: CoreDeps): GCFixture; diff --git a/examples/gc-shared/gc-fixture.js b/examples/gc-shared/gc-fixture.js new file mode 100644 index 000000000000..a32f8b7ebcbd --- /dev/null +++ b/examples/gc-shared/gc-fixture.js @@ -0,0 +1,514 @@ +'use strict'; +/** + * Deterministic GC fixtures shared by the Node, browser, and Android GC harnesses. + * + * Builds state + candidate queues outside timed work. Fixtures match + * entities/entitiesMeta and endpoints/meta so sweeps are eligible immediately + * under BenchmarkGCPolicy (expiresAt → 0). + * + * CommonJS on purpose (see ./README.md). `@data-client/core` is injected via + * createGCFixture() so each harness supplies its own build of core (Node: the + * webpack dist bundle; browser/Android: the package source). + */ +const { splitMixedCount } = require('./protocol.js'); + +const ENTITY_KEY = 'BenchEntity'; +const ZERO_META = Object.freeze({ + date: 0, + fetchedAt: 0, + expiresAt: 0, +}); + +function entityPath(pk) { + return { key: ENTITY_KEY, pk: String(pk) }; +} + +function endpointKey(i) { + return `bench-endpoint-${i}`; +} + +/** Scalar expectations only — no retained path/key arrays (avoids observer heap). */ +function expectedScalars(overrides) { + return { + queueEntries: overrides.queueEntries, + uniqueTargets: overrides.uniqueTargets, + expectedEntitiesInAction: overrides.expectedEntitiesInAction, + expectedEndpointsInAction: overrides.expectedEndpointsInAction, + expectedUniqueEntityDeletions: overrides.expectedUniqueEntityDeletions, + expectedEndpointDeletions: overrides.expectedEndpointDeletions, + }; +} + +/** + * Queue candidates via createCountRef mount/release (outside timed work). + * Duplicate pattern repeatedly releases one entity path. + * Does not retain parallel target arrays — callers that need a prebuilt GC + * action use buildPrebuiltAction(). + */ +function queueCandidates(policy, spec) { + const { candidateKind, pattern, count } = spec; + + if (pattern === 'duplicate') { + if (candidateKind !== 'entity') { + throw new Error( + `duplicate pattern only supports candidateKind=entity (got ${candidateKind})`, + ); + } + const path = entityPath(0); + const countRef = policy.createCountRef({ paths: [path] }); + for (let i = 0; i < count; i++) { + const release = countRef(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: 1, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: 1, + expectedEndpointDeletions: 0, + }); + } + + if (candidateKind === 'entity') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: count, + expectedEndpointsInAction: 0, + expectedUniqueEntityDeletions: count, + expectedEndpointDeletions: 0, + }); + } + + if (candidateKind === 'endpoint') { + for (let i = 0; i < count; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: 0, + expectedEndpointsInAction: count, + expectedUniqueEntityDeletions: 0, + expectedEndpointDeletions: count, + }); + } + + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + for (let i = 0; i < entities; i++) { + const release = policy.createCountRef({ paths: [entityPath(i)] })(); + release(); + } + for (let i = 0; i < endpoints; i++) { + const release = policy.createCountRef({ key: endpointKey(i) })(); + release(); + } + return expectedScalars({ + queueEntries: count, + uniqueTargets: count, + expectedEntitiesInAction: entities, + expectedEndpointsInAction: endpoints, + expectedUniqueEntityDeletions: entities, + expectedEndpointDeletions: endpoints, + }); + } + + throw new Error(`unknown candidateKind: ${candidateKind}`); +} + +/** Count deletions vs. fixture after a destructive GC apply. */ +function countRemaining(state, spec, expected) { + const bucket = state.entities?.[ENTITY_KEY]; + const entityRemaining = bucket ? Object.keys(bucket).length : 0; + const endpointRemaining = + state.endpoints ? Object.keys(state.endpoints).length : 0; + + // unique/mixed start with expectedUniqueEntityDeletions entities; duplicate starts with 1 + const startedEntities = + spec.pattern === 'duplicate' ? 1 : expected.expectedUniqueEntityDeletions; + const startedEndpoints = expected.expectedEndpointDeletions; + + return { + entityDeleted: startedEntities - entityRemaining, + endpointDeleted: startedEndpoints - endpointRemaining, + entityRemaining, + endpointRemaining, + }; +} + +/** + * Bind the fixture to one build of `@data-client/core`. + * + * Takes named pieces (not the module namespace) so bundlers can tree-shake + * the caller's core import. + */ +function createGCFixture({ + GCPolicy, + Controller, + createReducer, + initialState, + GC, +}) { + /** Timerless explicit policy: no intervals/idleness; one-shot public sweep. */ + class BenchmarkGCPolicy extends GCPolicy { + constructor() { + super({ expiresAt: () => 0 }); + } + + init(controller) { + this.controller = controller; + } + + cleanup() {} + + /** Public one-shot entry that calls the protected monolithic runSweep. */ + sweep() { + this.runSweep(); + } + + get entityQueueLength() { + return this.entitiesQ.length; + } + + get endpointQueueSize() { + return this.endpointsQ.size; + } + + get queueEntries() { + return this.entityQueueLength + this.endpointQueueSize; + } + } + + /** Build store state with matching entities + entitiesMeta. */ + function buildEntityState(count) { + const entities = { [ENTITY_KEY]: {} }; + const entitiesMeta = { [ENTITY_KEY]: {} }; + for (let i = 0; i < count; i++) { + const pk = String(i); + entities[ENTITY_KEY][pk] = { id: pk }; + entitiesMeta[ENTITY_KEY][pk] = { ...ZERO_META }; + } + return { + ...initialState, + entities, + entitiesMeta, + }; + } + + /** Build store state with matching endpoints + meta. */ + function buildEndpointState(count) { + const endpoints = {}; + const meta = {}; + for (let i = 0; i < count; i++) { + const key = endpointKey(i); + endpoints[key] = String(i); + meta[key] = { ...ZERO_META }; + } + return { + ...initialState, + endpoints, + meta, + }; + } + + /** Mixed state: entityCount distinct entities + endpointCount distinct endpoints. */ + function buildMixedState(entityCount, endpointCount) { + const state = buildEntityState(entityCount); + const endpoints = {}; + const meta = {}; + for (let i = 0; i < endpointCount; i++) { + const key = endpointKey(i); + endpoints[key] = String(i); + meta[key] = { ...ZERO_META }; + } + return { ...state, endpoints, meta }; + } + + /** Build a GC action for reducer mode only (scan/e2e get targets from sweep). */ + function buildPrebuiltAction(spec) { + const { candidateKind, pattern, count } = spec; + + if (pattern === 'duplicate') { + const path = entityPath(0); + return { + type: GC, + entities: Array.from({ length: count }, () => ({ ...path })), + endpoints: [], + }; + } + + if (candidateKind === 'entity') { + return { + type: GC, + entities: Array.from({ length: count }, (_, i) => entityPath(i)), + endpoints: [], + }; + } + + if (candidateKind === 'endpoint') { + return { + type: GC, + entities: [], + endpoints: Array.from({ length: count }, (_, i) => endpointKey(i)), + }; + } + + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + return { + type: GC, + entities: Array.from({ length: entities }, (_, i) => entityPath(i)), + endpoints: Array.from({ length: endpoints }, (_, i) => endpointKey(i)), + }; + } + + throw new Error(`unknown candidateKind: ${candidateKind}`); + } + + function buildStateForSpec(spec) { + const { candidateKind, pattern, count } = spec; + if (pattern === 'duplicate') { + return buildEntityState(1); + } + if (candidateKind === 'entity') return buildEntityState(count); + if (candidateKind === 'endpoint') return buildEndpointState(count); + if (candidateKind === 'mixed') { + const { entities, endpoints } = splitMixedCount(count); + return buildMixedState(entities, endpoints); + } + throw new Error(`unknown candidateKind: ${candidateKind}`); + } + + /** + * Wire a Controller + BenchmarkGCPolicy for the given mode. + * - scan: dispatch captures action only; no prebuilt action arrays + * - end-to-end: dispatch reduces; no prebuilt action arrays + * - reducer: prebuilt GC action only (needed for timed reducer path) + */ + function createHarness(spec, mode) { + const state = buildStateForSpec(spec); + const policy = new BenchmarkGCPolicy(); + const controller = new Controller({ gcPolicy: policy }); + const reducer = createReducer(controller); + + let capturedAction = null; + let workingState = state; + /** Seed retained only until reducer takes it (then nulled). */ + let seedState = mode === 'reducer' ? state : null; + + controller.getState = () => workingState; + + if (mode === 'scan') { + controller.dispatch = action => { + capturedAction = action; + }; + } else if (mode === 'end-to-end') { + controller.dispatch = action => { + capturedAction = action; + workingState = reducer(workingState, action); + }; + } else if (mode === 'reducer') { + // Reducer mode does not sweep; dispatch unused during timed work + controller.dispatch = action => { + capturedAction = action; + }; + } else { + throw new Error(`unknown harness mode: ${mode}`); + } + + policy.init(controller); + const expected = queueCandidates(policy, spec); + + if (policy.queueEntries !== expected.queueEntries) { + throw new Error( + `fixture queue cardinality ${policy.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + + // Only reducer needs a prebuilt action; scan/e2e would duplicate 100k target arrays + let prebuiltAction = mode === 'reducer' ? buildPrebuiltAction(spec) : null; + + const harness = { + policy, + reducer, + expected, + get prebuiltAction() { + return prebuiltAction; + }, + getState: () => workingState, + getCapturedAction: () => capturedAction, + clearCapturedAction() { + capturedAction = null; + }, + /** + * Drop observer-only refs (captured action, prebuilt action) while keeping + * the live store + policy for retained-cache heap measurement. + */ + releaseObserverRefs() { + capturedAction = null; + prebuiltAction = null; + seedState = null; + }, + /** + * Reducer: hand the freshly built seed to the reducer and drop the seed + * ref. The fixture is built per harness, so there is nothing to protect + * by cloning. + */ + takeReducerState() { + if (!seedState) { + throw new Error( + 'takeReducerState() requires reducer mode with a seed', + ); + } + workingState = seedState; + seedState = null; + return workingState; + }, + /** Drop store/policy/controller so a later V8 GC can reclaim everything. */ + dispose() { + capturedAction = null; + prebuiltAction = null; + seedState = null; + workingState = initialState; + controller.getState = () => initialState; + controller.dispatch = () => Promise.resolve(); + policy.cleanup(); + }, + }; + + return harness; + } + + /** + * Validate an accepted sample against fixture expectations. + * Throws on invalid fixture/result. + */ + function validateSample(spec, harness, sampleMetrics, mode, control) { + const { expected, policy, getCapturedAction, getState } = harness; + + if (control === 'no-gc') { + if (sampleMetrics.actionCount !== 0) { + throw new Error( + `no-gc control expected actionCount 0, got ${sampleMetrics.actionCount}`, + ); + } + if (sampleMetrics.deletionCount !== 0) { + throw new Error( + `no-gc control expected deletionCount 0, got ${sampleMetrics.deletionCount}`, + ); + } + return; + } + + if (sampleMetrics.queueEntries !== expected.queueEntries) { + throw new Error( + `queueEntries ${sampleMetrics.queueEntries} !== expected ${expected.queueEntries}`, + ); + } + if (sampleMetrics.uniqueTargets !== expected.uniqueTargets) { + throw new Error( + `uniqueTargets ${sampleMetrics.uniqueTargets} !== expected ${expected.uniqueTargets}`, + ); + } + + const actionTargetCount = + expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; + if (sampleMetrics.actionTargetCount !== actionTargetCount) { + throw new Error( + `actionTargetCount ${sampleMetrics.actionTargetCount} !== expected ${actionTargetCount}`, + ); + } + + if (mode === 'scan' || mode === 'end-to-end') { + const action = getCapturedAction(); + if (!action || action.type !== GC) { + throw new Error('expected a GC action to be dispatched'); + } + if (action.entities.length !== expected.expectedEntitiesInAction) { + throw new Error( + `action.entities.length ${action.entities.length} !== ${expected.expectedEntitiesInAction}`, + ); + } + if (action.endpoints.length !== expected.expectedEndpointsInAction) { + throw new Error( + `action.endpoints.length ${action.endpoints.length} !== ${expected.expectedEndpointsInAction}`, + ); + } + if (spec.pattern === 'duplicate') { + const unique = new Set(action.entities.map(p => `${p.key}:${p.pk}`)); + if (unique.size !== 1) { + throw new Error( + `duplicate pattern expected 1 unique entity path in action, got ${unique.size}`, + ); + } + } + } + + if (mode === 'reducer' || mode === 'end-to-end') { + const expectedDeletions = + expected.expectedUniqueEntityDeletions + + expected.expectedEndpointDeletions; + if (sampleMetrics.deletionCount !== expectedDeletions) { + throw new Error( + `deletionCount ${sampleMetrics.deletionCount} !== expected ${expectedDeletions}`, + ); + } + const remaining = countRemaining(getState(), spec, expected); + if ( + remaining.entityRemaining !== 0 || + remaining.endpointRemaining !== 0 + ) { + throw new Error( + `expected empty GC targets after deletion; remaining entities=${remaining.entityRemaining} endpoints=${remaining.endpointRemaining}`, + ); + } + } + + if (mode === 'scan' && sampleMetrics.deletionCount !== 0) { + throw new Error( + `scan mode must not delete; got ${sampleMetrics.deletionCount}`, + ); + } + + // After a successful sweep, queues should be drained (gc path). + if ( + (mode === 'scan' || mode === 'end-to-end') && + policy.queueEntries !== 0 + ) { + throw new Error( + `expected empty queues after sweep, got ${policy.queueEntries}`, + ); + } + } + + return { + ENTITY_KEY, + ZERO_META, + BenchmarkGCPolicy, + buildEntityState, + buildEndpointState, + buildMixedState, + buildStateForSpec, + queueCandidates, + buildPrebuiltAction, + createHarness, + countRemaining, + validateSample, + GC, + }; +} + +module.exports = { + ENTITY_KEY, + ZERO_META, + queueCandidates, + countRemaining, + createGCFixture, +}; diff --git a/examples/gc-shared/protocol-cli.js b/examples/gc-shared/protocol-cli.js new file mode 100644 index 000000000000..a27b0424061a --- /dev/null +++ b/examples/gc-shared/protocol-cli.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +'use strict'; +/** + * Host CLI over ./protocol.js for shell scripts (benchmark-native run-matrix / + * collect-report). Never imported by app bundles. + * + * node protocol-cli.js list [filter] + * Prints matching stable scenario ids, one per line. + * node protocol-cli.js validate [samples] + * Exits 0 when valid; otherwise prints `error: ...` to stderr and exits 1. + */ +const { + listScenarios, + validateAxes, + validateSampleCount, +} = require('./protocol.js'); + +/** Env var names the shell scripts accept, used in validation messages. */ +const ENV_LABELS = { + candidateKind: 'CANDIDATE_KIND', + pattern: 'PATTERN', + count: 'COUNT', + control: 'CONTROL', + samples: 'SAMPLES', + oneOf: '', +}; + +/** Decimal integer strings only (no sign, exponent, or leading zeros). */ +function parseIntArg(raw) { + return /^(0|[1-9][0-9]*)$/.test(raw) ? Number(raw) : raw; +} + +function main(argv) { + const [cmd, ...rest] = argv; + if (cmd === 'list') { + const [platform, filter] = rest; + for (const { id } of listScenarios(platform, { filter })) { + process.stdout.write(`${id}\n`); + } + return 0; + } + if (cmd === 'validate') { + const [candidateKind, pattern, count, control, samples = '1'] = rest; + validateAxes( + { candidateKind, pattern, count: parseIntArg(count ?? ''), control }, + ENV_LABELS, + ); + validateSampleCount(parseIntArg(samples), ENV_LABELS); + return 0; + } + process.stderr.write( + 'usage: protocol-cli.js list [filter]\n' + + ' protocol-cli.js validate [samples]\n', + ); + return 2; +} + +try { + process.exitCode = main(process.argv.slice(2)); +} catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exitCode = 1; +} diff --git a/examples/gc-shared/protocol.d.ts b/examples/gc-shared/protocol.d.ts new file mode 100644 index 000000000000..95d91bf00d42 --- /dev/null +++ b/examples/gc-shared/protocol.d.ts @@ -0,0 +1,62 @@ +export type Platform = 'node' | 'browser' | 'android'; +export type CandidateKind = 'entity' | 'endpoint' | 'mixed'; +export type Pattern = 'unique' | 'duplicate'; +export type Control = 'gc' | 'no-gc'; +export type Mode = 'scan' | 'reducer' | 'end-to-end' | 'interaction'; + +export interface ScenarioAxesInput { + candidateKind: CandidateKind; + pattern: Pattern; + count: number; + control: Control; +} + +export interface ScenarioAxes extends ScenarioAxesInput { + platform: Platform; + mode: Mode; +} + +export interface ScenarioDescriptor extends ScenarioAxes { + id: string; +} + +/** Field names in validation messages, plus the prefix before allowed values. */ +export type AxisLabels = Record< + 'candidateKind' | 'pattern' | 'count' | 'control' | 'samples' | 'oneOf', + string +>; + +export declare const CANONICAL_COUNTS: readonly [1000, 10000, 100000]; +export declare const CANDIDATE_KINDS: readonly CandidateKind[]; +export declare const PATTERNS: readonly Pattern[]; +export declare const CONTROLS: readonly Control[]; +export declare const MODES_BY_PLATFORM: { + readonly node: readonly ['scan', 'reducer', 'end-to-end']; + readonly browser: readonly ['end-to-end']; + readonly android: readonly ['interaction']; +}; +export declare const MAX_SAMPLES: 50; +export declare const AXIS_LABELS: AxisLabels; + +/** Throws on invalid axes; does not coerce. */ +export declare function validateAxes( + axes: ScenarioAxesInput, + labels?: AxisLabels, +): void; +export declare function validateSampleCount( + samples: number, + labels?: AxisLabels, +): void; +/** `{platform}/{candidateKind}/{pattern}/{count}/{mode}/{control}`; throws on invalid axes. */ +export declare function scenarioId(axes: ScenarioAxes): string; +export declare function parseScenarioId(id: string): ScenarioAxes; +/** Empty → all; `^text` → prefix; otherwise slash-bounded contiguous segments. */ +export declare function matchScenarioId(id: string, filter?: string): boolean; +export declare function listScenarios( + platform: Platform, + options?: { filter?: string }, +): ScenarioDescriptor[]; +export declare function splitMixedCount(total: number): { + entities: number; + endpoints: number; +}; diff --git a/examples/gc-shared/protocol.js b/examples/gc-shared/protocol.js new file mode 100644 index 000000000000..fb75ab0e6b27 --- /dev/null +++ b/examples/gc-shared/protocol.js @@ -0,0 +1,225 @@ +'use strict'; +/** + * GC measurement protocol shared by the Node, browser, and Android harnesses: + * scenario axes, validation, stable scenario ids, and scenario filtering. + * + * Stable id: `{platform}/{candidateKind}/{pattern}/{count}/{mode}/{control}`. + * + * CommonJS on purpose (see ./README.md). Bundled into browser/Android apps, so + * no Node globals here; the host CLI lives in ./protocol-cli.js. + */ + +const CANONICAL_COUNTS = [1_000, 10_000, 100_000]; +const CANDIDATE_KINDS = ['entity', 'endpoint', 'mixed']; +const PATTERNS = ['unique', 'duplicate']; +const CONTROLS = ['gc', 'no-gc']; +const MODES_BY_PLATFORM = { + node: ['scan', 'reducer', 'end-to-end'], + browser: ['end-to-end'], + android: ['interaction'], +}; +const PLATFORMS = Object.keys(MODES_BY_PLATFORM); + +/** Positive sample counts capped to keep accidental huge runs from launching. */ +const MAX_SAMPLES = 50; + +/** + * Wording of validation messages: field names plus the prefix before an + * allowed-value list. Callers may pass their own (the host CLI uses env var + * names like CANDIDATE_KIND and no `one of` prefix). + */ +const AXIS_LABELS = { + candidateKind: 'candidateKind', + pattern: 'pattern', + count: 'count', + control: 'control', + samples: 'samples', + oneOf: 'one of ', +}; + +function invalid(labels, field, value, expected) { + return new Error( + `invalid ${labels[field]}=${String(value)}; expected ${expected}`, + ); +} + +/** + * Validate scenario axes. Throws on invalid input; does not coerce. + * `duplicate` is entity-only (one path released `count` times). + */ +function validateAxes(axes, labels = AXIS_LABELS) { + const { candidateKind, pattern, count, control } = axes; + if (!CANDIDATE_KINDS.includes(candidateKind)) { + throw invalid( + labels, + 'candidateKind', + candidateKind, + `${labels.oneOf}${CANDIDATE_KINDS.join('|')}`, + ); + } + if (!PATTERNS.includes(pattern)) { + throw invalid( + labels, + 'pattern', + pattern, + `${labels.oneOf}${PATTERNS.join('|')}`, + ); + } + if (!CONTROLS.includes(control)) { + throw invalid( + labels, + 'control', + control, + `${labels.oneOf}${CONTROLS.join('|')}`, + ); + } + if (!Number.isInteger(count) || !CANONICAL_COUNTS.includes(count)) { + throw invalid( + labels, + 'count', + count, + `${labels.oneOf}${CANONICAL_COUNTS.join('|')}`, + ); + } + if (pattern === 'duplicate' && candidateKind !== 'entity') { + throw new Error( + `duplicate pattern only supports ${labels.candidateKind}=entity (got ${candidateKind})`, + ); + } +} + +function validateSampleCount(samples, labels = AXIS_LABELS) { + if (!Number.isInteger(samples) || samples < 1 || samples > MAX_SAMPLES) { + throw invalid(labels, 'samples', samples, `integer 1..${MAX_SAMPLES}`); + } +} + +function validatePlatformMode(platform, mode) { + const modes = MODES_BY_PLATFORM[platform]; + if (!modes) { + throw new Error( + `invalid platform=${String(platform)}; expected one of ${PLATFORMS.join('|')}`, + ); + } + if (!modes.includes(mode)) { + throw new Error( + `invalid mode=${String(mode)} for platform=${platform}; expected one of ${modes.join('|')}`, + ); + } +} + +/** Stable scenario id from validated axes (throws on invalid axes). */ +function scenarioId(axes) { + const { platform, candidateKind, pattern, count, mode, control } = axes; + validatePlatformMode(platform, mode); + validateAxes(axes); + return [platform, candidateKind, pattern, String(count), mode, control].join( + '/', + ); +} + +/** Parse a stable id back to axes (throws on malformed or invalid ids). */ +function parseScenarioId(id) { + const parts = String(id).split('/'); + if (parts.length !== 6) { + throw new Error(`malformed GC scenario id: ${id}`); + } + const axes = { + platform: parts[0], + candidateKind: parts[1], + pattern: parts[2], + count: Number(parts[3]), + mode: parts[4], + control: parts[5], + }; + if (scenarioId(axes) !== id) { + throw new Error(`GC scenario id failed revalidation: ${id}`); + } + return axes; +} + +/** + * Scenario filter against a stable id. + * - empty: matches everything + * - `^text`: id starts with `text` + * - otherwise: contiguous slash-bounded segments. One leading and one + * trailing slash are removed, so `/100000/` matches the 100000 segment + * only, and `1000` does not match `10000` or `100000`. + */ +function matchScenarioId(id, filter) { + if (!filter) return true; + if (filter.startsWith('^')) return id.startsWith(filter.slice(1)); + let needle = filter; + if (needle.startsWith('/')) needle = needle.slice(1); + if (needle.endsWith('/')) needle = needle.slice(0, -1); + if (needle.length === 0) return true; + return `/${id}/`.includes(`/${needle}/`); +} + +/** + * Every supported scenario for a platform, in that platform's run order: + * - node/browser: count → candidateKind → mode → control, then the entity + * duplicate baseline per count + * - android: candidateKind → count → control, then all duplicate rows + * (device matrix order) + * Only descriptors — no fixtures are built here. + */ +function listScenarios(platform, { filter } = {}) { + const modes = MODES_BY_PLATFORM[platform]; + if (!modes) { + throw new Error( + `invalid platform=${String(platform)}; expected one of ${PLATFORMS.join('|')}`, + ); + } + const scenarios = []; + const add = (candidateKind, pattern, count) => { + for (const mode of modes) { + for (const control of CONTROLS) { + const axes = { platform, candidateKind, pattern, count, mode, control }; + const id = scenarioId(axes); + if (matchScenarioId(id, filter)) scenarios.push({ id, ...axes }); + } + } + }; + + if (platform === 'android') { + for (const candidateKind of CANDIDATE_KINDS) { + for (const count of CANONICAL_COUNTS) add(candidateKind, 'unique', count); + } + for (const count of CANONICAL_COUNTS) add('entity', 'duplicate', count); + } else { + for (const count of CANONICAL_COUNTS) { + for (const candidateKind of CANDIDATE_KINDS) { + add(candidateKind, 'unique', count); + } + add('entity', 'duplicate', count); + } + } + return scenarios; +} + +/** + * Split mixed total into entity + endpoint counts (entities get the remainder + * when odd so entityCount + endpointCount === total). + */ +function splitMixedCount(total) { + const endpoints = Math.floor(total / 2); + return { entities: total - endpoints, endpoints }; +} + +module.exports = { + CANONICAL_COUNTS, + CANDIDATE_KINDS, + PATTERNS, + CONTROLS, + MODES_BY_PLATFORM, + MAX_SAMPLES, + AXIS_LABELS, + validateAxes, + validateSampleCount, + scenarioId, + parseScenarioId, + matchScenarioId, + listScenarios, + splitMixedCount, +}; diff --git a/examples/gc-shared/stats.d.ts b/examples/gc-shared/stats.d.ts new file mode 100644 index 000000000000..ef676e9fa5da --- /dev/null +++ b/examples/gc-shared/stats.d.ts @@ -0,0 +1,13 @@ +export interface NumberSummary { + median: number; + min: number; + max: number; + p95: number; + p99: number; +} + +/** Linear-interpolated percentile over an ascending-sorted array. */ +export declare function percentile(sorted: number[], p: number): number; +export declare function summarizeNumbers( + values: number[], +): NumberSummary | null; diff --git a/examples/gc-shared/stats.js b/examples/gc-shared/stats.js new file mode 100644 index 000000000000..4c7572824470 --- /dev/null +++ b/examples/gc-shared/stats.js @@ -0,0 +1,33 @@ +'use strict'; +/** + * Report summaries shared by the Node, browser, and Android GC reports. + * CommonJS on purpose (see ./README.md). + */ + +/** Linear-interpolated percentile over an ascending-sorted array. */ +function percentile(sorted, p) { + if (sorted.length === 1) return sorted[0]; + const idx = (p / 100) * (sorted.length - 1); + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + if (lo === hi) return sorted[lo]; + const w = idx - lo; + return sorted[lo] * (1 - w) + sorted[hi] * w; +} + +function summarizeNumbers(values) { + if (values.length === 0) return null; + const sorted = values.slice().sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; + return { + median, + min: sorted[0], + max: sorted[sorted.length - 1], + p95: percentile(sorted, 95), + p99: percentile(sorted, 99), + }; +} + +module.exports = { percentile, summarizeNumbers }; From 9cbee1fe62fe6cf684ab9e4b68c28a638b4dec35 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 13:39:25 -0400 Subject: [PATCH 21/32] refactor(benchmark): Use examples/gc-shared in the Node GC harness gc-policy-scenarios.js binds the shared fixture to the dist core and lists scenarios via the shared protocol; gc-policy.js uses shared summary stats. The build manifest digest and dirty pathspecs now cover examples/gc-shared. start:gc filters are now slash-bounded segments (or ^prefix) against the stable id, so `1000` no longer selects 10000/100000 scenarios. Co-Authored-By: Claude Opus 5.5 --- examples/benchmark/README.md | 14 +- examples/benchmark/gc-build-manifest.js | 13 +- examples/benchmark/gc-policy-scenarios.js | 562 ++-------------------- examples/benchmark/gc-policy.js | 26 +- 4 files changed, 48 insertions(+), 567 deletions(-) diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index 3d67ec117149..5d6c5fe01d6e 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -96,8 +96,8 @@ yarn workspace example-benchmark start:gc:verify Every `yarn build:benchmark` / workspace `build` runs webpack then writes `dist/gc-build-manifest.json` (BuildManifest v1: `schemaVersion`, `buildId`, `commit`, `dirty`, `sourceDigest`, `artifacts`). `sourceDigest` hashes sorted -relevant inputs on disk (`packages/core/src/**`, GC harness/runner/config/ -package files), so dirty or untracked relevant files change it. `artifacts` +relevant inputs on disk (`packages/core/src/**`, `examples/gc-shared/**`, +GC harness/runner/config/package files), so dirty or untracked relevant files change it. `artifacts` maps each emitted `dist/*` file (except the manifest) to sha256. `buildId` is the digest of those canonical fields excluding itself. @@ -113,7 +113,7 @@ tampering are rejected and restores all files afterward. Examples: ```bash -# All 1k scenarios (substring filter); JSON on stdout +# All 1k scenarios (not 10k/100k); JSON on stdout yarn workspace example-benchmark start:gc /1000/ --samples=5 # Single 100k entity scan without building unrelated fixtures @@ -126,8 +126,12 @@ yarn workspace example-benchmark start:gc entity/duplicate/1000 yarn workspace example-benchmark start:gc /1000/scan/gc --no-table > gc-report.json ``` -Filter syntax matches the other suites (`text` substring, `^text` prefix) against -stable scenario IDs: +Unlike the other suites' substring filter, `start:gc` filters match +**slash-bounded segments** of the stable scenario ID (`1000` and `/1000/` match +the 1000 count only, never 10000/100000; `entity/duplicate/1000` must be +contiguous segments), or `^text` for an ID prefix. The axes, IDs, and filter +are shared with the browser and Android harnesses via +[`examples/gc-shared`](../gc-shared/README.md): `node/{entity|endpoint|mixed}/{unique|duplicate}/{1000|10000|100000}/{scan|reducer|end-to-end}/{gc|no-gc}` diff --git a/examples/benchmark/gc-build-manifest.js b/examples/benchmark/gc-build-manifest.js index 2a8b0380c738..6a7c0c57f602 100644 --- a/examples/benchmark/gc-build-manifest.js +++ b/examples/benchmark/gc-build-manifest.js @@ -39,7 +39,6 @@ const BENCH_SOURCE_FILES = [ 'examples/benchmark/gc-build-manifest.js', 'examples/benchmark/gc-policy.js', 'examples/benchmark/gc-policy-scenarios.js', - 'examples/benchmark/filter.js', 'examples/benchmark/package.json', 'examples/benchmark/webpack.config.cjs', 'examples/benchmark/src/index.ts', @@ -47,6 +46,8 @@ const BENCH_SOURCE_FILES = [ ]; const CORE_SRC_DIR = 'packages/core/src'; +/** Source directories walked recursively (relative to repo root). */ +const SOURCE_DIRS = [CORE_SRC_DIR, 'examples/gc-shared']; function sha256Buffer(buf) { return createHash('sha256').update(buf).digest('hex'); @@ -79,14 +80,16 @@ function walkFiles(absDir, repoRelDir) { /** * Sorted list of relevant source paths (repo-relative posix). - * Includes untracked files under packages/core/src when present on disk. + * Includes untracked files under SOURCE_DIRS when present on disk. */ export function listRelevantSourcePaths() { - const coreFiles = walkFiles(path.join(REPO_ROOT, CORE_SRC_DIR), CORE_SRC_DIR); + const dirFiles = SOURCE_DIRS.flatMap(dir => + walkFiles(path.join(REPO_ROOT, dir), dir), + ); const benchFiles = BENCH_SOURCE_FILES.filter(rel => existsSync(path.join(REPO_ROOT, rel)), ); - return [...new Set([...coreFiles, ...benchFiles])].sort((a, b) => + return [...new Set([...dirFiles, ...benchFiles])].sort((a, b) => a.localeCompare(b), ); } @@ -156,7 +159,7 @@ export function gitCommitFull() { export function computeDirty() { try { // Pathspecs already scope status to relevant inputs. - const pathspecs = [CORE_SRC_DIR, ...BENCH_SOURCE_FILES]; + const pathspecs = [...SOURCE_DIRS, ...BENCH_SOURCE_FILES]; const out = execFileSync( 'git', ['status', '--porcelain', '-u', '--', ...pathspecs], diff --git a/examples/benchmark/gc-policy-scenarios.js b/examples/benchmark/gc-policy-scenarios.js index e17fa922ebc8..daaeb5b38814 100644 --- a/examples/benchmark/gc-policy-scenarios.js +++ b/examples/benchmark/gc-policy-scenarios.js @@ -1,9 +1,6 @@ /** - * Deterministic GC fixtures and scenario descriptors for the Node GC harness. - * - * Builds state + candidate queues outside timed work. Fixtures match - * entities/entitiesMeta and endpoints/meta so sweeps are eligible immediately - * under BenchmarkGCPolicy (expiresAt → 0). + * Node GC harness binding of the shared fixture/protocol (examples/gc-shared) + * to the webpack-built core in ./dist (not `@data-client/core` source). */ import { GCPolicy, @@ -12,537 +9,38 @@ import { initialState, actionTypes, } from './dist/index.js'; -import { createMatcher } from './filter.js'; - -const { GC } = actionTypes; - -export const CANONICAL_COUNTS = [1_000, 10_000, 100_000]; -export const ENTITY_KEY = 'BenchEntity'; -export const ZERO_META = Object.freeze({ - date: 0, - fetchedAt: 0, - expiresAt: 0, +import { createGCFixture } from '../gc-shared/gc-fixture.js'; +import { listScenarios as listSharedScenarios } from '../gc-shared/protocol.js'; + +export const { + ENTITY_KEY, + ZERO_META, + BenchmarkGCPolicy, + buildEntityState, + buildEndpointState, + buildMixedState, + queueCandidates, + buildPrebuiltAction, + createHarness, + countRemaining, + validateSample, + GC, +} = createGCFixture({ + GCPolicy, + Controller, + createReducer, + initialState, + GC: actionTypes.GC, }); -/** Timerless explicit policy: no intervals/idleness; one-shot public sweep. */ -export class BenchmarkGCPolicy extends GCPolicy { - constructor() { - super({ expiresAt: () => 0 }); - } - - init(controller) { - this.controller = controller; - } - - cleanup() {} - - /** Public one-shot entry that calls the protected monolithic runSweep. */ - sweep() { - this.runSweep(); - } - - get entityQueueLength() { - return this.entitiesQ.length; - } - - get endpointQueueSize() { - return this.endpointsQ.size; - } - - get queueEntries() { - return this.entityQueueLength + this.endpointQueueSize; - } -} - -/** - * Split mixed total into entity + endpoint counts (entities get the remainder - * when odd so entityCount + endpointCount === total). - */ -export function splitMixedCount(total) { - const endpoints = Math.floor(total / 2); - return { entities: total - endpoints, endpoints }; -} - -function entityPath(pk) { - return { key: ENTITY_KEY, pk: String(pk) }; -} - -function endpointKey(i) { - return `bench-endpoint-${i}`; -} - -/** Build store state with matching entities + entitiesMeta. */ -export function buildEntityState(count) { - const entities = { [ENTITY_KEY]: {} }; - const entitiesMeta = { [ENTITY_KEY]: {} }; - for (let i = 0; i < count; i++) { - const pk = String(i); - entities[ENTITY_KEY][pk] = { id: pk }; - entitiesMeta[ENTITY_KEY][pk] = { ...ZERO_META }; - } - return { - ...initialState, - entities, - entitiesMeta, - }; -} - -/** Build store state with matching endpoints + meta. */ -export function buildEndpointState(count) { - const endpoints = {}; - const meta = {}; - for (let i = 0; i < count; i++) { - const key = endpointKey(i); - endpoints[key] = String(i); - meta[key] = { ...ZERO_META }; - } - return { - ...initialState, - endpoints, - meta, - }; -} - -/** Mixed state: entityCount distinct entities + endpointCount distinct endpoints. */ -export function buildMixedState(entityCount, endpointCount) { - const state = buildEntityState(entityCount); - const endpoints = {}; - const meta = {}; - for (let i = 0; i < endpointCount; i++) { - const key = endpointKey(i); - endpoints[key] = String(i); - meta[key] = { ...ZERO_META }; - } - return { ...state, endpoints, meta }; -} - -/** Scalar expectations only — no retained path/key arrays (avoids observer heap). */ -function expectedScalars(overrides) { - return { - queueEntries: overrides.queueEntries, - uniqueTargets: overrides.uniqueTargets, - expectedEntitiesInAction: overrides.expectedEntitiesInAction, - expectedEndpointsInAction: overrides.expectedEndpointsInAction, - expectedUniqueEntityDeletions: overrides.expectedUniqueEntityDeletions, - expectedEndpointDeletions: overrides.expectedEndpointDeletions, - }; -} - /** - * Queue candidates via createCountRef mount/release (outside timed work). - * Duplicate pattern repeatedly releases one entity path. - * Does not retain parallel target arrays — callers that need a prebuilt GC - * action use buildPrebuiltAction(). - */ -export function queueCandidates(policy, spec) { - const { candidateKind, pattern, count } = spec; - - if (pattern === 'duplicate') { - if (candidateKind !== 'entity') { - throw new Error( - `duplicate pattern only supports candidateKind=entity (got ${candidateKind})`, - ); - } - const path = entityPath(0); - const countRef = policy.createCountRef({ paths: [path] }); - for (let i = 0; i < count; i++) { - const release = countRef(); - release(); - } - return expectedScalars({ - queueEntries: count, - uniqueTargets: 1, - expectedEntitiesInAction: count, - expectedEndpointsInAction: 0, - expectedUniqueEntityDeletions: 1, - expectedEndpointDeletions: 0, - }); - } - - if (candidateKind === 'entity') { - for (let i = 0; i < count; i++) { - const release = policy.createCountRef({ paths: [entityPath(i)] })(); - release(); - } - return expectedScalars({ - queueEntries: count, - uniqueTargets: count, - expectedEntitiesInAction: count, - expectedEndpointsInAction: 0, - expectedUniqueEntityDeletions: count, - expectedEndpointDeletions: 0, - }); - } - - if (candidateKind === 'endpoint') { - for (let i = 0; i < count; i++) { - const release = policy.createCountRef({ key: endpointKey(i) })(); - release(); - } - return expectedScalars({ - queueEntries: count, - uniqueTargets: count, - expectedEntitiesInAction: 0, - expectedEndpointsInAction: count, - expectedUniqueEntityDeletions: 0, - expectedEndpointDeletions: count, - }); - } - - if (candidateKind === 'mixed') { - const { entities, endpoints } = splitMixedCount(count); - for (let i = 0; i < entities; i++) { - const release = policy.createCountRef({ paths: [entityPath(i)] })(); - release(); - } - for (let i = 0; i < endpoints; i++) { - const release = policy.createCountRef({ key: endpointKey(i) })(); - release(); - } - return expectedScalars({ - queueEntries: count, - uniqueTargets: count, - expectedEntitiesInAction: entities, - expectedEndpointsInAction: endpoints, - expectedUniqueEntityDeletions: entities, - expectedEndpointDeletions: endpoints, - }); - } - - throw new Error(`unknown candidateKind: ${candidateKind}`); -} - -/** Build a GC action for reducer mode only (scan/e2e get targets from sweep). */ -export function buildPrebuiltAction(spec) { - const { candidateKind, pattern, count } = spec; - - if (pattern === 'duplicate') { - const path = entityPath(0); - return { - type: GC, - entities: Array.from({ length: count }, () => ({ ...path })), - endpoints: [], - }; - } - - if (candidateKind === 'entity') { - return { - type: GC, - entities: Array.from({ length: count }, (_, i) => entityPath(i)), - endpoints: [], - }; - } - - if (candidateKind === 'endpoint') { - return { - type: GC, - entities: [], - endpoints: Array.from({ length: count }, (_, i) => endpointKey(i)), - }; - } - - if (candidateKind === 'mixed') { - const { entities, endpoints } = splitMixedCount(count); - return { - type: GC, - entities: Array.from({ length: entities }, (_, i) => entityPath(i)), - endpoints: Array.from({ length: endpoints }, (_, i) => endpointKey(i)), - }; - } - - throw new Error(`unknown candidateKind: ${candidateKind}`); -} - -function buildStateForSpec(spec) { - const { candidateKind, pattern, count } = spec; - if (pattern === 'duplicate') { - return buildEntityState(1); - } - if (candidateKind === 'entity') return buildEntityState(count); - if (candidateKind === 'endpoint') return buildEndpointState(count); - if (candidateKind === 'mixed') { - const { entities, endpoints } = splitMixedCount(count); - return buildMixedState(entities, endpoints); - } - throw new Error(`unknown candidateKind: ${candidateKind}`); -} - -export function scenarioId(axes) { - return [ - axes.platform, - axes.candidateKind, - axes.pattern, - String(axes.count), - axes.mode, - axes.control, - ].join('/'); -} - -/** - * Wire a Controller + BenchmarkGCPolicy for the given mode. - * - scan: dispatch captures action only; no prebuilt action arrays - * - end-to-end: dispatch reduces; no prebuilt action arrays - * - reducer: prebuilt GC action only (needed for timed reducer path) - */ -export function createHarness(spec, mode) { - const state = buildStateForSpec(spec); - const policy = new BenchmarkGCPolicy(); - const controller = new Controller({ gcPolicy: policy }); - const reducer = createReducer(controller); - - let capturedAction = null; - let workingState = state; - /** Seed retained only until reducer takes it (then nulled). */ - let seedState = mode === 'reducer' ? state : null; - - controller.getState = () => workingState; - - if (mode === 'scan') { - controller.dispatch = action => { - capturedAction = action; - }; - } else if (mode === 'end-to-end') { - controller.dispatch = action => { - capturedAction = action; - workingState = reducer(workingState, action); - }; - } else if (mode === 'reducer') { - // Reducer mode does not sweep; dispatch unused during timed work - controller.dispatch = action => { - capturedAction = action; - }; - } else { - throw new Error(`unknown harness mode: ${mode}`); - } - - policy.init(controller); - const expected = queueCandidates(policy, spec); - - if (policy.queueEntries !== expected.queueEntries) { - throw new Error( - `fixture queue cardinality ${policy.queueEntries} !== expected ${expected.queueEntries}`, - ); - } - - // Only reducer needs a prebuilt action; scan/e2e would duplicate 100k target arrays - let prebuiltAction = mode === 'reducer' ? buildPrebuiltAction(spec) : null; - - const harness = { - policy, - reducer, - expected, - get prebuiltAction() { - return prebuiltAction; - }, - getState: () => workingState, - getCapturedAction: () => capturedAction, - clearCapturedAction() { - capturedAction = null; - }, - /** - * Drop observer-only refs (captured action, prebuilt action) while keeping - * the live store + policy for retained-cache heap measurement. - */ - releaseObserverRefs() { - capturedAction = null; - prebuiltAction = null; - seedState = null; - }, - /** - * Reducer: hand the freshly built seed to the reducer and drop the seed - * ref. The fixture is built per harness, so there is nothing to protect - * by cloning. - */ - takeReducerState() { - if (!seedState) { - throw new Error('takeReducerState() requires reducer mode with a seed'); - } - workingState = seedState; - seedState = null; - return workingState; - }, - /** Drop store/policy/controller so a later V8 GC can reclaim everything. */ - dispose() { - capturedAction = null; - prebuiltAction = null; - seedState = null; - workingState = null; - controller.getState = () => initialState; - controller.dispatch = () => {}; - policy.cleanup(); - }, - }; - - return harness; -} - -/** Count deletions vs. fixture after a destructive GC apply. */ -export function countRemaining(state, spec, expected) { - const bucket = state.entities?.[ENTITY_KEY]; - const entityRemaining = bucket ? Object.keys(bucket).length : 0; - const endpointRemaining = - state.endpoints ? Object.keys(state.endpoints).length : 0; - - // unique/mixed start with expectedUniqueEntityDeletions entities; duplicate starts with 1 - const startedEntities = - spec.pattern === 'duplicate' ? 1 : expected.expectedUniqueEntityDeletions; - const startedEndpoints = expected.expectedEndpointDeletions; - - return { - entityDeleted: startedEntities - entityRemaining, - endpointDeleted: startedEndpoints - endpointRemaining, - entityRemaining, - endpointRemaining, - }; -} - -/** - * Validate an accepted sample against fixture expectations. - * Throws on invalid fixture/result. - */ -export function validateSample(spec, harness, sampleMetrics, mode, control) { - const { expected, policy, getCapturedAction, getState } = harness; - - if (control === 'no-gc') { - if (sampleMetrics.actionCount !== 0) { - throw new Error( - `no-gc control expected actionCount 0, got ${sampleMetrics.actionCount}`, - ); - } - if (sampleMetrics.deletionCount !== 0) { - throw new Error( - `no-gc control expected deletionCount 0, got ${sampleMetrics.deletionCount}`, - ); - } - return; - } - - if (sampleMetrics.queueEntries !== expected.queueEntries) { - throw new Error( - `queueEntries ${sampleMetrics.queueEntries} !== expected ${expected.queueEntries}`, - ); - } - if (sampleMetrics.uniqueTargets !== expected.uniqueTargets) { - throw new Error( - `uniqueTargets ${sampleMetrics.uniqueTargets} !== expected ${expected.uniqueTargets}`, - ); - } - - const actionTargetCount = - expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; - if (sampleMetrics.actionTargetCount !== actionTargetCount) { - throw new Error( - `actionTargetCount ${sampleMetrics.actionTargetCount} !== expected ${actionTargetCount}`, - ); - } - - if (mode === 'scan' || mode === 'end-to-end') { - const action = getCapturedAction(); - if (!action || action.type !== GC) { - throw new Error('expected a GC action to be dispatched'); - } - if (action.entities.length !== expected.expectedEntitiesInAction) { - throw new Error( - `action.entities.length ${action.entities.length} !== ${expected.expectedEntitiesInAction}`, - ); - } - if (action.endpoints.length !== expected.expectedEndpointsInAction) { - throw new Error( - `action.endpoints.length ${action.endpoints.length} !== ${expected.expectedEndpointsInAction}`, - ); - } - if (spec.pattern === 'duplicate') { - const unique = new Set(action.entities.map(p => `${p.key}:${p.pk}`)); - if (unique.size !== 1) { - throw new Error( - `duplicate pattern expected 1 unique entity path in action, got ${unique.size}`, - ); - } - } - } - - if (mode === 'reducer' || mode === 'end-to-end') { - const expectedDeletions = - expected.expectedUniqueEntityDeletions + - expected.expectedEndpointDeletions; - if (sampleMetrics.deletionCount !== expectedDeletions) { - throw new Error( - `deletionCount ${sampleMetrics.deletionCount} !== expected ${expectedDeletions}`, - ); - } - const remaining = countRemaining(getState(), spec, expected); - if (remaining.entityRemaining !== 0 || remaining.endpointRemaining !== 0) { - throw new Error( - `expected empty GC targets after deletion; remaining entities=${remaining.entityRemaining} endpoints=${remaining.endpointRemaining}`, - ); - } - } - - if (mode === 'scan' && sampleMetrics.deletionCount !== 0) { - throw new Error( - `scan mode must not delete; got ${sampleMetrics.deletionCount}`, - ); - } - - // After a successful sweep, queues should be drained (gc path). - if ((mode === 'scan' || mode === 'end-to-end') && policy.queueEntries !== 0) { - throw new Error( - `expected empty queues after sweep, got ${policy.queueEntries}`, - ); - } -} - -const MODES = ['scan', 'reducer', 'end-to-end']; -const CONTROLS = ['gc', 'no-gc']; -const UNIQUE_KINDS = ['entity', 'endpoint', 'mixed']; - -/** - * Scenario axis list. Fixtures are not built here — only descriptors — - * so filtering a single 100k case never constructs unrelated stores. + * Node scenario descriptors (count → kind → mode → control, then the entity + * duplicate baseline per count). Fixtures are not built here, so filtering a + * single 100k case never constructs unrelated stores. * - * @param {string} [filter] substring or ^prefix against scenario id - * @returns {Array} + * @param {string} [filter] slash-bounded segments or ^prefix against the id + * @returns {Array} `{ id, platform, candidateKind, pattern, count, mode, control }` */ export function listScenarios(filter) { - const match = createMatcher(filter); - const scenarios = []; - - for (const count of CANONICAL_COUNTS) { - for (const candidateKind of UNIQUE_KINDS) { - for (const mode of MODES) { - for (const control of CONTROLS) { - const axes = { - platform: 'node', - candidateKind, - pattern: 'unique', - count, - mode, - control, - }; - const id = scenarioId(axes); - if (match(id)) scenarios.push({ id, ...axes }); - } - } - } - - // Duplicate baseline: one entity path released `count` times - for (const mode of MODES) { - for (const control of CONTROLS) { - const axes = { - platform: 'node', - candidateKind: 'entity', - pattern: 'duplicate', - count, - mode, - control, - }; - const id = scenarioId(axes); - if (match(id)) scenarios.push({ id, ...axes }); - } - } - } - - return scenarios; + return listSharedScenarios('node', { filter }); } - -export { GC }; diff --git a/examples/benchmark/gc-policy.js b/examples/benchmark/gc-policy.js index 21ac8f1bf51a..5ef3493304d8 100644 --- a/examples/benchmark/gc-policy.js +++ b/examples/benchmark/gc-policy.js @@ -30,6 +30,7 @@ import { countRemaining, GC, } from './gc-policy-scenarios.js'; +import { summarizeNumbers } from '../gc-shared/stats.js'; const DEFAULT_SAMPLES = 11; @@ -67,31 +68,6 @@ function cpuModel() { return os.cpus()[0]?.model ?? 'unknown'; } -function percentile(sorted, p) { - if (sorted.length === 1) return sorted[0]; - const idx = (p / 100) * (sorted.length - 1); - const lo = Math.floor(idx); - const hi = Math.ceil(idx); - if (lo === hi) return sorted[lo]; - const w = idx - lo; - return sorted[lo] * (1 - w) + sorted[hi] * w; -} - -function summarizeNumbers(values) { - if (values.length === 0) return null; - const sorted = values.slice().sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - const median = - sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; - return { - median, - min: sorted[0], - max: sorted[sorted.length - 1], - p95: percentile(sorted, 95), - p99: percentile(sorted, 99), - }; -} - function summarizeScenario(samples) { const summary = { totalMs: summarizeNumbers(samples.map(s => s.totalMs)), From 2eb09762e3e5cc04f11933b6cdff18352160cf23 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 13:45:04 -0400 Subject: [PATCH 22/32] refactor(benchmark-react): Use examples/gc-shared in the browser GC harness gcBrowserHarness binds the shared fixture (end-to-end mode) to the app's core; gcInteractionMetrics re-exports shared frame helpers and builds/parses ids via the shared protocol; gc-report uses shared stats. The build manifest digest covers examples/gc-shared. tsconfig: allowImportingTsExtensions (tsx tests import .ts) and lib ES2023 (findLast in measure.ts) so `tsc --noEmit` is clean. dist/data-client.js is byte-identical; the fixture lands in the lazy gc-browser-harness chunk. Co-Authored-By: Claude Opus 5.5 --- .../benchmark-react/bench/build-manifest.ts | 6 +- .../bench/gc-interaction-metrics.test.ts | 2 +- examples/benchmark-react/bench/gc-report.ts | 39 +- .../src/data-client/gcBrowserHarness.ts | 384 +----------------- .../src/data-client/gcInteractionMetrics.ts | 125 +----- examples/benchmark-react/tsconfig.json | 3 +- 6 files changed, 46 insertions(+), 513 deletions(-) diff --git a/examples/benchmark-react/bench/build-manifest.ts b/examples/benchmark-react/bench/build-manifest.ts index ee554a63ea54..496acbb82adc 100644 --- a/examples/benchmark-react/bench/build-manifest.ts +++ b/examples/benchmark-react/bench/build-manifest.ts @@ -74,13 +74,13 @@ function walkFiles(dir: string): string[] { /** * Sorted relevant inputs for sourceDigest: - * packages/core/src + benchmark-react src/bench/config/package files. + * packages/core/src + examples/gc-shared + benchmark-react src/bench/config/package files. * Hashes actual file contents (includes dirty/untracked). */ export function listSourceInputFiles(): string[] { const files = new Set(); - for (const f of walkFiles(path.join(REPO_ROOT, 'packages/core/src'))) { - files.add(f); + for (const dir of ['packages/core/src', 'examples/gc-shared']) { + for (const f of walkFiles(path.join(REPO_ROOT, dir))) files.add(f); } const benchRoots = [ path.join(BENCH_ROOT, 'src'), diff --git a/examples/benchmark-react/bench/gc-interaction-metrics.test.ts b/examples/benchmark-react/bench/gc-interaction-metrics.test.ts index fe816cb5a9fa..7d901dcd46fc 100644 --- a/examples/benchmark-react/bench/gc-interaction-metrics.test.ts +++ b/examples/benchmark-react/bench/gc-interaction-metrics.test.ts @@ -226,7 +226,7 @@ async function testThrowingWorkRejectsPromptly() { await import('../src/data-client/gcInteractionProbe.ts'); const pendingRaf = new Set>(); - const g = globalThis as typeof globalThis & { + const g = globalThis as unknown as { requestAnimationFrame?: (cb: (t: number) => void) => number; cancelAnimationFrame?: (id: number) => void; }; diff --git a/examples/benchmark-react/bench/gc-report.ts b/examples/benchmark-react/bench/gc-report.ts index ffcf2ea41630..a172e20b233d 100644 --- a/examples/benchmark-react/bench/gc-report.ts +++ b/examples/benchmark-react/bench/gc-report.ts @@ -7,6 +7,8 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { BuildManifestV1 } from './build-manifest.js'; +import { summarizeNumbers } from '../../gc-shared/stats.js'; +import type { NumberSummary } from '../../gc-shared/stats.js'; import { browserGCScenarioId } from '../src/data-client/gcInteractionMetrics.js'; import type { GCBrowserMeasurement, @@ -19,13 +21,8 @@ export interface GCSampleResult extends GCBrowserMeasurement { heapDeltaBytes?: number; } -export interface NumberSummary { - median: number; - min: number; - max: number; - p95: number; - p99: number; -} +export type { NumberSummary }; +export { summarizeNumbers }; export interface GCScenarioReport { /** Stable id: browser/{kind}/{pattern}/{count}/end-to-end/{control} */ @@ -90,34 +87,6 @@ export interface GCMeasurementReport { scenarios: GCScenarioReport[]; } -/** Caller must pass a non-empty sorted array. */ -function percentile(sorted: number[], p: number): number { - if (sorted.length === 1) return sorted[0]!; - const idx = (p / 100) * (sorted.length - 1); - const lo = Math.floor(idx); - const hi = Math.ceil(idx); - if (lo === hi) return sorted[lo]!; - const w = idx - lo; - return sorted[lo]! * (1 - w) + sorted[hi]! * w; -} - -export function summarizeNumbers(values: number[]): NumberSummary | null { - if (values.length === 0) return null; - const sorted = values.slice().sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - const median = - sorted.length % 2 === 0 ? - (sorted[mid - 1]! + sorted[mid]!) / 2 - : sorted[mid]!; - return { - median, - min: sorted[0]!, - max: sorted[sorted.length - 1]!, - p95: percentile(sorted, 95), - p99: percentile(sorted, 99), - }; -} - export function summarizeGCSamples( samples: GCSampleResult[], ): Record { diff --git a/examples/benchmark-react/src/data-client/gcBrowserHarness.ts b/examples/benchmark-react/src/data-client/gcBrowserHarness.ts index 371d647b306b..c5322881b854 100644 --- a/examples/benchmark-react/src/data-client/gcBrowserHarness.ts +++ b/examples/benchmark-react/src/data-client/gcBrowserHarness.ts @@ -2,7 +2,8 @@ * Isolated browser GC harness — separate from DataProvider's benchGC singleton. * * Builds a benchmark-only Controller + createReducer + timerless GCPolicy with - * raw deterministic state, queues via createCountRef, mode=end-to-end. Measures + * raw deterministic state, queues via createCountRef, mode=end-to-end, using + * the fixture shared with the Node/Android harnesses (examples/gc-shared). Measures * real core cache GC on the main thread without 100k React components or network. * * Timing boundaries: @@ -17,7 +18,6 @@ import { createReducer, initialState, } from '@data-client/core'; -import type { State } from '@data-client/core'; import type { GCBrowserMeasurement, GCPreparedSummary, @@ -31,374 +31,22 @@ import { runChromiumInteractionProbe, syntheticBlockMs, } from './gcInteractionProbe'; +import { createGCFixture } from '../../../gc-shared/gc-fixture.js'; +import type { Harness } from '../../../gc-shared/gc-fixture.js'; -const { GC } = actionTypes; +// Destructure (not `actionTypes.GC` inline): a direct member read lets webpack +// inline the constant across chunks, which changes data-client.js bytes. +const { GC: GC_ACTION } = actionTypes; -type EntityPath = { key: string; pk: string }; - -export const ENTITY_KEY = 'BenchEntity'; -export const ZERO_META = Object.freeze({ - date: 0, - fetchedAt: 0, - expiresAt: 0, +/** Shared fixture (examples/gc-shared) bound to this app's core. */ +const { createHarness, countRemaining, validateSample, GC } = createGCFixture({ + GCPolicy, + Controller, + createReducer, + initialState, + GC: GC_ACTION, }); -/** Timerless explicit policy: no intervals; public sweep → protected runSweep. */ -class BrowserBenchmarkGCPolicy extends GCPolicy { - constructor() { - super({ expiresAt: () => 0 }); - } - - init(controller: Controller) { - this.controller = controller; - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function - cleanup() {} - - sweep() { - this.runSweep(); - } - - get entityQueueLength() { - return this.entitiesQ.length; - } - - get endpointQueueSize() { - return this.endpointsQ.size; - } - - get queueEntries() { - return this.entityQueueLength + this.endpointQueueSize; - } -} - -interface ExpectedScalars { - queueEntries: number; - uniqueTargets: number; - expectedEntitiesInAction: number; - expectedEndpointsInAction: number; - expectedUniqueEntityDeletions: number; - expectedEndpointDeletions: number; -} - -interface Harness { - policy: BrowserBenchmarkGCPolicy; - expected: ExpectedScalars; - getState: () => State; - getCapturedAction: () => { - type: string; - entities: EntityPath[]; - endpoints: string[]; - } | null; - clearCapturedAction: () => void; - dispose: () => void; -} - -function splitMixedCount(total: number) { - const endpoints = Math.floor(total / 2); - return { entities: total - endpoints, endpoints }; -} - -function entityPath(pk: number): EntityPath { - return { key: ENTITY_KEY, pk: String(pk) }; -} - -function endpointKey(i: number) { - return `bench-endpoint-${i}`; -} - -function buildEntityState(count: number): State { - const entities = { [ENTITY_KEY]: {} as Record }; - const entitiesMeta = { - [ENTITY_KEY]: {} as Record, - }; - for (let i = 0; i < count; i++) { - const pk = String(i); - entities[ENTITY_KEY][pk] = { id: pk }; - entitiesMeta[ENTITY_KEY][pk] = { ...ZERO_META }; - } - return { ...initialState, entities, entitiesMeta }; -} - -function buildEndpointState(count: number): State { - const endpoints: Record = {}; - const meta: Record = {}; - for (let i = 0; i < count; i++) { - const key = endpointKey(i); - endpoints[key] = String(i); - meta[key] = { ...ZERO_META }; - } - return { ...initialState, endpoints, meta }; -} - -function buildMixedState( - entityCount: number, - endpointCount: number, -): State { - const entities = buildEntityState(entityCount); - const endpoints = buildEndpointState(endpointCount); - return { - ...entities, - endpoints: endpoints.endpoints, - meta: endpoints.meta, - }; -} - -function buildStateForSpec(config: GCScenarioConfig): State { - const { candidateKind, pattern, count } = config; - if (pattern === 'duplicate') return buildEntityState(1); - if (candidateKind === 'entity') return buildEntityState(count); - if (candidateKind === 'endpoint') return buildEndpointState(count); - if (candidateKind === 'mixed') { - const { entities, endpoints } = splitMixedCount(count); - return buildMixedState(entities, endpoints); - } - throw new Error(`unknown candidateKind: ${candidateKind}`); -} - -function queueCandidates( - policy: BrowserBenchmarkGCPolicy, - config: GCScenarioConfig, -): ExpectedScalars { - const { candidateKind, pattern, count } = config; - - if (pattern === 'duplicate') { - if (candidateKind !== 'entity') { - throw new Error( - `duplicate pattern only supports candidateKind=entity (got ${candidateKind})`, - ); - } - const path = entityPath(0); - const countRef = policy.createCountRef({ paths: [path] }); - for (let i = 0; i < count; i++) { - const release = countRef(); - release(); - } - return { - queueEntries: count, - uniqueTargets: 1, - expectedEntitiesInAction: count, - expectedEndpointsInAction: 0, - expectedUniqueEntityDeletions: 1, - expectedEndpointDeletions: 0, - }; - } - - if (candidateKind === 'entity') { - for (let i = 0; i < count; i++) { - const release = policy.createCountRef({ paths: [entityPath(i)] })(); - release(); - } - return { - queueEntries: count, - uniqueTargets: count, - expectedEntitiesInAction: count, - expectedEndpointsInAction: 0, - expectedUniqueEntityDeletions: count, - expectedEndpointDeletions: 0, - }; - } - - if (candidateKind === 'endpoint') { - for (let i = 0; i < count; i++) { - const release = policy.createCountRef({ key: endpointKey(i) })(); - release(); - } - return { - queueEntries: count, - uniqueTargets: count, - expectedEntitiesInAction: 0, - expectedEndpointsInAction: count, - expectedUniqueEntityDeletions: 0, - expectedEndpointDeletions: count, - }; - } - - if (candidateKind === 'mixed') { - const { entities, endpoints } = splitMixedCount(count); - for (let i = 0; i < entities; i++) { - const release = policy.createCountRef({ paths: [entityPath(i)] })(); - release(); - } - for (let i = 0; i < endpoints; i++) { - const release = policy.createCountRef({ key: endpointKey(i) })(); - release(); - } - return { - queueEntries: count, - uniqueTargets: count, - expectedEntitiesInAction: entities, - expectedEndpointsInAction: endpoints, - expectedUniqueEntityDeletions: entities, - expectedEndpointDeletions: endpoints, - }; - } - - throw new Error(`unknown candidateKind: ${candidateKind}`); -} - -/** End-to-end harness: sweep dispatches and reduces synchronously. */ -function createHarness(config: GCScenarioConfig): Harness { - const state = buildStateForSpec(config); - const policy = new BrowserBenchmarkGCPolicy(); - const controller = new Controller({ gcPolicy: policy }); - const reducer = createReducer(controller); - - let capturedAction: { - type: string; - entities: EntityPath[]; - endpoints: string[]; - } | null = null; - let workingState: State = state; - - controller.getState = () => workingState; - controller.dispatch = ((action: any) => { - capturedAction = action; - workingState = reducer(workingState, action); - }) as typeof controller.dispatch; - - policy.init(controller); - const expected = queueCandidates(policy, config); - - if (policy.queueEntries !== expected.queueEntries) { - throw new Error( - `fixture queue cardinality ${policy.queueEntries} !== expected ${expected.queueEntries}`, - ); - } - - return { - policy, - expected, - getState: () => workingState, - getCapturedAction: () => capturedAction, - clearCapturedAction() { - capturedAction = null; - }, - dispose() { - capturedAction = null; - // Release the full store held by this scope - workingState = initialState; - controller.getState = () => initialState; - controller.dispatch = (() => - Promise.resolve()) as typeof controller.dispatch; - policy.cleanup(); - }, - }; -} - -function countRemaining( - state: State, - config: GCScenarioConfig, - expected: ExpectedScalars, -) { - const bucket = state.entities?.[ENTITY_KEY]; - const entityRemaining = bucket ? Object.keys(bucket).length : 0; - const endpointRemaining = - state.endpoints ? Object.keys(state.endpoints).length : 0; - - const startedEntities = - config.pattern === 'duplicate' ? 1 : expected.expectedUniqueEntityDeletions; - const startedEndpoints = expected.expectedEndpointDeletions; - - return { - entityDeleted: startedEntities - entityRemaining, - endpointDeleted: startedEndpoints - endpointRemaining, - entityRemaining, - endpointRemaining, - }; -} - -function validateMeasurement( - config: GCScenarioConfig, - harness: Harness, - sample: Pick< - GCBrowserMeasurement, - | 'actionCount' - | 'deletionCount' - | 'queueEntries' - | 'uniqueTargets' - | 'actionTargetCount' - >, -) { - const { expected, policy, getCapturedAction, getState } = harness; - - if (config.control === 'no-gc') { - if (sample.actionCount !== 0) { - throw new Error( - `no-gc control expected actionCount 0, got ${sample.actionCount}`, - ); - } - if (sample.deletionCount !== 0) { - throw new Error( - `no-gc control expected deletionCount 0, got ${sample.deletionCount}`, - ); - } - return; - } - - if (sample.queueEntries !== expected.queueEntries) { - throw new Error( - `queueEntries ${sample.queueEntries} !== expected ${expected.queueEntries}`, - ); - } - if (sample.uniqueTargets !== expected.uniqueTargets) { - throw new Error( - `uniqueTargets ${sample.uniqueTargets} !== expected ${expected.uniqueTargets}`, - ); - } - - const actionTargetCount = - expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; - if (sample.actionTargetCount !== actionTargetCount) { - throw new Error( - `actionTargetCount ${sample.actionTargetCount} !== expected ${actionTargetCount}`, - ); - } - - const action = getCapturedAction(); - if (!action || action.type !== GC) { - throw new Error('expected a GC action to be dispatched'); - } - if (action.entities.length !== expected.expectedEntitiesInAction) { - throw new Error( - `action.entities.length ${action.entities.length} !== ${expected.expectedEntitiesInAction}`, - ); - } - if (action.endpoints.length !== expected.expectedEndpointsInAction) { - throw new Error( - `action.endpoints.length ${action.endpoints.length} !== ${expected.expectedEndpointsInAction}`, - ); - } - if (config.pattern === 'duplicate') { - const unique = new Set(action.entities.map(p => `${p.key}:${p.pk}`)); - if (unique.size !== 1) { - throw new Error( - `duplicate pattern expected 1 unique entity path in action, got ${unique.size}`, - ); - } - } - - const expectedDeletions = - expected.expectedUniqueEntityDeletions + expected.expectedEndpointDeletions; - if (sample.deletionCount !== expectedDeletions) { - throw new Error( - `deletionCount ${sample.deletionCount} !== expected ${expectedDeletions}`, - ); - } - const remaining = countRemaining(getState(), config, expected); - if (remaining.entityRemaining !== 0 || remaining.endpointRemaining !== 0) { - throw new Error( - `expected empty GC targets after deletion; remaining entities=${remaining.entityRemaining} endpoints=${remaining.endpointRemaining}`, - ); - } - if (policy.queueEntries !== 0) { - throw new Error( - `expected empty queues after sweep, got ${policy.queueEntries}`, - ); - } -} - interface Session { config: GCScenarioConfig; harness: Harness; @@ -415,7 +63,7 @@ export async function prepareGCScenario( session = null; } - const harness = createHarness(config); + const harness = createHarness(config, 'end-to-end'); const displayPeriodMs = await measureDisplayPeriodMs(); session = { config, harness, displayPeriodMs }; @@ -486,7 +134,7 @@ export async function runGCScenario(): Promise { longTaskTotalMs: probe.longTaskTotalMs, }; - validateMeasurement(config, harness, measurement); + validateSample(config, harness, measurement, 'end-to-end', config.control); // Drop captured GC action arrays before Playwright heap snapshot; keep live store harness.clearCapturedAction(); diff --git a/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts b/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts index 81aaaaee8799..bc3f9fe0bb85 100644 --- a/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts +++ b/examples/benchmark-react/src/data-client/gcInteractionMetrics.ts @@ -5,6 +5,19 @@ import type { GCScenarioConfig } from '@shared/types'; +import { + CANONICAL_COUNTS, + parseScenarioId, + scenarioId, +} from '../../../gc-shared/protocol.js'; + +export { + computeMaxInputDelayMs, + frameIntervalsFromTimestamps, + measureDisplayPeriodMs, + median, +} from '../../../gc-shared/frames.js'; + /** * Count excess whole display periods beyond the single expected vsync gap. * @@ -27,27 +40,6 @@ export function excessMissedFrames( return missed; } -/** - * Responsiveness proxy from timer + frame probes (not pointer input). - * max(timerDelayMs, largest frame-interval excess over one display period). - */ -export function computeMaxInputDelayMs( - timerDelayMs: number, - frameIntervalsMs: number[], - displayPeriodMs: number, -): number { - let maxExcessFrame = 0; - if (displayPeriodMs > 0) { - for (const interval of frameIntervalsMs) { - maxExcessFrame = Math.max( - maxExcessFrame, - Math.max(0, interval - displayPeriodMs), - ); - } - } - return Math.max(timerDelayMs, maxExcessFrame); -} - /** LongTask overlaps measurement window (entry may start just before windowStart). */ export function longTaskOverlapsWindow( entry: { startTime: number; duration: number }, @@ -60,45 +52,8 @@ export function longTaskOverlapsWindow( ); } -/** Convert consecutive rAF timestamps into raw intervals. */ -export function frameIntervalsFromTimestamps(timestamps: number[]): number[] { - const intervals: number[] = []; - for (let i = 1; i < timestamps.length; i++) { - intervals.push(timestamps[i] - timestamps[i - 1]); - } - return intervals; -} - -export function median(values: number[]): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 ? - (sorted[mid - 1]! + sorted[mid]!) / 2 - : sorted[mid]!; -} - -/** Quiet rAF intervals → display period (never hardcode 16.67). */ -export async function measureDisplayPeriodMs(samples = 8): Promise { - const intervals: number[] = []; - await new Promise(resolve => { - let last = 0; - let n = 0; - const frame = (now: number) => { - if (n > 0) intervals.push(now - last); - last = now; - n++; - if (n <= samples) requestAnimationFrame(frame); - else resolve(); - }; - requestAnimationFrame(frame); - }); - return median(intervals); -} - -/** Canonical GC counts matching the Node GC harness / shared vocabulary. */ -export const GC_CANONICAL_COUNTS = [1_000, 10_000, 100_000] as const; -const CANONICAL_COUNTS: readonly number[] = GC_CANONICAL_COUNTS; +/** Canonical GC counts shared with the Node/Android harnesses (gc-shared protocol). */ +export const GC_CANONICAL_COUNTS = CANONICAL_COUNTS; /** * Stable detailed-report scenario ID from validated axes (not display names). @@ -110,32 +65,10 @@ export function browserGCScenarioId( 'candidateKind' | 'pattern' | 'count' | 'control' >, ): string { - const { candidateKind, pattern, count, control } = axes; - if ( - candidateKind !== 'entity' && - candidateKind !== 'endpoint' && - candidateKind !== 'mixed' - ) { - throw new Error(`invalid candidateKind: ${candidateKind}`); - } - if (pattern !== 'unique' && pattern !== 'duplicate') { - throw new Error(`invalid pattern: ${pattern}`); - } - if (pattern === 'duplicate' && candidateKind !== 'entity') { - throw new Error('duplicate pattern requires candidateKind=entity'); - } - if (!Number.isInteger(count) || !CANONICAL_COUNTS.includes(count)) { - throw new Error( - `invalid count: ${count}; expected one of ${CANONICAL_COUNTS.join('|')}`, - ); - } - if (control !== 'gc' && control !== 'no-gc') { - throw new Error(`invalid control: ${control}`); - } - return `browser/${candidateKind}/${pattern}/${count}/end-to-end/${control}`; + return scenarioId({ platform: 'browser', mode: 'end-to-end', ...axes }); } -/** Parse a stable ID back to axes (throws on malformed). */ +/** Parse a stable ID back to axes (throws on malformed or non-browser ids). */ export function parseBrowserGCScenarioId(id: string): { platform: 'browser'; candidateKind: GCScenarioConfig['candidateKind']; @@ -144,27 +77,9 @@ export function parseBrowserGCScenarioId(id: string): { mode: 'end-to-end'; control: GCScenarioConfig['control']; } { - const parts = id.split('/'); - if ( - parts.length !== 6 || - parts[0] !== 'browser' || - parts[4] !== 'end-to-end' - ) { + const axes = parseScenarioId(id); + if (axes.platform !== 'browser') { throw new Error(`malformed browser GC scenario id: ${id}`); } - const axes = { - candidateKind: parts[1] as GCScenarioConfig['candidateKind'], - pattern: parts[2] as GCScenarioConfig['pattern'], - count: Number(parts[3]), - control: parts[5] as GCScenarioConfig['control'], - }; - // Re-validate via builder (rejects invalid axis combos) - if (browserGCScenarioId(axes) !== id) { - throw new Error(`browser GC scenario id failed revalidation: ${id}`); - } - return { - platform: 'browser', - ...axes, - mode: 'end-to-end', - }; + return { ...axes, platform: 'browser', mode: 'end-to-end' }; } diff --git a/examples/benchmark-react/tsconfig.json b/examples/benchmark-react/tsconfig.json index c7f0e2350887..526ff1df4344 100644 --- a/examples/benchmark-react/tsconfig.json +++ b/examples/benchmark-react/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "strict": true, "esModuleInterop": true, @@ -11,6 +11,7 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, + "allowImportingTsExtensions": true, "paths": { "@shared/*": ["./src/shared/*"] }, From 247bd39fafa60532d8a9dc18cade4dbff12b6bcf Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 13:47:59 -0400 Subject: [PATCH 23/32] refactor(benchmark-native): Use examples/gc-shared and one host filter/validator gcHarness binds the shared fixture; frames, report, types, scenario, and validateConfig delegate to shared frames/stats/protocol (public exports and messages kept). run-matrix.sh and collect-report.sh list/validate through gc-shared/protocol-cli.js; the duplicate bash filter/validator and the host-only TS listScenarios/parseScenarioId/parseHostEnvConfig are removed. Protocol coverage moves to __tests__/protocol.test.ts. Matrix filters now match the full stable id android/{kind}/{pattern}/{count}/interaction/{control} (slash-bounded or ^prefix, same as Node/browser), so kind/pattern/count/control no longer matches; README and run-matrix tests document the new form. The build manifest digest covers examples/gc-shared. Co-Authored-By: Claude Opus 5.5 --- examples/benchmark-native/README.md | 4 +- .../__tests__/protocol.test.ts | 163 ++++++++ .../__tests__/run-matrix-filter.test.ts | 18 + .../__tests__/scenario-and-frames.test.ts | 41 +- .../__tests__/validate-config.test.ts | 21 - .../scripts/build-manifest.cjs | 6 +- .../scripts/collect-report.sh | 7 +- .../benchmark-native/scripts/run-matrix.sh | 52 +-- .../scripts/scenario-filter.sh | 19 - .../scripts/validate-config.sh | 55 --- examples/benchmark-native/src/frames.ts | 58 +-- examples/benchmark-native/src/gcHarness.ts | 364 +----------------- examples/benchmark-native/src/report.ts | 29 +- examples/benchmark-native/src/scenario.ts | 109 +----- examples/benchmark-native/src/types.ts | 16 +- .../benchmark-native/src/validateConfig.ts | 109 ++---- 16 files changed, 291 insertions(+), 780 deletions(-) create mode 100644 examples/benchmark-native/__tests__/protocol.test.ts delete mode 100644 examples/benchmark-native/scripts/scenario-filter.sh delete mode 100644 examples/benchmark-native/scripts/validate-config.sh diff --git a/examples/benchmark-native/README.md b/examples/benchmark-native/README.md index 60b01134e291..bb1a05ee21fd 100644 --- a/examples/benchmark-native/README.md +++ b/examples/benchmark-native/README.md @@ -72,11 +72,12 @@ CANDIDATE_KIND=entity PATTERN=unique COUNT=100000 CONTROL=no-gc SAMPLES=5 \ ### Matrix -Full canonical matrix via loops; 100k skipped unless `FULL=1` or a filter selects those rows. Filters match slash-bounded segments, so `entity/unique/1000` does not select 10k or 100k. `/100000/` selects only the 100k rows and does not need `FULL=1`. +Full canonical matrix from the shared GC protocol ([`examples/gc-shared`](../gc-shared/README.md); `node ../gc-shared/protocol-cli.js list android [filter]` prints it); 100k skipped unless `FULL=1` or a filter selects those rows. Filters match slash-bounded contiguous segments of the stable id `android/{kind}/{pattern}/{count}/interaction/{control}` (or `^prefix`), the same syntax as the Node and browser GC harnesses. So `entity/unique/1000` does not select 10k or 100k, and `/100000/` selects only the 100k rows without `FULL=1`. Because `interaction` sits between count and control, select a control with e.g. `unique/1000/interaction/gc` or `interaction/no-gc` (the old `kind/pattern/count/control` form no longer matches). Host axis validation (`collect`, `matrix`) also goes through `protocol-cli.js validate`. ```bash SAMPLES=3 yarn workspace example-benchmark-native matrix SAMPLES=5 yarn workspace example-benchmark-native matrix entity/unique/100000 +SAMPLES=5 yarn workspace example-benchmark-native matrix interaction/no-gc FULL=1 SAMPLES=5 yarn workspace example-benchmark-native matrix ``` @@ -98,6 +99,7 @@ Without forced Hermes GC, JS heap and process PSS/RSS are observational/noisy ```bash bash -n scripts/*.sh +DRY_RUN=1 bash scripts/run-matrix.sh /100000/ yarn workspace example-benchmark-native test yarn workspace example-benchmark-native typecheck ``` diff --git a/examples/benchmark-native/__tests__/protocol.test.ts b/examples/benchmark-native/__tests__/protocol.test.ts new file mode 100644 index 000000000000..192dc7f4594b --- /dev/null +++ b/examples/benchmark-native/__tests__/protocol.test.ts @@ -0,0 +1,163 @@ +/** + * Shared GC protocol (examples/gc-shared) — the single implementation behind + * the Node/browser/Android scenario lists, ids, filters, and the host CLI used + * by run-matrix.sh / collect-report.sh. + */ +const { execFileSync, spawnSync } = require('child_process'); +const path = require('path'); + +const { + listScenarios, + matchScenarioId, + parseScenarioId, + scenarioId, +} = require('../../gc-shared/protocol.js'); + +export {}; + +// Jest runs with cwd = this workspace (see run-matrix-filter.test.ts) +const CLI = path.resolve('../gc-shared/protocol-cli.js'); + +const ids = (platform: string, filter?: string): string[] => + listScenarios(platform, { filter }).map((s: { id: string }) => s.id); +const countOf = (id: string) => id.split('/')[3]; + +describe('scenario ids', () => { + it('round-trips parseScenarioId', () => { + for (const id of [ + 'android/mixed/unique/10000/interaction/no-gc', + 'browser/entity/duplicate/1000/end-to-end/gc', + 'node/endpoint/unique/100000/reducer/gc', + ]) { + expect(scenarioId(parseScenarioId(id))).toBe(id); + } + }); + + it('rejects malformed ids, wrong platform modes, and invalid axes', () => { + expect(() => parseScenarioId('android/entity/unique/1000/gc')).toThrow(); + expect(() => + parseScenarioId('node/entity/unique/1000/interaction/gc'), + ).toThrow(/mode/); + expect(() => + parseScenarioId('android/endpoint/duplicate/1000/interaction/gc'), + ).toThrow(/duplicate pattern only supports/); + expect(() => + parseScenarioId('android/entity/unique/999/interaction/gc'), + ).toThrow(/count/); + }); +}); + +describe('listScenarios', () => { + it('lists android in device matrix order (kind → count → control, then duplicates)', () => { + const all = ids('android'); + expect(all).toHaveLength(24); + expect(all.slice(0, 7)).toEqual([ + 'android/entity/unique/1000/interaction/gc', + 'android/entity/unique/1000/interaction/no-gc', + 'android/entity/unique/10000/interaction/gc', + 'android/entity/unique/10000/interaction/no-gc', + 'android/entity/unique/100000/interaction/gc', + 'android/entity/unique/100000/interaction/no-gc', + 'android/endpoint/unique/1000/interaction/gc', + ]); + expect(all.slice(-2)).toEqual([ + 'android/entity/duplicate/100000/interaction/gc', + 'android/entity/duplicate/100000/interaction/no-gc', + ]); + }); + + it('lists node/browser count-first with every mode', () => { + expect(ids('node')).toHaveLength(72); + expect(ids('browser')).toHaveLength(24); + expect(ids('browser').slice(0, 8).map(countOf)).toEqual( + Array(8).fill('1000'), + ); + }); + + it('lists duplicate only for entity', () => { + const all = ids('android'); + expect(all.some(id => id.includes('/duplicate/'))).toBe(true); + expect(all.some(id => id.startsWith('android/endpoint/duplicate'))).toBe( + false, + ); + }); +}); + +describe('matchScenarioId (slash-bounded filter)', () => { + it('matches segments so 1000 does not select 10000 or 100000', () => { + const counts = (filter: string) => ids('android', filter).map(countOf); + + expect(counts('1000')).toEqual(expect.arrayContaining(['1000'])); + expect(counts('1000').every(count => count === '1000')).toBe(true); + expect(counts('entity/unique/1000')).toEqual(['1000', '1000']); + expect(counts('/100000/').length).toBeGreaterThan(0); + expect(counts('/100000/').every(count => count === '100000')).toBe(true); + expect(counts('100000/').every(count => count === '100000')).toBe(true); + expect(counts('/100000').every(count => count === '100000')).toBe(true); + }); + + it('matches against the full stable id, including the mode segment', () => { + const id = 'android/entity/unique/1000/interaction/gc'; + expect(matchScenarioId(id, 'unique/1000/interaction/gc')).toBe(true); + expect(matchScenarioId(id, 'interaction/gc')).toBe(true); + // kind/pattern/count/control is not contiguous in the stable id + expect(matchScenarioId(id, 'entity/unique/1000/gc')).toBe(false); + }); + + it('supports ^prefix against the stable id', () => { + expect(ids('android', '^android/mixed/')).toHaveLength(6); + expect(ids('android', '^entity')).toHaveLength(0); + }); + + it('treats empty filters as match-all', () => { + expect(ids('android', '')).toHaveLength(24); + expect(ids('android', '/')).toHaveLength(24); + }); +}); + +describe('protocol-cli.js', () => { + it('lists ids one per line', () => { + const out = execFileSync('node', [CLI, 'list', 'android', '/10000/'], { + encoding: 'utf8', + }); + expect(out.trim().split('\n')).toEqual(ids('android', '/10000/')); + }); + + it('validates host config with env-var wording', () => { + const run = (...args: string[]) => + spawnSync('node', [CLI, 'validate', ...args], { encoding: 'utf8' }); + + expect(run('entity', 'duplicate', '100000', 'gc', '5').status).toBe(0); + const cases: [string[], string][] = [ + [ + ['widget', 'unique', '1000', 'gc'], + 'error: invalid CANDIDATE_KIND=widget; expected entity|endpoint|mixed', + ], + [ + ['entity', 'twice', '1000', 'gc'], + 'error: invalid PATTERN=twice; expected unique|duplicate', + ], + [ + ['entity', 'unique', '1000', 'off'], + 'error: invalid CONTROL=off; expected gc|no-gc', + ], + [ + ['entity', 'unique', '01000', 'gc'], + 'error: invalid COUNT=01000; expected 1000|10000|100000', + ], + [ + ['endpoint', 'duplicate', '1000', 'gc'], + 'error: duplicate pattern only supports CANDIDATE_KIND=entity (got endpoint)', + ], + [ + ['entity', 'unique', '1000', 'gc', '51'], + 'error: invalid SAMPLES=51; expected integer 1..50', + ], + ]; + for (const [args, message] of cases) { + const res = run(...args); + expect(res.status).toBe(1); + expect(res.stderr.trim()).toBe(message); + } + }); +}); diff --git a/examples/benchmark-native/__tests__/run-matrix-filter.test.ts b/examples/benchmark-native/__tests__/run-matrix-filter.test.ts index c0aa44fd1bf0..2000855d6be1 100644 --- a/examples/benchmark-native/__tests__/run-matrix-filter.test.ts +++ b/examples/benchmark-native/__tests__/run-matrix-filter.test.ts @@ -47,4 +47,22 @@ describe('run-matrix.sh filter', () => { true, ); }); + + it('matches the full stable id (mode segment between count and control)', () => { + expect(dryRun('unique/1000/interaction/no-gc')).toEqual([ + 'entity/unique/1000/no-gc', + 'endpoint/unique/1000/no-gc', + 'mixed/unique/1000/no-gc', + ]); + expect(dryRun('^android/mixed/unique/10000/')).toEqual([ + 'mixed/unique/10000/gc', + 'mixed/unique/10000/no-gc', + ]); + }); + + it('fails when nothing matches', () => { + for (const filter of ['entity/unique/1000/gc', 'widget/unique/1000']) { + expect(() => dryRun(filter)).toThrow(); + } + }); }); diff --git a/examples/benchmark-native/__tests__/scenario-and-frames.test.ts b/examples/benchmark-native/__tests__/scenario-and-frames.test.ts index 2e2281632f90..e223cfcc8949 100644 --- a/examples/benchmark-native/__tests__/scenario-and-frames.test.ts +++ b/examples/benchmark-native/__tests__/scenario-and-frames.test.ts @@ -5,12 +5,7 @@ import { median, missedFramesFromTimestamps, } from '../src/frames'; -import { - splitMixedCount, - scenarioId, - parseScenarioId, - listScenarios, -} from '../src/scenario'; +import { splitMixedCount, scenarioId } from '../src/scenario'; describe('scenarioId', () => { it('builds stable android interaction ids', () => { @@ -24,31 +19,15 @@ describe('scenarioId', () => { ).toBe('android/entity/unique/1000/interaction/gc'); }); - it('round-trips parseScenarioId', () => { - const id = 'android/mixed/unique/10000/interaction/no-gc'; - expect(scenarioId(parseScenarioId(id))).toBe(id); - }); - - it('matches slash-bounded segments so 1000 does not select 10000 or 100000', () => { - const countOf = (id: string) => id.split('/')[3]; - const counts = (filter: string) => - listScenarios(filter).map(scenarioId).map(countOf); - - expect(counts('1000')).toEqual(expect.arrayContaining(['1000'])); - expect(counts('1000').every(count => count === '1000')).toBe(true); - expect(counts('entity/unique/1000')).toEqual(['1000', '1000']); - expect(counts('/100000/').length).toBeGreaterThan(0); - expect(counts('/100000/').every(count => count === '100000')).toBe(true); - expect(counts('100000/').every(count => count === '100000')).toBe(true); - expect(counts('/100000').every(count => count === '100000')).toBe(true); - }); - - it('lists duplicate only for entity', () => { - const ids = listScenarios().map(scenarioId); - expect(ids.some(id => id.includes('/duplicate/'))).toBe(true); - expect(ids.some(id => id.startsWith('android/endpoint/duplicate'))).toBe( - false, - ); + it('rejects invalid axes', () => { + expect(() => + scenarioId({ + candidateKind: 'endpoint', + pattern: 'duplicate', + count: 1000, + control: 'gc', + }), + ).toThrow(/duplicate pattern only supports/); }); }); diff --git a/examples/benchmark-native/__tests__/validate-config.test.ts b/examples/benchmark-native/__tests__/validate-config.test.ts index 1428bbfe2b4e..5df13263e92b 100644 --- a/examples/benchmark-native/__tests__/validate-config.test.ts +++ b/examples/benchmark-native/__tests__/validate-config.test.ts @@ -1,7 +1,6 @@ import { ConfigValidationError, MAX_SAMPLES, - parseHostEnvConfig, parseLaunchConfig, validateSampleCount, validateScenarioConfig, @@ -117,23 +116,3 @@ describe('parseLaunchConfig', () => { ); }); }); - -describe('parseHostEnvConfig', () => { - it('parses shell-like strings', () => { - expect( - parseHostEnvConfig({ - candidateKind: 'entity', - pattern: 'duplicate', - count: '100000', - control: 'gc', - samples: '5', - }), - ).toEqual({ - candidateKind: 'entity', - pattern: 'duplicate', - count: 100000, - control: 'gc', - samples: 5, - }); - }); -}); diff --git a/examples/benchmark-native/scripts/build-manifest.cjs b/examples/benchmark-native/scripts/build-manifest.cjs index 8e94e21bdf22..ad87bbddb5ee 100644 --- a/examples/benchmark-native/scripts/build-manifest.cjs +++ b/examples/benchmark-native/scripts/build-manifest.cjs @@ -10,7 +10,7 @@ * verify — verify manifest and/or sidecar identity * * Inputs: sorted paths under this app (tracked+untracked contents) plus - * packages/core/src, excluding build/node_modules/artifacts/.jdk/generated. + * packages/core/src and examples/gc-shared, excluding build/node_modules/artifacts/.jdk/generated. */ const { execSync } = require('child_process'); const crypto = require('crypto'); @@ -63,7 +63,9 @@ function walkFiles(dir, out) { function collectInputFiles() { const files = []; walkFiles(ROOT, files); - walkFiles(path.join(REPO, 'packages/core/src'), files); + for (const dir of ['packages/core/src', 'examples/gc-shared']) { + walkFiles(path.join(REPO, dir), files); + } return files.map(f => path.resolve(f)).sort((a, b) => a.localeCompare(b)); } diff --git a/examples/benchmark-native/scripts/collect-report.sh b/examples/benchmark-native/scripts/collect-report.sh index 6c8d873d84ae..57c6952b79e9 100644 --- a/examples/benchmark-native/scripts/collect-report.sh +++ b/examples/benchmark-native/scripts/collect-report.sh @@ -11,8 +11,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=validate-config.sh -source "${ROOT}/scripts/validate-config.sh" +PROTOCOL_CLI="${ROOT}/../gc-shared/protocol-cli.js" APP_ID="${APP_ID:-com.dataclient.benchmarknative}" ACTIVITY="${ACTIVITY:-${APP_ID}/.MainActivity}" @@ -29,8 +28,8 @@ SAMPLES="${SAMPLES:-1}" LABEL="${LABEL:-}" INSTALL="${INSTALL:-1}" -# Validate axes before any install/device work. -validate_host_config "${CANDIDATE_KIND}" "${PATTERN}" "${COUNT}" "${CONTROL}" "${SAMPLES}" +# Validate axes before any install/device work (shared GC protocol). +node "${PROTOCOL_CLI}" validate "${CANDIDATE_KIND}" "${PATTERN}" "${COUNT}" "${CONTROL}" "${SAMPLES}" mkdir -p "$(dirname "${OUT}")" diff --git a/examples/benchmark-native/scripts/run-matrix.sh b/examples/benchmark-native/scripts/run-matrix.sh index b5bcd57061fe..2e2d340b59c4 100644 --- a/examples/benchmark-native/scripts/run-matrix.sh +++ b/examples/benchmark-native/scripts/run-matrix.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Matrix runner over the full canonical android interaction axes. # -# Generates every supported scenario: +# Scenarios come from the shared GC protocol (examples/gc-shared), in device +# matrix order: # entity|endpoint|mixed × unique × {1000,10000,100000} × {gc,no-gc} # entity × duplicate × {1000,10000,100000} × {gc,no-gc} # @@ -12,17 +13,20 @@ # bash scripts/run-matrix.sh entity/unique/1000 # 1000 only, not 10k/100k # bash scripts/run-matrix.sh entity/unique/100000 # 100k via filter # bash scripts/run-matrix.sh /100000/ # only 100k; no FULL=1 +# bash scripts/run-matrix.sh interaction/no-gc # every no-gc control row +# bash scripts/run-matrix.sh ^android/mixed/ # id prefix # FULL=1 SAMPLES=5 bash scripts/run-matrix.sh # entire matrix incl. 100k # -# Filters match slash-bounded segments, not raw substrings. -# DRY_RUN=1 prints matched specs and does not install or collect. +# Filters match slash-bounded contiguous segments of the stable id +# android/{kind}/{pattern}/{count}/interaction/{control} (or ^prefix), not raw +# substrings. kind/pattern/count/control is not contiguous (interaction sits +# between count and control); use e.g. unique/1000/interaction/gc. +# DRY_RUN=1 prints matched specs (kind/pattern/count/control) and does not +# install or collect. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=validate-config.sh -source "${ROOT}/scripts/validate-config.sh" -# shellcheck source=scenario-filter.sh -source "${ROOT}/scripts/scenario-filter.sh" +PROTOCOL_CLI="${ROOT}/../gc-shared/protocol-cli.js" FILTER="${1:-}" OUT_DIR="${OUT_DIR:-${ROOT}/artifacts/matrix}" @@ -32,25 +36,11 @@ FULL="${FULL:-0}" mkdir -p "${OUT_DIR}" -KINDS=(entity endpoint mixed) -COUNTS=(1000 10000 100000) -CONTROLS=(gc no-gc) - -SCENARIOS=() - -for kind in "${KINDS[@]}"; do - for count in "${COUNTS[@]}"; do - for control in "${CONTROLS[@]}"; do - SCENARIOS+=("${kind}/unique/${count}/${control}") - done - done -done - -for count in "${COUNTS[@]}"; do - for control in "${CONTROLS[@]}"; do - SCENARIOS+=("entity/duplicate/${count}/${control}") - done -done +# Stable ids already filtered by the shared protocol. +SCENARIO_IDS=() +while IFS= read -r id; do + SCENARIO_IDS+=("${id}") +done < <(node "${PROTOCOL_CLI}" list android "${FILTER}") install_flag=1 if [[ "${INSTALL_FIRST}" != "1" ]]; then @@ -59,11 +49,9 @@ fi ran=0 skipped_100k=0 -for spec in "${SCENARIOS[@]}"; do - if ! scenario_filter_matches "${spec}" "${FILTER}"; then - continue - fi - IFS='/' read -r kind pattern count control <<<"${spec}" +for id in ${SCENARIO_IDS[@]+"${SCENARIO_IDS[@]}"}; do + IFS='/' read -r _platform kind pattern count _mode control <<<"${id}" + spec="${kind}/${pattern}/${count}/${control}" # 100k safety: require FULL=1, or an explicit filter that selected this row. if [[ "${count}" == "100000" && "${FULL}" != "1" && -z "${FILTER}" ]]; then @@ -77,7 +65,7 @@ for spec in "${SCENARIOS[@]}"; do continue fi - validate_host_config "${kind}" "${pattern}" "${count}" "${control}" "${SAMPLES}" + node "${PROTOCOL_CLI}" validate "${kind}" "${pattern}" "${count}" "${control}" "${SAMPLES}" echo "=== matrix ${kind}/${pattern}/${count}/interaction/${control} ===" OUT="${OUT_DIR}/android-${kind}-${pattern}-${count}-interaction-${control}.json" \ diff --git a/examples/benchmark-native/scripts/scenario-filter.sh b/examples/benchmark-native/scripts/scenario-filter.sh deleted file mode 100644 index 9abc5b73b546..000000000000 --- a/examples/benchmark-native/scripts/scenario-filter.sh +++ /dev/null @@ -1,19 +0,0 @@ -# Slash-bounded scenario filter shared by run-matrix.sh. -# -# Empty filter matches every spec. One leading slash and one trailing slash -# are removed, so /100000/ matches the 100000 segment only. -# The remaining text must be a contiguous run of spec segments: -# entity/unique/1000 matches entity/unique/1000/gc and not entity/unique/10000/gc. -scenario_filter_matches() { - local spec="$1" - local filter="${2:-}" - if [[ -z "${filter}" ]]; then - return 0 - fi - filter="${filter#/}" - filter="${filter%/}" - if [[ -z "${filter}" ]]; then - return 0 - fi - [[ "/${spec}/" == *"/${filter}/"* ]] -} diff --git a/examples/benchmark-native/scripts/validate-config.sh b/examples/benchmark-native/scripts/validate-config.sh deleted file mode 100644 index 50c5be1df891..000000000000 --- a/examples/benchmark-native/scripts/validate-config.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# Shared host-side validation for collect-report / run-matrix. -# Usage: source scripts/validate-config.sh && validate_host_config KIND PATTERN COUNT CONTROL SAMPLES - -validate_host_config() { - local kind="${1:-}" - local pattern="${2:-}" - local count="${3:-}" - local control="${4:-}" - local samples="${5:-1}" - - case "${kind}" in - entity|endpoint|mixed) ;; - *) - echo "error: invalid CANDIDATE_KIND=${kind}; expected entity|endpoint|mixed" >&2 - return 1 - ;; - esac - - case "${pattern}" in - unique|duplicate) ;; - *) - echo "error: invalid PATTERN=${pattern}; expected unique|duplicate" >&2 - return 1 - ;; - esac - - case "${control}" in - gc|no-gc) ;; - *) - echo "error: invalid CONTROL=${control}; expected gc|no-gc" >&2 - return 1 - ;; - esac - - case "${count}" in - 1000|10000|100000) ;; - *) - echo "error: invalid COUNT=${count}; expected 1000|10000|100000" >&2 - return 1 - ;; - esac - - if [[ "${pattern}" == "duplicate" && "${kind}" != "entity" ]]; then - echo "error: duplicate pattern only supports CANDIDATE_KIND=entity (got ${kind})" >&2 - return 1 - fi - - if ! [[ "${samples}" =~ ^[0-9]+$ ]] || [[ "${samples}" -lt 1 ]] || [[ "${samples}" -gt 50 ]]; then - echo "error: invalid SAMPLES=${samples}; expected integer 1..50" >&2 - return 1 - fi - - return 0 -} diff --git a/examples/benchmark-native/src/frames.ts b/examples/benchmark-native/src/frames.ts index fe2868578a58..3f4c2c79afcd 100644 --- a/examples/benchmark-native/src/frames.ts +++ b/examples/benchmark-native/src/frames.ts @@ -6,24 +6,14 @@ * - Choreographer deltas are *intervals* → round(interval/period) − 1 (same as JS rAF) */ import type { UiFrameCaptureResult } from './BenchNative'; +import { frameIntervalsFromTimestamps } from '../../gc-shared/frames.js'; -export function median(values: number[]): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 - ? (sorted[mid - 1] + sorted[mid]) / 2 - : sorted[mid]; -} - -/** Adjacent differences of rAF timestamps (ms). */ -export function frameIntervalsFromTimestamps(timestampsMs: number[]): number[] { - const intervals: number[] = []; - for (let i = 1; i < timestampsMs.length; i++) { - intervals.push(timestampsMs[i] - timestampsMs[i - 1]); - } - return intervals; -} +export { + computeMaxInputDelayMs, + frameIntervalsFromTimestamps, + measureDisplayPeriodMs, + median, +} from '../../gc-shared/frames.js'; /** * FrameMetrics TOTAL_DURATION → excess missed frames. @@ -72,40 +62,6 @@ export function missedFramesFromTimestamps( ); } -export function computeMaxInputDelayMs( - timerDelayMs: number, - frameIntervalsMs: number[], - displayPeriodMs: number, -): number { - let maxExcessFrame = 0; - if (displayPeriodMs > 0) { - for (const interval of frameIntervalsMs) { - maxExcessFrame = Math.max( - maxExcessFrame, - Math.max(0, interval - displayPeriodMs), - ); - } - } - return Math.max(timerDelayMs, maxExcessFrame); -} - -export async function measureDisplayPeriodMs(samples = 8): Promise { - const intervals: number[] = []; - await new Promise(resolve => { - let last = 0; - let n = 0; - const frame = (now: number) => { - if (n > 0) intervals.push(now - last); - last = now; - n++; - if (n <= samples) requestAnimationFrame(frame); - else resolve(); - }; - requestAnimationFrame(frame); - }); - return median(intervals); -} - /** * Reject invalid refresh period or zero/insufficient native frames. * Missed-frame math must match capture source semantics. diff --git a/examples/benchmark-native/src/gcHarness.ts b/examples/benchmark-native/src/gcHarness.ts index eb72a1840604..b0576199fcec 100644 --- a/examples/benchmark-native/src/gcHarness.ts +++ b/examples/benchmark-native/src/gcHarness.ts @@ -3,7 +3,8 @@ * * Builds a benchmark-only Controller + createReducer + timerless GCPolicy with * raw deterministic state, queues via createCountRef, mode=end-to-end. - * Setup/queue outside timing. Same axes as Node/browser. + * Setup/queue outside timing. Fixture shared with the Node/browser harnesses + * (examples/gc-shared). */ import { Controller, @@ -12,285 +13,29 @@ import { createReducer, initialState, } from '@data-client/core'; -import type { State } from '@data-client/core'; -import { splitMixedCount } from './scenario'; import type { GCAndroidMeasurement, GCScenarioConfig } from './types'; +import { createGCFixture } from '../../gc-shared/gc-fixture.js'; +import type { Harness } from '../../gc-shared/gc-fixture.js'; -const { GC } = actionTypes; +export type { ExpectedScalars, Harness } from '../../gc-shared/gc-fixture.js'; -type EntityPath = { key: string; pk: string }; - -export const ENTITY_KEY = 'BenchEntity'; -export const ZERO_META = Object.freeze({ - date: 0, - fetchedAt: 0, - expiresAt: 0, +const fixture = createGCFixture({ + GCPolicy, + Controller, + createReducer, + initialState, + GC: actionTypes.GC, }); -/** Timerless explicit policy: no intervals; public sweep → protected runSweep. */ -class AndroidBenchmarkGCPolicy extends GCPolicy { - constructor() { - super({ expiresAt: () => 0 }); - } - - init(controller: Controller) { - this.controller = controller; - } - - cleanup(): void { - // Timerless policy — nothing to clear. - } - - sweep() { - this.runSweep(); - } - - get entityQueueLength() { - return this.entitiesQ.length; - } - - get endpointQueueSize() { - return this.endpointsQ.size; - } - - get queueEntries() { - return this.entityQueueLength + this.endpointQueueSize; - } -} - -export interface ExpectedScalars { - queueEntries: number; - uniqueTargets: number; - expectedEntitiesInAction: number; - expectedEndpointsInAction: number; - expectedUniqueEntityDeletions: number; - expectedEndpointDeletions: number; -} - -export interface Harness { - policy: AndroidBenchmarkGCPolicy; - expected: ExpectedScalars; - getState: () => State; - getCapturedAction: () => { - type: string; - entities: EntityPath[]; - endpoints: string[]; - } | null; - clearCapturedAction: () => void; - dispose: () => void; -} - -function entityPath(pk: number): EntityPath { - return { key: ENTITY_KEY, pk: String(pk) }; -} - -function endpointKey(i: number) { - return `bench-endpoint-${i}`; -} - -function buildEntityState(count: number): State { - const entities = { [ENTITY_KEY]: {} as Record }; - const entitiesMeta = { - [ENTITY_KEY]: {} as Record, - }; - for (let i = 0; i < count; i++) { - const pk = String(i); - entities[ENTITY_KEY][pk] = { id: pk }; - entitiesMeta[ENTITY_KEY][pk] = { ...ZERO_META }; - } - return { ...initialState, entities, entitiesMeta }; -} - -function buildEndpointState(count: number): State { - const endpoints: Record = {}; - const meta: Record = {}; - for (let i = 0; i < count; i++) { - const key = endpointKey(i); - endpoints[key] = String(i); - meta[key] = { ...ZERO_META }; - } - return { ...initialState, endpoints, meta }; -} - -function buildMixedState( - entityCount: number, - endpointCount: number, -): State { - const entities = buildEntityState(entityCount); - const endpoints = buildEndpointState(endpointCount); - return { - ...entities, - endpoints: endpoints.endpoints, - meta: endpoints.meta, - }; -} - -function buildStateForSpec(config: GCScenarioConfig): State { - const { candidateKind, pattern, count } = config; - if (pattern === 'duplicate') return buildEntityState(1); - if (candidateKind === 'entity') return buildEntityState(count); - if (candidateKind === 'endpoint') return buildEndpointState(count); - if (candidateKind === 'mixed') { - const { entities, endpoints } = splitMixedCount(count); - return buildMixedState(entities, endpoints); - } - throw new Error(`unknown candidateKind: ${candidateKind}`); -} - -export function queueCandidates( - policy: AndroidBenchmarkGCPolicy, - config: GCScenarioConfig, -): ExpectedScalars { - const { candidateKind, pattern, count } = config; - - if (pattern === 'duplicate') { - if (candidateKind !== 'entity') { - throw new Error( - `duplicate pattern only supports candidateKind=entity (got ${candidateKind})`, - ); - } - const path = entityPath(0); - const countRef = policy.createCountRef({ paths: [path] }); - for (let i = 0; i < count; i++) { - const release = countRef(); - release(); - } - return { - queueEntries: count, - uniqueTargets: 1, - expectedEntitiesInAction: count, - expectedEndpointsInAction: 0, - expectedUniqueEntityDeletions: 1, - expectedEndpointDeletions: 0, - }; - } - - if (candidateKind === 'entity') { - for (let i = 0; i < count; i++) { - const release = policy.createCountRef({ paths: [entityPath(i)] })(); - release(); - } - return { - queueEntries: count, - uniqueTargets: count, - expectedEntitiesInAction: count, - expectedEndpointsInAction: 0, - expectedUniqueEntityDeletions: count, - expectedEndpointDeletions: 0, - }; - } - - if (candidateKind === 'endpoint') { - for (let i = 0; i < count; i++) { - const release = policy.createCountRef({ key: endpointKey(i) })(); - release(); - } - return { - queueEntries: count, - uniqueTargets: count, - expectedEntitiesInAction: 0, - expectedEndpointsInAction: count, - expectedUniqueEntityDeletions: 0, - expectedEndpointDeletions: count, - }; - } - - if (candidateKind === 'mixed') { - const { entities, endpoints } = splitMixedCount(count); - for (let i = 0; i < entities; i++) { - const release = policy.createCountRef({ paths: [entityPath(i)] })(); - release(); - } - for (let i = 0; i < endpoints; i++) { - const release = policy.createCountRef({ key: endpointKey(i) })(); - release(); - } - return { - queueEntries: count, - uniqueTargets: count, - expectedEntitiesInAction: entities, - expectedEndpointsInAction: endpoints, - expectedUniqueEntityDeletions: entities, - expectedEndpointDeletions: endpoints, - }; - } - - throw new Error(`unknown candidateKind: ${candidateKind}`); -} +export const { ENTITY_KEY, ZERO_META, countRemaining, GC } = fixture; /** End-to-end harness: sweep dispatches and reduces synchronously. */ export function createHarness(config: GCScenarioConfig): Harness { - const state = buildStateForSpec(config); - const policy = new AndroidBenchmarkGCPolicy(); - const controller = new Controller({ gcPolicy: policy }); - const reducer = createReducer(controller); - - let capturedAction: { - type: string; - entities: EntityPath[]; - endpoints: string[]; - } | null = null; - let workingState: State = state; - - controller.getState = () => workingState; - controller.dispatch = ((action: any) => { - capturedAction = action; - workingState = reducer(workingState, action); - }) as typeof controller.dispatch; - - policy.init(controller); - const expected = queueCandidates(policy, config); - - if (policy.queueEntries !== expected.queueEntries) { - throw new Error( - `fixture queue cardinality ${policy.queueEntries} !== expected ${expected.queueEntries}`, - ); - } - - return { - policy, - expected, - getState: () => workingState, - getCapturedAction: () => capturedAction, - clearCapturedAction() { - capturedAction = null; - }, - dispose() { - capturedAction = null; - // Release the full store held by this scope - workingState = initialState; - controller.getState = () => initialState; - controller.dispatch = (() => - Promise.resolve()) as typeof controller.dispatch; - policy.cleanup(); - }, - }; -} - -export function countRemaining( - state: State, - config: GCScenarioConfig, - expected: ExpectedScalars, -) { - const bucket = state.entities?.[ENTITY_KEY]; - const entityRemaining = bucket ? Object.keys(bucket).length : 0; - const endpointRemaining = state.endpoints - ? Object.keys(state.endpoints).length - : 0; - - const startedEntities = - config.pattern === 'duplicate' ? 1 : expected.expectedUniqueEntityDeletions; - const startedEndpoints = expected.expectedEndpointDeletions; - - return { - entityDeleted: startedEntities - entityRemaining, - endpointDeleted: startedEndpoints - endpointRemaining, - entityRemaining, - endpointRemaining, - }; + return fixture.createHarness(config, 'end-to-end'); } +/** Throws when an accepted sample disagrees with fixture expectations. */ export function validateMeasurement( config: GCScenarioConfig, harness: Harness, @@ -302,83 +47,6 @@ export function validateMeasurement( | 'uniqueTargets' | 'actionTargetCount' >, -) { - const { expected, policy, getCapturedAction, getState } = harness; - - if (config.control === 'no-gc') { - if (sample.actionCount !== 0) { - throw new Error( - `no-gc control expected actionCount 0, got ${sample.actionCount}`, - ); - } - if (sample.deletionCount !== 0) { - throw new Error( - `no-gc control expected deletionCount 0, got ${sample.deletionCount}`, - ); - } - return; - } - - if (sample.queueEntries !== expected.queueEntries) { - throw new Error( - `queueEntries ${sample.queueEntries} !== expected ${expected.queueEntries}`, - ); - } - if (sample.uniqueTargets !== expected.uniqueTargets) { - throw new Error( - `uniqueTargets ${sample.uniqueTargets} !== expected ${expected.uniqueTargets}`, - ); - } - - const actionTargetCount = - expected.expectedEntitiesInAction + expected.expectedEndpointsInAction; - if (sample.actionTargetCount !== actionTargetCount) { - throw new Error( - `actionTargetCount ${sample.actionTargetCount} !== expected ${actionTargetCount}`, - ); - } - - const action = getCapturedAction(); - if (!action || action.type !== GC) { - throw new Error('expected a GC action to be dispatched'); - } - if (action.entities.length !== expected.expectedEntitiesInAction) { - throw new Error( - `action.entities.length ${action.entities.length} !== ${expected.expectedEntitiesInAction}`, - ); - } - if (action.endpoints.length !== expected.expectedEndpointsInAction) { - throw new Error( - `action.endpoints.length ${action.endpoints.length} !== ${expected.expectedEndpointsInAction}`, - ); - } - if (config.pattern === 'duplicate') { - const unique = new Set(action.entities.map(p => `${p.key}:${p.pk}`)); - if (unique.size !== 1) { - throw new Error( - `duplicate pattern expected 1 unique entity path in action, got ${unique.size}`, - ); - } - } - - const expectedDeletions = - expected.expectedUniqueEntityDeletions + expected.expectedEndpointDeletions; - if (sample.deletionCount !== expectedDeletions) { - throw new Error( - `deletionCount ${sample.deletionCount} !== expected ${expectedDeletions}`, - ); - } - const remaining = countRemaining(getState(), config, expected); - if (remaining.entityRemaining !== 0 || remaining.endpointRemaining !== 0) { - throw new Error( - `expected empty GC targets after deletion; remaining entities=${remaining.entityRemaining} endpoints=${remaining.endpointRemaining}`, - ); - } - if (policy.queueEntries !== 0) { - throw new Error( - `expected empty queues after sweep, got ${policy.queueEntries}`, - ); - } +): void { + fixture.validateSample(config, harness, sample, 'end-to-end', config.control); } - -export { GC }; diff --git a/examples/benchmark-native/src/report.ts b/examples/benchmark-native/src/report.ts index 8fde0d627113..f5571e6f4be7 100644 --- a/examples/benchmark-native/src/report.ts +++ b/examples/benchmark-native/src/report.ts @@ -9,34 +9,9 @@ import type { GCScenarioReport, NumberSummary, } from './types'; +import { summarizeNumbers } from '../../gc-shared/stats.js'; -/** Caller must pass a non-empty sorted array. */ -function percentile(sorted: number[], p: number): number { - if (sorted.length === 1) return sorted[0]!; - const idx = (p / 100) * (sorted.length - 1); - const lo = Math.floor(idx); - const hi = Math.ceil(idx); - if (lo === hi) return sorted[lo]!; - const w = idx - lo; - return sorted[lo]! * (1 - w) + sorted[hi]! * w; -} - -export function summarizeNumbers(values: number[]): NumberSummary | null { - if (values.length === 0) return null; - const sorted = values.slice().sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - const median = - sorted.length % 2 === 0 - ? (sorted[mid - 1]! + sorted[mid]!) / 2 - : sorted[mid]!; - return { - median, - min: sorted[0]!, - max: sorted[sorted.length - 1]!, - p95: percentile(sorted, 95), - p99: percentile(sorted, 99), - }; -} +export { summarizeNumbers }; function optionalField( samples: GCAndroidMeasurement[], diff --git a/examples/benchmark-native/src/scenario.ts b/examples/benchmark-native/src/scenario.ts index 7f540b2b9c83..b62479e771a1 100644 --- a/examples/benchmark-native/src/scenario.ts +++ b/examples/benchmark-native/src/scenario.ts @@ -4,8 +4,9 @@ import type { GCScenarioConfig, Pattern, } from './types'; -import { CANONICAL_COUNTS } from './types'; -import { validateScenarioConfig } from './validateConfig'; +import { scenarioId as sharedScenarioId } from '../../gc-shared/protocol.js'; + +export { splitMixedCount } from '../../gc-shared/protocol.js'; export interface ScenarioAxes { platform: 'android'; @@ -16,104 +17,18 @@ export interface ScenarioAxes { control: Control; } -/** Stable scenario ID: `android/{kind}/{pattern}/{count}/interaction/{control}`. */ -export function scenarioId(axes: ScenarioAxes | GCScenarioConfig): string { - return [ - 'android', - axes.candidateKind, - axes.pattern, - String(axes.count), - 'interaction', - axes.control, - ].join('/'); -} - /** - * Split mixed total into entity + endpoint counts (entities get the remainder - * when odd so entityCount + endpointCount === total). + * Stable scenario ID: `android/{kind}/{pattern}/{count}/interaction/{control}`. + * Throws on invalid axes. Listing/filtering lives in the shared protocol + * (`examples/gc-shared/protocol-cli.js list android`, used by run-matrix.sh). */ -export function splitMixedCount(total: number): { - entities: number; - endpoints: number; -} { - const endpoints = Math.floor(total / 2); - return { entities: total - endpoints, endpoints }; -} - -export function parseScenarioId(id: string): ScenarioAxes { - const parts = id.split('/'); - if ( - parts.length !== 6 || - parts[0] !== 'android' || - parts[4] !== 'interaction' - ) { - throw new Error(`invalid android scenario id: ${id}`); - } - const config: GCScenarioConfig = { - candidateKind: parts[1] as CandidateKind, - pattern: parts[2] as Pattern, - count: Number(parts[3]), - control: parts[5] as Control, - }; - validateScenarioConfig(config); - return { +export function scenarioId(axes: ScenarioAxes | GCScenarioConfig): string { + return sharedScenarioId({ platform: 'android', - ...config, + candidateKind: axes.candidateKind, + pattern: axes.pattern, + count: axes.count, mode: 'interaction', - }; -} - -export function listScenarios(filter?: string): ScenarioAxes[] { - const kinds: CandidateKind[] = ['entity', 'endpoint', 'mixed']; - const controls: Control[] = ['gc', 'no-gc']; - const out: ScenarioAxes[] = []; - - for (const candidateKind of kinds) { - for (const count of CANONICAL_COUNTS) { - for (const control of controls) { - out.push({ - platform: 'android', - candidateKind, - pattern: 'unique', - count, - mode: 'interaction', - control, - }); - } - } - } - - for (const count of CANONICAL_COUNTS) { - for (const control of controls) { - out.push({ - platform: 'android', - candidateKind: 'entity', - pattern: 'duplicate', - count, - mode: 'interaction', - control, - }); - } - } - - if (!filter) return out; - const prefix = filter.startsWith('^'); - const needle = prefix ? filter.slice(1) : filter; - return out.filter(s => { - const id = scenarioId(s); - return prefix ? id.startsWith(needle) : slashBoundedIncludes(id, needle); + control: axes.control, }); } - -/** - * Contiguous slash-bounded segments. One leading slash and one trailing - * slash are removed, so `/100000/` matches the 100000 segment only. - * `1000` does not match `10000` or `100000`. - */ -function slashBoundedIncludes(id: string, filter: string): boolean { - let needle = filter; - if (needle.startsWith('/')) needle = needle.slice(1); - if (needle.endsWith('/')) needle = needle.slice(0, -1); - if (needle.length === 0) return true; - return `/${id}/`.includes(`/${needle}/`); -} diff --git a/examples/benchmark-native/src/types.ts b/examples/benchmark-native/src/types.ts index f8d64e4867ee..7f71eac2d3e2 100644 --- a/examples/benchmark-native/src/types.ts +++ b/examples/benchmark-native/src/types.ts @@ -1,7 +1,9 @@ /** * Local schemaVersion 1 types mirroring the documented GC measurement protocol. - * No shared runtime package — semantic compatibility only. + * Axes, ids, fixture, and stats are shared via examples/gc-shared. */ +import { CANONICAL_COUNTS } from '../../gc-shared/protocol.js'; +import type { NumberSummary } from '../../gc-shared/stats.js'; export type CandidateKind = 'entity' | 'endpoint' | 'mixed'; export type Pattern = 'unique' | 'duplicate'; @@ -9,8 +11,8 @@ export type Control = 'gc' | 'no-gc'; export type Mode = 'interaction'; export type UiCaptureSource = 'FrameMetrics' | 'Choreographer'; -/** Canonical scenario counts. */ -export const CANONICAL_COUNTS = [1_000, 10_000, 100_000] as const; +/** Canonical scenario counts (shared protocol, examples/gc-shared). */ +export { CANONICAL_COUNTS }; export type CanonicalCount = (typeof CANONICAL_COUNTS)[number]; /** Axes for Android GC scenarios. */ @@ -67,13 +69,7 @@ export interface GCAndroidMeasurement { jsHeapDeltaBytes?: number; } -export interface NumberSummary { - median: number; - min: number; - max: number; - p95: number; - p99: number; -} +export type { NumberSummary }; export interface GCScenarioReport { id: string; diff --git a/examples/benchmark-native/src/validateConfig.ts b/examples/benchmark-native/src/validateConfig.ts index 9d58a66420cf..978af5ee5334 100644 --- a/examples/benchmark-native/src/validateConfig.ts +++ b/examples/benchmark-native/src/validateConfig.ts @@ -1,24 +1,22 @@ -import { - CANONICAL_COUNTS, - type CandidateKind, - type Control, - type GCScenarioConfig, - type LaunchConfig, - type Pattern, +import type { + CandidateKind, + Control, + GCScenarioConfig, + LaunchConfig, + Pattern, } from './types'; - -export const CANDIDATE_KINDS: readonly CandidateKind[] = [ - 'entity', - 'endpoint', - 'mixed', -] as const; - -export const PATTERNS: readonly Pattern[] = ['unique', 'duplicate'] as const; - -export const CONTROLS: readonly Control[] = ['gc', 'no-gc'] as const; - -/** Positive sample counts capped to keep accidental huge runs from launching. */ -export const MAX_SAMPLES = 50; +import { + MAX_SAMPLES, + validateAxes, + validateSampleCount as sharedValidateSampleCount, +} from '../../gc-shared/protocol.js'; + +export { + CANDIDATE_KINDS, + CONTROLS, + MAX_SAMPLES, + PATTERNS, +} from '../../gc-shared/protocol.js'; export class ConfigValidationError extends Error { constructor(message: string) { @@ -27,8 +25,13 @@ export class ConfigValidationError extends Error { } } -function isCanonicalCount(count: number): boolean { - return (CANONICAL_COUNTS as readonly number[]).includes(count); +/** Rethrow shared-protocol validation failures as ConfigValidationError. */ +function asConfigError(validate: () => void): void { + try { + validate(); + } catch (err) { + throw new ConfigValidationError((err as Error).message); + } } /** @@ -36,44 +39,11 @@ function isCanonicalCount(count: number): boolean { * Does not coerce — callers must pass already-parsed values or use parseLaunchConfig. */ export function validateScenarioConfig(config: GCScenarioConfig): void { - if (!CANDIDATE_KINDS.includes(config.candidateKind)) { - throw new ConfigValidationError( - `invalid candidateKind=${String(config.candidateKind)}; expected one of ${CANDIDATE_KINDS.join('|')}`, - ); - } - if (!PATTERNS.includes(config.pattern)) { - throw new ConfigValidationError( - `invalid pattern=${String(config.pattern)}; expected one of ${PATTERNS.join('|')}`, - ); - } - if (!CONTROLS.includes(config.control)) { - throw new ConfigValidationError( - `invalid control=${String(config.control)}; expected one of ${CONTROLS.join('|')}`, - ); - } - if (!Number.isInteger(config.count) || !isCanonicalCount(config.count)) { - throw new ConfigValidationError( - `invalid count=${String(config.count)}; expected one of ${CANONICAL_COUNTS.join('|')}`, - ); - } - if (config.pattern === 'duplicate' && config.candidateKind !== 'entity') { - throw new ConfigValidationError( - `duplicate pattern only supports candidateKind=entity (got ${config.candidateKind})`, - ); - } + asConfigError(() => validateAxes(config)); } export function validateSampleCount(samples: number): void { - if ( - !Number.isInteger(samples) || - samples < 1 || - samples > MAX_SAMPLES || - !Number.isFinite(samples) - ) { - throw new ConfigValidationError( - `invalid samples=${String(samples)}; expected integer 1..${MAX_SAMPLES}`, - ); - } + asConfigError(() => sharedValidateSampleCount(samples)); } export function validateLaunchConfig(config: LaunchConfig): void { @@ -155,28 +125,3 @@ export function parseLaunchConfig(raw: { validateLaunchConfig(config); return config; } - -/** Host-env shape used by collect-report / matrix (strings from the shell). */ -export function parseHostEnvConfig(env: { - candidateKind?: string; - pattern?: string; - count?: string | number; - control?: string; - samples?: string | number; -}): GCScenarioConfig & { samples: number } { - const parsed = parseLaunchConfig({ - autoRun: false, - candidateKind: env.candidateKind, - pattern: env.pattern, - count: env.count, - control: env.control, - samples: env.samples, - }); - return { - candidateKind: parsed.candidateKind, - pattern: parsed.pattern, - count: parsed.count, - control: parsed.control, - samples: parsed.samples, - }; -} From 7e884abadeb007b460f8839ec46535f44d977f5c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 13:52:01 -0400 Subject: [PATCH 24/32] refactor(benchmark-react): Split browser GC into a dedicated bench:gc runner bench/gc-runner.ts owns GC: provenance check, per-sample fresh page load (setupBenchPage), prepare/run/dispose with heap snapshots, and the detailed report. Scenarios come from the shared protocol and --scenario matches slash-bounded stable-id segments (or ^prefix). Exits 1 when incomplete. runner.ts loses every GC branch (category 'gc', resultMetric 'totalMs', --action gc, --samples, GC provenance/report imports); page and Chromium plumbing moves to bench/browser.ts for both runners. The hot-path stdout JSON no longer carries GC totalMs; the detailed GC JSON is unchanged. Co-Authored-By: Claude Opus 5.5 --- examples/benchmark-react/AGENTS.md | 6 +- examples/benchmark-react/README.md | 25 +- examples/benchmark-react/bench/browser.ts | 154 ++++++ examples/benchmark-react/bench/gc-runner.ts | 329 +++++++++++++ examples/benchmark-react/bench/runner.ts | 488 +------------------ examples/benchmark-react/bench/scenarios.ts | 53 -- examples/benchmark-react/package.json | 2 +- examples/benchmark-react/src/shared/types.ts | 10 +- plans/garbage-collection.md | 9 +- 9 files changed, 529 insertions(+), 547 deletions(-) create mode 100644 examples/benchmark-react/bench/browser.ts create mode 100644 examples/benchmark-react/bench/gc-runner.ts diff --git a/examples/benchmark-react/AGENTS.md b/examples/benchmark-react/AGENTS.md index 62d305e7784c..f643b09ad034 100644 --- a/examples/benchmark-react/AGENTS.md +++ b/examples/benchmark-react/AGENTS.md @@ -32,7 +32,7 @@ Filtering: `yarn bench --lib data-client --size small --action update` ## Scenario System -`BASE_SCENARIOS` in `bench/scenarios.ts` × `LIBRARIES` via `flatMap`. `onlyLibs` restricts to specific libs. CI runs data-client hot-path only (no memory/startup/deterministic/gc). Memory and GC are opt-in locally (`--action memory` / `--action gc` / `yarn bench:gc`). Convergent timing uses single page load with adaptive iterations and early stopping on statistical convergence. Ref-stability scenarios run once (deterministic count, not ops/s). GC uses a dedicated fixed-sample phase with an isolated Controller harness (`src/data-client/gcBrowserHarness.ts`), not the DataProvider `benchGC` singleton. +`BASE_SCENARIOS` in `bench/scenarios.ts` × `LIBRARIES` via `flatMap`. `onlyLibs` restricts to specific libs. CI runs data-client hot-path only (no memory/startup/deterministic). Memory is opt-in locally (`--action memory`). Convergent timing uses single page load with adaptive iterations and early stopping on statistical convergence. Ref-stability scenarios run once (deterministic count, not ops/s). Cache GC is not a `Scenario`: `yarn bench:gc` runs `bench/gc-runner.ts`, a fixed-sample runner over the shared protocol (`examples/gc-shared`) that drives an isolated Controller harness (`src/data-client/gcBrowserHarness.ts`, lazy chunk), not the DataProvider `benchGC` singleton. `bench/browser.ts` holds page/Chromium plumbing shared by both runners. `--scenario` is a display-name substring for `yarn bench` but slash-bounded stable-id segments for `yarn bench:gc`. ## Update Data Flow @@ -75,7 +75,7 @@ Filtering: `yarn bench --lib data-client --size small --action update` | `BENCH_TRACE=true` | Chrome tracing for duration scenarios | | `BENCH_V8_TRACE=true` | Launch Chromium with `--trace-opt --trace-deopt`; output to `v8-trace.log` | | `BENCH_V8_DEOPT=true` | Launch Chromium with `--prof`; V8 logs to `v8-logs/` | -| `BENCH_GC_SAMPLES` | Samples per GC scenario (default 5; also `--samples`) | -| `BENCH_GC_OUTPUT` | Override path for detailed GC JSON (default `gc-measurement-output.json`, gitignored) | +| `BENCH_GC_SAMPLES` | `bench:gc` samples per GC scenario (default 5; also `--samples`) | +| `BENCH_GC_OUTPUT` | `bench:gc` detailed GC JSON path (default `gc-measurement-output.json`, gitignored) | BuildManifest v1 is written to `dist/gc-build-manifest.json` after webpack (`yarn build`). `buildId` digests schemaVersion, commit, dirty, sourceDigest, and sorted artifact hashes. GC runs require a matching served manifest. diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index e81081b8db5b..4f2d30026034 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -30,7 +30,7 @@ All implementations share presentational components, fixture data, fetch functio - **With network (local)** — Same shared-author update but with simulated network delay (consistent ms per "request"). Normalized caches propagate via a single store update; query-keyed caches invalidate and refetch affected queries. **Not run in CI** — run locally with `yarn bench` (no `CI` env) to include these. - **Memory (local only)** — Heap delta after repeated mount/unmount cycles. - **Startup (local only)** — FCP and task duration via CDP `Performance.getMetrics`. -- **GC (local only)** — Isolated data-client cache GC interaction + heap deltas on Chromium. Excluded by default and from CI; opt in with `--action gc`. +- **GC (local only)** — Isolated data-client cache GC interaction + heap deltas on Chromium. Separate runner (`yarn bench:gc`, `bench/gc-runner.ts`); never part of `yarn bench` or CI. ## Scenarios @@ -64,14 +64,15 @@ Isolated monolithic cache-GC baseline (not V8/engine GC). Builds a separate Cont - **Failures:** incomplete runs write `complete:false` with failure records and exit nonzero - **Bundle fairness:** GC harness is a dynamic `import()` chunk loaded during prepare — not in the initial data-client bundle - **Provenance:** `yarn build` writes `dist/gc-build-manifest.json` (BuildManifest v1). GC runner verifies local source digest + artifact hashes and matching served manifest `buildId` before measuring; report embeds verified provenance (never live HEAD) -- **Outputs:** scalar median `totalMs` on stdout. Detailed schemaVersion 1 JSON is always written to `gc-measurement-output.json` (gitignored; override path with `BENCH_GC_OUTPUT`). Failed runs still write `complete:false` reports before exiting nonzero +- **Outputs:** per-scenario medians on stderr; nothing on stdout. Detailed schemaVersion 1 JSON is always written to `gc-measurement-output.json` (gitignored; override path with `BENCH_GC_OUTPUT`). Failed runs still write `complete:false` reports before exiting nonzero +- **Shared code:** fixture, axes/ids/filter, frame helpers, and stats come from [`examples/gc-shared`](../gc-shared/README.md), shared with the Node and Android GC harnesses ```bash yarn build # webpack + BuildManifest yarn bench:gc # all GC scenarios, 5 samples each -yarn bench:gc --scenario unique-1000 --samples 3 -yarn bench:gc --scenario entity-unique-100000 -BENCH_GC_OUTPUT=./custom-gc.json yarn bench:gc --scenario mixed-unique-100000 +yarn bench:gc --scenario /1000/ --samples 3 # 1k only (not 10k/100k) +yarn bench:gc --scenario entity/unique/100000 +BENCH_GC_OUTPUT=./custom-gc.json yarn bench:gc --scenario mixed/unique/100000 yarn test:gc-metrics yarn test:gc-provenance # after build ``` @@ -195,9 +196,15 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar |---|---|---| | `--lib ` | `BENCH_LIB` | Comma-separated library names (e.g. `data-client,swr`) | | `--size ` | `BENCH_SIZE` | Run only `small` (cheap, full rigor) or `large` (expensive, reduced runs) scenarios | - | `--action ` | `BENCH_ACTION` | Filter by action group (`mount`, `update`, `mutation`, `memory`, `gc`) or exact action name. Memory and GC are **not run by default**; use `--action memory` or `--action gc` to include. | + | `--action ` | `BENCH_ACTION` | Filter by action group (`mount`, `update`, `mutation`, `memory`) or exact action name. Memory is **not run by default**; use `--action memory` to include. | | `--scenario ` | `BENCH_SCENARIO` | Substring filter on scenario name | - | `--samples ` | `BENCH_GC_SAMPLES` | Samples per GC scenario (default 5; GC phase only) | + + `yarn bench:gc` (`bench/gc-runner.ts`) takes its own flags: + + | CLI flag | Env var | Description | + |---|---|---| + | `--scenario ` | `BENCH_SCENARIO` | Slash-bounded segments of the stable id `browser/{kind}/{pattern}/{count}/end-to-end/{control}` (e.g. `100000`, `entity/unique/100000`, `/1000/`), or `^prefix`. Same syntax as the Node/Android GC harnesses; unlike `yarn bench`, not a substring match. | + | `--samples ` | `BENCH_GC_SAMPLES` | Samples per GC scenario (default 5) | | (env only) | `BENCH_GC_OUTPUT` | Override detailed GC report path (default `gc-measurement-output.json`) | CLI flags take precedence over env vars. Examples: @@ -207,7 +214,6 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar yarn bench --size small # only cheap scenarios (full warmup/measurement) yarn bench --action mount # init, mountSortedView yarn bench --action memory # memory-mount-unmount-cycle (heap delta; opt-in category) - yarn bench --action gc # isolated cache GC scenarios (data-client; opt-in) yarn bench --action update --lib swr # update scenarios for swr only yarn bench --scenario sorted-view # only sorted-view scenarios ``` @@ -218,7 +224,7 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar yarn bench:small # --size small yarn bench:large # --size large yarn bench:dc # --lib data-client - yarn bench:gc # --lib data-client --action gc + yarn bench:gc # isolated cache GC (bench/gc-runner.ts; data-client only) ``` 5. **Scenario sizes** @@ -229,7 +235,6 @@ CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported mar - **Small** (deterministic, single run): `ref-stability-*` - **Large** (convergent: 5 warmup + 10–50 measurement iterations): `getlist-500`, `getlist-500-sorted`, `update-user`, `update-user-10000`, `update-entity-sorted`, `update-entity-multi-view`, `list-detail-switch-10` - **Memory** (opt-in, 1 warmup + 3 measurement rounds): `memory-mount-unmount-cycle` — run with `--action memory` - - **GC** (opt-in, fixed samples default 5): `gc-*-{1000,10000,100000}-{gc,no-gc}` — run with `yarn bench:gc` or `--action gc` Timing scenarios use convergent mode (single page load, inline convergence per scenario). Each group uses its own warmup/measurement config. Use `--size` to run only one group. diff --git a/examples/benchmark-react/bench/browser.ts b/examples/benchmark-react/bench/browser.ts new file mode 100644 index 000000000000..280e66ebf11e --- /dev/null +++ b/examples/benchmark-react/bench/browser.ts @@ -0,0 +1,154 @@ +/// +/** + * Browser/page plumbing shared by the hot-path runner (runner.ts) and the GC + * runner (gc-runner.ts): served app URL, Chromium launch flags, and per-load + * bench page setup. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { chromium } from 'playwright'; +import type { Browser, BrowserServer, Locator, Page } from 'playwright'; + +import { NETWORK_SIM_CONFIG } from './scenarios.js'; +import type { Scenario } from '../src/shared/types.js'; + +export const BASE_URL = + process.env.BENCH_BASE_URL ?? + `http://localhost:${process.env.BENCH_PORT ?? '5173'}`; +const BENCH_V8_TRACE = process.env.BENCH_V8_TRACE === 'true'; +export const BENCH_V8_DEOPT = process.env.BENCH_V8_DEOPT === 'true'; +export const V8_LOG_DIR = path.resolve('v8-logs'); + +/** Fresh page load of one library app; waits for `[data-app-ready]` and the harness. */ +export async function setupBenchPage( + page: Page, + lib: string, + scenario: Pick, + networkSim: boolean, +): Promise<{ harness: Locator; bench: any }> { + await page.goto(`${BASE_URL}/${lib}/`, { + waitUntil: 'networkidle', + timeout: 120000, + }); + await page.waitForSelector('[data-app-ready]', { + timeout: 120000, + state: 'attached', + }); + + const harness = page.locator('[data-bench-harness]'); + await harness.waitFor({ state: 'attached' }); + + const bench = await page.evaluateHandle('window.__BENCH__'); + if (await bench.evaluate(b => b == null)) + throw new Error('window.__BENCH__ not found'); + + if (networkSim) { + await (bench as any).evaluate( + (api: any, cfg: { baseLatencyMs: number; recordsPerMs: number }) => + api.setNetworkSim(cfg), + NETWORK_SIM_CONFIG, + ); + } + + if (scenario.renderLimit != null) { + await (bench as any).evaluate( + (api: any, n: number) => api.setRenderLimit(n), + scenario.renderLimit, + ); + } + + return { harness, bench }; +} + +/** Chromium from `launch()` does not expose `process()`; use `launchServer` when piping V8 trace output. */ +export async function launchBenchChromium(): Promise<{ + browser: Browser; + closeBenchBrowser: () => Promise; +}> { + const launchOpts = { + headless: true, + args: buildLaunchArgs(), + }; + + if (BENCH_V8_TRACE) { + const server: BrowserServer = await chromium.launchServer(launchOpts); + let v8TraceStream: fs.WriteStream | undefined; + const proc = server.process(); + if (proc?.stderr ?? proc?.stdout) { + v8TraceStream = fs.createWriteStream('v8-trace.log'); + proc.stderr?.pipe(v8TraceStream, { end: false }); + proc.stdout?.pipe(v8TraceStream, { end: false }); + process.stderr.write( + 'V8 trace output → v8-trace.log (root browser process stderr/stdout)\n', + ); + } else { + process.stderr.write( + 'Warning: BENCH_V8_TRACE but browser server process streams unavailable; v8-trace.log may be empty.\n', + ); + } + const browser = await chromium.connect({ wsEndpoint: server.wsEndpoint() }); + return { + browser, + closeBenchBrowser: async () => { + await browser.close(); + await server.close(); + if (v8TraceStream) { + v8TraceStream.end(); + process.stderr.write( + '\nV8 opt/deopt trace written to v8-trace.log\n', + ); + } + }, + }; + } + + const browser = await chromium.launch(launchOpts); + return { + browser, + closeBenchBrowser: () => browser.close(), + }; +} + +function buildLaunchArgs(): string[] { + const args = [ + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--disable-hang-monitor', + ]; + const jsFlags: string[] = []; + if (BENCH_V8_TRACE) { + jsFlags.push('--trace-opt', '--trace-deopt'); + } + if (BENCH_V8_DEOPT) { + fs.rmSync(V8_LOG_DIR, { recursive: true, force: true }); + fs.mkdirSync(V8_LOG_DIR, { recursive: true }); + jsFlags.push('--prof', `--logfile=${V8_LOG_DIR}/v8-%p.log`); + } + if (jsFlags.length > 0) args.push(`--js-flags=${jsFlags.join(' ')}`); + return args; +} + +export function reportV8Logs(): void { + if (!BENCH_V8_DEOPT) return; + try { + const logs = fs.readdirSync(V8_LOG_DIR).filter(f => f.endsWith('.log')); + if (logs.length === 0) return; + process.stderr.write(`\nV8 profiling logs written to ${V8_LOG_DIR}/:\n`); + for (const log of logs) { + const size = fs.statSync(path.join(V8_LOG_DIR, log)).size; + process.stderr.write(` ${log} (${(size / 1024).toFixed(1)} KB)\n`); + } + const largest = logs.reduce((a, b) => { + const sa = fs.statSync(path.join(V8_LOG_DIR, a)).size; + const sb = fs.statSync(path.join(V8_LOG_DIR, b)).size; + return sa >= sb ? a : b; + }); + process.stderr.write( + `\nProcess the renderer log (typically the largest file) with:\n` + + ` node --prof-process ${V8_LOG_DIR}/${largest}\n\n`, + ); + } catch { + // best-effort reporting + } +} diff --git a/examples/benchmark-react/bench/gc-runner.ts b/examples/benchmark-react/bench/gc-runner.ts new file mode 100644 index 000000000000..8237da1bdeef --- /dev/null +++ b/examples/benchmark-react/bench/gc-runner.ts @@ -0,0 +1,329 @@ +/// +/** + * Browser GC runner (data-client only): isolated cache-GC interaction + * measurement via the lazy gc-browser-harness chunk. Separate from the + * hot-path runner (runner.ts); shares page/browser plumbing (browser.ts) and + * the GC protocol/fixture (examples/gc-shared) with the Node/Android harnesses. + * + * yarn bench:gc [--samples N] [--scenario FILTER] + * + * --samples / BENCH_GC_SAMPLES samples per scenario (default 5) + * --scenario / BENCH_SCENARIO slash-bounded segments of the stable id + * browser/{kind}/{pattern}/{count}/end-to-end/{control}, or ^prefix + * (e.g. `100000`, `entity/unique/100000`, `/1000/`) + * + * Writes the detailed report (BENCH_GC_OUTPUT, default + * gc-measurement-output.json); exits 1 when the report is incomplete. + */ +import * as fs from 'node:fs'; +import type { Browser, CDPSession, Page } from 'playwright'; + +import { BASE_URL, launchBenchChromium, setupBenchPage } from './browser.js'; +import { + MANIFEST_FILENAME, + MANIFEST_PATH, + readBuildManifest, + verifyLocalManifest, + type BuildManifestV1, +} from './build-manifest.js'; +import { + buildGCReport, + scenarioReportFromConfig, + writeGCReport, + type GCFailureRecord, + type GCSampleResult, + type GCScenarioReport, +} from './gc-report.js'; +import { collectHeapUsed } from './memory.js'; +import type { ScenarioDescriptor } from '../../gc-shared/protocol.js'; +import { listScenarios } from '../../gc-shared/protocol.js'; +import type { + GCBrowserMeasurement, + GCScenarioConfig, +} from '../src/shared/types.js'; + +type GCScenarioDescriptor = ScenarioDescriptor & GCScenarioConfig; + +function parseArgs(): { samples: number; filter: string | null } { + const argv = process.argv.slice(2); + const get = (flag: string, envVar: string): string | undefined => { + const idx = argv.indexOf(flag); + if (idx !== -1 && idx + 1 < argv.length) return argv[idx + 1]; + return process.env[envVar] || undefined; + }; + + const samplesRaw = get('--samples', 'BENCH_GC_SAMPLES') ?? '5'; + const samples = Number.parseInt(samplesRaw, 10); + if (!Number.isFinite(samples) || !Number.isInteger(samples) || samples < 1) { + throw new Error( + `invalid --samples / BENCH_GC_SAMPLES=${JSON.stringify(samplesRaw)}; expected integer ≥ 1`, + ); + } + return { samples, filter: get('--scenario', 'BENCH_SCENARIO') ?? null }; +} + +async function settlePage(page: Page): Promise { + await page.evaluate( + () => + new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ), + ); + await page.waitForTimeout(50); +} + +async function verifyGCBuildProvenance( + baseUrl: string, +): Promise { + if (!fs.existsSync(MANIFEST_PATH)) { + throw new Error( + `Missing ${MANIFEST_FILENAME}. Run yarn build (writes BuildManifest v1 after webpack).`, + ); + } + const local = readBuildManifest(); + verifyLocalManifest(local); + + const servedUrl = `${baseUrl.replace(/\/$/, '')}/${MANIFEST_FILENAME}`; + const res = await fetch(servedUrl); + if (!res.ok) { + throw new Error( + `Failed to fetch served ${MANIFEST_FILENAME} from ${servedUrl}: ${res.status}`, + ); + } + const served = (await res.json()) as BuildManifestV1; + if (served.schemaVersion !== 1) { + throw new Error(`served manifest schemaVersion ${served.schemaVersion}`); + } + if (served.buildId !== local.buildId) { + throw new Error( + `served manifest buildId mismatch (local ${local.buildId.slice(0, 12)}… vs served ${served.buildId.slice(0, 12)}…). Restart preview after rebuild.`, + ); + } + process.stderr.write( + `GC provenance OK buildId=${local.buildId.slice(0, 16)}… commit=${local.commit.slice(0, 8)} dirty=${local.dirty}\n`, + ); + return { ...local, servedManifestBuildId: served.buildId }; +} + +/** + * Dedicated GC phase (not the generic convergent/update path). + * Per sample: prepare → engine GC + heapBefore → run → settle → engine GC + + * heapAfter (store live) → dispose. Never force engine GC inside interaction timing. + * Failures are accumulated; report is always written with complete true/false; + * returns { reports, complete } and caller must exit nonzero when incomplete. + */ +async function runGCScenarioSample( + page: Page, + bench: any, + config: GCScenarioConfig, + cdp: CDPSession, +): Promise { + await (bench as any).evaluate(async (api: any, cfg: GCScenarioConfig) => { + if (!api.prepareGCScenario) { + throw new Error('prepareGCScenario not available'); + } + await api.prepareGCScenario(cfg); + }, config); + + // Forced engine GC + heapBefore (outside interaction timing) + const heapBeforeBytes = await collectHeapUsed(cdp); + + const measurement: GCBrowserMeasurement = await (bench as any).evaluate( + async (api: any) => { + if (!api.runGCScenario) { + throw new Error('runGCScenario not available'); + } + return api.runGCScenario(); + }, + ); + + await settlePage(page); + + // Forced engine GC + heapAfter while store remains live + const heapAfterBytes = await collectHeapUsed(cdp); + + await (bench as any).evaluate((api: any) => { + if (api.disposeGCScenario) api.disposeGCScenario(); + }); + + return { + ...measurement, + heapBeforeBytes, + heapAfterBytes, + heapDeltaBytes: heapAfterBytes - heapBeforeBytes, + }; +} + +async function runGCPhase( + browser: Browser, + scenarios: GCScenarioDescriptor[], + sampleCount: number, + scenarioFilter: string | null, + provenance: BuildManifestV1 & { servedManifestBuildId: string }, +): Promise<{ reports: GCScenarioReport[]; complete: boolean }> { + const reports: GCScenarioReport[] = []; + const failures: GCFailureRecord[] = []; + const requestedScenarios = scenarios.length; + const requestedSamples = scenarios.length * sampleCount; + let completedSamples = 0; + + if (scenarios.length === 0) { + return { reports, complete: true }; + } + + process.stderr.write( + `\n── GC (${scenarios.length} scenarios, ${sampleCount} samples each) ──\n`, + ); + + const context = await browser.newContext(); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + try { + await cdp.send('Performance.enable'); + } catch { + // best-effort + } + + for (const { + id: scenarioId, + candidateKind, + pattern, + count, + control, + } of scenarios) { + const config: GCScenarioConfig = { candidateKind, pattern, count, control }; + const scenarioSamples: GCSampleResult[] = []; + let scenarioFailed = false; + + process.stderr.write(` ${scenarioId}...\n`); + + for (let i = 0; i < sampleCount; i++) { + try { + // Fresh page load per sample isolates heap between samples + const { bench } = await setupBenchPage(page, 'data-client', {}, false); + const sample = await runGCScenarioSample(page, bench, config, cdp); + scenarioSamples.push(sample); + completedSamples++; + await bench.dispose(); + } catch (err) { + scenarioFailed = true; + const message = err instanceof Error ? err.message : String(err); + failures.push({ + scenarioId, + sampleIndex: i, + error: message, + }); + console.error(` ${scenarioId} sample ${i} FAILED:`, message); + try { + await page.evaluate(() => { + window.__BENCH__?.disposeGCScenario?.(); + }); + } catch { + // ignore cleanup failures + } + break; + } + } + + if (!scenarioFailed && scenarioSamples.length === sampleCount) { + const report = scenarioReportFromConfig(config, scenarioSamples); + const { summary } = report; + const medianTotal = summary.totalMs?.median ?? 0; + const maxFrame = + summary.frameIntervalsMs?.max ?? summary.frameIntervalsMs?.median; + process.stderr.write( + ` median totalMs=${medianTotal.toFixed(3)} ms` + + (summary.timerDelayMs ? + ` timerDelay=${summary.timerDelayMs.median.toFixed(2)} ms` + : '') + + (summary.displayPeriodMs ? + ` displayPeriod=${summary.displayPeriodMs.median.toFixed(2)} ms` + : '') + + (maxFrame != null ? + ` frameIntervalMax=${maxFrame.toFixed(2)} ms` + : '') + + (summary.maxInputDelayMs ? + ` maxInputDelay=${summary.maxInputDelayMs.median.toFixed(2)} ms` + : '') + + (summary.missedFrames != null ? + ` missedFrames=${summary.missedFrames.median}` + : '') + + (summary.longTaskCount ? + ` longTasks=${summary.longTaskCount.median}` + + (summary.longTaskTotalMs ? + `/${summary.longTaskTotalMs.median.toFixed(2)} ms` + : '') + : '') + + (summary.heapDeltaBytes ? + ` heapΔ=${Math.round(summary.heapDeltaBytes.median)} B` + : '') + + `\n`, + ); + reports.push(report); + } + } + + await cdp.detach().catch(() => {}); + await context.close(); + + const version = browser.version(); + const report = buildGCReport({ + scenarios: reports, + samplesPerScenario: sampleCount, + filter: scenarioFilter, + browserVersion: version, + headless: true, + requestedScenarios, + requestedSamples, + completedScenarios: reports.length, + completedSamples, + failures, + provenance, + }); + writeGCReport(report); + + if (!report.complete) { + process.stderr.write( + `GC phase incomplete: ${failures.length} failure(s), completed ${completedSamples}/${requestedSamples} samples\n`, + ); + } + + return { reports, complete: report.complete }; +} + +async function main() { + const { samples, filter } = parseArgs(); + const scenarios = listScenarios('browser', { + filter: filter ?? undefined, + }) as GCScenarioDescriptor[]; + if (scenarios.length === 0) { + process.stderr.write( + `No GC scenarios matched --scenario ${JSON.stringify(filter)}.\n`, + ); + process.exit(1); + } + + // Fail fast on a stale build before launching the browser + const provenance = await verifyGCBuildProvenance(BASE_URL); + + const { browser, closeBenchBrowser } = await launchBenchChromium(); + let complete = false; + try { + ({ complete } = await runGCPhase( + browser, + scenarios, + samples, + filter, + provenance, + )); + } finally { + await closeBenchBrowser(); + } + + if (!complete) process.exitCode = 1; +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 0419ac582f6e..bd8fdb1a6229 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -1,30 +1,13 @@ /// -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { chromium } from 'playwright'; -import type { - Browser, - BrowserServer, - CDPSession, - Locator, - Page, -} from 'playwright'; +import type { Browser, CDPSession, Locator, Page } from 'playwright'; import { - MANIFEST_FILENAME, - MANIFEST_PATH, - readBuildManifest, - verifyLocalManifest, - type BuildManifestV1, -} from './build-manifest.js'; -import { - buildGCReport, - scenarioReportFromConfig, - writeGCReport, - type GCFailureRecord, - type GCSampleResult, - type GCScenarioReport, -} from './gc-report.js'; + BENCH_V8_DEOPT, + V8_LOG_DIR, + launchBenchChromium, + reportV8Logs, + setupBenchPage, +} from './browser.js'; import { collectMeasures, getMeasureDuration } from './measure.js'; import { collectHeapUsed } from './memory.js'; import { formatReport, type BenchmarkResult } from './report.js'; @@ -34,18 +17,11 @@ import { RUN_CONFIG, CONVERGENT_CONFIG, ACTION_GROUPS, - NETWORK_SIM_CONFIG, } from './scenarios.js'; import type { ConvergentProfile } from './scenarios.js'; import { computeStats, isConverged } from './stats.js'; import { parseTraceDuration } from './tracing.js'; -import { browserGCScenarioId } from '../src/data-client/gcInteractionMetrics.js'; -import type { - GCBrowserMeasurement, - GCScenarioConfig, - Scenario, - ScenarioSize, -} from '../src/shared/types.js'; +import type { Scenario, ScenarioSize } from '../src/shared/types.js'; // --------------------------------------------------------------------------- // CLI + env var parsing @@ -58,8 +34,6 @@ function parseArgs(): { scenario?: string; networkSim: boolean; opsPerRound?: number; - /** Validated samples-per-GC-scenario (≥1 integer). */ - gcSamples: number; } { const argv = process.argv.slice(2); const get = (flag: string, envVar: string): string | undefined => { @@ -74,8 +48,6 @@ function parseArgs(): { const scenarioRaw = get('--scenario', 'BENCH_SCENARIO'); const networkSimRaw = get('--network-sim', 'BENCH_NETWORK_SIM'); const opsRaw = get('--ops-per-round', 'BENCH_OPS_PER_ROUND'); - // Flag / env / default resolved once here (filterScenarios must not re-read env). - const gcSamplesRaw = get('--samples', 'BENCH_GC_SAMPLES') ?? '5'; const libs = libRaw ? libRaw.split(',').map(s => s.trim()) : undefined; const size = sizeRaw === 'small' || sizeRaw === 'large' ? sizeRaw : undefined; @@ -84,16 +56,6 @@ function parseArgs(): { const networkSim = networkSimRaw != null ? networkSimRaw !== 'false' : !process.env.CI; const opsPerRound = opsRaw ? parseInt(opsRaw, 10) : undefined; - const gcSamplesParsed = Number.parseInt(gcSamplesRaw, 10); - if ( - !Number.isFinite(gcSamplesParsed) || - !Number.isInteger(gcSamplesParsed) || - gcSamplesParsed < 1 - ) { - throw new Error( - `invalid --samples / BENCH_GC_SAMPLES=${JSON.stringify(gcSamplesRaw)}; expected integer ≥ 1`, - ); - } return { libs, @@ -102,37 +64,26 @@ function parseArgs(): { scenario: scenarioRaw, networkSim, opsPerRound, - gcSamples: gcSamplesParsed, }; } -const isGCAction = (a: string) => a === 'gc' || a === 'runGCScenario'; - function filterScenarios(scenarios: Scenario[]): { filtered: Scenario[]; libraries: string[]; networkSim: boolean; opsPerRound?: number; - gcSamples: number; - scenarioFilter: string | null; } { const { libs, size, actions, - scenario: scenarioFilterArg, + scenario: scenarioFilter, networkSim, opsPerRound, - gcSamples, } = parseArgs(); - const scenarioFilter = scenarioFilterArg ?? null; const libraries = libs ?? (process.env.CI ? ['data-client'] : [...LIBRARIES]); - // GC scenarios are data-client-only; default lib when selecting --action gc - const effectiveLibraries = - !libs && actions && actions.every(isGCAction) ? ['data-client'] : libraries; - let filtered = scenarios; // In CI, restrict to data-client hot-path only (existing behavior) @@ -142,19 +93,14 @@ function filterScenarios(scenarios: Scenario[]): { s.name.startsWith('data-client:') && s.category !== 'memory' && s.category !== 'startup' && - s.category !== 'gc' && !s.deterministic, ); } else if ( !actions || - !actions.some( - a => a === 'memory' || a === 'mountUnmountCycle' || isGCAction(a), - ) + !actions.some(a => a === 'memory' || a === 'mountUnmountCycle') ) { - // Locally: exclude memory/gc by default; use --action memory|gc to include - filtered = filtered.filter( - s => s.category !== 'memory' && s.category !== 'gc', - ); + // Locally: exclude memory by default; use --action memory to include + filtered = filtered.filter(s => s.category !== 'memory'); } if (libs) { @@ -177,6 +123,7 @@ function filterScenarios(scenarios: Scenario[]): { filtered = filtered.filter(s => resolvedActions.has(s.action)); } + // Substring of the display name (the GC runner uses stable-id segments instead) if (scenarioFilter) { filtered = filtered.filter(s => s.name.includes(scenarioFilter)); } @@ -184,17 +131,14 @@ function filterScenarios(scenarios: Scenario[]): { // Multi-lib runs: omit scenarios that do not apply to every selected library (e.g. invalidate-and-resolve). filtered = filtered.filter( s => - !s.onlyLibs?.length || - effectiveLibraries.every(lib => s.onlyLibs!.includes(lib)), + !s.onlyLibs?.length || libraries.every(lib => s.onlyLibs!.includes(lib)), ); return { filtered, - libraries: effectiveLibraries, + libraries, networkSim, opsPerRound, - gcSamples, - scenarioFilter, }; } @@ -202,15 +146,9 @@ function filterScenarios(scenarios: Scenario[]): { // Config // --------------------------------------------------------------------------- -const BASE_URL = - process.env.BENCH_BASE_URL ?? - `http://localhost:${process.env.BENCH_PORT ?? '5173'}`; const BENCH_LABEL = process.env.BENCH_LABEL ? ` [${process.env.BENCH_LABEL}]` : ''; const USE_TRACE = process.env.BENCH_TRACE === 'true'; -const BENCH_V8_TRACE = process.env.BENCH_V8_TRACE === 'true'; -const BENCH_V8_DEOPT = process.env.BENCH_V8_DEOPT === 'true'; -const V8_LOG_DIR = path.resolve('v8-logs'); const MEMORY_WARMUP = 1; const MEMORY_MEASUREMENTS = 3; @@ -249,9 +187,7 @@ function isConvergentScenario(scenario: Scenario): boolean { return ( !scenario.deterministic && scenario.category !== 'memory' && - scenario.category !== 'gc' && scenario.resultMetric !== 'heapDelta' && - scenario.resultMetric !== 'totalMs' && !isRefStabilityScenario(scenario) ); } @@ -268,46 +204,6 @@ function classifyAction(scenario: Scenario): { return { isMountLike, isUpdate: !isMountLike }; } -async function setupBenchPage( - page: Page, - lib: string, - scenario: Scenario, - networkSim: boolean, -): Promise<{ harness: Locator; bench: any }> { - await page.goto(`${BASE_URL}/${lib}/`, { - waitUntil: 'networkidle', - timeout: 120000, - }); - await page.waitForSelector('[data-app-ready]', { - timeout: 120000, - state: 'attached', - }); - - const harness = page.locator('[data-bench-harness]'); - await harness.waitFor({ state: 'attached' }); - - const bench = await page.evaluateHandle('window.__BENCH__'); - if (await bench.evaluate(b => b == null)) - throw new Error('window.__BENCH__ not found'); - - if (networkSim) { - await (bench as any).evaluate( - (api: any, cfg: { baseLatencyMs: number; recordsPerMs: number }) => - api.setNetworkSim(cfg), - NETWORK_SIM_CONFIG, - ); - } - - if (scenario.renderLimit != null) { - await (bench as any).evaluate( - (api: any, n: number) => api.setRenderLimit(n), - scenario.renderLimit, - ); - } - - return { harness, bench }; -} - async function runPreMount( page: Page, harness: Locator, @@ -729,103 +625,9 @@ function shuffle(arr: T[]): T[] { return out; } -/** Chromium from `launch()` does not expose `process()`; use `launchServer` when piping V8 trace output. */ -async function launchBenchChromium(): Promise<{ - browser: Browser; - closeBenchBrowser: () => Promise; -}> { - const launchOpts = { - headless: true, - args: buildLaunchArgs(), - }; - - if (BENCH_V8_TRACE) { - const server: BrowserServer = await chromium.launchServer(launchOpts); - let v8TraceStream: fs.WriteStream | undefined; - const proc = server.process(); - if (proc?.stderr ?? proc?.stdout) { - v8TraceStream = fs.createWriteStream('v8-trace.log'); - proc.stderr?.pipe(v8TraceStream, { end: false }); - proc.stdout?.pipe(v8TraceStream, { end: false }); - process.stderr.write( - 'V8 trace output → v8-trace.log (root browser process stderr/stdout)\n', - ); - } else { - process.stderr.write( - 'Warning: BENCH_V8_TRACE but browser server process streams unavailable; v8-trace.log may be empty.\n', - ); - } - const browser = await chromium.connect({ wsEndpoint: server.wsEndpoint() }); - return { - browser, - closeBenchBrowser: async () => { - await browser.close(); - await server.close(); - if (v8TraceStream) { - v8TraceStream.end(); - process.stderr.write( - '\nV8 opt/deopt trace written to v8-trace.log\n', - ); - } - }, - }; - } - - const browser = await chromium.launch(launchOpts); - return { - browser, - closeBenchBrowser: () => browser.close(), - }; -} - -function buildLaunchArgs(): string[] { - const args = [ - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--disable-backgrounding-occluded-windows', - '--disable-hang-monitor', - ]; - const jsFlags: string[] = []; - if (BENCH_V8_TRACE) { - jsFlags.push('--trace-opt', '--trace-deopt'); - } - if (BENCH_V8_DEOPT) { - fs.rmSync(V8_LOG_DIR, { recursive: true, force: true }); - fs.mkdirSync(V8_LOG_DIR, { recursive: true }); - jsFlags.push('--prof', `--logfile=${V8_LOG_DIR}/v8-%p.log`); - } - if (jsFlags.length > 0) args.push(`--js-flags=${jsFlags.join(' ')}`); - return args; -} - -function reportV8Logs(): void { - if (!BENCH_V8_DEOPT) return; - try { - const logs = fs.readdirSync(V8_LOG_DIR).filter(f => f.endsWith('.log')); - if (logs.length === 0) return; - process.stderr.write(`\nV8 profiling logs written to ${V8_LOG_DIR}/:\n`); - for (const log of logs) { - const size = fs.statSync(path.join(V8_LOG_DIR, log)).size; - process.stderr.write(` ${log} (${(size / 1024).toFixed(1)} KB)\n`); - } - const largest = logs.reduce((a, b) => { - const sa = fs.statSync(path.join(V8_LOG_DIR, a)).size; - const sb = fs.statSync(path.join(V8_LOG_DIR, b)).size; - return sa >= sb ? a : b; - }); - process.stderr.write( - `\nProcess the renderer log (typically the largest file) with:\n` + - ` node --prof-process ${V8_LOG_DIR}/${largest}\n\n`, - ); - } catch { - // best-effort reporting - } -} - function scenarioUnit(scenario: Scenario): string { if (isRefStabilityScenario(scenario)) return 'count'; if (scenario.resultMetric === 'heapDelta') return 'bytes'; - if (scenario.resultMetric === 'totalMs') return 'ms'; return 'ops/s'; } @@ -847,241 +649,10 @@ function recordResult( function warmupCount(scenario: Scenario): number { if (scenario.deterministic) return 0; if (scenario.category === 'memory') return MEMORY_WARMUP; - if (scenario.category === 'gc') return 0; if (isConvergentScenario(scenario)) return 0; return RUN_CONFIG[scenario.size ?? 'small'].warmup; } -async function settlePage(page: Page): Promise { - await page.evaluate( - () => - new Promise(r => - requestAnimationFrame(() => requestAnimationFrame(() => r())), - ), - ); - await page.waitForTimeout(50); -} - -async function verifyGCBuildProvenance( - baseUrl: string, -): Promise { - if (!fs.existsSync(MANIFEST_PATH)) { - throw new Error( - `Missing ${MANIFEST_FILENAME}. Run yarn build (writes BuildManifest v1 after webpack).`, - ); - } - const local = readBuildManifest(); - verifyLocalManifest(local); - - const servedUrl = `${baseUrl.replace(/\/$/, '')}/${MANIFEST_FILENAME}`; - const res = await fetch(servedUrl); - if (!res.ok) { - throw new Error( - `Failed to fetch served ${MANIFEST_FILENAME} from ${servedUrl}: ${res.status}`, - ); - } - const served = (await res.json()) as BuildManifestV1; - if (served.schemaVersion !== 1) { - throw new Error(`served manifest schemaVersion ${served.schemaVersion}`); - } - if (served.buildId !== local.buildId) { - throw new Error( - `served manifest buildId mismatch (local ${local.buildId.slice(0, 12)}… vs served ${served.buildId.slice(0, 12)}…). Restart preview after rebuild.`, - ); - } - process.stderr.write( - `GC provenance OK buildId=${local.buildId.slice(0, 16)}… commit=${local.commit.slice(0, 8)} dirty=${local.dirty}\n`, - ); - return { ...local, servedManifestBuildId: served.buildId }; -} - -/** - * Dedicated GC phase (not the generic convergent/update path). - * Per sample: prepare → engine GC + heapBefore → run → settle → engine GC + - * heapAfter (store live) → dispose. Never force engine GC inside interaction timing. - * Failures are accumulated; report is always written with complete true/false; - * returns { reports, complete } and caller must exit nonzero when incomplete. - */ -async function runGCScenarioSample( - page: Page, - bench: any, - config: GCScenarioConfig, - cdp: CDPSession, -): Promise { - await (bench as any).evaluate(async (api: any, cfg: GCScenarioConfig) => { - if (!api.prepareGCScenario) { - throw new Error('prepareGCScenario not available'); - } - await api.prepareGCScenario(cfg); - }, config); - - // Forced engine GC + heapBefore (outside interaction timing) - const heapBeforeBytes = await collectHeapUsed(cdp); - - const measurement: GCBrowserMeasurement = await (bench as any).evaluate( - async (api: any) => { - if (!api.runGCScenario) { - throw new Error('runGCScenario not available'); - } - return api.runGCScenario(); - }, - ); - - await settlePage(page); - - // Forced engine GC + heapAfter while store remains live - const heapAfterBytes = await collectHeapUsed(cdp); - - await (bench as any).evaluate((api: any) => { - if (api.disposeGCScenario) api.disposeGCScenario(); - }); - - return { - ...measurement, - heapBeforeBytes, - heapAfterBytes, - heapDeltaBytes: heapAfterBytes - heapBeforeBytes, - }; -} - -async function runGCPhase( - browser: Browser, - scenarios: Scenario[], - sampleCount: number, - scenarioFilter: string | null, - samples: Map, - provenance: BuildManifestV1 & { servedManifestBuildId: string }, -): Promise<{ reports: GCScenarioReport[]; complete: boolean }> { - const reports: GCScenarioReport[] = []; - const failures: GCFailureRecord[] = []; - const requestedScenarios = scenarios.length; - const requestedSamples = scenarios.length * sampleCount; - let completedSamples = 0; - - if (scenarios.length === 0) { - return { reports, complete: true }; - } - - process.stderr.write( - `\n── GC (${scenarios.length} scenarios, ${sampleCount} samples each) ──\n`, - ); - - const context = await browser.newContext(); - const page = await context.newPage(); - const cdp = await context.newCDPSession(page); - try { - await cdp.send('Performance.enable'); - } catch { - // best-effort - } - - for (const scenario of scenarios) { - const config = scenario.args[0] as GCScenarioConfig; - const scenarioId = browserGCScenarioId(config); - const scenarioSamples: GCSampleResult[] = []; - let scenarioFailed = false; - - process.stderr.write(` ${scenario.name} (${scenarioId})...\n`); - - for (let i = 0; i < sampleCount; i++) { - try { - const { bench } = await setupBenchPage( - page, - 'data-client', - scenario, - false, - ); - const sample = await runGCScenarioSample(page, bench, config, cdp); - scenarioSamples.push(sample); - recordResult(samples, scenario, { value: sample.totalMs }); - completedSamples++; - await bench.dispose(); - } catch (err) { - scenarioFailed = true; - const message = err instanceof Error ? err.message : String(err); - failures.push({ - scenarioId, - sampleIndex: i, - error: message, - }); - console.error(` ${scenario.name} sample ${i} FAILED:`, message); - try { - await page.evaluate(() => { - window.__BENCH__?.disposeGCScenario?.(); - }); - } catch { - // ignore cleanup failures - } - break; - } - } - - if (!scenarioFailed && scenarioSamples.length === sampleCount) { - const report = scenarioReportFromConfig(config, scenarioSamples); - const { summary } = report; - const medianTotal = summary.totalMs?.median ?? 0; - const maxFrame = - summary.frameIntervalsMs?.max ?? summary.frameIntervalsMs?.median; - process.stderr.write( - ` median totalMs=${medianTotal.toFixed(3)} ms` + - (summary.timerDelayMs ? - ` timerDelay=${summary.timerDelayMs.median.toFixed(2)} ms` - : '') + - (summary.displayPeriodMs ? - ` displayPeriod=${summary.displayPeriodMs.median.toFixed(2)} ms` - : '') + - (maxFrame != null ? - ` frameIntervalMax=${maxFrame.toFixed(2)} ms` - : '') + - (summary.maxInputDelayMs ? - ` maxInputDelay=${summary.maxInputDelayMs.median.toFixed(2)} ms` - : '') + - (summary.missedFrames != null ? - ` missedFrames=${summary.missedFrames.median}` - : '') + - (summary.longTaskCount ? - ` longTasks=${summary.longTaskCount.median}` + - (summary.longTaskTotalMs ? - `/${summary.longTaskTotalMs.median.toFixed(2)} ms` - : '') - : '') + - (summary.heapDeltaBytes ? - ` heapΔ=${Math.round(summary.heapDeltaBytes.median)} B` - : '') + - `\n`, - ); - reports.push(report); - } - } - - await cdp.detach().catch(() => {}); - await context.close(); - - const version = browser.version(); - const report = buildGCReport({ - scenarios: reports, - samplesPerScenario: sampleCount, - filter: scenarioFilter, - browserVersion: version, - headless: true, - requestedScenarios, - requestedSamples, - completedScenarios: reports.length, - completedSamples, - failures, - provenance, - }); - writeGCReport(report); - - if (!report.complete) { - process.stderr.write( - `GC phase incomplete: ${failures.length} failure(s), completed ${completedSamples}/${requestedSamples} samples\n`, - ); - } - - return { reports, complete: report.complete }; -} - /** Run each scenario once per matching library (one browser context per lib). */ async function runRound( browser: Browser, @@ -1159,8 +730,6 @@ async function main() { libraries, networkSim, opsPerRound, - gcSamples, - scenarioFilter, } = filterScenarios(SCENARIOS); if (opsPerRound != null) { @@ -1178,10 +747,7 @@ async function main() { } const memoryScenarios = SCENARIOS_TO_RUN.filter(s => s.category === 'memory'); - const gcScenarios = SCENARIOS_TO_RUN.filter(s => s.category === 'gc'); - const mainScenarios = SCENARIOS_TO_RUN.filter( - s => s.category !== 'memory' && s.category !== 'gc', - ); + const mainScenarios = SCENARIOS_TO_RUN.filter(s => s.category !== 'memory'); const bySize: Record = { small: [], large: [] }; for (const s of mainScenarios) { @@ -1196,10 +762,6 @@ async function main() { samples.set(s.name, { value: [], reactCommit: [], trace: [] }); } - // Fail fast on a stale build before spending time on earlier phases - const gcProvenance = - gcScenarios.length > 0 ? await verifyGCBuildProvenance(BASE_URL) : null; - const { browser, closeBenchBrowser } = await launchBenchChromium(); if (BENCH_V8_DEOPT) { process.stderr.write(`V8 profiling logs → ${V8_LOG_DIR}/\n`); @@ -1357,20 +919,6 @@ async function main() { } } - // GC: dedicated phase (opt-in via --action gc) - let gcComplete = true; - if (gcProvenance) { - const { complete } = await runGCPhase( - browser, - gcScenarios, - gcSamples, - scenarioFilter, - samples, - gcProvenance, - ); - gcComplete = complete; - } - await closeBenchBrowser(); reportV8Logs(); @@ -1438,10 +986,6 @@ async function main() { process.stderr.write('\n'); process.stdout.write(formatReport(report)); - - if (!gcComplete) { - process.exitCode = 1; - } } main().catch(err => { diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 7cb64092b756..361282145452 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -1,4 +1,3 @@ -import { GC_CANONICAL_COUNTS } from '../src/data-client/gcInteractionMetrics.js'; import type { BenchAPI, Scenario, ScenarioSize } from '../src/shared/types.js'; /** Response-size-based network simulation used when --network-sim is enabled (default: on). @@ -66,7 +65,6 @@ export const ACTION_GROUPS: Record = { update: ['updateEntity', 'updateUser'], mutation: ['unshiftItem', 'deleteEntity', 'invalidateAndResolve', 'moveItem'], memory: ['mountUnmountCycle'], - gc: ['runGCScenario'], }; type BaseScenario = Omit & { @@ -222,56 +220,6 @@ export const LIBRARIES = [ 'baseline', ] as const; -const GC_UNIQUE_KINDS = ['entity', 'endpoint', 'mixed'] as const; -const GC_CONTROLS = ['gc', 'no-gc'] as const; - -/** data-client-only browser GC scenarios (excluded by default / CI; `--action gc`). */ -const GC_SCENARIOS: Scenario[] = (() => { - const out: Scenario[] = []; - for (const count of GC_CANONICAL_COUNTS) { - const size: ScenarioSize = count <= 1_000 ? 'small' : 'large'; - for (const candidateKind of GC_UNIQUE_KINDS) { - for (const control of GC_CONTROLS) { - out.push({ - name: `data-client: gc-${candidateKind}-unique-${count}-${control}`, - action: 'runGCScenario', - args: [ - { - candidateKind, - pattern: 'unique', - count, - control, - }, - ], - category: 'gc', - resultMetric: 'totalMs', - size, - onlyLibs: ['data-client'], - }); - } - } - for (const control of GC_CONTROLS) { - out.push({ - name: `data-client: gc-entity-duplicate-${count}-${control}`, - action: 'runGCScenario', - args: [ - { - candidateKind: 'entity', - pattern: 'duplicate', - count, - control, - }, - ], - category: 'gc', - resultMetric: 'totalMs', - size, - onlyLibs: ['data-client'], - }); - } - } - return out; -})(); - export const SCENARIOS: Scenario[] = [ ...LIBRARIES.flatMap(lib => BASE_SCENARIOS.filter( @@ -282,5 +230,4 @@ export const SCENARIOS: Scenario[] = [ ...(onlyLibs ? { onlyLibs: [...onlyLibs] } : {}), })), ), - ...GC_SCENARIOS, ]; diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index 1137d3244bda..d31550de002e 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -14,7 +14,7 @@ "bench:dc": "npx tsx bench/runner.ts --lib data-client", "bench:trace": "BENCH_V8_TRACE=true npx tsx bench/runner.ts --lib data-client --size small", "bench:deopt": "BENCH_V8_DEOPT=true npx tsx bench/runner.ts --lib data-client --size small", - "bench:gc": "npx tsx bench/runner.ts --lib data-client --action gc", + "bench:gc": "npx tsx bench/gc-runner.ts", "test:gc-metrics": "npx tsx bench/gc-interaction-metrics.test.ts", "test:gc-provenance": "npx tsx bench/gc-provenance.test.ts", "bench:run": "yarn build && (yarn preview &) && sleep 5 && yarn bench", diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index da646eecf5eb..00f89d3eed00 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -204,10 +204,10 @@ export type ScenarioAction = | { action: 'moveItem'; args: [number] }; export type ResultMetric = - 'duration' | 'issueRefChanged' | 'userRefChanged' | 'heapDelta' | 'totalMs'; + 'duration' | 'issueRefChanged' | 'userRefChanged' | 'heapDelta'; -/** hotPath = JS only, included in CI. memory = heap delta, not CI. startup = page load metrics, not CI. gc = cache GC interaction, not CI. */ -export type ScenarioCategory = 'hotPath' | 'memory' | 'startup' | 'gc'; +/** hotPath = JS only, included in CI. memory = heap delta, not CI. startup = page load metrics, not CI. (Cache GC runs separately via bench/gc-runner.ts.) */ +export type ScenarioCategory = 'hotPath' | 'memory' | 'startup'; /** small = cheap scenarios (full warmup + measurement). large = expensive scenarios (reduced runs). */ export type ScenarioSize = 'small' | 'large'; @@ -216,9 +216,9 @@ export interface Scenario { name: string; action: keyof BenchAPI; args: unknown[]; - /** Which value to report; default 'duration'. Ref-stability use issueRefChanged/userRefChanged; memory use heapDelta; gc use totalMs. */ + /** Which value to report; default 'duration'. Ref-stability use issueRefChanged/userRefChanged; memory use heapDelta. */ resultMetric?: ResultMetric; - /** hotPath (default) = run in CI. memory = heap delta. startup = page load metrics. gc = cache GC (opt-in). */ + /** hotPath (default) = run in CI. memory = heap delta. startup = page load metrics. */ category?: ScenarioCategory; /** small (default) = full runs. large = reduced warmup/measurement for expensive scenarios. */ size?: ScenarioSize; diff --git a/plans/garbage-collection.md b/plans/garbage-collection.md index 8813b5f6ba9b..378ec8b33d12 100644 --- a/plans/garbage-collection.md +++ b/plans/garbage-collection.md @@ -238,8 +238,9 @@ during unmount. A min-heap was worse on time and memory. `Object.keys` over ## Measuring -Three opt-in harnesses, not in CI, share one JSON report vocabulary but no -code: +Three opt-in harnesses, not in CI, share one JSON report vocabulary and, via +`examples/gc-shared`, the fixture, scenario axes/IDs, filter syntax, frame +helpers, and summary stats: | Harness | Command | Measures | | ------------------------------------ | -------------------- | ------------------------------------------------------------------- | @@ -257,7 +258,9 @@ proposed defaults. - **Scenario ID:** `platform` × `candidateKind` (`entity` | `endpoint` | `mixed`) × `pattern` (`unique` | `duplicate`) × `count` (1k, 10k, 100k) × `mode` (`scan` | `reducer` | `end-to-end` | `interaction` | `memory`) × - `control` (`gc` | `no-gc`). The 100k run is the main one. + `control` (`gc` | `no-gc`). The 100k run is the main one. Every harness + filters IDs the same way: slash-bounded segments (`/100000/`, + `entity/unique/1000`) or a `^prefix`. - **Timing window:** build fixtures before timing. Time only the cache-GC work, let the event loop settle, and force engine GC only for heap snapshots. Never confuse JS engine GC with cache GC. From 7c3bef241a406a02e267c03148e0007778b76145 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 13:53:05 -0400 Subject: [PATCH 25/32] fix(benchmark): Cover every bundled package in GC build provenance digests sourceDigest (and Node's dirty pathspecs) now include packages/normalizr/src and packages/endpoint/src in the Node and Android manifests, and normalizr/endpoint/rest/react in the browser manifest, so a change to any bundled package invalidates the build. examples/gc-shared was added in the previous commits. buildId/sourceDigest values change; report schemas do not. Co-Authored-By: Claude Opus 5.5 --- .../benchmark-native/scripts/build-manifest.cjs | 10 ++++++++-- examples/benchmark-react/bench/build-manifest.ts | 13 +++++++++++-- examples/benchmark/README.md | 5 +++-- examples/benchmark/gc-build-manifest.js | 12 ++++++++++-- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/examples/benchmark-native/scripts/build-manifest.cjs b/examples/benchmark-native/scripts/build-manifest.cjs index ad87bbddb5ee..b0d038b69bc4 100644 --- a/examples/benchmark-native/scripts/build-manifest.cjs +++ b/examples/benchmark-native/scripts/build-manifest.cjs @@ -10,7 +10,7 @@ * verify — verify manifest and/or sidecar identity * * Inputs: sorted paths under this app (tracked+untracked contents) plus - * packages/core/src and examples/gc-shared, excluding build/node_modules/artifacts/.jdk/generated. + * packages/{core,normalizr,endpoint}/src and examples/gc-shared, excluding build/node_modules/artifacts/.jdk/generated. */ const { execSync } = require('child_process'); const crypto = require('crypto'); @@ -63,7 +63,13 @@ function walkFiles(dir, out) { function collectInputFiles() { const files = []; walkFiles(ROOT, files); - for (const dir of ['packages/core/src', 'examples/gc-shared']) { + // Packages bundled into the APK (core → normalizr) plus shared GC code + for (const dir of [ + 'packages/core/src', + 'packages/normalizr/src', + 'packages/endpoint/src', + 'examples/gc-shared', + ]) { walkFiles(path.join(REPO, dir), files); } return files.map(f => path.resolve(f)).sort((a, b) => a.localeCompare(b)); diff --git a/examples/benchmark-react/bench/build-manifest.ts b/examples/benchmark-react/bench/build-manifest.ts index 496acbb82adc..52d34c2e0acf 100644 --- a/examples/benchmark-react/bench/build-manifest.ts +++ b/examples/benchmark-react/bench/build-manifest.ts @@ -74,12 +74,21 @@ function walkFiles(dir: string): string[] { /** * Sorted relevant inputs for sourceDigest: - * packages/core/src + examples/gc-shared + benchmark-react src/bench/config/package files. + * bundled @data-client package sources + examples/gc-shared + + * benchmark-react src/bench/config/package files. * Hashes actual file contents (includes dirty/untracked). */ export function listSourceInputFiles(): string[] { const files = new Set(); - for (const dir of ['packages/core/src', 'examples/gc-shared']) { + // Every @data-client package bundled into dist/, plus shared GC code + for (const dir of [ + 'packages/core/src', + 'packages/normalizr/src', + 'packages/endpoint/src', + 'packages/rest/src', + 'packages/react/src', + 'examples/gc-shared', + ]) { for (const f of walkFiles(path.join(REPO_ROOT, dir))) files.add(f); } const benchRoots = [ diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index 5d6c5fe01d6e..03caffed54f6 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -96,8 +96,9 @@ yarn workspace example-benchmark start:gc:verify Every `yarn build:benchmark` / workspace `build` runs webpack then writes `dist/gc-build-manifest.json` (BuildManifest v1: `schemaVersion`, `buildId`, `commit`, `dirty`, `sourceDigest`, `artifacts`). `sourceDigest` hashes sorted -relevant inputs on disk (`packages/core/src/**`, `examples/gc-shared/**`, -GC harness/runner/config/package files), so dirty or untracked relevant files change it. `artifacts` +relevant inputs on disk (`packages/{core,normalizr,endpoint}/src/**`, +`examples/gc-shared/**`, GC harness/runner/config/package files), so dirty or +untracked relevant files change it. `artifacts` maps each emitted `dist/*` file (except the manifest) to sha256. `buildId` is the digest of those canonical fields excluding itself. diff --git a/examples/benchmark/gc-build-manifest.js b/examples/benchmark/gc-build-manifest.js index 6a7c0c57f602..5e65a7efbf86 100644 --- a/examples/benchmark/gc-build-manifest.js +++ b/examples/benchmark/gc-build-manifest.js @@ -46,8 +46,16 @@ const BENCH_SOURCE_FILES = [ ]; const CORE_SRC_DIR = 'packages/core/src'; -/** Source directories walked recursively (relative to repo root). */ -const SOURCE_DIRS = [CORE_SRC_DIR, 'examples/gc-shared']; +/** + * Source directories walked recursively (relative to repo root): every + * package bundled into dist/index.js, plus the shared GC fixture/protocol. + */ +const SOURCE_DIRS = [ + CORE_SRC_DIR, + 'packages/normalizr/src', + 'packages/endpoint/src', + 'examples/gc-shared', +]; function sha256Buffer(buf) { return createHash('sha256').update(buf).digest('hex'); From 1b045d01941c7d8fb49e5791e78db2cbfb09c257 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 3 Oct 2026 13:53:14 -0400 Subject: [PATCH 26/32] ci: Run benchmark workflows when examples/gc-shared changes Co-Authored-By: Claude Opus 5.5 --- .github/workflows/benchmark-react.yml | 2 ++ .github/workflows/benchmark.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/benchmark-react.yml b/.github/workflows/benchmark-react.yml index 4c9638572d62..7b15e428a4aa 100644 --- a/.github/workflows/benchmark-react.yml +++ b/.github/workflows/benchmark-react.yml @@ -10,6 +10,7 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/normalizr/src/**' - 'examples/benchmark-react/**' + - 'examples/gc-shared/**' - '.github/workflows/benchmark-react.yml' push: branches: @@ -20,6 +21,7 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/normalizr/src/**' - 'examples/benchmark-react/**' + - 'examples/gc-shared/**' - '.github/workflows/benchmark-react.yml' concurrency: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 19411426e69c..ba3c353bb94f 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -9,6 +9,7 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/core/src/**' - 'examples/benchmark/**' + - 'examples/gc-shared/**' - '.github/workflows/benchmark.yml' push: branches: @@ -18,6 +19,7 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/core/src/**' - 'examples/benchmark/**' + - 'examples/gc-shared/**' - '.github/workflows/benchmark.yml' permissions: contents: write From 77f8118f89ac0d5336a63df394508d53e35170d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 02:45:55 +0000 Subject: [PATCH 27/32] fix: Read adb devices without mapfile on Bash 3.2 Stock macOS bash has no mapfile, so yarn collect exited before install. Fill _DEVICES with a read loop and keep the same device checks. Co-authored-by: Nathaniel Tucker --- examples/benchmark-native/scripts/collect-report.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/benchmark-native/scripts/collect-report.sh b/examples/benchmark-native/scripts/collect-report.sh index 57c6952b79e9..f3dda53f291f 100644 --- a/examples/benchmark-native/scripts/collect-report.sh +++ b/examples/benchmark-native/scripts/collect-report.sh @@ -56,7 +56,13 @@ if [[ "$(basename "${APK}")" != "app-release.apk" ]]; then fi # --- device selection --- -mapfile -t _DEVICES < <(adb devices | awk 'NR>1 && $2=="device" {print $1}') +# Bash 3.2 (stock macOS) has no mapfile. Keep an empty array when adb +# lists no eligible devices. +_DEVICES=() +while IFS= read -r _device; do + [[ -n "${_device}" ]] || continue + _DEVICES+=("${_device}") +done < <(adb devices | awk 'NR>1 && $2=="device" {print $1}') if [[ -n "${ANDROID_SERIAL:-}" ]]; then FOUND=0 for d in "${_DEVICES[@]:-}"; do From 162570e93729792b82c60f07513005e2e1cd6284 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 03:36:04 +0000 Subject: [PATCH 28/32] fix(benchmark-native): Stop the app before clearing its report Force-stop must succeed before collect deletes device reports and checks they are gone. Reject a pulled report whose kind, pattern, count, control, or sample count does not match the request. Co-authored-by: Nathaniel Tucker --- .../__tests__/accept-collected-report.test.ts | 108 ++++++++++++++ .../__tests__/collect-report-script.test.ts | 25 ++++ .../scripts/accept-collected-report.cjs | 139 ++++++++++++++++++ .../scripts/collect-report.sh | 35 ++--- 4 files changed, 282 insertions(+), 25 deletions(-) create mode 100644 examples/benchmark-native/__tests__/accept-collected-report.test.ts create mode 100644 examples/benchmark-native/scripts/accept-collected-report.cjs diff --git a/examples/benchmark-native/__tests__/accept-collected-report.test.ts b/examples/benchmark-native/__tests__/accept-collected-report.test.ts new file mode 100644 index 000000000000..3a801e45c80f --- /dev/null +++ b/examples/benchmark-native/__tests__/accept-collected-report.test.ts @@ -0,0 +1,108 @@ +/** + * Pulled-report acceptance: buildId plus scenario axes and sample count. + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + assertCollectedReport, +} = require('../scripts/accept-collected-report.cjs'); + +export {}; + +function report(overrides: Record = {}) { + return { + build: { buildId: 'abc' }, + scenarios: [ + { + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + samples: [{ schemaVersion: 1 }, { schemaVersion: 1 }], + ...overrides, + }, + ], + }; +} + +const expected = { + buildId: 'abc', + candidateKind: 'entity', + pattern: 'unique', + count: 1000, + control: 'gc', + samples: 2, +}; + +describe('assertCollectedReport', () => { + it('accepts a report that matches the request', () => { + expect(() => assertCollectedReport(report(), expected)).not.toThrow(); + }); + + it('rejects a different kind, pattern, count, control, or sample count', () => { + expect(() => + assertCollectedReport(report({ candidateKind: 'endpoint' }), expected), + ).toThrow(/kind endpoint != entity/); + expect(() => + assertCollectedReport(report({ pattern: 'duplicate' }), expected), + ).toThrow(/pattern duplicate != unique/); + expect(() => + assertCollectedReport(report({ count: 100000 }), expected), + ).toThrow(/count 100000 != 1000/); + expect(() => + assertCollectedReport(report({ control: 'no-gc' }), expected), + ).toThrow(/control no-gc != gc/); + const short = report(); + short.scenarios[0].samples = [{ schemaVersion: 1 }]; + expect(() => assertCollectedReport(short, expected)).toThrow( + /samples 1 != 2/, + ); + }); + + it('rejects a buildId mismatch before trusting the axes', () => { + const forged = report(); + forged.build.buildId = 'other'; + expect(() => assertCollectedReport(forged, expected)).toThrow(/buildId/); + }); + + it('does not rewrite the report file when the axes do not match', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-accept-')); + const out = path.join(dir, 'gc-report.json'); + const sidecar = path.join(dir, 'sidecar.json'); + const body = report({ candidateKind: 'mixed' }); + fs.writeFileSync(out, JSON.stringify(body)); + fs.writeFileSync( + sidecar, + JSON.stringify({ + buildId: 'abc', + sourceDigest: 'd', + apkSha256: 'a', + apkPath: '/apk', + sidecarId: 's', + apkSizeBytes: 1, + }), + ); + const before = fs.readFileSync(out); + const { spawnSync } = require('child_process'); + const result = spawnSync( + 'node', + [ + path.join('scripts', 'accept-collected-report.cjs'), + out, + sidecar, + 'installed', + 'entity', + 'unique', + '1000', + 'gc', + '2', + ], + { encoding: 'utf8' }, + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/does not match request/); + expect(fs.readFileSync(out).equals(before)).toBe(true); + }); +}); diff --git a/examples/benchmark-native/__tests__/collect-report-script.test.ts b/examples/benchmark-native/__tests__/collect-report-script.test.ts index 618efbdbadd6..39e1d94bff04 100644 --- a/examples/benchmark-native/__tests__/collect-report-script.test.ts +++ b/examples/benchmark-native/__tests__/collect-report-script.test.ts @@ -33,4 +33,29 @@ describe('collect-report.sh report I/O', () => { /content delete --uri content:\/\/media\/external\/downloads/, ); }); + + it('force-stops successfully before clearing reports, then starts', () => { + const forceStop = executable.lastIndexOf('am force-stop'); + const clearCall = executable.lastIndexOf('clear_device_reports'); + const absentCall = executable.lastIndexOf('assert_device_reports_absent'); + const start = executable.lastIndexOf('am start'); + expect(forceStop).toBeGreaterThan(-1); + expect(clearCall).toBeGreaterThan(forceStop); + expect(absentCall).toBeGreaterThan(clearCall); + expect(start).toBeGreaterThan(absentCall); + const forceStopLine = executable + .split('\n') + .find((line: string) => line.includes('am force-stop')); + expect(forceStopLine).toBeDefined(); + expect(forceStopLine).not.toMatch(/\|\|\s*true/); + }); + + it('checks the pulled report axes and sample count', () => { + expect(executable).toMatch(/accept-collected-report\.cjs/); + expect(executable).toMatch(/"\$\{CANDIDATE_KIND\}"/); + expect(executable).toMatch(/"\$\{PATTERN\}"/); + expect(executable).toMatch(/"\$\{COUNT\}"/); + expect(executable).toMatch(/"\$\{CONTROL\}"/); + expect(executable).toMatch(/"\$\{SAMPLES\}"/); + }); }); diff --git a/examples/benchmark-native/scripts/accept-collected-report.cjs b/examples/benchmark-native/scripts/accept-collected-report.cjs new file mode 100644 index 000000000000..efece63abd56 --- /dev/null +++ b/examples/benchmark-native/scripts/accept-collected-report.cjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * Reject a pulled report that is not this run, then attach sidecar provenance. + * + * buildId alone is not enough: another scenario from the same APK shares it. + * kind, pattern, count, control, and sample count must match the request. + * + * Usage: + * node accept-collected-report.cjs \ + * + */ +const fs = require('fs'); + +function parseCount(label, raw) { + const n = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isInteger(n) || n < 0) { + throw new Error(`error: invalid ${label}: ${raw}`); + } + return n; +} + +/** + * @param {object} report + * @param {{ buildId: string, candidateKind: string, pattern: string, count: number, control: string, samples: number }} expected + */ +function assertCollectedReport(report, expected) { + const embedded = report.build && report.build.buildId; + if (!embedded || embedded !== expected.buildId) { + throw new Error( + `error: report buildId ${embedded} != sidecar ${expected.buildId}`, + ); + } + const scenario = report.scenarios && report.scenarios[0]; + if (!scenario) { + throw new Error('error: pulled report has no scenario'); + } + const sampleCount = Array.isArray(scenario.samples) + ? scenario.samples.length + : NaN; + const problems = []; + if (scenario.candidateKind !== expected.candidateKind) { + problems.push( + `kind ${scenario.candidateKind} != ${expected.candidateKind}`, + ); + } + if (scenario.pattern !== expected.pattern) { + problems.push(`pattern ${scenario.pattern} != ${expected.pattern}`); + } + if (scenario.count !== expected.count) { + problems.push(`count ${scenario.count} != ${expected.count}`); + } + if (scenario.control !== expected.control) { + problems.push(`control ${scenario.control} != ${expected.control}`); + } + if (sampleCount !== expected.samples) { + problems.push(`samples ${sampleCount} != ${expected.samples}`); + } + if (problems.length) { + throw new Error( + `error: pulled report does not match request: ${problems.join('; ')}`, + ); + } +} + +/** + * @param {object} report + * @param {object} sidecar + * @param {string} installedSha + */ +function attachSidecar(report, sidecar, installedSha) { + report.build = report.build || {}; + report.build.sidecar = { + buildId: sidecar.buildId, + sourceDigest: sidecar.sourceDigest, + apkSha256: sidecar.apkSha256, + apkPath: sidecar.apkPath, + sidecarId: sidecar.sidecarId, + }; + report.build.installedApkSha256 = installedSha; + report.build.apkSizeBytes = sidecar.apkSizeBytes; + return report; +} + +function acceptCollectedReportFile(argv) { + const [ + out, + sidecarPath, + installedSha, + candidateKind, + pattern, + countRaw, + control, + samplesRaw, + ] = argv; + if ( + !out || + !sidecarPath || + !installedSha || + !candidateKind || + !pattern || + countRaw == null || + !control || + samplesRaw == null + ) { + throw new Error( + 'usage: accept-collected-report.cjs ', + ); + } + const report = JSON.parse(fs.readFileSync(out, 'utf8')); + const sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); + assertCollectedReport(report, { + buildId: sidecar.buildId, + candidateKind, + pattern, + count: parseCount('count', countRaw), + control, + samples: parseCount('samples', samplesRaw), + }); + attachSidecar(report, sidecar, installedSha); + fs.writeFileSync(out, JSON.stringify(report, null, 2)); + console.log( + `provenance ok buildId=${sidecar.buildId} sidecarId=${sidecar.sidecarId}`, + ); +} + +module.exports = { + assertCollectedReport, + attachSidecar, + parseCount, +}; + +if (require.main === module) { + try { + acceptCollectedReportFile(process.argv.slice(2)); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + } +} diff --git a/examples/benchmark-native/scripts/collect-report.sh b/examples/benchmark-native/scripts/collect-report.sh index f3dda53f291f..4e0bb16958e9 100644 --- a/examples/benchmark-native/scripts/collect-report.sh +++ b/examples/benchmark-native/scripts/collect-report.sh @@ -226,6 +226,11 @@ if [[ "${INSTALLED_SHA}" != "${SIDECAR_APK_SHA}" ]]; then fi echo "installedApkSha256=${INSTALLED_SHA}" +# Stop a previous run before deleting its report. A still-running app can +# write after the absence check (INSTALL=0, or a retry after timeout). +echo "Stopping ${APP_ID} before clearing leftover reports…" +"${ADB[@]}" shell am force-stop "${APP_ID}" + # Clear previous report (externalFilesDir + Downloads mirror; no run-as). # Verify gone so a leftover cannot complete wait for the wrong scenario. clear_device_reports @@ -233,7 +238,6 @@ assert_device_reports_absent echo "Starting ${ACTIVITY} with axes ${CANDIDATE_KIND}/${PATTERN}/${COUNT}/interaction/${CONTROL}…" "${ADB[@]}" logcat -c || true -"${ADB[@]}" shell am force-stop "${APP_ID}" || true START_ARGS=( am start -n "${ACTIVITY}" @@ -278,29 +282,10 @@ if ! pull_device_report; then fi echo "Wrote ${OUT}" -# Verify embedded buildId matches sidecar; attach sidecar provenance + installed hash. -node -e ' -const fs=require("fs"); -const out=process.argv[1], sidecarPath=process.argv[2], installedSha=process.argv[3]; -const report=JSON.parse(fs.readFileSync(out,"utf8")); -const sidecar=JSON.parse(fs.readFileSync(sidecarPath,"utf8")); -const embedded=report.build && report.build.buildId; -if (!embedded || embedded !== sidecar.buildId) { - console.error("error: report buildId", embedded, "!= sidecar", sidecar.buildId); - process.exit(1); -} -report.build = report.build || {}; -report.build.sidecar = { - buildId: sidecar.buildId, - sourceDigest: sidecar.sourceDigest, - apkSha256: sidecar.apkSha256, - apkPath: sidecar.apkPath, - sidecarId: sidecar.sidecarId, -}; -report.build.installedApkSha256 = installedSha; -report.build.apkSizeBytes = sidecar.apkSizeBytes; -fs.writeFileSync(out, JSON.stringify(report, null, 2)); -console.log("provenance ok buildId="+sidecar.buildId+" sidecarId="+sidecar.sidecarId); -' "${OUT}" "${SIDECAR}" "${INSTALLED_SHA}" +# Reject another scenario from this APK (buildId alone is not enough), then +# attach sidecar provenance + installed hash. +node "${ROOT}/scripts/accept-collected-report.cjs" \ + "${OUT}" "${SIDECAR}" "${INSTALLED_SHA}" \ + "${CANDIDATE_KIND}" "${PATTERN}" "${COUNT}" "${CONTROL}" "${SAMPLES}" echo "Done." From 8e1ab6ab09e746a155bd159212f41fdd01f5bd5f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 03:36:04 +0000 Subject: [PATCH 29/32] test(benchmark-react): Verify the existing GC manifest first The provenance test no longer calls writeBuildManifest(), so a stale dist cannot be re-stamped into a passing run. Tamper cases restore the original manifest bytes. Co-authored-by: Nathaniel Tucker --- .../bench/gc-provenance.test.ts | 153 ++++++++++-------- 1 file changed, 88 insertions(+), 65 deletions(-) diff --git a/examples/benchmark-react/bench/gc-provenance.test.ts b/examples/benchmark-react/bench/gc-provenance.test.ts index b6e33ac38608..5d774b2ac95e 100644 --- a/examples/benchmark-react/bench/gc-provenance.test.ts +++ b/examples/benchmark-react/bench/gc-provenance.test.ts @@ -1,6 +1,10 @@ /** * Provenance tamper tests: artifact / commit / dirty / buildId must fail verify. * + * Verifies the manifest `yarn build` already wrote against the existing dist. + * Does not call writeBuildManifest(), so a stale or tampered bundle cannot be + * re-stamped into a passing run. Tamper cases restore the original bytes. + * * yarn test:gc-provenance */ import * as fs from 'node:fs'; @@ -12,7 +16,6 @@ import { computeBuildId, readBuildManifest, verifyLocalManifest, - writeBuildManifest, type BuildManifestV1, } from './build-manifest.ts'; @@ -21,7 +24,7 @@ function assert(cond: boolean, msg: string): asserts cond { } function expectVerifyFail(label: string, mutate: (m: BuildManifestV1) => void) { - const original = fs.readFileSync(MANIFEST_PATH, 'utf8'); + const original = fs.readFileSync(MANIFEST_PATH); try { const m = readBuildManifest(); mutate(m); @@ -47,79 +50,99 @@ if ( ) { throw new Error('dist/ incomplete — run yarn build first'); } - -// Ensure a fresh manifest exists for the current tree -writeBuildManifest(); -const manifest = readBuildManifest(); -verifyLocalManifest(manifest); -process.stderr.write('provenance: clean verify OK\n'); - -// Canonical buildId includes schemaVersion, commit, dirty, sourceDigest, artifacts -{ - const expected = computeBuildId({ - schemaVersion: 1, - commit: manifest.commit, - dirty: manifest.dirty, - sourceDigest: manifest.sourceDigest, - artifacts: manifest.artifacts, - }); - assert( - expected === manifest.buildId, - 'stored buildId must match canonical digest of manifest fields', +if (!fs.existsSync(MANIFEST_PATH)) { + throw new Error( + `missing manifest at ${MANIFEST_PATH} — run yarn build; this test does not write one`, ); - const flippedDirty = computeBuildId({ - schemaVersion: 1, - commit: manifest.commit, - dirty: !manifest.dirty, - sourceDigest: manifest.sourceDigest, - artifacts: manifest.artifacts, - }); - assert(flippedDirty !== manifest.buildId, 'dirty bit must affect buildId'); - const otherCommit = computeBuildId({ - schemaVersion: 1, - commit: `${manifest.commit}dead`, - dirty: manifest.dirty, - sourceDigest: manifest.sourceDigest, - artifacts: manifest.artifacts, - }); - assert(otherCommit !== manifest.buildId, 'commit must affect buildId'); } -// Artifact content tamper +const originalManifestBytes = fs.readFileSync(MANIFEST_PATH); const target = path.join(DIST_DIR, 'data-client.js'); const originalArtifact = fs.readFileSync(target); + try { - fs.writeFileSync( - target, - Buffer.concat([originalArtifact, Buffer.from('\n/* tamper */\n')]), - ); - let failed = false; + let manifest: BuildManifestV1; try { - verifyLocalManifest(readBuildManifest()); + manifest = readBuildManifest(); + verifyLocalManifest(manifest); } catch (err) { - failed = true; - process.stderr.write( - `provenance: artifact tamper rejected: ${err instanceof Error ? err.message : err}\n`, + throw new Error( + `existing manifest does not match dist: ${err instanceof Error ? err.message : err}`, ); } - assert(failed, 'tampered artifact must fail verifyLocalManifest'); -} finally { - fs.writeFileSync(target, originalArtifact); - writeBuildManifest(); + process.stderr.write('provenance: clean verify OK\n'); + + // Canonical buildId includes schemaVersion, commit, dirty, sourceDigest, artifacts + { + const expected = computeBuildId({ + schemaVersion: 1, + commit: manifest.commit, + dirty: manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts, + }); + assert( + expected === manifest.buildId, + 'stored buildId must match canonical digest of manifest fields', + ); + const flippedDirty = computeBuildId({ + schemaVersion: 1, + commit: manifest.commit, + dirty: !manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts, + }); + assert(flippedDirty !== manifest.buildId, 'dirty bit must affect buildId'); + const otherCommit = computeBuildId({ + schemaVersion: 1, + commit: `${manifest.commit}dead`, + dirty: manifest.dirty, + sourceDigest: manifest.sourceDigest, + artifacts: manifest.artifacts, + }); + assert(otherCommit !== manifest.buildId, 'commit must affect buildId'); + } + + // Artifact content tamper. Restore the original bytes; do not rewrite the manifest. + try { + fs.writeFileSync( + target, + Buffer.concat([originalArtifact, Buffer.from('\n/* tamper */\n')]), + ); + let failed = false; + try { + verifyLocalManifest(readBuildManifest()); + } catch (err) { + failed = true; + process.stderr.write( + `provenance: artifact tamper rejected: ${err instanceof Error ? err.message : err}\n`, + ); + } + assert(failed, 'tampered artifact must fail verifyLocalManifest'); + } finally { + fs.writeFileSync(target, originalArtifact); + } verifyLocalManifest(readBuildManifest()); -} -// Manifest field tampers (buildId left stale → mismatch) -expectVerifyFail('commit field tamper', m => { - m.commit = `${m.commit}0`; -}); -expectVerifyFail('dirty field tamper', m => { - m.dirty = !m.dirty; -}); -expectVerifyFail('buildId field tamper', m => { - m.buildId = '0'.repeat(64); -}); + // Manifest field tampers (buildId left stale → mismatch) + expectVerifyFail('commit field tamper', m => { + m.commit = `${m.commit}0`; + }); + expectVerifyFail('dirty field tamper', m => { + m.dirty = !m.dirty; + }); + expectVerifyFail('buildId field tamper', m => { + m.buildId = '0'.repeat(64); + }); -verifyLocalManifest(readBuildManifest()); -assert(fs.existsSync(MANIFEST_PATH), 'manifest exists'); -process.stderr.write('gc-provenance: all assertions passed\n'); + verifyLocalManifest(readBuildManifest()); + assert( + fs.readFileSync(MANIFEST_PATH).equals(originalManifestBytes), + 'tamper cases must not replace the real manifest', + ); + assert(fs.existsSync(MANIFEST_PATH), 'manifest exists'); + process.stderr.write('gc-provenance: all assertions passed\n'); +} finally { + fs.writeFileSync(MANIFEST_PATH, originalManifestBytes); + fs.writeFileSync(target, originalArtifact); +} From 46ff297dafcebd414773f8e1359504570370b14c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 03:36:04 +0000 Subject: [PATCH 30/32] fix(benchmark): Hash the Babel config and Metro lib the builds use Node sourceDigest includes repo babel.config.js because webpack loads it with rootMode upward. Android sourceDigest hashes the react-native export lib Metro bundles, not package src. Co-authored-by: Nathaniel Tucker --- .../__tests__/source-digest.test.ts | 77 +++++++++++++++++ .../scripts/build-manifest.cjs | 83 ++++++++++++++++--- examples/benchmark-native/src/types.ts | 2 +- examples/benchmark/README.md | 3 +- examples/benchmark/gc-build-manifest.js | 16 +++- examples/benchmark/gc-build-manifest.test.js | 39 +++++++++ 6 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 examples/benchmark-native/__tests__/source-digest.test.ts create mode 100644 examples/benchmark/gc-build-manifest.test.js diff --git a/examples/benchmark-native/__tests__/source-digest.test.ts b/examples/benchmark-native/__tests__/source-digest.test.ts new file mode 100644 index 000000000000..bb61b5b0d1df --- /dev/null +++ b/examples/benchmark-native/__tests__/source-digest.test.ts @@ -0,0 +1,77 @@ +/** + * Release sourceDigest must follow Metro's react-native export (lib/), + * not package src. The APK bundles that lib; src-only edits do not. + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + bundleRootForPackage, + collectInputFiles, + hashInputFiles, + sourceDigest, +} = require('../scripts/build-manifest.cjs'); + +export {}; + +// Jest cwd is this workspace (examples/benchmark-native). +const REPO = path.resolve('../..'); + +function rel(file: string): string { + return path.relative(REPO, file).split(path.sep).join('/'); +} + +describe('android sourceDigest inputs', () => { + it('hashes the Metro react-native lib, not package src or legacy', () => { + for (const name of ['core', 'normalizr', 'endpoint']) { + const pkg = require(`../../../packages/${name}/package.json`); + expect(pkg.exports['.']['react-native']).toBe('./lib/index.js'); + expect(pkg['react-native']).toBe('legacy/index.js'); + expect(bundleRootForPackage(`packages/${name}`)).toBe( + path.join(REPO, 'packages', name, 'lib'), + ); + } + + const rels: string[] = collectInputFiles().map(rel); + expect(rels).toEqual( + expect.arrayContaining([ + 'packages/core/lib/index.js', + 'packages/normalizr/lib/index.js', + 'packages/endpoint/lib/index.js', + 'examples/gc-shared/protocol.js', + ]), + ); + expect( + rels.some((r: string) => + /^packages\/(core|normalizr|endpoint)\/(src|legacy)\//.test(r), + ), + ).toBe(false); + expect( + rels.some( + (r: string) => r.startsWith('packages/core/lib/') && !r.endsWith('.js'), + ), + ).toBe(false); + expect(sourceDigest()).toBe(hashInputFiles(collectInputFiles())); + }); + + it('changes when bundled lib bytes change', () => { + const lib = collectInputFiles().find((file: string) => + rel(file).endsWith('packages/core/lib/index.js'), + ); + expect(lib).toBeDefined(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-digest-')); + const copy = path.join(dir, 'index.js'); + const bytes = fs.readFileSync(lib, 'utf8'); + fs.writeFileSync(copy, bytes); + const before = hashInputFiles([copy]); + fs.writeFileSync(copy, `${bytes}\n`); + expect(hashInputFiles([copy])).not.toBe(before); + }); + + it('does not include package src, so a src-only file is outside the digest', () => { + const src = path.join(REPO, 'packages/core/src/index.ts'); + expect(fs.existsSync(src)).toBe(true); + expect(collectInputFiles().includes(path.resolve(src))).toBe(false); + }); +}); diff --git a/examples/benchmark-native/scripts/build-manifest.cjs b/examples/benchmark-native/scripts/build-manifest.cjs index b0d038b69bc4..d77a2adeaa3b 100644 --- a/examples/benchmark-native/scripts/build-manifest.cjs +++ b/examples/benchmark-native/scripts/build-manifest.cjs @@ -10,7 +10,10 @@ * verify — verify manifest and/or sidecar identity * * Inputs: sorted paths under this app (tracked+untracked contents) plus - * packages/{core,normalizr,endpoint}/src and examples/gc-shared, excluding build/node_modules/artifacts/.jdk/generated. + * the Metro-resolved lib of packages/{core,normalizr,endpoint} (react-native + * export condition → lib/, the bytes the release APK bundles — not package + * src or the legacy main field) and examples/gc-shared, excluding + * build/node_modules/artifacts/.jdk/generated. */ const { execSync } = require('child_process'); const crypto = require('crypto'); @@ -60,18 +63,57 @@ function walkFiles(dir, out) { } } +/** + * Packages the release bundle loads through Metro's react-native condition. + * Metro (`unstable_conditionNames: ["react-native"]`, package exports on) + * resolves these to lib/, not src/ and not the legacy "react-native" field. + */ +const BUNDLED_PACKAGES = [ + 'packages/core', + 'packages/normalizr', + 'packages/endpoint', +]; + +function bundleRootForPackage(packageRel) { + const pkgPath = path.join(REPO, packageRel, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const dot = pkg.exports && pkg.exports['.']; + const rel = dot && dot['react-native']; + if (typeof rel !== 'string' || rel.length === 0) { + throw new Error( + `${packageRel} has no exports["."].react-native (Metro condition)`, + ); + } + const entry = path.resolve(path.join(REPO, packageRel), rel); + if (!fs.existsSync(entry)) { + throw new Error( + `Metro react-native entry missing: ${path.relative(REPO, entry)} (run that package's build:lib)`, + ); + } + return path.dirname(entry); +} + +/** .js files under a Metro lib root. Declarations and external maps are not bundled. */ +function walkJsFiles(dir, out) { + if (!fs.existsSync(dir)) return; + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (shouldSkipDir(ent.name)) continue; + walkJsFiles(full, out); + } else if (ent.isFile() && ent.name.endsWith('.js')) { + out.push(full); + } + } +} + function collectInputFiles() { const files = []; walkFiles(ROOT, files); - // Packages bundled into the APK (core → normalizr) plus shared GC code - for (const dir of [ - 'packages/core/src', - 'packages/normalizr/src', - 'packages/endpoint/src', - 'examples/gc-shared', - ]) { - walkFiles(path.join(REPO, dir), files); + for (const packageRel of BUNDLED_PACKAGES) { + walkJsFiles(bundleRootForPackage(packageRel), files); } + walkFiles(path.join(REPO, 'examples/gc-shared'), files); return files.map(f => path.resolve(f)).sort((a, b) => a.localeCompare(b)); } @@ -81,9 +123,12 @@ function sha256File(filePath) { return h.digest('hex'); } -function sourceDigest() { +function hashInputFiles(files) { const h = crypto.createHash('sha256'); - for (const file of collectInputFiles()) { + const sorted = files + .map(f => path.resolve(f)) + .sort((a, b) => a.localeCompare(b)); + for (const file of sorted) { const rel = path.relative(REPO, file).split(path.sep).join('/'); h.update(rel); h.update('\0'); @@ -93,6 +138,10 @@ function sourceDigest() { return h.digest('hex'); } +function sourceDigest() { + return hashInputFiles(collectInputFiles()); +} + function gitMeta() { try { const commit = execSync('git rev-parse HEAD', { @@ -214,6 +263,14 @@ function verify() { } } +module.exports = { + BUNDLED_PACKAGES, + bundleRootForPackage, + collectInputFiles, + hashInputFiles, + sourceDigest, +}; + function main() { const cmd = process.argv[2] || 'digest'; if (cmd === 'prepare') { @@ -239,4 +296,6 @@ function main() { } } -main(); +if (require.main === module) { + main(); +} diff --git a/examples/benchmark-native/src/types.ts b/examples/benchmark-native/src/types.ts index 7f71eac2d3e2..3511b7a768ea 100644 --- a/examples/benchmark-native/src/types.ts +++ b/examples/benchmark-native/src/types.ts @@ -109,7 +109,7 @@ export interface BuildManifestV1 { buildId: string; gitCommit: string; gitDirty: boolean; - /** Content digest of sorted app + packages/core/src inputs. */ + /** Content digest of the app tree plus Metro-resolved package lib inputs. */ sourceDigest: string; createdAt: string; } diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index 03caffed54f6..37403ac76f1a 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -97,7 +97,8 @@ Every `yarn build:benchmark` / workspace `build` runs webpack then writes `dist/gc-build-manifest.json` (BuildManifest v1: `schemaVersion`, `buildId`, `commit`, `dirty`, `sourceDigest`, `artifacts`). `sourceDigest` hashes sorted relevant inputs on disk (`packages/{core,normalizr,endpoint}/src/**`, -`examples/gc-shared/**`, GC harness/runner/config/package files), so dirty or +`examples/gc-shared/**`, repo `babel.config.js` — webpack loads it with +`rootMode: 'upward'` — and GC harness/runner/config/package files), so dirty or untracked relevant files change it. `artifacts` maps each emitted `dist/*` file (except the manifest) to sha256. `buildId` is the digest of those canonical fields excluding itself. diff --git a/examples/benchmark/gc-build-manifest.js b/examples/benchmark/gc-build-manifest.js index 5e65a7efbf86..64cef86ae605 100644 --- a/examples/benchmark/gc-build-manifest.js +++ b/examples/benchmark/gc-build-manifest.js @@ -45,6 +45,12 @@ const BENCH_SOURCE_FILES = [ 'examples/benchmark/tsconfig.json', ]; +/** + * Repo Babel config. Webpack's babel-loader uses rootMode: 'upward', so this + * file is what compiles the benchmark bundle. A change must change sourceDigest. + */ +const ROOT_CONFIG_FILES = ['babel.config.js']; + const CORE_SRC_DIR = 'packages/core/src'; /** * Source directories walked recursively (relative to repo root): every @@ -97,8 +103,8 @@ export function listRelevantSourcePaths() { const benchFiles = BENCH_SOURCE_FILES.filter(rel => existsSync(path.join(REPO_ROOT, rel)), ); - return [...new Set([...dirFiles, ...benchFiles])].sort((a, b) => - a.localeCompare(b), + return [...new Set([...dirFiles, ...benchFiles, ...ROOT_CONFIG_FILES])].sort( + (a, b) => a.localeCompare(b), ); } @@ -167,7 +173,11 @@ export function gitCommitFull() { export function computeDirty() { try { // Pathspecs already scope status to relevant inputs. - const pathspecs = [...SOURCE_DIRS, ...BENCH_SOURCE_FILES]; + const pathspecs = [ + ...SOURCE_DIRS, + ...BENCH_SOURCE_FILES, + ...ROOT_CONFIG_FILES, + ]; const out = execFileSync( 'git', ['status', '--porcelain', '-u', '--', ...pathspecs], diff --git a/examples/benchmark/gc-build-manifest.test.js b/examples/benchmark/gc-build-manifest.test.js new file mode 100644 index 000000000000..72dd67b18aaa --- /dev/null +++ b/examples/benchmark/gc-build-manifest.test.js @@ -0,0 +1,39 @@ +/** + * Node GC sourceDigest includes repo babel.config.js. + * Webpack's babel-loader uses rootMode: 'upward', so that file compiles the bundle. + * + * node --test examples/benchmark/gc-build-manifest.test.js + */ +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + computeSourceDigest, + listRelevantSourcePaths, +} from './gc-build-manifest.js'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const babelPath = path.join(repoRoot, 'babel.config.js'); + +test('repo babel.config.js is a sourceDigest input', () => { + assert.ok(listRelevantSourcePaths().includes('babel.config.js')); +}); + +test('a babel.config.js change changes sourceDigest and restores', () => { + const original = readFileSync(babelPath); + const before = computeSourceDigest(); + try { + writeFileSync(babelPath, `${original.toString('utf8')}\n`); + const after = computeSourceDigest(); + assert.notEqual(after, before); + } finally { + writeFileSync(babelPath, original); + } + assert.equal(computeSourceDigest(), before); +}); From e733cc374b3efc919d3b4a62335af48784cbfaad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 03:36:04 +0000 Subject: [PATCH 31/32] fix(benchmark-native): Count FrameMetrics drops separately from missed frames dropCountSinceLastInvocation is its own loss count on the sample and sets uiFrameMetricsDropped when that count is non-zero. It is not added to missedFrames or uiMissedFrames. Co-authored-by: Nathaniel Tucker --- .../__tests__/frame-semantics.test.ts | 73 +++++++++++++++++++ .../__tests__/summarize.test.ts | 7 ++ .../benchmarknative/BenchNativeModule.kt | 18 ++++- examples/benchmark-native/src/BenchNative.ts | 4 + examples/benchmark-native/src/frames.ts | 36 +++++++++ examples/benchmark-native/src/measure.ts | 48 ++++++------ examples/benchmark-native/src/report.ts | 7 ++ examples/benchmark-native/src/types.ts | 7 ++ 8 files changed, 177 insertions(+), 23 deletions(-) diff --git a/examples/benchmark-native/__tests__/frame-semantics.test.ts b/examples/benchmark-native/__tests__/frame-semantics.test.ts index 1919c854ae04..b44acf25db27 100644 --- a/examples/benchmark-native/__tests__/frame-semantics.test.ts +++ b/examples/benchmark-native/__tests__/frame-semantics.test.ts @@ -2,6 +2,7 @@ import { missedFramesFromDurationMs, missedFramesFromIntervalMs, validateUiFrameCapture, + withFrameMetricsLoss, } from '../src/frames'; const P = 16.67; @@ -36,6 +37,8 @@ describe('validateUiFrameCapture', () => { maxFrameDurationMs: 20, totalFrameDurationMs: 50, missedFrames: 0, + droppedFrameMetrics: 0, + frameMetricsDropped: false, refreshPeriodMs: P, refreshRateHz: 60, }; @@ -53,3 +56,73 @@ describe('validateUiFrameCapture', () => { ); }); }); + +describe('withFrameMetricsLoss', () => { + const base = { missedFrames: 2, uiMissedFrames: 1 }; + + it('flags a non-zero drop count without adding it to missed frames', () => { + expect(withFrameMetricsLoss(base, 4)).toEqual({ + missedFrames: 2, + uiMissedFrames: 1, + uiDroppedFrameMetrics: 4, + uiFrameMetricsDropped: true, + }); + }); + + it('leaves a clean capture unflagged', () => { + expect(withFrameMetricsLoss(base, 0)).toEqual({ + missedFrames: 2, + uiMissedFrames: 1, + uiDroppedFrameMetrics: 0, + uiFrameMetricsDropped: false, + }); + }); + + it('rejects a negative drop count', () => { + expect(() => withFrameMetricsLoss(base, -1)).toThrow(/drop count/); + }); +}); + +describe('BenchNativeModule drop accounting', () => { + const kt = require('fs').readFileSync( + 'android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt', + 'utf8', + ) as string; + + function sliceBetween(start: string, end: string): string { + const from = kt.indexOf(start); + const to = kt.indexOf(end, from + start.length); + expect(from).toBeGreaterThan(-1); + expect(to).toBeGreaterThan(from); + return kt.slice(from, to); + } + + it('sums dropCountSinceLastInvocation and does not add it to missed frames', () => { + expect(kt).toMatch( + /OnFrameMetricsAvailableListener \{ _, frameMetrics, dropCountSinceLastInvocation ->/, + ); + expect(kt).toMatch( + /recordFrameMetricsDrop\(dropCountSinceLastInvocation\)/, + ); + const dropFn = sliceBetween( + 'private fun recordFrameMetricsDrop', + 'private fun recordFrameMetricsDuration', + ); + expect(dropFn).toMatch(/droppedFrameMetrics \+= dropCount/); + expect(dropFn).not.toMatch(/missedFrames/); + const durationFn = sliceBetween( + 'private fun recordFrameMetricsDuration', + 'private fun recordChoreographerInterval', + ); + expect(durationFn).not.toMatch(/drop/); + const intervalFn = sliceBetween( + 'private fun recordChoreographerInterval', + 'private fun resetCaptureCounters', + ); + expect(intervalFn).not.toMatch(/drop/); + expect(kt).toMatch(/putInt\("droppedFrameMetrics", droppedFrameMetrics\)/); + expect(kt).toMatch( + /putBoolean\("frameMetricsDropped", droppedFrameMetrics > 0\)/, + ); + }); +}); diff --git a/examples/benchmark-native/__tests__/summarize.test.ts b/examples/benchmark-native/__tests__/summarize.test.ts index feab5db2f7e7..404bb56c6b0e 100644 --- a/examples/benchmark-native/__tests__/summarize.test.ts +++ b/examples/benchmark-native/__tests__/summarize.test.ts @@ -23,6 +23,8 @@ function baseSample( uiMaxFrameDurationMs: 16, uiTotalFrameDurationMs: 160, uiMissedFrames: 0, + uiDroppedFrameMetrics: 0, + uiFrameMetricsDropped: false, uiRefreshPeriodMs: 16.67, uiRefreshRateHz: 60, ...overrides, @@ -37,6 +39,8 @@ describe('summarizeGCSamples', () => { uiMaxFrameDurationMs: 20, uiTotalFrameDurationMs: 160, uiMissedFrames: 1, + uiDroppedFrameMetrics: 2, + uiFrameMetricsDropped: true, processPssBeforeKb: 100, processPssAfterKb: 90, processPssDeltaKb: -10, @@ -52,6 +56,8 @@ describe('summarizeGCSamples', () => { uiMaxFrameDurationMs: 22, uiTotalFrameDurationMs: 180, uiMissedFrames: 0, + uiDroppedFrameMetrics: 0, + uiFrameMetricsDropped: false, processPssBeforeKb: 110, processPssAfterKb: 95, processPssDeltaKb: -15, @@ -68,6 +74,7 @@ describe('summarizeGCSamples', () => { expect(summary.uiTotalFrameDurationMs?.min).toBe(160); expect(summary.uiMaxFrameDurationMs?.max).toBe(22); expect(summary.uiMissedFrames?.median).toBe(0.5); + expect(summary.uiDroppedFrameMetrics?.median).toBe(1); expect(summary.processPssBeforeKb?.median).toBe(105); expect(summary.processPssAfterKb?.median).toBe(92.5); expect(summary.processPssDeltaKb?.median).toBe(-12.5); diff --git a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt index aa6bffda8f9a..dbf274587aab 100644 --- a/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt +++ b/examples/benchmark-native/android/app/src/main/java/com/dataclient/benchmarknative/BenchNativeModule.kt @@ -31,6 +31,7 @@ import kotlin.math.roundToInt * process memory snapshots, launch extras, report I/O, and embedded build manifest. * * FrameMetrics TOTAL_DURATION is a duration → ceil(duration/period)−1. + * dropCountSinceLastInvocation is a separate loss count, not a missed frame. * Choreographer deltas are intervals → round(interval/period)−1. */ class BenchNativeModule( @@ -51,6 +52,8 @@ class BenchNativeModule( private var maxFrameDurationNs = 0L private var totalFrameDurationNs = 0L private var missedFrames = 0 + /** Sum of FrameMetrics dropCountSinceLastInvocation. Not added to missedFrames. */ + private var droppedFrameMetrics = 0 private var refreshPeriodNs = 16_666_666L private var refreshRateHz = 60.0 @@ -196,7 +199,8 @@ class BenchNativeModule( val window = activity.window if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && window != null) { val listener = - Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> + Window.OnFrameMetricsAvailableListener { _, frameMetrics, dropCountSinceLastInvocation -> + recordFrameMetricsDrop(dropCountSinceLastInvocation) val totalNs = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) recordFrameMetricsDuration(totalNs) } @@ -344,6 +348,8 @@ class BenchNativeModule( map.putDouble("maxFrameDurationMs", maxFrameDurationNs / 1_000_000.0) map.putDouble("totalFrameDurationMs", totalFrameDurationNs / 1_000_000.0) map.putInt("missedFrames", missedFrames) + map.putInt("droppedFrameMetrics", droppedFrameMetrics) + map.putBoolean("frameMetricsDropped", droppedFrameMetrics > 0) map.putDouble("refreshPeriodMs", refreshPeriodNs / 1_000_000.0) map.putDouble("refreshRateHz", refreshRateHz) map.putBoolean("wasCapturing", frameCount > 0 || captureSource != "none") @@ -373,6 +379,15 @@ class BenchNativeModule( promise.resolve(result) } + /** + * Lost FrameMetrics reports since the previous callback. + * Kept separate from missedFrames / the Choreographer interval count. + */ + private fun recordFrameMetricsDrop(dropCount: Int) { + if (dropCount <= 0) return + droppedFrameMetrics += dropCount + } + /** FrameMetrics TOTAL_DURATION — duration semantics (ceil). */ private fun recordFrameMetricsDuration(durationNs: Long) { if (durationNs <= 0L) return @@ -406,6 +421,7 @@ class BenchNativeModule( maxFrameDurationNs = 0L totalFrameDurationNs = 0L missedFrames = 0 + droppedFrameMetrics = 0 lastChoreographerNs = 0L captureSource = "none" captureWindowRef = null diff --git a/examples/benchmark-native/src/BenchNative.ts b/examples/benchmark-native/src/BenchNative.ts index bb0737da4b3a..6bc0f32df3da 100644 --- a/examples/benchmark-native/src/BenchNative.ts +++ b/examples/benchmark-native/src/BenchNative.ts @@ -14,6 +14,10 @@ export interface UiFrameCaptureResult { maxFrameDurationMs: number; totalFrameDurationMs: number; missedFrames: number; + /** Sum of FrameMetrics dropCountSinceLastInvocation. Zero for Choreographer. */ + droppedFrameMetrics: number; + /** True when droppedFrameMetrics is non-zero. */ + frameMetricsDropped: boolean; refreshPeriodMs: number; refreshRateHz: number; wasCapturing?: boolean; diff --git a/examples/benchmark-native/src/frames.ts b/examples/benchmark-native/src/frames.ts index 3f4c2c79afcd..a2cf95f4f55d 100644 --- a/examples/benchmark-native/src/frames.ts +++ b/examples/benchmark-native/src/frames.ts @@ -62,6 +62,34 @@ export function missedFramesFromTimestamps( ); } +/** + * FrameMetrics dropCountSinceLastInvocation is a loss count, not jank. + * The flag is set when the summed count is non-zero. Callers must not add + * the count to missedFrames or uiMissedFrames. + */ +export function withFrameMetricsLoss< + T extends { missedFrames: number; uiMissedFrames: number }, +>( + sample: T, + droppedFrameMetrics: number, +): T & { + uiDroppedFrameMetrics: number; + uiFrameMetricsDropped: boolean; +} { + if (!Number.isInteger(droppedFrameMetrics) || droppedFrameMetrics < 0) { + throw new Error( + `invalid FrameMetrics drop count: ${String(droppedFrameMetrics)}`, + ); + } + return { + ...sample, + missedFrames: sample.missedFrames, + uiMissedFrames: sample.uiMissedFrames, + uiDroppedFrameMetrics: droppedFrameMetrics, + uiFrameMetricsDropped: droppedFrameMetrics > 0, + }; +} + /** * Reject invalid refresh period or zero/insufficient native frames. * Missed-frame math must match capture source semantics. @@ -98,4 +126,12 @@ export function validateUiFrameCapture( if (result.source !== 'FrameMetrics' && result.source !== 'Choreographer') { throw new Error(`unknown ui capture source: ${String(result.source)}`); } + if ( + !Number.isInteger(result.droppedFrameMetrics) || + result.droppedFrameMetrics < 0 + ) { + throw new Error( + `invalid FrameMetrics drop count: ${String(result.droppedFrameMetrics)}`, + ); + } } diff --git a/examples/benchmark-native/src/measure.ts b/examples/benchmark-native/src/measure.ts index f0d37848e933..534322f7e8a9 100644 --- a/examples/benchmark-native/src/measure.ts +++ b/examples/benchmark-native/src/measure.ts @@ -15,6 +15,7 @@ import { frameIntervalsFromTimestamps, measureDisplayPeriodMs, validateUiFrameCapture, + withFrameMetricsLoss, } from './frames'; import { countRemaining, @@ -154,28 +155,31 @@ export async function runGCScenario(): Promise { displayPeriodMs, ); - const measurement: GCAndroidMeasurement = { - schemaVersion: 1, - totalMs: sequence.totalMs, - sliceDurationsMs: config.control === 'gc' ? [sequence.totalMs] : [], - actionCount: sequence.actionCount, - queueEntries: expected.queueEntries, - uniqueTargets: expected.uniqueTargets, - actionTargetCount, - deletionCount, - timerDelayMs: sequence.timerDelayMs, - frameIntervalsMs, - displayPeriodMs, - missedFrames, - maxInputDelayMs, - uiCaptureSource: uiFrames.source, - uiFrameCount: uiFrames.frameCount, - uiMaxFrameDurationMs: uiFrames.maxFrameDurationMs, - uiTotalFrameDurationMs: uiFrames.totalFrameDurationMs, - uiMissedFrames: uiFrames.missedFrames, - uiRefreshPeriodMs: uiFrames.refreshPeriodMs, - uiRefreshRateHz: uiFrames.refreshRateHz, - }; + const measurement: GCAndroidMeasurement = withFrameMetricsLoss( + { + schemaVersion: 1 as const, + totalMs: sequence.totalMs, + sliceDurationsMs: config.control === 'gc' ? [sequence.totalMs] : [], + actionCount: sequence.actionCount, + queueEntries: expected.queueEntries, + uniqueTargets: expected.uniqueTargets, + actionTargetCount, + deletionCount, + timerDelayMs: sequence.timerDelayMs, + frameIntervalsMs, + displayPeriodMs, + missedFrames, + maxInputDelayMs, + uiCaptureSource: uiFrames.source, + uiFrameCount: uiFrames.frameCount, + uiMaxFrameDurationMs: uiFrames.maxFrameDurationMs, + uiTotalFrameDurationMs: uiFrames.totalFrameDurationMs, + uiMissedFrames: uiFrames.missedFrames, + uiRefreshPeriodMs: uiFrames.refreshPeriodMs, + uiRefreshRateHz: uiFrames.refreshRateHz, + }, + uiFrames.droppedFrameMetrics, + ); validateMeasurement(config, harness, measurement); diff --git a/examples/benchmark-native/src/report.ts b/examples/benchmark-native/src/report.ts index f5571e6f4be7..6cda0d00c295 100644 --- a/examples/benchmark-native/src/report.ts +++ b/examples/benchmark-native/src/report.ts @@ -54,6 +54,9 @@ export function summarizeGCSamples( samples.map(s => s.uiTotalFrameDurationMs), ), uiMissedFrames: summarizeNumbers(samples.map(s => s.uiMissedFrames)), + uiDroppedFrameMetrics: summarizeNumbers( + samples.map(s => s.uiDroppedFrameMetrics), + ), uiRefreshPeriodMs: summarizeNumbers(samples.map(s => s.uiRefreshPeriodMs)), }; @@ -132,6 +135,10 @@ export const REPORT_UNITS: Record = { 'milliseconds (sum of FrameMetrics durations or Choreographer intervals)', uiMissedFrames: 'count (FrameMetrics: ceil(duration/period)−1; Choreographer: round(interval/period)−1)', + uiDroppedFrameMetrics: + 'count (FrameMetrics dropCountSinceLastInvocation; not missed frames)', + uiFrameMetricsDropped: + 'boolean (true when uiDroppedFrameMetrics is non-zero)', uiRefreshPeriodMs: 'milliseconds', uiRefreshRateHz: 'hertz', processPssBeforeKb: 'kilobytes (Android Debug.MemoryInfo totalPss)', diff --git a/examples/benchmark-native/src/types.ts b/examples/benchmark-native/src/types.ts index 3511b7a768ea..ddc293b75131 100644 --- a/examples/benchmark-native/src/types.ts +++ b/examples/benchmark-native/src/types.ts @@ -56,6 +56,13 @@ export interface GCAndroidMeasurement { /** Aggregate sum of frame durations/intervals (ms). */ uiTotalFrameDurationMs: number; uiMissedFrames: number; + /** + * FrameMetrics `dropCountSinceLastInvocation` summed over the capture. + * Lost reports, not jank. Not included in `missedFrames` or `uiMissedFrames`. + */ + uiDroppedFrameMetrics: number; + /** True when `uiDroppedFrameMetrics` is non-zero. */ + uiFrameMetricsDropped: boolean; uiRefreshPeriodMs: number; uiRefreshRateHz: number; processPssBeforeKb?: number; From 5cd894ca8d7ee513f037a7abf61de47d332cfae7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 03:49:30 +0000 Subject: [PATCH 32/32] fix(benchmark-native): Hash the dist files Metro actually bundles Metro matches package exports in the package's own key order, so the import condition (node.mjs, which re-exports dist) wins before the react-native field (lib/). prepare runs build:bundle for those packages before hashing, and the digest test checks metro-resolver's path. Co-authored-by: Nathaniel Tucker --- .../__tests__/source-digest.test.ts | 150 +++++++--- .../scripts/build-android-release.sh | 3 +- .../scripts/build-manifest.cjs | 278 +++++++++++++++--- examples/benchmark-native/src/types.ts | 2 +- 4 files changed, 355 insertions(+), 78 deletions(-) diff --git a/examples/benchmark-native/__tests__/source-digest.test.ts b/examples/benchmark-native/__tests__/source-digest.test.ts index bb61b5b0d1df..435e8e776fa0 100644 --- a/examples/benchmark-native/__tests__/source-digest.test.ts +++ b/examples/benchmark-native/__tests__/source-digest.test.ts @@ -1,15 +1,18 @@ /** - * Release sourceDigest must follow Metro's react-native export (lib/), - * not package src. The APK bundles that lib; src-only edits do not. + * Release sourceDigest must hash the files Metro puts in the Android bundle. + * Package exports are matched in the package's own key order, so `import` + * (node.mjs → dist) wins before the `react-native` field (lib/). */ const fs = require('fs'); +const { resolve } = require('metro-resolver'); const os = require('os'); const path = require('path'); const { - bundleRootForPackage, collectInputFiles, hashInputFiles, + metroBundledPackageFiles, + resolveMetroFile, sourceDigest, } = require('../scripts/build-manifest.cjs'); @@ -17,59 +20,138 @@ export {}; // Jest cwd is this workspace (examples/benchmark-native). const REPO = path.resolve('../..'); +const ORIGIN = path.resolve('src/gcHarness.ts'); function rel(file: string): string { return path.relative(REPO, file).split(path.sep).join('/'); } describe('android sourceDigest inputs', () => { - it('hashes the Metro react-native lib, not package src or legacy', () => { - for (const name of ['core', 'normalizr', 'endpoint']) { - const pkg = require(`../../../packages/${name}/package.json`); - expect(pkg.exports['.']['react-native']).toBe('./lib/index.js'); - expect(pkg['react-native']).toBe('legacy/index.js'); - expect(bundleRootForPackage(`packages/${name}`)).toBe( - path.join(REPO, 'packages', name, 'lib'), - ); - } + it('hashes the files Metro resolves, not the react-native lib field', () => { + const esmEntry = resolveMetroFile(ORIGIN, '@data-client/core', true); + const cjsEntry = resolveMetroFile(ORIGIN, '@data-client/core', false); + expect(rel(esmEntry)).toBe('packages/core/node.mjs'); + expect(rel(cjsEntry)).toBe('packages/core/dist/index.js'); + + const bundled = metroBundledPackageFiles().map(rel); + expect(bundled).toEqual([ + 'packages/core/dist/index.js', + 'packages/core/node.mjs', + 'packages/normalizr/dist/normalizr.js', + ]); const rels: string[] = collectInputFiles().map(rel); - expect(rels).toEqual( - expect.arrayContaining([ - 'packages/core/lib/index.js', - 'packages/normalizr/lib/index.js', - 'packages/endpoint/lib/index.js', - 'examples/gc-shared/protocol.js', - ]), + for (const file of bundled) { + expect(rels).toContain(file); + } + expect(rels.some((r: string) => r.startsWith('packages/core/lib/'))).toBe( + false, + ); + expect(rels.some((r: string) => r.startsWith('packages/core/src/'))).toBe( + false, ); expect( - rels.some((r: string) => - /^packages\/(core|normalizr|endpoint)\/(src|legacy)\//.test(r), - ), - ).toBe(false); - expect( - rels.some( - (r: string) => r.startsWith('packages/core/lib/') && !r.endsWith('.js'), - ), + rels.some((r: string) => r.startsWith('packages/normalizr/lib/')), ).toBe(false); + expect(rels.some((r: string) => r.startsWith('packages/endpoint/'))).toBe( + false, + ); + expect(rels).toContain('examples/gc-shared/protocol.js'); expect(sourceDigest()).toBe(hashInputFiles(collectInputFiles())); }); - it('changes when bundled lib bytes change', () => { - const lib = collectInputFiles().find((file: string) => - rel(file).endsWith('packages/core/lib/index.js'), + it('asks metro-resolver for the release entry and uses that path', () => { + const config = require('../metro.config.js'); + const resolver = config.resolver; + const origin = ORIGIN; + function lookup(filePath: string) { + try { + const stat = fs.lstatSync(filePath); + return { + exists: true, + type: stat.isDirectory() ? 'd' : 'f', + realPath: fs.realpathSync(filePath), + }; + } catch { + return { exists: false }; + } + } + function getPackageForModule(abs: string) { + let dir = abs; + try { + if (!fs.statSync(abs).isDirectory()) dir = path.dirname(abs); + } catch { + dir = path.dirname(abs); + } + while (true) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const relative = path.relative(dir, abs); + return { + rootPath: dir, + packageJson: JSON.parse(fs.readFileSync(pkgPath, 'utf8')), + packageRelativePath: relative === '' ? '' : relative, + }; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + } + const resolved = resolve( + { + allowHaste: false, + assetExts: new Set(resolver.assetExts), + dev: false, + disableHierarchicalLookup: !!resolver.disableHierarchicalLookup, + doesFileExist: (filePath: string) => { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } + }, + extraNodeModules: resolver.extraNodeModules || {}, + fileSystemLookup: lookup, + getPackage: (pkgPath: string) => + JSON.parse(fs.readFileSync(pkgPath, 'utf8')), + getPackageForModule, + isESMImport: true, + mainFields: resolver.resolverMainFields, + nodeModulesPaths: resolver.nodeModulesPaths, + originModulePath: origin, + preferNativePlatform: true, + resolveAsset: () => null, + resolveRequest: resolver.resolveRequest || null, + sourceExts: resolver.sourceExts, + unstable_conditionNames: resolver.unstable_conditionNames, + unstable_conditionsByPlatform: resolver.unstable_conditionsByPlatform, + unstable_enablePackageExports: resolver.unstable_enablePackageExports, + unstable_logWarning: () => {}, + }, + '@data-client/core', + 'android', ); - expect(lib).toBeDefined(); + expect(rel(resolved.filePath)).toBe('packages/core/node.mjs'); + expect(metroBundledPackageFiles().map(rel)).toContain( + 'packages/core/node.mjs', + ); + expect(rel(resolved.filePath)).not.toContain('/lib/'); + }); + + it('changes when a bundled dist file changes and ignores package src', () => { + const dist = collectInputFiles().find((file: string) => + rel(file).endsWith('packages/core/dist/index.js'), + ); + expect(dist).toBeDefined(); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-digest-')); const copy = path.join(dir, 'index.js'); - const bytes = fs.readFileSync(lib, 'utf8'); + const bytes = fs.readFileSync(dist, 'utf8'); fs.writeFileSync(copy, bytes); const before = hashInputFiles([copy]); fs.writeFileSync(copy, `${bytes}\n`); expect(hashInputFiles([copy])).not.toBe(before); - }); - it('does not include package src, so a src-only file is outside the digest', () => { const src = path.join(REPO, 'packages/core/src/index.ts'); expect(fs.existsSync(src)).toBe(true); expect(collectInputFiles().includes(path.resolve(src))).toBe(false); diff --git a/examples/benchmark-native/scripts/build-android-release.sh b/examples/benchmark-native/scripts/build-android-release.sh index 07d4692f5f82..79f3a8e5b1c8 100644 --- a/examples/benchmark-native/scripts/build-android-release.sh +++ b/examples/benchmark-native/scripts/build-android-release.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Authoritative release build: prepare BuildManifest → Gradle assembleRelease → sidecar. +# Authoritative release build: prepare BuildManifest (rebuilds the dist files +# Metro bundles, then hashes them) → Gradle assembleRelease → sidecar. # Split APKs are unsupported; requires single app-release.apk. set -euo pipefail diff --git a/examples/benchmark-native/scripts/build-manifest.cjs b/examples/benchmark-native/scripts/build-manifest.cjs index d77a2adeaa3b..375603843981 100644 --- a/examples/benchmark-native/scripts/build-manifest.cjs +++ b/examples/benchmark-native/scripts/build-manifest.cjs @@ -9,11 +9,11 @@ * digest — print current sourceDigest (for stale checks) * verify — verify manifest and/or sidecar identity * - * Inputs: sorted paths under this app (tracked+untracked contents) plus - * the Metro-resolved lib of packages/{core,normalizr,endpoint} (react-native - * export condition → lib/, the bytes the release APK bundles — not package - * src or the legacy main field) and examples/gc-shared, excluding - * build/node_modules/artifacts/.jdk/generated. + * Inputs: sorted paths under this app (tracked+untracked contents) plus the + * package files the release Metro bundle actually resolves (@data-client/* + * via package exports key order: import → node.mjs, require → dist/) and + * examples/gc-shared, excluding build/node_modules/artifacts/.jdk/generated. + * prepare rebuilds those package bundles (build:bundle) before hashing. */ const { execSync } = require('child_process'); const crypto = require('crypto'); @@ -63,56 +63,248 @@ function walkFiles(dir, out) { } } +const { resolve: metroResolve } = require('metro-resolver'); + +let metroResolverConfig; +function loadMetroResolverConfig() { + if (!metroResolverConfig) { + metroResolverConfig = require(path.join(ROOT, 'metro.config.js')).resolver; + } + return metroResolverConfig; +} + +function realpathOrSelf(filePath) { + try { + return fs.realpathSync(filePath); + } catch { + return filePath; + } +} + +function fileSystemLookup(filePath) { + try { + const stat = fs.lstatSync(filePath); + return { + exists: true, + type: stat.isDirectory() ? 'd' : 'f', + realPath: realpathOrSelf(filePath), + }; + } catch { + return { exists: false }; + } +} + +function getPackageForModule(abs) { + let dir = abs; + try { + if (!fs.statSync(abs).isDirectory()) dir = path.dirname(abs); + } catch { + dir = path.dirname(abs); + } + while (true) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const rel = path.relative(dir, abs); + return { + rootPath: dir, + packageJson: JSON.parse(fs.readFileSync(pkgPath, 'utf8')), + packageRelativePath: rel === '' ? '' : rel, + }; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + /** - * Packages the release bundle loads through Metro's react-native condition. - * Metro (`unstable_conditionNames: ["react-native"]`, package exports on) - * resolves these to lib/, not src/ and not the legacy "react-native" field. + * Same resolver context the release bundle uses (metro.config.js + RN defaults). + * isESMImport selects the `import` condition; otherwise Metro asserts `require`. + * Both are tried before `react-native` because matching follows the package's + * own exports key order. */ -const BUNDLED_PACKAGES = [ - 'packages/core', - 'packages/normalizr', - 'packages/endpoint', -]; - -function bundleRootForPackage(packageRel) { - const pkgPath = path.join(REPO, packageRel, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - const dot = pkg.exports && pkg.exports['.']; - const rel = dot && dot['react-native']; - if (typeof rel !== 'string' || rel.length === 0) { +function metroResolveContext(originModulePath, isESMImport) { + const resolver = loadMetroResolverConfig(); + return { + allowHaste: false, + assetExts: new Set(resolver.assetExts || []), + dev: false, + disableHierarchicalLookup: !!resolver.disableHierarchicalLookup, + doesFileExist: filePath => { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } + }, + extraNodeModules: resolver.extraNodeModules || {}, + fileSystemLookup, + getPackage: pkgPath => JSON.parse(fs.readFileSync(pkgPath, 'utf8')), + getPackageForModule, + isESMImport, + mainFields: resolver.resolverMainFields, + nodeModulesPaths: resolver.nodeModulesPaths, + originModulePath, + preferNativePlatform: true, + resolveAsset: () => null, + resolveRequest: resolver.resolveRequest || null, + sourceExts: resolver.sourceExts, + unstable_conditionNames: resolver.unstable_conditionNames, + unstable_conditionsByPlatform: resolver.unstable_conditionsByPlatform, + unstable_enablePackageExports: resolver.unstable_enablePackageExports, + unstable_logWarning: () => {}, + }; +} + +function resolveMetroFile(originModulePath, specifier, isESMImport) { + const resolved = metroResolve( + metroResolveContext(originModulePath, isESMImport), + specifier, + 'android', + ); + if (!resolved || resolved.type !== 'sourceFile' || !resolved.filePath) { throw new Error( - `${packageRel} has no exports["."].react-native (Metro condition)`, + `Metro did not resolve ${specifier} from ${originModulePath}`, ); } - const entry = path.resolve(path.join(REPO, packageRel), rel); - if (!fs.existsSync(entry)) { - throw new Error( - `Metro react-native entry missing: ${path.relative(REPO, entry)} (run that package's build:lib)`, - ); + return resolved.filePath; +} + +function staticDependencies(code) { + const deps = []; + const seen = new Set(); + const add = (specifier, isESMImport) => { + if (!specifier.startsWith('.') && !specifier.startsWith('@data-client/')) { + return; + } + const key = `${isESMImport ? 'esm' : 'cjs'}:${specifier}`; + if (seen.has(key)) return; + seen.add(key); + deps.push({ specifier, isESMImport }); + }; + const esmFrom = + /(?:^|[;\n])\s*(?:import|export)\s+(?!type\b)[\s\S]*?\sfrom\s*['"]([^'"]+)['"]/g; + const esmSide = /(?:^|[;\n])\s*import\s+['"]([^'"]+)['"]/g; + const cjs = /require\(\s*['"]([^'"]+)['"]\s*\)/g; + let match; + while ((match = esmFrom.exec(code))) add(match[1], true); + while ((match = esmSide.exec(code))) add(match[1], true); + while ((match = cjs.exec(code))) add(match[1], false); + return deps; +} + +function shouldFollowResolved(filePath) { + const rel = path.relative(REPO, filePath).split(path.sep).join('/'); + if (rel.startsWith('..')) return false; + return ( + rel.startsWith('examples/benchmark-native/') || + rel.startsWith('examples/gc-shared/') || + rel.startsWith('packages/') + ); +} + +/** + * Package files reachable from the release entry the same way Metro resolves + * them (android, dev false). This is the closure in the release bundle, not + * the `react-native` exports field (lib/) and not unused dist siblings. + */ +function metroBundledPackageFiles() { + const entry = path.join(ROOT, 'index.js'); + const queue = [entry]; + const seen = new Set(); + const packageFiles = []; + while (queue.length) { + const file = queue.pop(); + const real = realpathOrSelf(file); + if (seen.has(real)) continue; + seen.add(real); + const rel = path.relative(REPO, real).split(path.sep).join('/'); + if (rel.startsWith('packages/')) packageFiles.push(real); + if (!shouldFollowResolved(real)) continue; + let code; + try { + code = fs.readFileSync(real, 'utf8'); + } catch { + continue; + } + for (const dep of staticDependencies(code)) { + let next; + try { + next = resolveMetroFile(real, dep.specifier, dep.isESMImport); + } catch (err) { + throw new Error( + `Metro resolve failed for ${dep.specifier} from ${path.relative(REPO, real)}: ${err.message}`, + ); + } + queue.push(next); + } } - return path.dirname(entry); + return packageFiles.sort((a, b) => a.localeCompare(b)); } -/** .js files under a Metro lib root. Declarations and external maps are not bundled. */ -function walkJsFiles(dir, out) { - if (!fs.existsSync(dir)) return; - for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, ent.name); - if (ent.isDirectory()) { - if (shouldSkipDir(ent.name)) continue; - walkJsFiles(full, out); - } else if (ent.isFile() && ent.name.endsWith('.js')) { - out.push(full); +function dataClientPackageName(filePath) { + const rel = path.relative(REPO, filePath).split(path.sep); + if (rel[0] !== 'packages' || !rel[1]) return null; + return rel[1]; +} + +function runPackageBuildBundle(packageDirName) { + const pkgPath = path.join(REPO, 'packages', packageDirName, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!pkg.scripts || !pkg.scripts['build:bundle']) { + throw new Error(`${pkg.name} has no build:bundle script`); + } + console.log( + `== build:bundle ${pkg.name} (file the release Metro bundle resolves) ==`, + ); + execSync(`yarn workspace ${pkg.name} run build:bundle`, { + cwd: REPO, + stdio: 'inherit', + }); +} + +/** + * Rebuild dist outputs Metro will bundle, then return that file list. + * Direct @data-client imports are built first so node.mjs → dist can resolve; + * transitive packages (normalizr, required from core's dist) are built next. + */ +function ensureMetroBundlesBuilt() { + const built = new Set(); + const build = name => { + if (built.has(name)) return; + built.add(name); + runPackageBuildBundle(name); + }; + for (let attempt = 0; attempt < 6; attempt++) { + let files; + try { + files = metroBundledPackageFiles(); + } catch (err) { + const missing = String(err.message).match(/packages\/([^/]+)\/dist/); + if (missing && !built.has(missing[1])) { + build(missing[1]); + continue; + } + throw err; + } + const pending = []; + for (const file of files) { + const name = dataClientPackageName(file); + const rel = path.relative(REPO, file).split(path.sep).join('/'); + if (name && rel.includes('/dist/') && !built.has(name)) { + pending.push(name); + } } + if (!pending.length) return files; + for (const name of pending) build(name); } + throw new Error('could not build the package bundles Metro resolves'); } function collectInputFiles() { const files = []; walkFiles(ROOT, files); - for (const packageRel of BUNDLED_PACKAGES) { - walkJsFiles(bundleRootForPackage(packageRel), files); - } + for (const file of metroBundledPackageFiles()) files.push(file); walkFiles(path.join(REPO, 'examples/gc-shared'), files); return files.map(f => path.resolve(f)).sort((a, b) => a.localeCompare(b)); } @@ -162,6 +354,7 @@ function gitMeta() { } function prepare() { + ensureMetroBundlesBuilt(); const digest = sourceDigest(); const { gitCommit, gitDirty } = gitMeta(); const schemaVersion = 1; @@ -264,10 +457,11 @@ function verify() { } module.exports = { - BUNDLED_PACKAGES, - bundleRootForPackage, collectInputFiles, + ensureMetroBundlesBuilt, hashInputFiles, + metroBundledPackageFiles, + resolveMetroFile, sourceDigest, }; diff --git a/examples/benchmark-native/src/types.ts b/examples/benchmark-native/src/types.ts index ddc293b75131..415c99c23257 100644 --- a/examples/benchmark-native/src/types.ts +++ b/examples/benchmark-native/src/types.ts @@ -116,7 +116,7 @@ export interface BuildManifestV1 { buildId: string; gitCommit: string; gitDirty: boolean; - /** Content digest of the app tree plus Metro-resolved package lib inputs. */ + /** Content digest of the app tree plus the package files Metro bundles. */ sourceDigest: string; createdAt: string; }