npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@perf-labs/react-native

v0.1.0-alpha.11

Published

PerfLab in-app SDK for React Native / Expo: zero-code measurement windows, React commit hook, native signposts and log file

Downloads

678

Readme

@perf-labs/react-native

The PerfLab in-app SDK for React Native / Expo apps. It measures from inside the app without any change to app source: press windows from generic taps, screen windows from navigation state, React commit counts from a DevTools hook, PerformanceObserver entries, iOS signposts / Android trace sections, a log file in the app sandbox and an optional push to the farm ingest.

Instrumentation is build-time gated. Without the build flag withPerfLab returns the Metro config unchanged, the JS bundle is byte-identical to a bundle built without the package (research docs 21, 22, 23 §1), and the config plugin keeps the native module out of the binary.

With the flag on, a build can also be dormant (EXPO_PUBLIC_PERF_LAB_MODE=dormant): the SDK is in the binary but does nothing until a run activates it by link, from the next launch on (plan 36 §1.2). One build can then serve ordinary e2e/perf jobs and PerfLab runs.

Integration (three config lines, no app code)

bun add @perf-labs/react-native   # or npm / yarn; see "Local development" for file: installs

metro.config.js: wrap the final config, outermost:

const { withPerfLab } = require('@perf-labs/react-native/metro');
module.exports = withPerfLab(config);

app.config.js / app.json:

plugins: [/* ... */, '@perf-labs/react-native'],

Native code needs no further setup: Expo autolinking links the module from expo-module.config.json (iOS pod PerfLab, Android expo.modules.perflab.PerfLabModule) when the flag is on. Set EXPO_PUBLIC_PERF_LAB the same way for expo prebuild (or the EAS build) and for the JS bundle, then pod install / rebuild.

What the config plugin does

It decides whether the native module is linked, from EXPO_PUBLIC_PERF_LAB at prebuild time:

  • Flag off: the package is excluded from Expo autolinking. iOS: the Podfile's use_expo_modules! becomes use_expo_modules!({ exclude: ['<package>'] }) (this also reaches the expo-configure-project.sh build phase that regenerates ExpoModulesProvider.swift). Android: expoAutolinking.exclude is set in settings.gradle before expoAutolinking.useExpoModules(). expo-modules-autolinking 57 reads exclusions only from the app's package.json and from these call sites. Result: no PerfLab pod, no Android project, no provider entry.
  • Flag on: the exclusion is removed again (prebuild without --clean edits existing files) and extra.perflab = { native: true, profilingRenderer } is added to the app config, so flag-on and flag-off builds have different native fingerprints. profilingRenderer is always (PERF_LAB_REACT_PROFILING=1), switch (=switch) or off, read at prebuild time, so the three builds fingerprint differently too. Set PERF_LAB_REACT_PROFILING the same way for prebuild and for the bundle.

A Podfile whose use_expo_modules! already takes arguments (or a settings.gradle without expoAutolinking.useExpoModules()) cannot be edited safely; with the flag off, prebuild then fails with a message saying what to add. No Info.plist, entitlement or manifest change is needed: the module only uses os_signpost/OSLog, android.os.Trace, logcat and the app's own files directory.

What withPerfLab does (flag on)

  1. Appends js/hook.polyfill.js to serializer.getPolyfills, so a __REACT_DEVTOOLS_GLOBAL_HOOK__ exists before React evaluates (it chains onto an existing hook instead of replacing it; in dev it defers to React DevTools). In a dormant build it first reads the activation and installs nothing when not activated.
  2. Sets transformer.babelTransformerPath to js/babel-transformer.js, which delegates to the app's transformer and
    • prepends import "<relative path>/js/sdk/boot"; to the entry file (package.json main), which puts the SDK into the graph;
    • inlines process.env.EXPO_PUBLIC_* in the SDK's own files with the build-time values. babel-preset-expo does not inline EXPO_PUBLIC_* inside node_modules, so without this step the SDK would read undefined at runtime.
  3. Appends the boot module to serializer.getModulesRunBeforeMainModule (ignored by Expo's serializer, harmless: the boot import is the entry's first statement; kept for plain Metro).

The upstream transformer path, entry file and boot file reach Metro's transform workers through PERFLAB_UPSTREAM_BABEL_TRANSFORMER, PERFLAB_ENTRY_FILE and PERFLAB_BOOT_FILE (internal, set by withPerfLab).

Environment / config contract

All values are read at bundle time (Expo CLI loads .env* into the Metro process). Change them, then rebuild the JS bundle with a clean cache (--reset-cache / expo start -c).

| Variable (current name) | Proposed public name | Values | Effect | |---|---|---|---| | EXPO_PUBLIC_PERF_LAB | PERFLAB | 1 = on, anything else = off | Build flag. Off: Metro config untouched, SDK not in the bundle. On: hook polyfill + SDK boot in the bundle. | | EXPO_PUBLIC_PERF_LAB_MODE | PERFLAB_MODE | dormant / auto | auto: measure from the first frame (same as PERF_LAB_AUTO=1). dormant: nothing runs until activated (below); once activated the process measures like auto. Unset: auto if PERF_LAB_AUTO=1, else manual (markers until a settings link turns collection on). | | EXPO_PUBLIC_PERF_LAB_AUTO | PERFLAB_AUTO | 1 = on | Measure-only builds: collection (React hook walk, PerformanceObserver, log to console and native file) is on from the first frame, with no deep link needed. Off: only markers until a settings link turns collection on. Equivalent to PERF_LAB_MODE=auto; PERF_LAB_MODE wins when both are set. | | EXPO_PUBLIC_PERFLAB_INGEST_URL | PERFLAB_INGEST_URL | URL of the farm ingest (POST /v1/ingest) | Push client. Push is on only if both the URL and the key are set. | | EXPO_PUBLIC_PERFLAB_KEY | PERFLAB_KEY | ingest key | Sent as x-perflab-key. | | EXPO_PUBLIC_PERF_LAB_SAMPLER | PERFLAB_SAMPLER | auto / manual / off | Native resource sampler default. Unset: auto when PERF_LAB_AUTO=1, else off. | | EXPO_PUBLIC_PERF_LAB_SAMPLER_MS | PERFLAB_SAMPLER_MS | ms, 250–60000 | Auto sampling interval (default 4000; windows also get a sample at open and at close). | | EXPO_PUBLIC_PERF_LAB_PROFILE | PERFLAB_PROFILE | 1 = on | Default of profile: per-component update causes per window (any renderer), plus self/total ms and updaters with the profiling renderer (react.profile). | | EXPO_PUBLIC_PERF_LAB_REACT_EXPORT | PERFLAB_REACT_EXPORT | 1 = on | Default of rexport: a React DevTools v5 export per window (Documents/perf-lab/react-<process>-<window id>-<name>.json, TraceSift's input). Inside a run (a run token is set) the files are held and written at flush=1; otherwise at window end. | | EXPO_PUBLIC_PERF_LAB_CPUPROF, ..._CPUPROF_HZ | PERFLAB_CPUPROF | 1 = on; Hz (default 250) | Default of cpuprof/cpuhz: Hermes sampling profiler (iOS), Documents/perf-lab/cpu-<process>-<span>.cpuprofile. Inside a run it samples from the first window to flush=1 and stops and dumps there, so the synchronous dump never lands in a measured window; otherwise one file per span of overlapping windows, dumped when the last one ends. <process> is the launch's processId (startup line), so launches never overwrite each other. | | PERF_LAB_REACT_PROFILING (Metro process; also read by the config plugin) | | 1 / switch / unset | 1: withPerfLab resolves RN's production renderer to ReactFabric-profiling (fiber durations, updaters), flag or not; for dedicated perf builds (docs/plans/37). switch (flag on only, ignored with the flag off): both Fabric renderers go into the bundle and each process loads one at launch (below). Unset: production renderer only. |

Only the names above are read today. The proposed names drop the EXPO_PUBLIC_ prefix because the values never need to be readable by app code: the transformer inlines them into the SDK only, so a non-prefixed PERFLAB=1 would also keep the flag out of the app's own process.env surface and match the CLI / CI wording (PERFLAB=1 in doc 23). Migration plan: accept both names (new name wins), then drop the old ones.

Renderer switch (PERF_LAB_REACT_PROFILING=switch)

For a shared build (plan 40 §2.1): withPerfLab resolves ReactFabric-prod and ReactFabric-profiling to js/renderer-switch.js, which requires the profiling renderer when __PERFLAB_ACTIVATION__.profile is true and the production one otherwise. Both are in the bundle; only the chosen one is evaluated. The choice is made once, before React loads:

  • dormant build: the activation's profile (link activate=1&profile=1), read by the hook polyfill. A process that is not activated, or whose activation expired, runs the production renderer, as without the switch. If the polyfill cannot reach the native module (Android may install expo.modules lazily), the boot module reads the activation and sets profile before the renderer loads;
  • auto / manual build: EXPO_PUBLIC_PERF_LAB_PROFILE=1. The runtime profile=1 link turns causes and render times on, but cannot change the renderer of a running process.

The renderer that was loaded shows as activation.renderer (prod / profiling) in the startup report. Cost: on the reference app, the dormant switch bundle is +133 KiB JS / +175 KiB Hermes bytecode over the dormant bundle. Fabric only (React Native's legacy renderer is not switched). Checks: bun run check:s1 (flag off: no SDK, no profiling renderer) and bun run check:switch (dormant switch bundle: both renderers) in apps/reference.

Dormant mode and activation

A dormant process installs nothing: __REACT_DEVTOOLS_GLOBAL_HOOK__ is left as without the SDK (absent in release), fetch/XMLHttpRequest are not touched, no AppState/RawEventEmitter/navigation listener, no PerformanceObserver, no timer, no log line or file, no push client, no sampler or display link. The JS modules evaluated are boot, activation, dormant and links; the measuring SDK (active.ts and what it imports) is in the bundle but only required when the process measures. What remains:

  • one synchronous native call at startup, activationRead(), from the hook polyfill (Expo installs expo.modules before the bundle runs, so the polyfill reaches the module directly; checked on iOS / Expo SDK 57). If expo.modules is not there yet (Android may install it lazily), the polyfill prepares the hook without installing it and the boot module reads the activation instead, still before the React renderer loads;
  • the Linking wrapper: one substring check per incoming URL, and a no-op url subscription so a running app receives links even if it never subscribes itself.

Activation is by settings link and applies from the next launch, never in the running process, so a process is wholly measured or wholly dormant and carries exactly one run token:

| Link | Writes perf-lab/activation.json | |---|---| | devtools/perf-lab?activate=1&runToken=<pl_rt_…>&ttl=<s>&diag=1&profile=1&cpuprof=1&cpuhz=<Hz> | { active: true, runToken, expiresAt, diag, profile, cpuprof }; ttl default 7200 s, max 21600 s (the run token's own life); diag (default off) marks the launch as a diagnostic iteration; profile (default off) loads the profiling renderer in a switch build and turns causes and render times on; cpuprof (default off) stores a Hermes sampling rate in Hz: 1/true means cpuhz (default 250), a number above 1 is the rate itself, clamped to 10–10000 | | devtools/perf-lab?activate=0 | { active: false }, every field off | | devtools/perf-lab?runToken=<t> | keeps the current activation, replaces the token (empty clears it); keeps diag, profile and cpuprof unless the link sets them |

The launch that reads the record applies profile and cpuprof for its whole life (they switch the settings of the same name on; off leaves the build's env defaults). Records written before these fields existed read as both off.

An expired activation is dormant. Uninstall and clearState remove the file. In a dormant (not activated) process the link wrapper only handles these parameters and dump=1, which answers with one PERF_LAB dormant {json} line on os_log / logcat (no file): hook, fetch/XHR wrapping, SDK global and link patch status. In auto and manual builds a runToken link applies at once, as before, and is also persisted, so a relaunched process keeps it until it expires.

Launch arguments as a second activation path are not implemented.

Settings link (runtime)

<app scheme>://devtools/perf-lab?<params> is intercepted before the router sees it (Linking.getInitialURL, Linking.addEventListener('url'), expo-linking's getLinkingURL and getInitialURL are wrapped), so it never navigates the app.

| Param | Values | Meaning | |---|---|---| | reset | 1 | Restore defaults first | | marks, hook, po, file, clear, gcwin | 1/true or 0 | Markers, React hook walk, PerformanceObserver, write the log file, clear the file at start, GC per window (on by default in a PerfLab run, one with a run token, so the leak slope has heap-after-GC readings) | | log | console / native / both | Log sink | | fault | none or cpu:50,rerender,leak:5,leakjs,defer,dupnet | Injected faults (calibration) | | target | comma-separated window name parts | Which press windows the faults run on (default community.nav.chat) | | runToken | string, empty clears | Correlates pushed batches with a farm run (redacted in logs). Persisted; in a dormant build it applies from the next launch only | | activate, ttl | 1/0, seconds | Activation of a dormant build, from the next launch (see above) | | gc | 1 | Force a Hermes GC now and log heap before/after | | flush | 1 | End the open windows and send every unsent line now; result as the perflab.flushed element (see Push). Ignored by a dormant process | | dump | 1 | Write a startup report now (its activation section has mode: active, the build mode and the next launch's activation); in a dormant process: the PERF_LAB dormant line | | sampler | auto / manual / off | Native resource sampler mode | | samplerMs | 250–60000 | Auto sampling interval | | sample | 1 | Take one sample now (ignored when the sampler is off) | | net | 1/true or 0 | Count network requests per window (default on with PERF_LAB_AUTO=1) | | profile, rexport, cpuprof | 1/true or 0 | Update causes + render times per component, React export file per window, Hermes .cpuprofile around windows (see the env defaults above) | | cpuhz | 10–10000 | Hermes sampling frequency | | diag | 1/true or 0 | Diagnostic iteration: React exports on (as rexport=1) and every raw file of the run kept and uploaded at flush=1 (see Profile files). In a dormant build it comes from the activation only |

With activate=1 (or a bare runToken), profile, cpuprof and cpuhz are also stored in the activation for the next launch (above). In a dormant build they come from the activation only, like diag: a link in an activated process changes the next launch, never the running one.

Push

With EXPO_PUBLIC_PERFLAB_INGEST_URL and EXPO_PUBLIC_PERFLAB_KEY set, every window line is queued for POST /v1/windows.

  • Measured runs (a dormant build that was activated, or any process started with a run token): nothing is sent while the run is active. Lines go to the log file and the push journal only. At the end of the iteration the flow opens devtools/perf-lab?flush=1: the SDK ends the open windows, sends every unsent line (retrying a failed batch up to 3 times) and then shows the result as an accessibility element for the driver, id perflab.flushed, label ok / error / off (no ingest configured), value with batch and line counts or the error. It is a transparent 1x1 pt view on the key window (iOS), created only on request in a measuring process; Android only logs PERF_LAB flushed <status> for now. A dormant process ignores flush.
  • Other builds: idle flush after 10 s, never while a window is open, and when the app goes to the background, as before.

Maestro, end of an iteration:

- openLink: <scheme>://devtools/perf-lab?flush=1&activate=0
- extendedWaitUntil:
    visible:
      id: perflab.flushed
    timeout: 60000
- assertVisible:
    id: perflab.flushed
    text: ok

The run token is stamped on each line when it is queued, and a batch never mixes tokens, so a window belongs to the run that was current when it was recorded. Every queued line is also journalled in perf-lab/push-spool.jsonl (with its token); delivered lines are marked done and the journal is emptied whenever the queue drains. A process that starts pushing first queues what earlier processes recorded and never sent, ahead of its own lines and with their own tokens. A batch id names exactly its lines (process, first and last sequence, count), and the batch is journalled before it is first sent, so a later process resends the same lines under the same id: the server drops a replay it already stored and never drops lines it does not have. The journal is read at the first idle send or at the flush=1 link, never at startup or inside a measured window, and is rewritten to the live queue whenever it passes twice the queue caps; lines dropped by the cap are counted (dropped, resumeDropped) across processes. On Android, activation.json and the journal live in internal storage (filesDir); only logs and dumps go to external storage. An install from before that move has both files moved in at the first state access, and the external copies deleted.

Profile files of a run

With profile=1 each window line carries react.profile:

  • components: up to 30 rows [name, renders, mounts, instances, selfMs, totalMs], top by self time with the profiling renderer, else by renders. instances counts the distinct component instances that rendered.
  • causes: render counts of the top 3 cause keys per component.
  • shares: the same keys as fractions of renders, mount included.
  • updaters: top 5.
  • commitSeq: [first, last] commit index in the process.
  • overBudget: commits over 8.3 ms, 16.7 ms and the device frame budget. That budget is frameBudget, taken from the sampler: the display's top rate on iOS, the refresh rate on Android, 16.7 ms until the sampler has run.
  • omitted: rows left out.

Inside a run, at flush=1 the SDK keeps the raw files plan 40 §3.3 asks for:

  • in a diagnostic iteration (diag), every .cpuprofile and React export;
  • otherwise only the files that cover a window with a commit over the device frame budget or a long task > 50 ms.

It lists them in perf-lab/artifacts-<processId>.json and, after the lines, uploads them: a signed URL from POST /v1/artifacts, then a gzip PUT by the native module, then complete (contract in plan 40 §3.6). It never uploads while a window is open. A missing endpoint (404), a missing native function (older binary) or no ingest config leaves the files on the device with the reason in the manifest. The flush still ends normally, and its detail gets artifacts=<uploaded>/<total>. Each file goes up under the run token that was current when it was recorded, also when a later link changed the token (the token is kept in memory, never in the manifest). The native upload streams the gzip through a temp file on Android, maps the file on iOS, and skips a file over the API's 64 MiB cap without reading it.

What caused the work (REQ-MEAS-32)

A window ends at the next action, so it also catches work the test only waits through. The SDK records what caused each React commit and keeps foreign work out of the window's own numbers (js/sdk/causes.ts, installed first at boot):

  • Causes start at a touch (typing, focus and editing belong to the latest touch), a deep link (url), the launch (AppRegistry.runApplication), a WebSocket message (a push) and every other native event.
  • A cause follows the code it started through timers, animation frames, microtasks, Promise.prototype.then (so await on Hermes), XMLHttpRequest responses, WebSocket open/close, React's scheduler tasks and the first onLayout/onLoad of the views it mounted. Scrolling, momentum, gesture-handler, refresh and slider events belong to the latest touch while it is the newest action (after a deep link they have no cause until the next touch: a push can scroll the list of the screen the link opened); other host events, including the pager-view page events, have no cause.
  • A commit is foreign to a window when every cause is a push or an action older than the window's anchor (the newest action when the window started). It then counts in react.external.<source> (commits, renderMs) instead of every other react.* field. Anything else, including work with no cause and other native events, stays in the window.
  • Foreign work that no open window takes, including a back-dated window that opens later, goes to a window background:<source> (kind background: websocket, launch, link, touch), open from its first commit until the app leaves the foreground or the flush. A commit is routed there only when every window ends (app background or flush), so a back-dated window that opens later still takes it however many commits came after; the recent-commit ring bounds only react.backfill of judged work. The foreign commits that left the ring wait in memory until that end, each with its profile and, with the React export on, its exported fibers: on the S23 TapIn "join live" iteration that is about 71 push commits (the chat burst, 6 A/A exports), and the flush at the end of every iteration empties the list. It has react only.
  • The React export marks each commit with perflabCauses; every window carries react.causes.awaitThroughThen.
  • A timer callback that runs after a newer action than the one its registration carries is a carried tick: its work keeps the old cause (so it stays foreign to later windows) and is counted again in react.external.<source>.timerCarried, the upper bound of work a queue-serving timer did for the open window. Every run of the callback is its own tick, for repeating and re-registering timers alike; a run that starts a request (itself or in a continuation) is timer-net while the request counter runs (react.causes.requestsMarked). The export marks each commit with perflabCarrier.
  • A zustand store write made under a carried tick, to a store whose last write came from a newer action, runs under that action (withPerfLab resolves zustand/vanilla to js/zustand-vanilla.js, which routes every store's setState through the tracker): a timer that serves a queue does the work of the action that queued it. A store remembers the newest action that wrote it. The export marks such commits with perflabStore (touch#3>touch#9) and perflabTask (their causes and carrier without the move); the app must have one zustand copy; react.causes.storesWrapped says how many stores the wrapper saw (0: none can move).
  • The commit stream (js/sdk/stream.ts) records every commit of the process once, with its cause facts (root ids, timer runs, store writes, render time or null) and the tables of roots, runs and writes, in segment files stream-<process>-<seq>.json kept in every run (artifact kind commit-stream). The backend replays the split from it; until it does, the window values above are the SDK's own.

Known limits: a server reply to a tap that arrives over the WebSocket is foreign, and so is a screen the app opens from a socket handler; direct JSI calls into JS (Reanimated runOnJS) carry no cause and stay in the window.

Native resource sampler

Off unless enabled. auto samples every samplerMs on a native utility thread (no JS work); manual keeps only the frame counter running and samples on request (sample=1 link or __PERF_LAB__.sample()), plus one sample when each window opens and one when it closes in auto and manual. Every sample is one PERF_LAB sample {json} line in the native log (os_log + Documents/perf-lab/log.txt on iOS, logcat on Android), whatever the log sink. Windows get a sampler object (footprint start/end/delta, CPU cores, frames missed, longest frame gap) when samples bracket them. Record format and limits: docs/research/31-native-sampler.md.

Network requests

Every window gets a net object (null when net is off): requests, byMethod, bySource (fetch/xhr), duplicates (the same method, path and query started while an identical request is in flight or within the last 3 s), duplicatesInFlight, responses and errors (end events in the window: no response or status ≥ 400), paths (top 10 METHOD /path, query and host stripped), backfill for backdated windows, and cumulative cumStart/cumEnd with a per-process proc id so derive can total a run without counting overlapping windows twice.

The SDK patches XMLHttpRequest.prototype.open/send (end on loadend) and wraps the global fetch. React Native's own fetch (whatwg-fetch) is built on XHR, but Expo SDK 52+ replaces the global fetch with the native expo/fetch (unless EXPO_PUBLIC_USE_RN_FETCH=1), which is not, so both layers are needed. A request is counted once: the XHR that whatwg-fetch opens inside a wrapped fetch call is skipped. The push client's own requests are counted by neither layer. Headers, bodies and responses are never read. Expo defines its fetch after the SDK boots, so the wrap is re-applied after the entry module loads, at the first commit and at every window start; a request made during module initialisation, before that, is missed.

Not counted: anything outside JS fetch/XMLHttpRequest, such as images loaded natively (Image, expo-image), WebSockets, and native SDK traffic (Stream, LiveKit, Firebase).

Interaction windows

Every one-finger touch opens a window at the touch's hardware time. The window closes at the next touch (at that touch's time), when the app goes to the background, or when the run is flushed; never after a quiet period, and there is no +3s twin (window model 2, declared as windowModel: 2 on every window line). A tap is press:<name>, where the name is the touched element's: its testID, else accessibilityLabel, else its text, else the nearest named component (searched from the touched view up to the first named component above the onPress/responder fiber).

A touch that moves 10 pt or more, or scrolls something, becomes a drag and is renamed drag:<container> after the scroll view it moved, never after the row under the finger (row names carry item ids, so they differ from run to run and the windows could not be compared). The container is the nearest RCTScrollView/AndroidHorizontalScrollView above the touched view whose axis matches the gesture's main axis (else the nearest one), so a horizontal swipe on a carousel inside a vertical feed names the carousel and a vertical swipe on the same spot names the feed. ScrollView, FlatList, SectionList, VirtualizedList and FlashList all render that host. Its name is, in order: the list's testID, accessibilityLabel (or aria-label), nativeID (or id), all read from the scroll host and the components above it up to the first component with a specific name; then that component's name; then <route name>/<host type>; then the host type. Give a list a testID for a name that survives refactors.

A drag window's interaction details carry touched (the element name a tap would have used), container (name, source, hostType, axis, depth = scroll views skipped) and nameSource (container.testID, container.accessibilityLabel, container.nativeID, container.component, container.route, container.host, or fallback.touched when no scroll view is above the touch and the window keeps the touched element's name).

Changed 2026-10: drag windows used to be named after the touched element (drag:live.audience.participant.mockviewer4). Their names, and with them the series keys of drag windows, change with this version: runs from older SDK builds will not line up with newer ones for drag windows. Press and screen windows are unchanged.

Screen windows

The first screen window of the process is the launch screen (REQ-MEAS-22): it starts when the app starts its first surface (AppRegistry.runApplication, or renderSurface on bridgeless), whatever opened it, so it holds the whole launch on every delivery path; its screen details carry launch: true and startSource: launch (without the cause tracker the start falls back to the rules below, still with launch: true). Every later screen window (screen:<route chain>#<n>) starts at what caused the navigation, never at the moment the navigator reports it (that comes after the commit that mounted the screen): the touch, link (a url event; startSource: link) or navigation dispatch (startSource: action) whose cause root the cause tracker finds behind the state change (the cause of the first state event), however long ago, but not before the previous visit opened. A cause with no such event (data pushed to the app, typing, the launch's own code) starts the visit at that cause's own time when it is after the previous visit opened (startSource: cause), else at the state change. With no known cause it starts at the first touch, link or dispatch since the previous visit opened, else at the state change. A deep link that arrives while another screen is still opening is older than that screen's visit, so the visit it causes starts at the state change (the floor rule). The launch screen window belongs to the launch (its anchor is the launch's cause root), so a link, touch or PerfLab control link before the first state does not push the launch work out of it; every app start (an activity recreated in the same process) is a new launch. The commits between that start and the report come from the ring of recent commits (react.backfill): it keeps every commit since the current visit opened (no later visit starts earlier), or since the app's start until the launch window opens, or since the newest action once a flush or a trip to the background came first, and since the start of any open window; the flush and the app leaving the foreground empty it and are a floor for later screen starts, so complete is false only when a window starts before the previous visit opened. Every window's react.ring gives the records the SDK holds when it ends (records in the ring, unclaimed foreign ones waiting for the flush), so their memory can be read against the window's Hermes readings.

App log lines

globalThis.__PERF_LAB__?.log(message) writes PERF_LAB app <message> to the native log (and the console), the same file and stream as the SDK's own lines, whatever the log sink. __PERF_LAB__ exists only when the SDK is in the bundle.

Layout

| Path | Role | |---|---| | metro.js | @perf-labs/react-native/metro: withPerfLab(config) | | app.plugin.js | Expo config plugin: excludes the native module from autolinking when the flag is off | | expo-module.config.json | Expo autolinking for the native module | | ios/PerfLab.podspec, ios/PerfLabModule.swift | iOS: signposts, OSLog, log file in Documents/perf-lab | | ios/PerfLabSampler.swift | iOS: resource sampler (task_vm_info, thread CPU, CADisplayLink frames) | | android/build.gradle, android/src/... | Android: Trace sections, logcat, log file, resource sampler (PerfLabSampler.kt, untested on device) | | js/sdk/sampler.ts | Sampler settings parsing and per-window aggregation (pure) | | js/sdk/net.ts, net-install.ts | Request counter and fetch/XHR hooks (pure), and their wiring | | js/hook.polyfill.js | React DevTools hook (Metro polyfill, ES5, no require) | | js/babel-transformer.js | Transformer wrapper (boot import, env inlining) | | js/sdk/boot.ts | SDK entry run before the app's entry: takes the activation decision, then requires active.ts or dormant.ts | | js/sdk/activation.ts | Build mode, activation record, link parameters (no React Native imports) | | js/sdk/active.ts, dormant.ts | The measuring SDK's startup, and the dormant link handler | | js/sdk/core.ts | Settings, windows, commit walk, observers, faults, logging | | js/sdk/fiber.ts, screens.ts, nav.ts | Fiber registries, target naming, screen windows | | js/sdk/interactions.ts | Press windows from RawEventEmitter | | js/sdk/links.ts | Settings link interception | | js/sdk/push.ts, push-install.ts | Push client (pure logic) and its React Native wiring | | test/ | bun test: push client, fiber helpers, query parsing, sampler, window counters and writing (not shipped) |

The package has no JS entry point (main) on purpose: app code never imports it.

Local development

bun install            # at the perflab repo root (workspace)
bun run typecheck      # in packages/react-native: SDK (tsconfig.json) + tests (tsconfig.test.json)
bun test

Consuming a local checkout (the Gigaverse test bed)

The app consumes a packed tarball, the same artifact npm would serve:

# in packages/react-native
bun run pack:local                 # -> dist/perf-labs-react-native-0.1.0.tgz (dist/ is gitignored)
# in the app
bun add @perf-labs/react-native@file:../perflab/packages/react-native/dist/perf-labs-react-native-0.1.0.tgz

After editing the package, run both commands again (re-adding refreshes the integrity hash in bun.lock), then rebuild the JS bundle with --reset-cache; pod install only when native files changed. Bun 1.4 caches a file: tarball by path under ~/.bun/install/cache/@T@<hash>@@@1 and reuses it while the version is unchanged, so a repack with the same version can install the old contents: delete those entries (their package.json names the package), install with a fresh cache (BUN_INSTALL_CACHE_DIR=$(mktemp -d) bun add …, which also refreshes the bun.lock integrity) or bump the version, and check with diff -r node_modules/@perf-labs/react-native/js <repo>/packages/react-native/js.

Why not a folder file: dependency or bun link: for a folder outside the app, bun 1.4 symlinks every file back into this repo and installs the package's devDependencies next to it. Metro then sees the SDK at its real path outside the app's watched folders, and react-native would resolve from this repo's node_modules, a second React Native in the bundle. The tarball installs real files, honours files, and installs no devDependencies, so no Metro watchFolders or resolver change is needed.

Store builds

With the flag off the JS bundle carries no SDK code (check: grep -c __PERFLAB_HOOK__ main.jsbundle is 0) and, after a prebuild with the flag off, the binary carries no SDK native code (no PerfLab in Podfile.lock, in ExpoModulesProvider.swift or in the app binary's symbols).