@keepsnappy/react-native 0.1.0-alpha.16 → 0.1.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,6 +72,7 @@ All values are read at **bundle time** (Expo CLI loads `.env*` into the Metro pr
72
72
  | `EXPO_PUBLIC_KEEPSNAPPY_SAMPLER` | `KEEPSNAPPY_SAMPLER` | `auto` / `manual` / `off` | Native resource sampler default. Unset: `auto` when `KEEPSNAPPY_AUTO=1`, else `off`. |
73
73
  | `EXPO_PUBLIC_KEEPSNAPPY_SAMPLER_MS` | `KEEPSNAPPY_SAMPLER_MS` | ms, 250–60000 | Auto sampling interval (default 4000; windows also get a sample at open and at close). |
74
74
  | `EXPO_PUBLIC_KEEPSNAPPY_PROFILE` | `KEEPSNAPPY_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`). |
75
+ | `EXPO_PUBLIC_KEEPSNAPPY_TAP_TEXT` | `KEEPSNAPPY_TAP_TEXT` | `0` = off, anything else = on (default) | Privacy switch for tap names. On: a tap with no `testID` and no `accessibilityLabel` falls back to the text under it (first 40 characters) in its legacy name. Off: that text is never read; the tap is named by its nearest named component, else `tag:<n>`. Place keys never contain text either way. Set `0` when screens show personal data in buttons. |
75
76
  | `EXPO_PUBLIC_KEEPSNAPPY_REACT_EXPORT` | `KEEPSNAPPY_REACT_EXPORT` | `1` = on | Default of `rexport`: a React DevTools v5 export per window (`Documents/keepsnappy/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. |
76
77
  | `EXPO_PUBLIC_KEEPSNAPPY_CPUPROF`, `..._CPUPROF_HZ` | `KEEPSNAPPY_CPUPROF` | `1` = on; Hz (default 250) | Default of `cpuprof`/`cpuhz`: Hermes sampling profiler (iOS), `Documents/keepsnappy/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. |
77
78
  | `KEEPSNAPPY_REACT_PROFILING` (Metro process; also read by the config plugin) | | `1` / `switch` / unset | `1`: `withKeepSnappy` 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. |
@@ -17,10 +17,12 @@ import android.view.View
17
17
  import android.view.ViewGroup
18
18
  import android.widget.FrameLayout
19
19
  import android.widget.TextView
20
+ import expo.modules.kotlin.Promise
20
21
  import expo.modules.kotlin.modules.Module
21
22
  import expo.modules.kotlin.modules.ModuleDefinition
22
23
  import java.io.File
23
24
  import java.io.FileOutputStream
25
+ import java.io.InterruptedIOException
24
26
  import java.io.RandomAccessFile
25
27
  import java.net.HttpURLConnection
26
28
  import java.net.URL
@@ -29,6 +31,12 @@ import java.security.MessageDigest
29
31
  import java.util.concurrent.ConcurrentHashMap
30
32
  import java.util.concurrent.ExecutorService
31
33
  import java.util.concurrent.Executors
34
+ import java.util.concurrent.LinkedBlockingQueue
35
+ import java.util.concurrent.ScheduledExecutorService
36
+ import java.util.concurrent.ScheduledThreadPoolExecutor
37
+ import java.util.concurrent.ThreadPoolExecutor
38
+ import java.util.concurrent.TimeUnit
39
+ import java.util.concurrent.atomic.AtomicReference
32
40
  import java.util.zip.GZIPOutputStream
33
41
 
34
42
  private const val TAG = "KEEPSNAPPY"
@@ -53,6 +61,11 @@ private const val SPOOL_READ_MAX = 16L * 1024 * 1024
53
61
  // The API parses at most 64 MiB of a raw profile file (MAX_RAW_BYTES in apps/api cpu-worker.ts).
54
62
  private const val ARTIFACT_MAX_BYTES = 64L * 1024 * 1024
55
63
  private const val COPY_BUFFER = 64 * 1024
64
+ // Upload workers, one per SDK lane (ARTIFACT_UPLOAD_CONCURRENCY in js/sdk/artifacts.ts; test/artifacts.test.ts keeps
65
+ // them equal). Expo runs every AsyncFunction of a module on one shared thread, which would PUT the lanes' files one
66
+ // after the other.
67
+ private const val ARTIFACT_UPLOAD_LANES = 8
68
+ private const val POOL_IDLE_SECONDS = 30L
56
69
 
57
70
  // Files that lived in external storage before the move to filesDir.
58
71
  private val LEGACY_STATE_FILES = listOf("activation.json", "push-spool.jsonl")
@@ -60,7 +73,6 @@ private val LEGACY_STATE_FILES = listOf("activation.json", "push-spool.jsonl")
60
73
  // Created by Expo at app start whether or not the SDK measures: nothing here may
61
74
  // do work before a function is called (a dormant build calls activationRead only).
62
75
  // UNTESTED ON DEVICE (activation, spool): compiled only, like the sampler.
63
- // NOT YET COMPILED: moveLegacyState and the streamed uploadArtifact (night of 2026-10-14).
64
76
  class KeepSnappyModule : Module() {
65
77
  private val mainHandler = Handler(Looper.getMainLooper())
66
78
 
@@ -162,9 +174,27 @@ class KeepSnappyModule : Module() {
162
174
  samplerCreated = true
163
175
  KeepSnappySampler({ appContext.reactContext }, { appContext.currentActivity }, ::writeLog)
164
176
  }
165
- private val io: ExecutorService by lazy {
166
- Executors.newSingleThreadExecutor { r -> Thread(r, "keepsnappy-io") }
177
+ // The three pools below are created on first use and shut down in OnDestroy (a JS reload creates a new module); their
178
+ // idle threads also time out, so a pool used once does not keep a thread.
179
+ private val ioPool = lazy { Executors.newSingleThreadExecutor { r -> Thread(r, "keepsnappy-io") } }
180
+ private val io: ExecutorService get() = ioPool.value
181
+ private val uploadsPool = lazy {
182
+ ThreadPoolExecutor(
183
+ ARTIFACT_UPLOAD_LANES, ARTIFACT_UPLOAD_LANES, POOL_IDLE_SECONDS, TimeUnit.SECONDS, LinkedBlockingQueue<Runnable>(),
184
+ ) { r -> Thread(r, "keepsnappy-upload").apply { isDaemon = true } }.apply { allowCoreThreadTimeOut(true) }
167
185
  }
186
+ private val uploads: ExecutorService get() = uploadsPool.value
187
+ // Ends a PUT at its deadline: HttpURLConnection's timeouts bound one connect or read, not a write or the whole upload.
188
+ private val uploadTimerPool = lazy {
189
+ ScheduledThreadPoolExecutor(1) { r -> Thread(r, "keepsnappy-upload-timer").apply { isDaemon = true } }.apply {
190
+ setKeepAliveTime(POOL_IDLE_SECONDS, TimeUnit.SECONDS)
191
+ allowCoreThreadTimeOut(true)
192
+ removeOnCancelPolicy = true
193
+ }
194
+ }
195
+ private val uploadTimer: ScheduledExecutorService get() = uploadTimerPool.value
196
+ // Set once the module is destroyed: a worker still running then must not touch the (gone) JS context.
197
+ @Volatile private var destroyed = false
168
198
 
169
199
  // activation.json and the push journal (run tokens) live in internal storage: on Android 9
170
200
  // and older another app with storage permission could read or plant files in external storage.
@@ -231,7 +261,9 @@ class KeepSnappyModule : Module() {
231
261
  // sha256 of the gzip, PUT to the signed URL the API handed out. status 0: no HTTP answer.
232
262
  // The gzip is streamed to a temp file (hashed while written) and PUT from it, so neither
233
263
  // the file nor its gzip is held in memory; a file over the API's cap is not read at all.
264
+ // `timeoutMs` bounds gzip + PUT from here, whatever the call waited for a worker.
234
265
  private fun uploadArtifact(fileName: String, url: String, headers: Map<String, String>, timeoutMs: Double): Map<String, Any?> {
266
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs.toLong()
235
267
  val context = appContext.reactContext
236
268
  val base = context?.getExternalFilesDir(null)
237
269
  val file = base?.let { File(File(it, "keepsnappy"), fileName.replace('/', '_')) }
@@ -259,25 +291,38 @@ class KeepSnappyModule : Module() {
259
291
  val sha = digest.digest().joinToString("") { "%02x".format(it) }
260
292
  val gzBytes = gzFile.length()
261
293
  val out = mutableMapOf<String, Any?>("bytes" to length.toInt(), "gzBytes" to gzBytes.toInt(), "sha256" to sha)
262
- var connection: HttpURLConnection? = null
294
+ val left = deadline - SystemClock.elapsedRealtime()
295
+ if (left <= 0) {
296
+ out["status"] = 0
297
+ out["error"] = "timeout before the PUT"
298
+ return out
299
+ }
300
+ val connection = AtomicReference<HttpURLConnection?>(null)
301
+ val cut = uploadTimer.schedule({ connection.get()?.disconnect() }, left, TimeUnit.MILLISECONDS)
263
302
  try {
264
303
  val conn = URL(url).openConnection() as HttpURLConnection
265
- connection = conn
304
+ connection.set(conn)
266
305
  conn.requestMethod = "PUT"
267
306
  conn.doOutput = true
268
- conn.connectTimeout = timeoutMs.toInt()
269
- conn.readTimeout = timeoutMs.toInt()
307
+ conn.connectTimeout = left.toInt()
308
+ conn.readTimeout = left.toInt()
270
309
  conn.setFixedLengthStreamingMode(gzBytes)
271
310
  for ((key, value) in headers) {
272
311
  conn.setRequestProperty(key, value)
273
312
  }
274
- conn.outputStream.use { sink -> gzFile.inputStream().use { it.copyTo(sink, COPY_BUFFER) } }
313
+ conn.outputStream.use { sink ->
314
+ // disconnect() before the stream is open cuts nothing, so a deadline that passed while it opened is caught
315
+ // here; one that passes after this is a disconnect on an open stream, which fails the write.
316
+ if (cut.isDone) throw InterruptedIOException("deadline")
317
+ gzFile.inputStream().use { it.copyTo(sink, COPY_BUFFER) }
318
+ }
275
319
  out["status"] = conn.responseCode
276
320
  } catch (e: Exception) {
277
321
  out["status"] = 0
278
- out["error"] = e.toString()
322
+ out["error"] = if (cut.isDone) "timeout after ${timeoutMs.toLong()} ms: $e" else e.toString()
279
323
  } finally {
280
- connection?.disconnect()
324
+ cut.cancel(false)
325
+ connection.get()?.disconnect()
281
326
  }
282
327
  return out
283
328
  } finally {
@@ -356,8 +401,14 @@ class KeepSnappyModule : Module() {
356
401
  }
357
402
 
358
403
  OnDestroy {
404
+ destroyed = true
359
405
  if (samplerCreated) sampler.configure("off", 0.0)
360
406
  commandListen(false)
407
+ // Queued uploads are dropped; a PUT already running ends at its own deadline (the timer, shut down, still fires
408
+ // what it holds).
409
+ if (uploadsPool.isInitialized()) uploadsPool.value.shutdownNow()
410
+ if (uploadTimerPool.isInitialized()) uploadTimerPool.value.shutdown()
411
+ if (ioPool.isInitialized()) ioPool.value.shutdown()
361
412
  }
362
413
 
363
414
  Function("commandListen") { on: Boolean ->
@@ -466,9 +517,25 @@ class KeepSnappyModule : Module() {
466
517
  )
467
518
  }
468
519
 
469
- // Called at flush only, never inside a measured window; runs off the JS thread.
470
- AsyncFunction("artifactUpload") { fileName: String, url: String, headers: Map<String, String>, timeoutMs: Double ->
471
- uploadArtifact(fileName, url, headers, timeoutMs)
520
+ // Called at flush only, never inside a measured window; runs on the upload workers, off the JS thread and off
521
+ // Expo's shared AsyncFunction thread.
522
+ AsyncFunction("artifactUpload") { fileName: String, url: String, headers: Map<String, String>, timeoutMs: Double, promise: Promise ->
523
+ uploads.execute {
524
+ if (destroyed) return@execute
525
+ val result = try {
526
+ uploadArtifact(fileName, url, headers, timeoutMs)
527
+ } catch (e: Throwable) {
528
+ mapOf("status" to 0, "error" to e.toString())
529
+ }
530
+ // A reload mid-flush destroys the module while this worker runs: its JS context is gone, nothing waits for it.
531
+ // UNTESTED ON DEVICE: compiled only; a reload during a flush needs a device run.
532
+ if (destroyed) return@execute
533
+ try {
534
+ promise.resolve(result)
535
+ } catch (e: Throwable) {
536
+ Log.w(TAG, "artifactUpload: no one to answer: $e")
537
+ }
538
+ }
472
539
  }
473
540
 
474
541
  // getExternalFilesDir needs no permission and `adb pull` reads it from a
@@ -0,0 +1,91 @@
1
+ import Compression
2
+ import CryptoKit
3
+ import Foundation
4
+
5
+ // Gzip (RFC 1952) of a file streamed into another, so neither the file nor its gzip is held in memory: one chunk at a
6
+ // time, whatever the file's size. The Compression framework's zlib codec writes raw DEFLATE (RFC 1951); the header and trailer are added
7
+ // here.
8
+ enum KeepSnappyGzip {
9
+ struct Result {
10
+ let bytes: Int
11
+ let gzBytes: Int
12
+ // Of the gzip as written.
13
+ let sha256: String
14
+ }
15
+
16
+ private static let chunk = 64 * 1024
17
+
18
+ // nil: the file could not be read, compressed or written.
19
+ static func file(_ source: URL, to target: URL) -> Result? {
20
+ guard let input = InputStream(url: source) else { return nil }
21
+ guard FileManager.default.createFile(atPath: target.path, contents: nil),
22
+ let output = try? FileHandle(forWritingTo: target) else { return nil }
23
+ defer { try? output.close() }
24
+ input.open()
25
+ defer { input.close() }
26
+
27
+ var hasher = SHA256()
28
+ var gzBytes = 0
29
+ var writeFailed = false
30
+ func write(_ data: Data) {
31
+ guard !writeFailed else { return }
32
+ do {
33
+ try output.write(contentsOf: data)
34
+ hasher.update(data: data)
35
+ gzBytes += data.count
36
+ } catch {
37
+ writeFailed = true
38
+ }
39
+ }
40
+
41
+ write(Data([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff]))
42
+ var crc: UInt32 = 0xFFFF_FFFF
43
+ var size = 0
44
+ do {
45
+ let filter = try OutputFilter(.compress, using: .zlib) { (deflated: Data?) in
46
+ if let deflated { write(deflated) }
47
+ }
48
+ var buffer = [UInt8](repeating: 0, count: chunk)
49
+ while true {
50
+ let n = input.read(&buffer, maxLength: buffer.count)
51
+ if n < 0 { return nil }
52
+ if n == 0 { break }
53
+ let data = Data(buffer[0..<n])
54
+ crc = crc32Update(crc, data)
55
+ size += n
56
+ try filter.write(data)
57
+ }
58
+ try filter.finalize()
59
+ } catch {
60
+ return nil
61
+ }
62
+ var crcOut = (crc ^ 0xFFFF_FFFF).littleEndian
63
+ var sizeOut = UInt32(truncatingIfNeeded: size).littleEndian
64
+ var trailer = Data()
65
+ withUnsafeBytes(of: &crcOut) { trailer.append(contentsOf: $0) }
66
+ withUnsafeBytes(of: &sizeOut) { trailer.append(contentsOf: $0) }
67
+ write(trailer)
68
+ if writeFailed { return nil }
69
+ let sha = hasher.finalize().map { String(format: "%02x", $0) }.joined()
70
+ return Result(bytes: size, gzBytes: gzBytes, sha256: sha)
71
+ }
72
+
73
+ private static let crcTable: [UInt32] = (0..<256).map { n -> UInt32 in
74
+ var c = UInt32(n)
75
+ for _ in 0..<8 {
76
+ c = (c & 1) != 0 ? 0xEDB8_8320 ^ (c >> 1) : c >> 1
77
+ }
78
+ return c
79
+ }
80
+
81
+ // CRC-32 (IEEE) running state: start at 0xFFFFFFFF, xor the end with 0xFFFFFFFF.
82
+ private static func crc32Update(_ start: UInt32, _ data: Data) -> UInt32 {
83
+ var c = start
84
+ data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in
85
+ for byte in buffer {
86
+ c = crcTable[Int((c ^ UInt32(byte)) & 0xFF)] ^ (c >> 8)
87
+ }
88
+ }
89
+ return c
90
+ }
91
+ }
@@ -1,4 +1,3 @@
1
- import CryptoKit
2
1
  import ExpoModulesCore
3
2
  import Foundation
4
3
  import UIKit
@@ -224,46 +223,12 @@ public class KeepSnappyModule: Module {
224
223
 
225
224
  // A raw profile file of a run (plan 40 §3.6): gzip, sha256 of the gzip, PUT to the
226
225
  // signed URL the API handed out. Called at flush only, never inside a measured window.
227
- // status 0: no HTTP answer (missing file, network error, timeout).
228
- // The file is memory-mapped (clean pages, not app memory) and checked against the API's cap
229
- // before it is read; only the gzip is built in memory.
230
- // NOT YET COMPILED: the size check and the mapped read (night of 2026-10-14).
226
+ // status 0: no HTTP answer (missing file, network error, timeout). Runs on the upload
227
+ // workers: Expo runs a module's AsyncFunctions on one serial queue.
231
228
  AsyncFunction("artifactUpload") { (fileName: String, url: String, headers: [String: String], timeoutMs: Double, promise: Promise) in
232
- guard let file = Self.fileIfPresent(fileName.replacingOccurrences(of: "/", with: "_")) else {
233
- promise.resolve(["status": 0, "error": "file missing"])
234
- return
229
+ Self.uploadQueue.addOperation {
230
+ promise.resolve(Self.uploadArtifact(fileName: fileName, url: url, headers: headers, timeoutMs: timeoutMs))
235
231
  }
236
- let size = ((try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber)?.int64Value ?? 0
237
- if size > Self.artifactMaxBytes {
238
- promise.resolve(["status": 413, "error": "file is \(size) bytes, over \(Self.artifactMaxBytes)"])
239
- return
240
- }
241
- guard let raw = try? Data(contentsOf: file, options: .alwaysMapped) else {
242
- promise.resolve(["status": 0, "error": "file missing"])
243
- return
244
- }
245
- guard let target = URL(string: url), let gz = Self.gzip(raw) else {
246
- promise.resolve(["status": 0, "bytes": raw.count, "error": "gzip or url failed"])
247
- return
248
- }
249
- let sha = SHA256.hash(data: gz).map { String(format: "%02x", $0) }.joined()
250
- var request = URLRequest(url: target, timeoutInterval: max(1, timeoutMs / 1000))
251
- request.httpMethod = "PUT"
252
- for (key, value) in headers {
253
- request.setValue(value, forHTTPHeaderField: key)
254
- }
255
- URLSession.shared.uploadTask(with: request, from: gz) { _, response, error in
256
- var out: [String: Any] = [
257
- "status": (response as? HTTPURLResponse)?.statusCode ?? 0,
258
- "bytes": raw.count,
259
- "gzBytes": gz.count,
260
- "sha256": sha,
261
- ]
262
- if let error {
263
- out["error"] = error.localizedDescription
264
- }
265
- promise.resolve(out)
266
- }.resume()
267
232
  }
268
233
 
269
234
  Function("cpuProfileDump") { (fileName: String) -> [String: Any]? in
@@ -281,6 +246,76 @@ public class KeepSnappyModule: Module {
281
246
  // The API parses at most 64 MiB of a raw profile file (MAX_RAW_BYTES in apps/api cpu-worker.ts).
282
247
  private static let artifactMaxBytes: Int64 = 64 * 1024 * 1024
283
248
 
249
+ // Upload workers, one per SDK lane (ARTIFACT_UPLOAD_CONCURRENCY in js/sdk/artifacts.ts; test/artifacts.test.ts keeps
250
+ // them equal). A worker holds its file until the PUT is answered.
251
+ private static let artifactUploadLanes = 8
252
+ // Static lets: initialised once, safely from any thread.
253
+ private static let uploadQueue: OperationQueue = {
254
+ let queue = OperationQueue()
255
+ queue.name = "com.gigaverse.keepsnappy.upload"
256
+ queue.maxConcurrentOperationCount = KeepSnappyModule.artifactUploadLanes
257
+ queue.qualityOfService = .utility
258
+ return queue
259
+ }()
260
+ private static let uploadSession: URLSession = {
261
+ let config = URLSessionConfiguration.default
262
+ config.httpMaximumConnectionsPerHost = KeepSnappyModule.artifactUploadLanes
263
+ return URLSession(configuration: config)
264
+ }()
265
+
266
+ // The gzip is streamed to a temp file (hashed while written) and PUT from it, so neither the file
267
+ // nor its gzip is held in memory; a file over the API's cap is not read at all. `timeoutMs`
268
+ // bounds gzip + PUT from here, whatever the call waited for a worker.
269
+ private static func uploadArtifact(fileName: String, url: String, headers: [String: String], timeoutMs: Double) -> [String: Any] {
270
+ let deadline = DispatchTime.now() + .milliseconds(Int(max(0, timeoutMs)))
271
+ guard let file = fileIfPresent(fileName.replacingOccurrences(of: "/", with: "_")) else {
272
+ return ["status": 0, "error": "file missing"]
273
+ }
274
+ let size = ((try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber)?.int64Value ?? 0
275
+ if size > Self.artifactMaxBytes {
276
+ return ["status": 413, "error": "file is \(size) bytes, over \(Self.artifactMaxBytes)"]
277
+ }
278
+ guard let target = URL(string: url) else {
279
+ return ["status": 0, "bytes": Int(size), "error": "bad url"]
280
+ }
281
+ let gzFile = FileManager.default.temporaryDirectory.appendingPathComponent("keepsnappy-upload-\(UUID().uuidString).gz")
282
+ defer { try? FileManager.default.removeItem(at: gzFile) }
283
+ guard let gz = KeepSnappyGzip.file(file, to: gzFile) else {
284
+ return ["status": 0, "bytes": Int(size), "error": "gzip failed"]
285
+ }
286
+ var out: [String: Any] = ["status": 0, "bytes": gz.bytes, "gzBytes": gz.gzBytes, "sha256": gz.sha256]
287
+ let leftNs = Int64(deadline.uptimeNanoseconds) - Int64(DispatchTime.now().uptimeNanoseconds)
288
+ if leftNs <= 0 {
289
+ out["error"] = "timeout before the PUT"
290
+ return out
291
+ }
292
+ var request = URLRequest(url: target, timeoutInterval: max(1, Double(leftNs) / 1e9))
293
+ request.httpMethod = "PUT"
294
+ for (key, value) in headers {
295
+ request.setValue(value, forHTTPHeaderField: key)
296
+ }
297
+ let answered = DispatchSemaphore(value: 0)
298
+ var status = 0
299
+ var failure: String?
300
+ let task = uploadSession.uploadTask(with: request, fromFile: gzFile) { _, response, error in
301
+ status = (response as? HTTPURLResponse)?.statusCode ?? 0
302
+ failure = error?.localizedDescription
303
+ answered.signal()
304
+ }
305
+ task.resume()
306
+ // URLRequest's timeout is between two packets; the whole PUT ends at the deadline.
307
+ if answered.wait(timeout: deadline) == .timedOut {
308
+ task.cancel()
309
+ answered.wait()
310
+ failure = "timeout after \(Int(timeoutMs)) ms: \(failure ?? "cancelled")"
311
+ }
312
+ out["status"] = status
313
+ if let failure {
314
+ out["error"] = failure
315
+ }
316
+ return out
317
+ }
318
+
284
319
  private static func outputDir() -> URL? {
285
320
  guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
286
321
  let dir = docs.appendingPathComponent("keepsnappy", isDirectory: true)
@@ -288,36 +323,6 @@ public class KeepSnappyModule: Module {
288
323
  return dir
289
324
  }
290
325
 
291
- // RFC 1952 around Foundation's zlib codec, which writes raw DEFLATE (RFC 1951).
292
- private static func gzip(_ data: Data) -> Data? {
293
- guard let deflated = try? (data as NSData).compressed(using: .zlib) as Data else { return nil }
294
- var out = Data([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff])
295
- out.append(deflated)
296
- var crc = crc32(data).littleEndian
297
- var size = UInt32(truncatingIfNeeded: data.count).littleEndian
298
- withUnsafeBytes(of: &crc) { out.append(contentsOf: $0) }
299
- withUnsafeBytes(of: &size) { out.append(contentsOf: $0) }
300
- return out
301
- }
302
-
303
- private static let crcTable: [UInt32] = (0..<256).map { n -> UInt32 in
304
- var c = UInt32(n)
305
- for _ in 0..<8 {
306
- c = (c & 1) != 0 ? 0xEDB8_8320 ^ (c >> 1) : c >> 1
307
- }
308
- return c
309
- }
310
-
311
- private static func crc32(_ data: Data) -> UInt32 {
312
- var c: UInt32 = 0xFFFF_FFFF
313
- data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in
314
- for byte in buffer {
315
- c = crcTable[Int((c ^ UInt32(byte)) & 0xFF)] ^ (c >> 8)
316
- }
317
- }
318
- return c ^ 0xFFFF_FFFF
319
- }
320
-
321
326
  private static let flushSignalTag = 0x9E7F_1A5
322
327
  private static let ackSignalTag = 0x9E7F_1A6
323
328
 
@@ -68,20 +68,27 @@ module.exports = {
68
68
  : args.src;
69
69
  args = { ...args, src: inlineEnv(src) };
70
70
  }
71
+ const root = args.options?.projectRoot ?? projectRoot ?? process.cwd();
72
+ // A client file built without its stamps keys its components to an ancestor's: never silently.
73
+ const skipped = error => console.warn(`KeepSnappy: component stamps skipped for ${path.relative(root, file)}: ${String(error)}`);
71
74
  let stamps;
72
75
  try {
73
76
  stamps = require('./component-stamps.js').instrumentation(
74
- original.src, file, args.options?.projectRoot ?? projectRoot ?? process.cwd(), clientPackages, file === entryFile ? 1 : 0,
77
+ original.src, file, root, clientPackages, file === entryFile ? 1 : 0,
75
78
  );
76
- } catch { return upstream.transform(args); }
79
+ } catch (error) { skipped(error); return upstream.transform(args); }
77
80
  if (!stamps) return upstream.transform(args);
78
81
  const complete = result => { stamps.complete(); return result; };
79
- const passthrough = () => { stamps.complete(true); return upstream.transform(args); };
82
+ const passthrough = error => {
83
+ skipped(error);
84
+ stamps.complete(true);
85
+ return upstream.transform(args);
86
+ };
80
87
  try {
81
88
  const result = upstream.transform({ ...args, plugins: [stamps.plugin, ...(args.plugins ?? [])] });
82
89
  return result && typeof result.then === 'function'
83
90
  ? result.then(complete, passthrough) : complete(result);
84
- } catch { return passthrough(); }
91
+ } catch (error) { return passthrough(error); }
85
92
  },
86
93
  getCacheKey(...args) {
87
94
  const base =
@@ -119,6 +119,15 @@ const anonymousRender = (node, t) => {
119
119
  && !node.id && !node.async && !node.generator && returnsJSX(node.body, t);
120
120
  };
121
121
 
122
+ // A plugin that ran first (React Compiler listed in the app's babel.config.js) may put back regenerated nodes without
123
+ // `loc`; the line then comes from the declaration in the source.
124
+ const declarationLine = (src, id, statement, lineOffset) => {
125
+ const line = id.loc?.start?.line ?? statement.loc?.start?.line;
126
+ if (typeof line === 'number') return Math.max(1, line - lineOffset);
127
+ const match = new RegExp(`^[ \\t]*(?:export[ \\t]+(?:default[ \\t]+)?)?(?:async[ \\t]+)?(?:function\\*?|class|const)[ \\t]+${id.name}\\b`, 'm').exec(src);
128
+ return match ? src.slice(0, match.index).split('\n').length : 1;
129
+ };
130
+
122
131
  function instrumentation(src, filename, projectRoot, clientPackages, lineOffset = 0) {
123
132
  const client = clientFile(filename, projectRoot, clientPackages);
124
133
  if (!client) return null;
@@ -166,13 +175,19 @@ function instrumentation(src, filename, projectRoot, clientPackages, lineOffset
166
175
  const stamps = [];
167
176
  for (const id of found) {
168
177
  const componentName = id === defaultId ? 'default' : id.name;
169
- const key = componentKey(componentName, client.file);
170
- components.push({ key, name: componentName, file: client.file, line: Math.max(1, id.loc.start.line - lineOffset) });
171
- const name = () => t.identifier(id.name);
172
- const typeIs = value => t.binaryExpression('===', t.unaryExpression('typeof', name()), t.stringLiteral(value));
173
- stamps.push(t.ifStatement(t.logicalExpression('||', typeIs('function'), t.logicalExpression('&&',
174
- t.binaryExpression('!==', name(), t.nullLiteral()), typeIs('object'))),
175
- t.expressionStatement(t.assignmentExpression('=', t.memberExpression(name(), t.identifier('__ks')), t.stringLiteral(key)))));
178
+ // One declaration that cannot be stamped must not cost the file its other stamps.
179
+ try {
180
+ const key = componentKey(componentName, client.file);
181
+ const line = declarationLine(src, id, statement.node, lineOffset);
182
+ const name = () => t.identifier(id.name);
183
+ const typeIs = value => t.binaryExpression('===', t.unaryExpression('typeof', name()), t.stringLiteral(value));
184
+ stamps.push(t.ifStatement(t.logicalExpression('||', typeIs('function'), t.logicalExpression('&&',
185
+ t.binaryExpression('!==', name(), t.nullLiteral()), typeIs('object'))),
186
+ t.expressionStatement(t.assignmentExpression('=', t.memberExpression(name(), t.identifier('__ks')), t.stringLiteral(key)))));
187
+ components.push({ key, name: componentName, file: client.file, line });
188
+ } catch (error) {
189
+ console.warn(`KeepSnappy: could not stamp ${componentName} in ${client.file}: ${String(error)}`);
190
+ }
176
191
  }
177
192
  if (stamps.length) statement.insertAfter(stamps);
178
193
  }
@@ -51,7 +51,18 @@ export const ARTIFACT_CALLS_PER_MINUTE = 200;
51
51
  export const ARTIFACT_UPLOAD_DEADLINE_MS =
52
52
  ((ARTIFACT_MAX_FILES_PER_RUN * ARTIFACT_CALLS_PER_FILE) / ARTIFACT_CALLS_PER_MINUTE) * 60_000;
53
53
  export const ARTIFACT_REQUEST_TIMEOUT_MS = 15_000;
54
+ // Counted by native from the moment it starts on the file (gzip + PUT), not from the call: the call may wait in the
55
+ // native upload pool first.
54
56
  export const ARTIFACT_PUT_TIMEOUT_MS = 60_000;
57
+ // Native ends its own upload at the PUT timeout; the JS side gives up on the call this much later, so a native call
58
+ // that hangs (or a worker that never answers) frees its lane instead of holding it to the flush deadline.
59
+ export const ARTIFACT_NATIVE_CALL_MARGIN_MS = 10_000;
60
+ // Files in flight at once (lanes). A file costs a lane three round trips (announce, PUT, complete with the API's inline
61
+ // conclusion): 1.84 s on the farm, so four lanes took 73 s for 159 files while the API instance stayed under half a CPU
62
+ // (on Android the PUTs also ran one at a time). Each native module uploads on a pool of this many workers (ARTIFACT_UPLOAD_LANES in KeepSnappyModule.kt,
63
+ // artifactUploadLanes in KeepSnappyModule.swift; test/artifacts.test.ts keeps them equal). The API's per-run bucket
64
+ // (ARTIFACT_CALLS_BURST in apps/api artifacts/service.ts) holds a whole run, so the lanes never wait on a 429.
65
+ export const ARTIFACT_UPLOAD_CONCURRENCY = 8;
55
66
  // One deadline per flush, started at the flush link (core.ts flushOnce) and shared by the lines push, the clock
56
67
  // report and the artifact upload (this file's ARTIFACT_UPLOAD_DEADLINE_MS is that deadline). The end report gets its
57
68
  // own request timeout after it, so `keepsnappy.flushed` is raised at most ARTIFACT_FLUSH_WORST_CASE_MS after the link.
@@ -154,6 +165,7 @@ const withoutToken = (entry: ArtifactEntry): Omit<ArtifactEntry, 'runToken'> =>
154
165
 
155
166
  // Gzips the file (named as written by writeText / cpuProfileDump), hashes the
156
167
  // gzip, PUTs it. status 0: no HTTP answer (network, timeout, missing file).
168
+ // `timeoutMs` bounds gzip + PUT from the moment native starts on the file.
157
169
  export interface ArtifactUploadNative {
158
170
  artifactUpload(
159
171
  fileName: string,
@@ -189,8 +201,10 @@ export interface ArtifactUploaderDeps {
189
201
  backoffBaseMs?: number;
190
202
  requestTimeoutMs?: number;
191
203
  putTimeoutMs?: number;
192
- // Files in flight at once: the upload must finish inside the driver's wait for the
193
- // flush signal, and one file after the other (about 0.85 s each) took 115 s for 135.
204
+ // How long past the PUT timeout the JS side still waits for the native call; default ARTIFACT_NATIVE_CALL_MARGIN_MS.
205
+ nativeMarginMs?: number;
206
+ // Files in flight at once; default ARTIFACT_UPLOAD_CONCURRENCY. The upload must finish inside the driver's wait
207
+ // for the flush signal.
194
208
  concurrency?: number;
195
209
  // The whole upload stops here; the rest stays on the device as `failed` and goes up
196
210
  // at the next flush. Default: the time the API's per-run budget needs for a full run.
@@ -291,8 +305,9 @@ export const createArtifactUploader = (deps: ArtifactUploaderDeps) => {
291
305
  const backoffBase = deps.backoffBaseMs ?? 1000;
292
306
  const requestTimeoutMs = deps.requestTimeoutMs ?? ARTIFACT_REQUEST_TIMEOUT_MS;
293
307
  const putTimeoutMs = deps.putTimeoutMs ?? ARTIFACT_PUT_TIMEOUT_MS;
308
+ const nativeMarginMs = deps.nativeMarginMs ?? ARTIFACT_NATIVE_CALL_MARGIN_MS;
294
309
  const deadlineMs = deps.deadlineMs ?? ARTIFACT_UPLOAD_DEADLINE_MS;
295
- const concurrency = Math.max(1, deps.concurrency ?? 4);
310
+ const concurrency = Math.max(1, deps.concurrency ?? ARTIFACT_UPLOAD_CONCURRENCY);
296
311
 
297
312
  // Rejects after `ms`: a hard bound for work that may ignore its abort signal (a hung fetch, a native PUT).
298
313
  const timeoutAfter = (ms: number) => {
@@ -405,12 +420,15 @@ export const createArtifactUploader = (deps: ArtifactUploaderDeps) => {
405
420
  if (!native) {
406
421
  return { ok: false, retry: false, stop: 'native upload unavailable', state: 'local', error: 'native upload unavailable' };
407
422
  }
408
- const budget = Math.min(putTimeoutMs, deadline - deps.now());
423
+ const left = deadline - deps.now();
424
+ const budget = Math.min(putTimeoutMs, left);
409
425
  if (budget <= 0) {
410
426
  return { ok: false, retry: false, stop: 'deadline', state: 'failed', error: 'put deadline' };
411
427
  }
412
428
  let result: Awaited<ReturnType<ArtifactUploadNative['artifactUpload']>>;
413
- const hard = timeoutAfter(budget);
429
+ // Native ends its own upload `budget` after it starts on the file. The pool has a worker per lane, so a call
430
+ // rarely waits before that; the margin covers that wait and the gzip, and the flush deadline still bounds it.
431
+ const hard = timeoutAfter(Math.min(left, budget + nativeMarginMs));
414
432
  try {
415
433
  result = await Promise.race([
416
434
  native.artifactUpload(entry.name, signed.url as string, signed.headers, budget),
package/js/sdk/causes.ts CHANGED
@@ -927,6 +927,8 @@ export const resetCausesForTest = () => {
927
927
  taskCauses = null;
928
928
  renderStarted = false;
929
929
  latestTouch = null;
930
+ latestAction = 0;
931
+ newestActionT = -Infinity;
930
932
  viewCause.clear();
931
933
  socketCause.clear();
932
934
  };
package/js/sdk/core.ts CHANGED
@@ -473,8 +473,10 @@ interface OpenSampleState {
473
473
  // - so does a window that starts within OPEN_SAMPLE_TOLERANCE_MS of the close sample (restarted key);
474
474
  // - a window detected long after its t0 (a screen window opened by a press, seen after the new screen rendered) gets
475
475
  // none: its bound is the press's open sample;
476
- // - else the sampler is asked to sample on its own thread.
477
- const openSampleFor = (id: number, t0: number): OpenSampleState => {
476
+ // - else the sampler is asked to sample on its own thread. A press always is, however late its touch reached JS: with
477
+ // no window before it (after a GC command, the background or the flush) the sample before the touch can be seconds
478
+ // old, so its own sample, a negative lead past t0, is the closer bound.
479
+ const openSampleFor = (id: number, t0: number, press: boolean): OpenSampleState => {
478
480
  const none = { openRecord: null, openPending: null };
479
481
  if (samplerState.mode === 'off') {
480
482
  return none;
@@ -487,7 +489,7 @@ const openSampleFor = (id: number, t0: number): OpenSampleState => {
487
489
  ) {
488
490
  return { openRecord: lastClose.record, openPending: null };
489
491
  }
490
- if (now() - samplerState.offsetMs - t0Native > OPEN_SAMPLE_TOLERANCE_MS) {
492
+ if (!press && now() - samplerState.offsetMs - t0Native > OPEN_SAMPLE_TOLERANCE_MS) {
491
493
  return none;
492
494
  }
493
495
  const reason = `window-start:${id}`;
@@ -938,7 +940,14 @@ const routeToBackground = (record: CommitRecord) => {
938
940
 
939
941
  // Segments written and not yet listed for upload (listed at the next flush).
940
942
  const pendingStreamFiles: WrittenFile[] = [];
941
- // A segment is cut at this many commits even between flushes, so a long stretch outside a run stays bounded.
943
+ // What the run lost since the last flush, which reports it. Commit-stream segments that failed to write are missing data
944
+ // (the backend splits work by cause from them). Kept React exports dropped at the run cap or not written, and stream fibers
945
+ // past a segment's cap, leave every measured value complete: counted notes only.
946
+ const runLosses = { stream: 0, exportsCapped: 0, exportsUnwritten: 0, streamFibersDropped: 0 };
947
+ export const setStreamFiberCapForTest = (fibers: number) => commitStream.setMaxFibers(fibers);
948
+ // A segment is cut at this many commits even between flushes, so a long stretch outside a run stays bounded. In a run
949
+ // the cut waits while a window is open (no file I/O inside a measured window, REQ-MEAS-23): every window ends at the
950
+ // flush, which cuts it, and a segment past its fiber cap keeps only the commits' facts.
942
951
  const MAX_SEGMENT_COMMITS = 5000;
943
952
 
944
953
  const cutStream = () => {
@@ -954,10 +963,23 @@ const cutStream = () => {
954
963
  }
955
964
  if (path) {
956
965
  pendingStreamFiles.push({ name: segment.name, windows: [], bytes: segment.json.length });
966
+ } else if (inRun()) {
967
+ runLosses.stream++;
957
968
  }
969
+ if (inRun()) runLosses.streamFibersDropped += segment.fibersDropped;
958
970
  perfLog(`stream ${JSON.stringify({ name: segment.name, commits: segment.commits, bytes: segment.json.length, fibersDropped: segment.fibersDropped, written: path !== null })}`);
959
971
  };
960
972
 
973
+ // What a test file leaves in the process-wide run: the open stream segment and the files listed for it, the answers of
974
+ // GC commands, the GC that ran since the last flush, and the close sample the next window would take as its open one.
975
+ export const resetRunStateForTest = () => {
976
+ cutStream();
977
+ pendingStreamFiles.length = 0;
978
+ commandAnswers.clear();
979
+ gcRanSinceFlush = false;
980
+ lastClose = null;
981
+ };
982
+
961
983
  const endBackground = (reason: string) => {
962
984
  for (const record of [...unclaimed.splice(0), ...recentCommits]) {
963
985
  routeToBackground(record);
@@ -998,7 +1020,7 @@ const start = (name: string, options: StartOptions = {}) => {
998
1020
  const statsStart = readHermesStats();
999
1021
  const id = nextWindowId++;
1000
1022
  const startT = options.at ?? now();
1001
- const openSample = openSampleFor(id, startT);
1023
+ const openSample = openSampleFor(id, startT, options.kind === 'press');
1002
1024
  const window: MarkerWindow = {
1003
1025
  id,
1004
1026
  key,
@@ -1301,7 +1323,7 @@ const onCommitFiberRoot = (rendererId: number, root: FiberRoot) => {
1301
1323
  // Work no open window judges is the background of its source: pushes, and loops of earlier actions or of the launch.
1302
1324
  record.background = record.judged ? null : backgroundSource(causes);
1303
1325
  commitStream.noteCommit({ seq, t: t0, renderMs, causes, fibers: exported?.fibers ?? null, updaters: profile?.updaters ?? null });
1304
- if (commitStream.size() >= MAX_SEGMENT_COMMITS) {
1326
+ if (commitStream.size() >= MAX_SEGMENT_COMMITS && !(inRun() && openWindows.size > 0)) {
1305
1327
  cutStream();
1306
1328
  }
1307
1329
  recordCommit(record);
@@ -1525,7 +1547,12 @@ interface PendingExport {
1525
1547
  }
1526
1548
 
1527
1549
  // React exports held until flush (inside a run), capped like a window's.
1528
- const MAX_PENDING_EXPORT_FIBERS = 400000;
1550
+ let maxPendingExportFibers = 400000;
1551
+ export const setPendingExportCapForTest = (fibers: number) => {
1552
+ const previous = maxPendingExportFibers;
1553
+ maxPendingExportFibers = fibers;
1554
+ return previous;
1555
+ };
1529
1556
  const pendingExports: PendingExport[] = [];
1530
1557
  let pendingExportFibers = 0;
1531
1558
 
@@ -1546,7 +1573,8 @@ const writeReactExport = (window: MarkerWindow) => {
1546
1573
  truncated: window.exportTruncated,
1547
1574
  };
1548
1575
  if (inRun()) {
1549
- if (pendingExportFibers + window.exportFibers > MAX_PENDING_EXPORT_FIBERS) {
1576
+ if (pendingExportFibers + window.exportFibers > maxPendingExportFibers) {
1577
+ if (settings.diag || budgetBreaks.has(window.id)) runLosses.exportsCapped++;
1550
1578
  return { ...base, deferred: true, dropped: true };
1551
1579
  }
1552
1580
  pendingExports.push({
@@ -1623,6 +1651,7 @@ const writeDeferredFiles = (): ArtifactEntry[] => {
1623
1651
  } catch (error) {
1624
1652
  result = { error: String(error) };
1625
1653
  }
1654
+ if (!result.path && (settings.diag || budgetBreaks.has(pending.window))) runLosses.exportsUnwritten++;
1626
1655
  perfLog(
1627
1656
  `rexport ${JSON.stringify({ name: pending.name, commits: pending.commits.length, fibers: pending.fibers, ...result })}`,
1628
1657
  );
@@ -2127,6 +2156,16 @@ const flushOnce = async (ack: string | undefined) => {
2127
2156
  } catch (error) {
2128
2157
  perfLog(`artifacts failed ${String(error)}`);
2129
2158
  }
2159
+ if (runToken !== null) {
2160
+ if (runLosses.stream > 0) {
2161
+ if (status === 'ok') status = 'error';
2162
+ detail += ` lost stream=${runLosses.stream}`;
2163
+ }
2164
+ if (runLosses.exportsCapped > 0) detail += ` exports capped=${runLosses.exportsCapped}`;
2165
+ if (runLosses.exportsUnwritten > 0) detail += ` exports unwritten=${runLosses.exportsUnwritten}`;
2166
+ if (runLosses.streamFibersDropped > 0) detail += ` stream fibersDropped=${runLosses.streamFibersDropped}`;
2167
+ }
2168
+ Object.assign(runLosses, { stream: 0, exportsCapped: 0, exportsUnwritten: 0, streamFibersDropped: 0 });
2130
2169
  try {
2131
2170
  await sendEarlierArtifacts(deadline);
2132
2171
  } catch (error) {
package/js/sdk/fiber.ts CHANGED
@@ -230,9 +230,11 @@ export const noteMounted = (fiber: Fiber) => {
230
230
  const props = propsOf(fiber);
231
231
  if (props && props.route !== undefined) {
232
232
  noteRoute(fiber, props);
233
+ // React Navigation core's SceneView: the route's `screen` config and `clearOptions`. Expo Router 58's fork no
234
+ // longer passes `getState`, so that prop does not mark it. Headers and layouts get `route` without these.
233
235
  const route = props.route as { key?: unknown } | null;
234
236
  if (typeof route?.key === 'string' &&
235
- typeof props.getState === 'function' &&
237
+ props.screen !== null && typeof props.screen === 'object' &&
236
238
  typeof props.clearOptions === 'function') {
237
239
  scenes.set(route.key, fiber);
238
240
  }
@@ -448,6 +450,10 @@ const authoredId = (value: unknown): string | null => {
448
450
  return s !== null && !GENERATED_ID.test(s) ? s : null;
449
451
  };
450
452
 
453
+ // EXPO_PUBLIC_KEEPSNAPPY_TAP_TEXT=0 keeps what a screen shows out of tap names: only testID, accessibilityLabel and
454
+ // component names are used. Read per call so the bundle-time value is inlined at the read.
455
+ const tapTextAllowed = () => (process.env.EXPO_PUBLIC_KEEPSNAPPY_TAP_TEXT) !== '0';
456
+
451
457
  const textUnder = (root: Fiber): string | null => {
452
458
  const parts: string[] = [];
453
459
  const stack: Fiber[] = [root];
@@ -501,7 +507,7 @@ export interface TargetDescription {
501
507
  place: TargetPlace;
502
508
  }
503
509
 
504
- // testID -> accessibilityLabel -> text -> nearest named component -> tag.
510
+ // testID -> accessibilityLabel -> text (unless switched off) -> nearest named component -> tag.
505
511
  // testID/label are searched from the touched view up to the first named
506
512
  // component above the actionable (onPress/responder) fiber.
507
513
  const legacyTarget = (tag: number): Omit<TargetDescription, 'legacyName' | 'legacySource' | 'place'> => {
@@ -560,7 +566,7 @@ const legacyTarget = (tag: number): Omit<TargetDescription, 'legacyName' | 'lega
560
566
  result.name = result.accessibilityLabel;
561
567
  result.source = 'accessibilityLabel';
562
568
  } else {
563
- const text = textUnder(actionable ?? host);
569
+ const text = tapTextAllowed() ? textUnder(actionable ?? host) : null;
564
570
  if (text) {
565
571
  result.name = text;
566
572
  result.source = 'text';
@@ -582,9 +588,11 @@ export interface TargetPlace {
582
588
  manySiblings?: number;
583
589
  }
584
590
 
591
+ // A scroll view's own responder handlers (RN sets them on RCTScrollView) serve the scroll gesture, so a scroll view is
592
+ // never the tap's region: the pressables inside it are.
585
593
  const actionableFiber = (f: Fiber) => {
586
594
  const props = propsOf(f);
587
- return props !== null && isActionable(props);
595
+ return props !== null && isActionable(props) && !isScrollHost(f);
588
596
  };
589
597
  const isListCell = (f: Fiber) => {
590
598
  if (!isComponentTag(f.tag) || clientKeyOf(f) !== null) return false;
@@ -594,6 +602,69 @@ const isListCell = (f: Fiber) => {
594
602
  (typeof p.getRenderedItem === 'function' && 'itemKey' in p));
595
603
  };
596
604
 
605
+ // React elements a component received in its props (children or any other prop), arrays included, by their props
606
+ // object: the fiber React creates from an element keeps that object as its props.
607
+ // Props objects do not change after their render, so the result is kept per object.
608
+ const MAX_PASSED_ELEMENTS = 64;
609
+ const NO_ELEMENTS: ReadonlySet<unknown> = new Set();
610
+ const passedByProps = new WeakMap<object, ReadonlySet<unknown>>();
611
+ const passedElementProps = (props: Props): ReadonlySet<unknown> => {
612
+ const known = passedByProps.get(props);
613
+ if (known) return known;
614
+ const found = new Set<unknown>();
615
+ try {
616
+ const stack: unknown[] = Object.values(props);
617
+ let seen = 0;
618
+ while (stack.length > 0 && seen++ < MAX_PASSED_ELEMENTS) {
619
+ const value = stack.pop();
620
+ if (Array.isArray(value)) {
621
+ stack.push(...value.slice(0, MAX_PASSED_ELEMENTS));
622
+ } else if (value !== null && typeof value === 'object' && '$$typeof' in value && 'props' in value) {
623
+ found.add((value as { props: unknown }).props);
624
+ }
625
+ }
626
+ } catch {
627
+ // A prop that throws on read (a revoked Proxy) carries no element we can match.
628
+ }
629
+ const result = found.size === 0 ? NO_ELEMENTS : found;
630
+ passedByProps.set(props, result);
631
+ return result;
632
+ };
633
+
634
+ // A client component between the target and the component that placed it passes the press through: it received a
635
+ // press handler from its owner (a button wrapper forwarding `onPress`), or it renders an element it received in its
636
+ // props around the target (a layout or animation wrapper given `children`). Neither tells two buttons apart.
637
+ const passesThrough = (f: Fiber, path: readonly Fiber[]) => {
638
+ const props = propsOf(f);
639
+ if (!props) return false;
640
+ if (isActionable(props)) return true;
641
+ const passed = passedElementProps(props);
642
+ if (passed.size === 0) return false;
643
+ return path.some(p => passed.has(p.memoizedProps) || (p.alternate !== null && passed.has(p.alternate.memoizedProps)));
644
+ };
645
+
646
+ // The parent of a place: the first client component above the target that does not pass the press through, else the
647
+ // nearest client component above it.
648
+ const placeParentOf = (target: Fiber): Fiber | null => {
649
+ let nearest: Fiber | null = null;
650
+ const path: Fiber[] = [target];
651
+ for (let f = target.return, n = 0; f && n < MAX_UP * 2; f = f.return, n++) {
652
+ if (clientFiber(f)) {
653
+ nearest ??= f;
654
+ if (!passesThrough(f, path)) return f;
655
+ }
656
+ path.push(f);
657
+ }
658
+ return nearest;
659
+ };
660
+
661
+ // A client component that can pass a press through for something below it; any other client component is the
662
+ // parent of every target under it.
663
+ const mayPassThrough = (f: Fiber) => {
664
+ const props = propsOf(f);
665
+ return props !== null && (isActionable(props) || passedElementProps(props).size > 0);
666
+ };
667
+
597
668
  const occurrenceOf = (
598
669
  parent: Fiber,
599
670
  target: Fiber,
@@ -611,13 +682,19 @@ const occurrenceOf = (
611
682
  const stack: { f: Fiber; inside: boolean }[] = root.child ? [{ f: root.child, inside: false }] : [];
612
683
  let count = 0;
613
684
  let index = 0;
685
+ const isParent = (f: Fiber | null) => f !== null && (f === root || f === root.alternate);
614
686
  while (stack.length && visited < 1000) {
615
687
  const { f, inside } = stack.pop()!;
616
688
  visited++;
617
689
  if (f.sibling) stack.push({ f: f.sibling, inside });
618
690
  if (isListCell(f)) continue;
619
691
  const client = clientFiber(f);
620
- if (client && (inside || clientKeyOf(f) !== key)) continue;
692
+ if (client && inside) continue;
693
+ if (client && clientKeyOf(f) !== key) {
694
+ if (mayPassThrough(f) && f.child) stack.push({ f: f.child, inside: false });
695
+ continue;
696
+ }
697
+ if (client && !isParent(placeParentOf(f))) continue;
621
698
  const inTarget = inside || client;
622
699
  if (inTarget && ((scroll && client) || (!scroll && actionableFiber(f)))) {
623
700
  count++;
@@ -649,10 +726,7 @@ const placeOf = (host: Fiber | null, scroll: boolean, screen: ScreenKeyResolutio
649
726
  if (!scroll && actionableFiber(f)) region = f;
650
727
  if (clientFiber(f)) { target = f; break; }
651
728
  }
652
- let parent: Fiber | null = null;
653
- for (let f = target?.return ?? null, n = 0; f && n < MAX_UP * 2; f = f.return, n++) {
654
- if (clientFiber(f)) { parent = f; break; }
655
- }
729
+ const parent = target ? placeParentOf(target) : null;
656
730
  return {
657
731
  screen: screen.screen,
658
732
  parent: parent ? clientKeyOf(parent) : null,
@@ -270,6 +270,19 @@ const finishPress = (press: ActivePress, reason: string, at?: number) => {
270
270
  }
271
271
  };
272
272
 
273
+ // A drag is named after its scroll view (REQ-MEAS-10). Returns whether the gesture is a drag.
274
+ const classify = (press: ActivePress) => {
275
+ const drag = press.maxMovePt >= DRAG_SLOP_PT || press.scrolled || press.takenByScroll;
276
+ press.kind = drag ? 'drag' : 'press';
277
+ if (drag) {
278
+ const axis = dragAxis(press);
279
+ const candidate = selectScrollHost(press.scrollHosts, axis);
280
+ press.container = candidate ? nameScrollContainer(candidate, press.screen) : null;
281
+ markers.rename(press.key, dragName(press), `drag:${press.container?.legacyName ?? press.target.legacyName}`);
282
+ }
283
+ return drag;
284
+ };
285
+
273
286
  const onTouchStart = (ne: Record<string, unknown>) => {
274
287
  const touches = ne.touches;
275
288
  if (Array.isArray(touches) && touches.length > 1) {
@@ -326,6 +339,11 @@ const onTouchStart = (ne: Record<string, unknown>) => {
326
339
  legacyName: `press:${target.legacyName}`,
327
340
  at: touchStartT,
328
341
  details: details(press),
342
+ // A touch whose touchEnd never reached JS (its view unmounted, the app left the foreground) is classified by how
343
+ // far it moved, so a swipe stays a drag on its list.
344
+ beforeEnd: () => {
345
+ if (press.kind === null) classify(press);
346
+ },
329
347
  });
330
348
  };
331
349
 
@@ -420,14 +438,7 @@ const onTouchEnd = (ne: Record<string, unknown>, cancelled: boolean) => {
420
438
  // A scroll view cancels the touch when it takes the gesture. On a list still moving from a fling it does so at once,
421
439
  // before the finger passes the slop, so the move alone would call a fling a press on the row under the finger.
422
440
  press.takenByScroll = cancelled && press.scrollHosts.length > 0;
423
- const drag = press.maxMovePt >= DRAG_SLOP_PT || press.scrolled || press.takenByScroll;
424
- press.kind = drag ? 'drag' : 'press';
425
- if (drag) {
426
- const axis = dragAxis(press);
427
- const candidate = selectScrollHost(press.scrollHosts, axis);
428
- press.container = candidate ? nameScrollContainer(candidate, press.screen) : null;
429
- const name = dragName(press);
430
- markers.rename(press.key, name, `drag:${press.container?.legacyName ?? press.target.legacyName}`);
441
+ if (classify(press)) {
431
442
  return;
432
443
  }
433
444
  if (cancelled) {
package/js/sdk/push.ts CHANGED
@@ -115,6 +115,8 @@ const serverClock = async (res: { json?: () => Promise<unknown> }, bound: Promis
115
115
  interface Item {
116
116
  line: string;
117
117
  bytes: number;
118
+ // The line's size inside the request body: a JSON string (escaped) plus its comma.
119
+ wire: number;
118
120
  token: string | null;
119
121
  // The process that recorded the line (its bootId) and its sequence there.
120
122
  origin: string;
@@ -127,7 +129,7 @@ interface Batch {
127
129
  id: string;
128
130
  items: Item[];
129
131
  token: string | null;
130
- bytes: number;
132
+ wire: number;
131
133
  }
132
134
 
133
135
  interface SpoolAdd {
@@ -211,6 +213,8 @@ export const pendingFromSpool = (content: string | null): PendingLine[] =>
211
213
  readSpool(content).pending;
212
214
 
213
215
  // Retrying cannot fix these; the batch is dropped instead of blocking the queue.
216
+ const wireBytes = (line: string) => utf8Length(JSON.stringify(line)) + 1;
217
+
214
218
  const isPermanent = (status: number) =>
215
219
  status >= 400 && status < 500 && status !== 408 && status !== 429;
216
220
 
@@ -261,6 +265,11 @@ export const createPushClient = (deps: PushDeps) => {
261
265
  let journalRecords = 0;
262
266
  // Lines dropped before delivery, by this process and (from the note) earlier ones.
263
267
  let undelivered = 0;
268
+ // Lines of a run dropped before delivery (queue cap, line over the batch limit), by run token, until its next flush.
269
+ const droppedOfRun = new Map<string, number>();
270
+ const noteDropped = (token: string | null) => {
271
+ if (token !== null) droppedOfRun.set(token, (droppedOfRun.get(token) ?? 0) + 1);
272
+ };
264
273
  // Round trips not reported yet, each with the run token it was measured under.
265
274
  let clockPending: { token: string; sample: ClockSample }[] = [];
266
275
  const clockOf = (token: string | null) =>
@@ -424,6 +433,7 @@ export const createPushClient = (deps: PushDeps) => {
424
433
  }
425
434
  }
426
435
  undelivered += dropped.length;
436
+ for (const item of dropped) noteDropped(item.token);
427
437
  return dropped;
428
438
  };
429
439
 
@@ -432,12 +442,14 @@ export const createPushClient = (deps: PushDeps) => {
432
442
  if (bytes > maxBatchBytes) {
433
443
  stats.dropped++;
434
444
  undelivered++;
445
+ noteDropped(deps.getRunToken());
435
446
  stats.lastError = `line of ${bytes} bytes exceeds batch limit`;
436
447
  return;
437
448
  }
438
449
  const item: Item = {
439
450
  line,
440
451
  bytes,
452
+ wire: wireBytes(line),
441
453
  token: deps.getRunToken(),
442
454
  origin: bootId,
443
455
  seq: nextSeq++,
@@ -486,11 +498,13 @@ export const createPushClient = (deps: PushDeps) => {
486
498
  if (bytes > maxBatchBytes) {
487
499
  stats.resumeDropped++;
488
500
  undelivered++;
501
+ noteDropped(record.t);
489
502
  continue;
490
503
  }
491
504
  earlier.push({
492
505
  line: record.l,
493
506
  bytes,
507
+ wire: wireBytes(record.l),
494
508
  token: record.t,
495
509
  origin: record.o,
496
510
  seq: record.s,
@@ -528,16 +542,16 @@ export const createPushClient = (deps: PushDeps) => {
528
542
  }
529
543
  const first = queue[0] as Item;
530
544
  const items: Item[] = [];
531
- let bytes = 0;
545
+ let wire = 0;
532
546
  if (first.batch !== undefined) {
533
547
  // Already sent once (by a killed process): resend exactly that batch.
534
548
  while (queue.length > 0 && queue[0]?.batch === first.batch) {
535
549
  const next = queue.shift() as Item;
536
550
  queuedBytes -= next.bytes;
537
551
  items.push(next);
538
- bytes += next.bytes;
552
+ wire += next.wire;
539
553
  }
540
- return { id: first.batch, items, token: first.token, bytes };
554
+ return { id: first.batch, items, token: first.token, wire };
541
555
  }
542
556
  while (queue.length > 0 && items.length < maxBatchItems) {
543
557
  const next = queue[0];
@@ -546,16 +560,16 @@ export const createPushClient = (deps: PushDeps) => {
546
560
  next.batch !== undefined ||
547
561
  next.token !== first.token ||
548
562
  next.origin !== first.origin ||
549
- (items.length > 0 && bytes + next.bytes > maxBatchBytes)
563
+ (items.length > 0 && wire + next.wire > maxBatchBytes)
550
564
  ) {
551
565
  break;
552
566
  }
553
567
  queue.shift();
554
568
  queuedBytes -= next.bytes;
555
569
  items.push(next);
556
- bytes += next.bytes;
570
+ wire += next.wire;
557
571
  }
558
- return { id: '', items, token: first.token, bytes };
572
+ return { id: '', items, token: first.token, wire };
559
573
  };
560
574
 
561
575
  let inflight: Promise<void> | null = null;
@@ -601,29 +615,48 @@ export const createPushClient = (deps: PushDeps) => {
601
615
  bound.clear();
602
616
  }
603
617
  }
618
+ const runToken = batch.token;
619
+ const reported = clockOf(runToken);
620
+ const sentDevice = device;
621
+ const bodyOf = (id: string) => JSON.stringify({
622
+ batch_id: id,
623
+ device: sentDevice,
624
+ items: batch.items.map(item => item.line),
625
+ ...(runToken ? { run_token: runToken } : {}),
626
+ ...(reported.length ? { clock: reported } : {}),
627
+ });
628
+ // Names exactly these lines (process, first and last sequence, count),
629
+ // so a different set of lines is always a different batch.
630
+ const idOf = () => {
631
+ const first = batch.items[0] as Item;
632
+ const last = batch.items[batch.items.length - 1] as Item;
633
+ return `${sentDevice.install_id}-${first.origin}-${first.seq}-${last.seq}-${batch.items.length}`;
634
+ };
635
+ let body = bodyOf(batch.id || idOf());
604
636
  if (!batch.id) {
605
- // Names exactly these lines (process, first and last sequence, count),
606
- // so a different set of lines is always a different batch. It is
607
- // journalled before the first send: a later process that resends after
637
+ // The cap is on the body as sent (escaped lines, device, clock samples): lines over it go back to the queue
638
+ // front for the next batch. A lone line still goes; a line alone over the cap was dropped at enqueue.
639
+ for (let over = utf8Length(body) - maxBatchBytes; over > 0 && batch.items.length > 1; over = utf8Length(body) - maxBatchBytes) {
640
+ const back: Item[] = [];
641
+ for (let freed = 0; freed < over && batch.items.length > 1;) {
642
+ const item = batch.items.pop() as Item;
643
+ freed += item.wire;
644
+ back.unshift(item);
645
+ }
646
+ queue.unshift(...back);
647
+ for (const item of back) queuedBytes += item.bytes;
648
+ body = bodyOf(idOf());
649
+ }
650
+ // Journalled before the first send: a later process that resends after
608
651
  // a kill sends the same lines under the same id, and the server's
609
652
  // replay check can only ever drop lines it already has.
653
+ batch.id = idOf();
610
654
  const first = batch.items[0] as Item;
611
- const last = batch.items[batch.items.length - 1] as Item;
612
- batch.id = `${device.install_id}-${first.origin}-${first.seq}-${last.seq}-${batch.items.length}`;
613
655
  for (const item of batch.items) {
614
656
  item.batch = batch.id;
615
657
  }
616
658
  journal({ b: batch.id, o: first.origin, s: batch.items.map(item => item.seq) });
617
659
  }
618
- const runToken = batch.token;
619
- const reported = clockOf(runToken);
620
- const body = JSON.stringify({
621
- batch_id: batch.id,
622
- device,
623
- items: batch.items.map(item => item.line),
624
- ...(runToken ? { run_token: runToken } : {}),
625
- ...(reported.length ? { clock: reported } : {}),
626
- });
627
660
  const headers: Record<string, string> = {
628
661
  'content-type': 'application/json',
629
662
  'x-keepsnappy-key': deps.key,
@@ -804,16 +837,23 @@ export const createPushClient = (deps: PushDeps) => {
804
837
  await sendClock(deadline);
805
838
  }
806
839
  const refused = refusedRunBatches - refusedBefore;
807
- const ok = sentAll && refused === 0;
840
+ // A run that lost lines before they were sent is missing windows, whatever went up after.
841
+ const token = deps.getRunToken();
842
+ const lost = token === null ? 0 : (droppedOfRun.get(token) ?? 0);
843
+ if (token !== null) droppedOfRun.delete(token);
844
+ const ok = sentAll && refused === 0 && lost === 0;
845
+ const error = ok
846
+ ? null
847
+ : !sentAll
848
+ ? (stats.lastError ?? 'flush deadline')
849
+ : refused > 0
850
+ ? `rejected ${lastRefusedStatus}: ${refused} ${refused === 1 ? 'batch' : 'batches'} of the run dropped`
851
+ : 'lines of the run dropped before sending';
808
852
  return {
809
853
  ok,
810
854
  batches: stats.batchesSent - sentBefore,
811
855
  lines: stats.linesSent - linesBefore,
812
- error: ok
813
- ? null
814
- : !sentAll
815
- ? (stats.lastError ?? 'flush deadline')
816
- : `rejected ${lastRefusedStatus}: ${refused} ${refused === 1 ? 'batch' : 'batches'} of the run dropped`,
856
+ error: error !== null && lost > 0 ? `${error} dropped=${lost}` : error,
817
857
  };
818
858
  } finally {
819
859
  flushLimit = null;
package/js/sdk/sampler.ts CHANGED
@@ -132,7 +132,7 @@ export const estimateOffset = (before: number, native: number, after: number) =>
132
132
  // late after its t0 a window can start and still take one. It gates only screen windows (a screen window opened by a
133
133
  // press is detected after the new screen rendered and takes the press's open sample, taken the touch delivery delay
134
134
  // past the press's t0) and the reuse of a close sample for a restarted key. Press windows do not depend on it: they
135
- // take the previous window's close sample by contiguity. Evidence for 50: idle lab event timing of 4.9-19.1 ms
135
+ // take the previous window's close sample by contiguity, else a sample of their own however late it is. Evidence for 50: idle lab event timing of 4.9-19.1 ms
136
136
  // (packages/derive/test/fixtures/p37-*.log); no loaded-device data. Measure the touchstart delivery delay p99 per
137
137
  // device from farm data (docs/log/followups.md).
138
138
  export const OPEN_SAMPLE_TOLERANCE_MS = 50;
package/js/sdk/screens.ts CHANGED
@@ -66,7 +66,8 @@ interface Visit {
66
66
  visit: number;
67
67
  screenKey: string | null;
68
68
  legacyName: string;
69
- pendingCommits: number;
69
+ // The screen component is not known yet: its scene has not mounted, or it is suspended.
70
+ pending: boolean;
70
71
  resolution: ScreenRouteResolution;
71
72
  lazyBoundary: Fiber | null;
72
73
  t0: number;
@@ -254,7 +255,7 @@ const endVisit = (reason: string, at?: number) => {
254
255
  if (!visit || visit.endedAt !== null) {
255
256
  return;
256
257
  }
257
- if (visit.pendingCommits > 0) resolveVisit(visit, true);
258
+ if (visit.pending) resolveVisit(visit, true);
258
259
  const t = Math.max(visit.t0, at ?? now());
259
260
  visit.endedAt = t;
260
261
  stats.ends[reason] = (stats.ends[reason] ?? 0) + 1;
@@ -269,7 +270,9 @@ const endVisit = (reason: string, at?: number) => {
269
270
  // state event.
270
271
  const chooseStart = (snapshot: NavSnapshot, floor: number) => {
271
272
  const inSpan = (t: number) => t >= floor && t <= snapshot.stateT;
272
- const presses = pressesSinceVisit.filter(p => inSpan(p.t0));
273
+ // Touches delivered since the previous visit opened, whatever their finger time: a finger that landed while the
274
+ // previous screen was mounting reaches JS after its state was read.
275
+ const presses = pressesSinceVisit.filter(p => p.t0 >= windowsEndedAt && p.t0 <= snapshot.stateT);
273
276
  const link = pendingLink && inSpan(pendingLink.t) ? pendingLink : null;
274
277
  const action = snapshot.action && inSpan(snapshot.action.t) ? snapshot.action : null;
275
278
  const touchStart = (p: Press) => ({
@@ -357,7 +360,7 @@ const resolveVisit = (visit: Visit, final = false) => {
357
360
  if (resolved.screenPending && !final) return;
358
361
  moveScreenCounter(visit, resolved.screen);
359
362
  visit.name = buildPlaceId({ kind: 'screen', screen: resolved.screen, visit: visit.visit });
360
- visit.pendingCommits = 0;
363
+ visit.pending = false;
361
364
  markers.rename(visit.key, visit.name, visit.legacyName);
362
365
  };
363
366
 
@@ -380,7 +383,7 @@ const extend = (visit: Visit, chain: RoutePart[], chainKey: string) => {
380
383
  visit.screenKey = null;
381
384
  }
382
385
  visit.name = visit.legacyName;
383
- visit.pendingCommits = 3;
386
+ visit.pending = true;
384
387
  markers.rename(visit.key, visit.name, visit.legacyName);
385
388
  } else resolveVisit(visit);
386
389
  stats.extended++;
@@ -447,7 +450,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
447
450
  visit: n,
448
451
  legacyName: nameOf(chainKey, n),
449
452
  screenKey: null,
450
- pendingCommits: 3,
453
+ pending: true,
451
454
  resolution: resolved,
452
455
  lazyBoundary: resolved.suspended ?? null,
453
456
  t0: start.t0,
@@ -465,7 +468,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
465
468
  if (!resolved.screenPending) {
466
469
  moveScreenCounter(visit, resolved.screen);
467
470
  visit.name = buildPlaceId({ kind: 'screen', screen: resolved.screen, visit: visit.visit });
468
- visit.pendingCommits = 0;
471
+ visit.pending = false;
469
472
  }
470
473
  current = visit;
471
474
  pressesSinceVisit = [];
@@ -481,7 +484,7 @@ const onSnapshot = (snapshot: NavSnapshot, nameSource: NameSource) => {
481
484
  kind: 'screen',
482
485
  legacyName: visit.legacyName,
483
486
  beforeEnd: () => {
484
- if (visit.pendingCommits > 0) resolveVisit(visit, true);
487
+ if (visit.pending) resolveVisit(visit, true);
485
488
  visit.lazyBoundary = null;
486
489
  visit.resolution = { screen: visit.screenKey ?? visit.resolution.screen };
487
490
  },
@@ -526,12 +529,14 @@ export const noteAppLink = (url: string, via: string) => {
526
529
  }
527
530
  };
528
531
 
532
+ // Every touch delivered while a visit is open is an action after the one that opened it, even when its finger landed
533
+ // before the SDK read the navigation state (the JS thread was busy with the mount): order decides, not the clocks.
529
534
  const onPress = (t0: number) => {
530
535
  const press = lastPressStart();
531
536
  if (press) {
532
537
  pressesSinceVisit.push(press);
533
538
  }
534
- if (current && current.endedAt === null && t0 > current.openedAt) {
539
+ if (current && current.endedAt === null) {
535
540
  endVisit('next-action', t0);
536
541
  }
537
542
  };
@@ -613,6 +618,10 @@ const onWindowsEnded = (reason: string) => {
613
618
  pendingLink = null;
614
619
  };
615
620
 
621
+ export const resetCurrentScreenForTest = () => {
622
+ current = null;
623
+ };
624
+
616
625
  export const resetLaunchForTest = () => {
617
626
  launchVisitOpened = false;
618
627
  windowsEndedSinceStart = false;
@@ -628,7 +637,7 @@ export const installScreens = () => {
628
637
  installed = true;
629
638
  setScreenKeyProvider(() => current ? {
630
639
  screen: current.screenKey ?? current.resolution.screen,
631
- ...(current.pendingCommits > 0 || current.lazyBoundary ? { screenPending: true } : {}),
640
+ ...(current.pending || current.lazyBoundary ? { screenPending: true } : {}),
632
641
  } : { screen: '(no screen)' });
633
642
  setRetainFloor(screenRetainFloor);
634
643
  onWindowsEnd(onWindowsEnded);
@@ -641,11 +650,12 @@ export const installScreens = () => {
641
650
  onAfterCommit(() => {
642
651
  const visit = current;
643
652
  if (visit && isWindowOpen(visit.key)) {
644
- if (visit.pendingCommits > 0) {
653
+ // A pending visit is resolved when its scene mounts, however many commits come first; it falls back to the route
654
+ // name only when it ends.
655
+ if (visit.pending && !visit.lazyBoundary) {
645
656
  visit.resolution = screenResolution(visit.chain);
646
657
  visit.lazyBoundary = visit.resolution.suspended ?? null;
647
- visit.pendingCommits--;
648
- resolveVisit(visit, visit.pendingCommits === 0);
658
+ resolveVisit(visit);
649
659
  } else if (visit.lazyBoundary) {
650
660
  const boundary = currentFiber(visit.lazyBoundary);
651
661
  if (!boundary || boundary.memoizedState == null) {
package/js/sdk/stream.ts CHANGED
@@ -39,6 +39,12 @@ export const createStream = (processId: string, maxFibers = MAX_SEGMENT_FIBERS)
39
39
  /** The segment the next commit goes into: windows name it so the backend can tell a stream is complete. */
40
40
  seq: () => seq,
41
41
  size: () => commits.length,
42
+ /** Sets the fiber cap of the segments from now on; returns the previous cap. */
43
+ setMaxFibers(n: number) {
44
+ const previous = maxFibers;
45
+ maxFibers = n;
46
+ return previous;
47
+ },
42
48
  noteRoot(root: CauseRoot) {
43
49
  roots.push(root);
44
50
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepsnappy/react-native",
3
- "version": "0.1.0-alpha.16",
3
+ "version": "0.1.0-alpha.18",
4
4
  "description": "KeepSnappy in-app SDK for React Native / Expo: zero-code measurement windows, React commit hook, native signposts and log file",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {