@goliapkg/sentori-react-native 1.0.0-rc.7 → 1.0.0-rc.9

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.
@@ -1,10 +1,12 @@
1
1
  package com.sentori
2
2
 
3
3
  import android.app.Activity
4
+ import android.graphics.drawable.BitmapDrawable
4
5
  import android.graphics.drawable.ColorDrawable
5
6
  import android.graphics.drawable.Drawable
6
7
  import android.graphics.drawable.GradientDrawable
7
8
  import android.graphics.drawable.LayerDrawable
9
+ import android.graphics.drawable.StateListDrawable
8
10
  import android.view.View
9
11
  import android.view.ViewGroup
10
12
  import android.widget.EditText
@@ -93,9 +95,18 @@ object SentoriReplayCapture {
93
95
  totalEmptyResultTicks++
94
96
  return null
95
97
  }
96
- val root = activity.window?.decorView
98
+ // rc.8 anchor the walk at android.R.id.content, NOT
99
+ // window.decorView. decorView includes the StatusBarBackground
100
+ // and NavigationBarBackground sibling views the PhoneWindow
101
+ // injects (full display width, positioned in absolute window
102
+ // coords). Insight 2026-05-18 saw those bleed into the
103
+ // wireframe as horizontal grey bars stretching beyond the
104
+ // viewport width. Anchoring at the content FrameLayout drops
105
+ // them while keeping the app's React tree intact.
106
+ val decor = activity.window?.decorView
107
+ val root = decor?.findViewById<View>(android.R.id.content)
97
108
  if (root == null) {
98
- lastDiagPath = "decorView.null"
109
+ lastDiagPath = if (decor == null) "decorView.null" else "contentView.null"
99
110
  totalEmptyResultTicks++
100
111
  return null
101
112
  }
@@ -217,18 +228,21 @@ object SentoriReplayCapture {
217
228
  kindEmitted = true
218
229
  }
219
230
  view.background != null -> {
220
- node.put("kind", "rect")
221
- // rc.5 extract the fill colour from the
222
- // background Drawable so wireframes show the host
223
- // app's actual brand palette, not a uniform grey
224
- // grid. iOS' UIView.backgroundColor already drives
225
- // this path; the Android side had been emitting
226
- // null since v0.9.6 and the dashboard fell back to
227
- // a neutral fill for every coloured CTA. Insight
228
- // 2026-05-18 verify event made the gap visible.
229
- val color = extractDrawableColor(view.background)
230
- if (color != null && (color shr 24 and 0xff) != 0) {
231
- node.put("color", colorToHex(color))
231
+ // rc.8 — backgrounds backed by a BitmapDrawable
232
+ // (a View whose backgroundImage / drawable
233
+ // resource is a raster) emit as `image` kind so
234
+ // the dashboard renders them as a media region,
235
+ // not as a grey rect. Everything else stays
236
+ // `rect` and tries to extract a fill colour.
237
+ val bg = view.background
238
+ if (bg is BitmapDrawable) {
239
+ node.put("kind", "image")
240
+ } else {
241
+ node.put("kind", "rect")
242
+ val color = extractDrawableColor(bg)
243
+ if (color != null && (color shr 24 and 0xff) != 0) {
244
+ node.put("color", colorToHex(color))
245
+ }
232
246
  }
233
247
  kindEmitted = true
234
248
  }
@@ -283,6 +297,17 @@ object SentoriReplayCapture {
283
297
  val csl = drawable.color
284
298
  csl?.defaultColor
285
299
  }
300
+ is StateListDrawable -> {
301
+ // rc.8 — Pressable / TouchableOpacity wrap their child
302
+ // in a StateListDrawable (default state + pressed
303
+ // state). `.current` returns the currently-applied
304
+ // state's drawable, which during a normal capture
305
+ // is the unpressed visual — exactly what we want.
306
+ // AnimatedStateListDrawable extends StateListDrawable
307
+ // so it inherits this branch.
308
+ extractDrawableColor(drawable.current)
309
+ }
310
+ is BitmapDrawable -> null
286
311
  is LayerDrawable -> {
287
312
  for (i in 0 until drawable.numberOfLayers) {
288
313
  val inner = drawable.getDrawable(i)
package/lib/replay.d.ts CHANGED
@@ -1,19 +1,35 @@
1
+ type Node = {
2
+ x: number;
3
+ y: number;
4
+ w: number;
5
+ h: number;
6
+ kind?: string;
7
+ text?: string;
8
+ color?: string;
9
+ };
1
10
  export type ReplayOptions = {
2
11
  mode?: 'off' | 'wireframe';
3
- /** Ticks per second. Default 1. */
12
+ /** Ticks per second. Default 4. */
4
13
  hz?: number;
14
+ /** Keyframe cadence in ms. Default 4000. */
15
+ keyframeMs?: number;
5
16
  };
6
17
  export declare function startReplay(opts: ReplayOptions): void;
7
18
  export declare function stopReplay(): void;
8
- /** rc.4 surface "is replay subsystem alive" so the captureException
9
- * debug log can label `wantReplay` alongside `wantScreenshot` /
10
- * `wantSessionTrail`. Insight 2026-05-18 verify flagged that the
11
- * pre-rc.4 log line was missing `wantReplay`, leaving the failure
12
- * shape ambiguous (config-off vs. ring-empty vs. attach-failed). */
19
+ type Delta = {
20
+ added: Node[];
21
+ changed: Node[];
22
+ removed: Pick<Node, 'x' | 'y' | 'w' | 'h'>[];
23
+ };
24
+ export declare function computeDelta(prev: Map<string, Node>, curr: Map<string, Node>): Delta;
13
25
  export declare function isReplayRunning(): boolean;
14
- /** Drain the ring as NDJSON (one snapshot per line). Empty string
15
- * when the ring is empty. Also clears the ring so the next session's
16
- * replay starts fresh. */
26
+ /** Drain the ring as NDJSON (keyframe or delta per line). Empty
27
+ * string when the ring is empty. Resets state so the next session's
28
+ * replay starts with a fresh keyframe. */
17
29
  export declare function drainReplay(): string;
18
30
  export declare function __resetReplayForTests(): void;
31
+ /** rc.9 — test seam. Lets unit tests drive the encoder without a
32
+ * native module; pretends we received `frameJson` on the tick. */
33
+ export declare function __feedTickForTests(frameJson: string): void;
34
+ export {};
19
35
  //# sourceMappingURL=replay.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../src/replay.ts"],"names":[],"mappings":"AAkEA,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,KAAK,GAAG,WAAW,CAAC;IAC3B,mCAAmC;IACnC,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,wBAAgB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CA+CrD;AAED,wBAAgB,UAAU,IAAI,IAAI,CAUjC;AAuKD;;;;qEAIqE;AACrE,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;2BAE2B;AAC3B,wBAAgB,WAAW,IAAI,MAAM,CAMpC;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CAI5C"}
1
+ {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../src/replay.ts"],"names":[],"mappings":"AAqDA,KAAK,IAAI,GAAG;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAkBF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,KAAK,GAAG,WAAW,CAAC;IAC3B,mCAAmC;IACnC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAIF,wBAAgB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAmCrD;AAED,wBAAgB,UAAU,IAAI,IAAI,CAajC;AAmID,KAAK,KAAK,GAAG;IAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAAC,OAAO,EAAE,IAAI,EAAE,CAAC;IAAC,OAAO,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE,CAAA;CAAE,CAAC;AAE9F,wBAAgB,YAAY,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,KAAK,CAsBpF;AAuED,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;2CAE2C;AAC3C,wBAAgB,WAAW,IAAI,MAAM,CAOpC;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CAK5C;AAED;mEACmE;AACnE,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAO1D"}
package/lib/replay.js CHANGED
@@ -1,70 +1,59 @@
1
- // v0.9.6 #2 — wireframe Session Replay (SDK side).
1
+ // rc.9 — wireframe Session Replay v2 encoding.
2
2
  //
3
- // 60-slot ring buffer of native-captured wireframe snapshots. Each
4
- // tick calls into `SentoriReplayCapture.captureWireframe(maskIds)`
5
- // which walks the iOS UIView / Android View hierarchy and returns
6
- // one JSON string per snapshot. captureException flushes the ring
7
- // as a `replay` attachment (NDJSON: one snapshot per line).
3
+ // Replaces rc.8's "one full snapshot per tick" with keyframe + delta:
4
+ // - Native walker still emits a full snapshot string every tick.
5
+ // - JS parses the snapshot, builds a fingerprint→node map, and either
6
+ // emits a keyframe (cold start / every KEYFRAME_INTERVAL_MS / when
7
+ // the delta would be bigger than a fresh key) OR a delta against
8
+ // the previous emit's reconstructed state.
9
+ // - Static-UI ticks produce zero-delta heartbeats which we drop.
8
10
  //
9
- // Why wireframe and not raster:
10
- // Storage: 80 nodes × ~80 bytes 6 KB per snapshot vs ~50 KB
11
- // for a downsampled JPEG. 60-slot ring 400 KB raw / ~80 KB
12
- // gzipped fits comfortably in the 500 KB attachment cap.
13
- // • Privacy: no pixels means no accidental PII leaks; mask
14
- // registry decides what text to replace with "***".
15
- // • Replay fidelity: less faithful to pixels but enough to see
16
- // which screen the user was on and what was on it. Dashboard
17
- // player renders SVG rects — denser-looking than a 1 Hz
18
- // screenshot strip.
11
+ // At 4 Hz capture / 4 s keyframes the wire bytes drop ~50 % vs rc.8 at
12
+ // 1 Hz for the same 60 s pre-error window, and the dashboard player
13
+ // cross-fades between captures at 24 fps so playback reads as motion
14
+ // rather than a 1 Hz slideshow.
15
+ //
16
+ // Wire schema: docs/replay-encoding-v2.md.
19
17
  import { startSpan } from '@goliapkg/sentori-core';
20
18
  import { getRegisteredMaskQuery } from './mask';
21
19
  import { describeWireframeNative } from './native';
22
- const TICK_INTERVAL_MS = 1000;
23
- const RING_SIZE = 60;
24
- /** Floor on tick period. < 250 ms (4 Hz) the native view-tree walk
25
- * dominates the JS thread on mid-tier Android, especially with mask
26
- * consultation. The default is 1 Hz; the option exists for
27
- * benchmarking, not for production. */
28
- const MIN_TICK_PERIOD_MS = 250;
20
+ /** Default capture interval (4 Hz). Override via `replay.hz`. */
21
+ const TICK_INTERVAL_MS = 250;
22
+ /** How often to emit a fresh keyframe caps reconstruction chain
23
+ * length and lets the player re-sync after a dropped line. */
24
+ const KEYFRAME_INTERVAL_MS = 4_000;
25
+ /** When the delta against the previous frame would carry ≥ this
26
+ * fraction of the current node count, prefer a fresh keyframe —
27
+ * emits roughly the same bytes but doesn't grow the chain. */
28
+ const DELTA_TO_KEYFRAME_RATIO = 0.4;
29
+ /** Floor under which the keyframe-vs-delta ratio heuristic does
30
+ * not apply. Trivial UIs (≤ 10 nodes — boot splash, dev panel,
31
+ * tests) shouldn't drop to keyframes on every change. */
32
+ const KEYFRAME_RATIO_MIN_NODES = 10;
33
+ /** Replay window kept in the ring buffer. captureException drains. */
34
+ const REPLAY_WINDOW_MS = 60_000;
35
+ /** Hard ceiling on ring item count — defence against a wedged tick
36
+ * clock filling memory; under normal capture rates we evict by time
37
+ * long before this fires. */
38
+ const MAX_RING_ITEMS = 1000;
39
+ /** Floor on tick period. < 100 ms the native view-tree walk dominates
40
+ * the JS thread on mid-tier Android. */
41
+ const MIN_TICK_PERIOD_MS = 100;
29
42
  let _ring = [];
30
43
  let _timer = null;
31
44
  let _running = false;
32
- /**
33
- * v0.9.13 frame-level delta encoding: when the new snapshot matches
34
- * the last one byte-for-byte (static UI, no animation, off-screen
35
- * app), skip pushing it. The ring stays meaningful (one frame =
36
- * one *change*), the attachment shrinks proportionally, and a real
37
- * idle phase no longer evicts a useful pre-error frame.
38
- *
39
- * We only check against the most-recently-pushed snapshot, not the
40
- * whole ring — that's cheap (one string comparison per tick) and
41
- * catches the dominant case (idle screens). True content changes
42
- * fall through and push as before.
43
- *
44
- * Budget verification on the iOS showcase (apps/ios-showcase): 60
45
- * frames at ~120 bytes each → ≈ 7 KB raw NDJSON, well under the
46
- * 500 KB attachment cap. Heavier RN apps with 200+ visible nodes
47
- * per frame can land in the 400 KB band; future work in v1.x adds
48
- * native gzip on upload if real-world traffic ever pushes the cap.
49
- */
50
- let _lastPushed = null;
51
- /** Native module ref, resolved once on first start. Caching here
52
- * avoids the cost of `requireNativeModule('Sentori')` on every
53
- * capture tick (Metro's require cache makes this cheap, but the
54
- * per-tick string lookup and possible throw still cost more than
55
- * reading a closed-over variable). */
45
+ /** Last emit's reconstructed state — fingerprint → node. Null until
46
+ * the first keyframe lands; reset on drain so the next session
47
+ * starts with a fresh keyframe. */
48
+ let _lastFrameState = null;
49
+ let _lastKeyframeTs = 0;
56
50
  let _nativeMod = null;
51
+ let _keyframeIntervalMs = KEYFRAME_INTERVAL_MS;
57
52
  export function startReplay(opts) {
58
53
  if (_running)
59
54
  return;
60
55
  if (opts.mode !== 'wireframe')
61
56
  return;
62
- // v0.9.10 — gate via expo-modules-core's registry (same path the
63
- // screenshot capture uses). The previous `isNativeModuleLinked`
64
- // check looked at the legacy `RN.NativeModules` map, but the
65
- // Sentori module is registered through expo-modules-core; the
66
- // legacy map never sees it, so this branch returned "not linked"
67
- // forever even with the pod correctly attached (Insight 2026-05-17).
68
57
  const info = describeWireframeNative();
69
58
  if (!info.bound) {
70
59
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
@@ -79,23 +68,15 @@ export function startReplay(opts) {
79
68
  }
80
69
  _running = true;
81
70
  _nativeMod = loadNativeReplay();
82
- const period = Math.max(MIN_TICK_PERIOD_MS, Math.floor(TICK_INTERVAL_MS / (opts.hz ?? 1)));
71
+ _keyframeIntervalMs = opts.keyframeMs ?? KEYFRAME_INTERVAL_MS;
72
+ const hz = opts.hz ?? 4;
73
+ const period = Math.max(MIN_TICK_PERIOD_MS, Math.round(1000 / hz));
83
74
  _timer = setInterval(() => {
84
75
  captureTick();
85
76
  }, period);
86
- // v0.9.12 — Insight 2026-05-17 report: 0.9.11 emitted the
87
- // "starting bound=true" line then went silent. Root cause was the
88
- // `.unref?.()` call that used to live here. Hermes 0.81 doesn't
89
- // ship a Timer object with the Node-style `unref` method, and the
90
- // optional-chained call ended up dereferencing a `prototype`
91
- // property on `undefined` — throwing synchronously inside
92
- // startReplay, which RN's bridge swallowed silently. Net effect:
93
- // setInterval registered, captureTick never invoked. Drop the
94
- // call; replay tick lifecycle is bound to the app process, no
95
- // event-loop tweak needed.
96
77
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
97
78
  // eslint-disable-next-line no-console
98
- console.warn('[sentori] replay: scheduled tick period=', period, 'ms');
79
+ console.warn('[sentori] replay: scheduled tick period=', period, 'ms keyframe=', _keyframeIntervalMs, 'ms');
99
80
  }
100
81
  }
101
82
  export function stopReplay() {
@@ -108,6 +89,9 @@ export function stopReplay() {
108
89
  _emptyTickCount = 0;
109
90
  _emptyTickLogStride = 1;
110
91
  _firstTickLogged = false;
92
+ _okTickCount = 0;
93
+ _thinTickCount = 0;
94
+ _thinTickLogStride = 1;
111
95
  }
112
96
  let _emptyTickCount = 0;
113
97
  let _emptyTickLogStride = 1;
@@ -115,30 +99,15 @@ let _thinTickCount = 0;
115
99
  let _thinTickLogStride = 1;
116
100
  let _okTickCount = 0;
117
101
  let _firstTickLogged = false;
118
- /** Anything below this many nodes is suspicious — likely the
119
- * walker bailed early (zero-size parent, masked root, etc.).
120
- * Insight 2026-05-18 verify event saw 800-node payloads on some
121
- * ticks and 1-3-node payloads on others; this threshold flags
122
- * the latter without spamming on small-but-valid screens. */
123
102
  const THIN_RESULT_NODES = 6;
124
103
  function captureTick() {
125
104
  if (!_running)
126
105
  return;
127
- // v0.9.12 — UNCONDITIONAL first-tick log. Proves the setInterval
128
- // callback is firing at all, before any other code that could
129
- // throw. 0.9.11's diagnostic was inside a `else if (snapshot==null)`
130
- // branch that could only surface AFTER the native call returned;
131
- // useless when the bug is that the tick body never enters.
132
106
  if (typeof __DEV__ !== 'undefined' && __DEV__ && !_firstTickLogged) {
133
107
  // eslint-disable-next-line no-console
134
108
  console.warn('[sentori] replay tick: FIRST INVOCATION');
135
109
  _firstTickLogged = true;
136
110
  }
137
- // 0.9.11 called startSpan OUTSIDE the catch block. If
138
- // `@goliapkg/sentori-core` failed to initialise (or startSpan
139
- // threw for any other reason on the first tick) the whole tick
140
- // callback died silently. Wrap so worst case is "no span for this
141
- // tick" not "no ticks for the session".
142
111
  let tickSpan = null;
143
112
  try {
144
113
  tickSpan = startSpan('sentori.replay.tick', { name: 'tick' });
@@ -148,64 +117,29 @@ function captureTick() {
148
117
  }
149
118
  try {
150
119
  const maskIds = readMaskIds();
151
- const snapshot = _nativeMod?.captureWireframe?.(maskIds);
152
- if (typeof snapshot === 'string' && snapshot.length > 0) {
153
- // v0.9.13 — skip pushing if the frame is identical to the last
154
- // pushed one. See _lastPushed comment for the rationale.
155
- if (snapshot !== _lastPushed) {
156
- _ring.push(snapshot);
157
- _lastPushed = snapshot;
158
- while (_ring.length > RING_SIZE) {
159
- _ring.shift();
160
- }
161
- }
162
- _emptyTickCount = 0;
163
- _emptyTickLogStride = 1;
164
- // v1.0.0-rc.3 — Insight 2026-05-18 report: some ticks land
165
- // valid non-empty JSON but with only the root View + 1-2 wrappers
166
- // (the Android zero-size-bails-subtree bug, now fixed natively;
167
- // this log catches similar regressions). Cheap node-count parse
168
- // — we only look at one digit-level character class.
169
- _okTickCount += 1;
170
- if (typeof __DEV__ !== 'undefined' && __DEV__) {
171
- const nodeCount = countNodesQuick(snapshot);
172
- const sizeBytes = snapshot.length;
173
- const isThin = nodeCount < THIN_RESULT_NODES;
174
- if (isThin) {
175
- _thinTickCount += 1;
176
- if (_thinTickCount === 1 || _thinTickCount === _thinTickLogStride) {
177
- // eslint-disable-next-line no-console
178
- console.warn(`[sentori] replay tick: thin result nodes=${nodeCount} sizeBytes=${sizeBytes} (thin ticks so far: ${_thinTickCount})`);
179
- _thinTickLogStride = Math.max(_thinTickLogStride * 10, 10);
180
- }
181
- }
182
- else {
183
- _thinTickCount = 0;
184
- _thinTickLogStride = 1;
185
- }
186
- // First good tick logs the shape so devs see it once.
187
- if (_okTickCount === 1) {
188
- // eslint-disable-next-line no-console
189
- console.warn(`[sentori] replay tick: first ok — nodes=${nodeCount} sizeBytes=${sizeBytes}`);
190
- }
191
- }
120
+ const snapshotJson = _nativeMod?.captureWireframe?.(maskIds);
121
+ if (typeof snapshotJson !== 'string' || snapshotJson.length === 0) {
122
+ handleEmptyTick(snapshotJson);
123
+ tickSpan?.finish({ status: 'ok' });
124
+ return;
125
+ }
126
+ let snapshot;
127
+ try {
128
+ snapshot = JSON.parse(snapshotJson);
192
129
  }
193
- else if (typeof __DEV__ !== 'undefined' && __DEV__) {
194
- // v0.9.11 Insight 2026-05-17 Finding 6: tick fires hundreds
195
- // of times but ring stays empty → native returned null/empty.
196
- // Log on a back-off schedule (1st, 10th, 100th, …) so the
197
- // diagnostic is visible without spamming Metro at 1 Hz for a
198
- // 15-minute session.
199
- _emptyTickCount += 1;
200
- if (_emptyTickCount === 1 || _emptyTickCount === _emptyTickLogStride) {
130
+ catch (e) {
131
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
201
132
  // eslint-disable-next-line no-console
202
- console.warn('[sentori] replay tick: native returned', snapshot === null
203
- ? 'null'
204
- : typeof snapshot === 'string'
205
- ? `empty (length=${snapshot.length})`
206
- : typeof snapshot, `(empty ticks so far: ${_emptyTickCount})`);
207
- _emptyTickLogStride = Math.max(_emptyTickLogStride * 10, 10);
133
+ console.warn('[sentori] replay tick: native JSON parse failed', e);
208
134
  }
135
+ tickSpan?.finish({ status: 'error' });
136
+ return;
137
+ }
138
+ _emptyTickCount = 0;
139
+ _emptyTickLogStride = 1;
140
+ encodeAndPush(snapshot);
141
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
142
+ diagnosticForTick(snapshot, snapshotJson.length);
209
143
  }
210
144
  tickSpan?.finish({ status: 'ok' });
211
145
  }
@@ -219,28 +153,124 @@ function captureTick() {
219
153
  }
220
154
  }
221
155
  }
222
- /**
223
- * Approximate node-count parse — counts occurrences of the
224
- * `"x":` key in the serialised payload. Every node JSON object
225
- * starts with `{"x":<n>,"y":<n>,"w":<n>,"h":<n>...}` so the
226
- * occurrence count matches the array length without paying for a
227
- * full `JSON.parse`. Cheap enough to run inside the 1 Hz tick.
228
- */
229
- function countNodesQuick(payload) {
230
- let count = 0;
231
- let i = 0;
232
- // Skip the outer {"ts":..,"width":..,"height":..,"nodes":[
233
- // and count `"x":` thereafter. The outer payload doesn't contain
234
- // a top-level "x" key so any match must be a node.
235
- const needle = '"x":';
236
- while (true) {
237
- const at = payload.indexOf(needle, i);
238
- if (at < 0)
239
- break;
240
- count += 1;
241
- i = at + needle.length;
156
+ function encodeAndPush(snapshot) {
157
+ const currentState = new Map();
158
+ for (const n of snapshot.nodes)
159
+ currentState.set(fingerprint(n), n);
160
+ const ts = snapshot.ts;
161
+ const isCold = _lastFrameState === null;
162
+ const keyframeOverdue = ts - _lastKeyframeTs >= _keyframeIntervalMs;
163
+ let line;
164
+ if (isCold || keyframeOverdue) {
165
+ line = encodeKeyframe(snapshot);
166
+ _lastKeyframeTs = ts;
167
+ }
168
+ else {
169
+ const delta = computeDelta(_lastFrameState, currentState);
170
+ const totalChanged = delta.added.length + delta.changed.length + delta.removed.length;
171
+ if (totalChanged === 0) {
172
+ // No-op heartbeat — drop. Keep _lastFrameState as-is (identical).
173
+ return;
174
+ }
175
+ if (currentState.size >= KEYFRAME_RATIO_MIN_NODES &&
176
+ totalChanged >= currentState.size * DELTA_TO_KEYFRAME_RATIO) {
177
+ // Big screen transition on a substantial UI — emit a fresh
178
+ // keyframe so reconstruction doesn't carry a near-rewrite delta.
179
+ line = encodeKeyframe(snapshot);
180
+ _lastKeyframeTs = ts;
181
+ }
182
+ else {
183
+ line = JSON.stringify({
184
+ ts,
185
+ kind: 'delta',
186
+ added: delta.added,
187
+ changed: delta.changed,
188
+ removed: delta.removed,
189
+ });
190
+ }
191
+ }
192
+ _ring.push({ ts, line });
193
+ evictRing(ts);
194
+ _lastFrameState = currentState;
195
+ }
196
+ function encodeKeyframe(snapshot) {
197
+ return JSON.stringify({
198
+ ts: snapshot.ts,
199
+ kind: 'key',
200
+ width: snapshot.width,
201
+ height: snapshot.height,
202
+ nodes: snapshot.nodes,
203
+ });
204
+ }
205
+ function evictRing(nowTs) {
206
+ const cutoff = nowTs - REPLAY_WINDOW_MS;
207
+ while (_ring.length > 0 && _ring[0].ts < cutoff)
208
+ _ring.shift();
209
+ while (_ring.length > MAX_RING_ITEMS)
210
+ _ring.shift();
211
+ }
212
+ /** Fingerprint integer-rounds before joining so sub-pixel jitter from
213
+ * RN's Fabric layout (occasionally floats) doesn't break stable
214
+ * matching across ticks. */
215
+ function fingerprint(n) {
216
+ return `${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`;
217
+ }
218
+ export function computeDelta(prev, curr) {
219
+ const added = [];
220
+ const changed = [];
221
+ const removed = [];
222
+ for (const [fp, node] of curr) {
223
+ const p = prev.get(fp);
224
+ if (!p) {
225
+ added.push(node);
226
+ continue;
227
+ }
228
+ if ((p.kind ?? '') !== (node.kind ?? '') ||
229
+ (p.color ?? '') !== (node.color ?? '') ||
230
+ (p.text ?? '') !== (node.text ?? '')) {
231
+ changed.push(node);
232
+ }
233
+ }
234
+ for (const [fp, node] of prev) {
235
+ if (!curr.has(fp))
236
+ removed.push({ x: node.x, y: node.y, w: node.w, h: node.h });
237
+ }
238
+ return { added, changed, removed };
239
+ }
240
+ function handleEmptyTick(snapshot) {
241
+ if (typeof __DEV__ === 'undefined' || !__DEV__)
242
+ return;
243
+ _emptyTickCount += 1;
244
+ if (_emptyTickCount === 1 || _emptyTickCount === _emptyTickLogStride) {
245
+ // eslint-disable-next-line no-console
246
+ console.warn('[sentori] replay tick: native returned', snapshot === null
247
+ ? 'null'
248
+ : typeof snapshot === 'string'
249
+ ? `empty (length=${snapshot.length})`
250
+ : typeof snapshot, `(empty ticks so far: ${_emptyTickCount})`);
251
+ _emptyTickLogStride = Math.max(_emptyTickLogStride * 10, 10);
252
+ }
253
+ }
254
+ function diagnosticForTick(snapshot, snapshotBytes) {
255
+ _okTickCount += 1;
256
+ const nodeCount = snapshot.nodes.length;
257
+ const isThin = nodeCount < THIN_RESULT_NODES;
258
+ if (isThin) {
259
+ _thinTickCount += 1;
260
+ if (_thinTickCount === 1 || _thinTickCount === _thinTickLogStride) {
261
+ // eslint-disable-next-line no-console
262
+ console.warn(`[sentori] replay tick: thin result nodes=${nodeCount} sizeBytes=${snapshotBytes} (thin ticks so far: ${_thinTickCount})`);
263
+ _thinTickLogStride = Math.max(_thinTickLogStride * 10, 10);
264
+ }
265
+ }
266
+ else {
267
+ _thinTickCount = 0;
268
+ _thinTickLogStride = 1;
269
+ }
270
+ if (_okTickCount === 1) {
271
+ // eslint-disable-next-line no-console
272
+ console.warn(`[sentori] replay tick: first ok — nodes=${nodeCount} sizeBytes=${snapshotBytes}`);
242
273
  }
243
- return count;
244
274
  }
245
275
  function readMaskIds() {
246
276
  const q = getRegisteredMaskQuery();
@@ -263,28 +293,35 @@ function loadNativeReplay() {
263
293
  return null;
264
294
  }
265
295
  }
266
- /** rc.4 — surface "is replay subsystem alive" so the captureException
267
- * debug log can label `wantReplay` alongside `wantScreenshot` /
268
- * `wantSessionTrail`. Insight 2026-05-18 verify flagged that the
269
- * pre-rc.4 log line was missing `wantReplay`, leaving the failure
270
- * shape ambiguous (config-off vs. ring-empty vs. attach-failed). */
271
296
  export function isReplayRunning() {
272
297
  return _running;
273
298
  }
274
- /** Drain the ring as NDJSON (one snapshot per line). Empty string
275
- * when the ring is empty. Also clears the ring so the next session's
276
- * replay starts fresh. */
299
+ /** Drain the ring as NDJSON (keyframe or delta per line). Empty
300
+ * string when the ring is empty. Resets state so the next session's
301
+ * replay starts with a fresh keyframe. */
277
302
  export function drainReplay() {
278
303
  if (_ring.length === 0)
279
304
  return '';
280
- const out = _ring.join('\n');
305
+ const out = _ring.map((r) => r.line).join('\n');
281
306
  _ring = [];
282
- _lastPushed = null;
307
+ _lastFrameState = null;
308
+ _lastKeyframeTs = 0;
283
309
  return out;
284
310
  }
285
311
  export function __resetReplayForTests() {
286
312
  stopReplay();
287
313
  _ring = [];
288
- _lastPushed = null;
314
+ _lastFrameState = null;
315
+ _lastKeyframeTs = 0;
316
+ }
317
+ /** rc.9 — test seam. Lets unit tests drive the encoder without a
318
+ * native module; pretends we received `frameJson` on the tick. */
319
+ export function __feedTickForTests(frameJson) {
320
+ if (!_running) {
321
+ // Simulate "running" without an actual setInterval — caller drives.
322
+ _running = true;
323
+ }
324
+ const snapshot = JSON.parse(frameJson);
325
+ encodeAndPush(snapshot);
289
326
  }
290
327
  //# sourceMappingURL=replay.js.map