@reticlehq/browser 2.11.0 → 2.13.0

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.
Files changed (63) hide show
  1. package/dist/actions/actions-dom.js +41 -3
  2. package/dist/actions/actions.js +43 -11
  3. package/dist/commands/commands.js +6 -2
  4. package/dist/connect-options.d.ts +18 -1
  5. package/dist/dom/a11y.js +92 -9
  6. package/dist/dom/dom-ignore.d.ts +9 -0
  7. package/dist/dom/dom-ignore.js +32 -3
  8. package/dist/dom/query.js +168 -97
  9. package/dist/dom/refs.d.ts +11 -0
  10. package/dist/dom/refs.js +30 -1
  11. package/dist/dom/snapshot.d.ts +37 -0
  12. package/dist/dom/snapshot.js +141 -37
  13. package/dist/edit-epoch.d.ts +32 -0
  14. package/dist/edit-epoch.js +108 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +5 -1
  17. package/dist/observers/animation.js +6 -8
  18. package/dist/observers/context-open.d.ts +12 -0
  19. package/dist/observers/context-open.js +25 -0
  20. package/dist/observers/focus.js +5 -6
  21. package/dist/observers/health.js +6 -9
  22. package/dist/observers/install-all.js +2 -0
  23. package/dist/observers/network-redact.d.ts +13 -0
  24. package/dist/observers/network-redact.js +12 -1
  25. package/dist/observers/network.d.ts +2 -2
  26. package/dist/observers/network.js +24 -17
  27. package/dist/observers/scroll.js +4 -2
  28. package/dist/observers/storage.js +1 -1
  29. package/dist/presenter/presenter-brand.d.ts +4 -18
  30. package/dist/presenter/presenter-brand.js +6 -18
  31. package/dist/presenter/presenter-config.d.ts +11 -8
  32. package/dist/presenter/presenter-config.js +9 -3
  33. package/dist/presenter/presenter-controls.d.ts +0 -3
  34. package/dist/presenter/presenter-controls.js +0 -3
  35. package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
  36. package/dist/presenter/presenter-report-copy.d.ts +30 -1
  37. package/dist/presenter/presenter-report-copy.js +31 -2
  38. package/dist/presenter/presenter-report-styles.js +24 -0
  39. package/dist/presenter/presenter-report.d.ts +2 -2
  40. package/dist/presenter/presenter-report.js +95 -3
  41. package/dist/presenter/presenter-settings.js +10 -2
  42. package/dist/presenter/presenter-shell-styles.js +20 -1
  43. package/dist/presenter/presenter-shell.d.ts +0 -2
  44. package/dist/presenter/presenter-shell.js +1 -6
  45. package/dist/presenter/presenter.d.ts +13 -0
  46. package/dist/presenter/presenter.js +37 -2
  47. package/dist/registry/auto-testids.d.ts +21 -0
  48. package/dist/registry/auto-testids.js +48 -0
  49. package/dist/registry/capabilities.d.ts +28 -1
  50. package/dist/registry/capabilities.js +59 -7
  51. package/dist/registry/store-adapters.d.ts +1 -1
  52. package/dist/registry/store-adapters.js +6 -1
  53. package/dist/registry/stores.d.ts +4 -0
  54. package/dist/registry/stores.js +35 -0
  55. package/dist/reticle.d.ts +12 -0
  56. package/dist/reticle.js +40 -1
  57. package/dist/transport/unreachable-message.d.ts +7 -0
  58. package/dist/transport/unreachable-message.js +11 -1
  59. package/package.json +4 -7
  60. package/dist/dom/blind-spots.d.ts +0 -13
  61. package/dist/dom/blind-spots.js +0 -29
  62. package/dist/observers/subresources.d.ts +0 -10
  63. package/dist/observers/subresources.js +0 -63
@@ -3,7 +3,7 @@ import { capturedRootOf } from './shadow-registry.js';
3
3
  import { isFrame } from './realm.js';
4
4
  import { getAccessibleName, getRole, getStates, getValue, isVisible } from './a11y.js';
5
5
  import { refs } from './refs.js';
6
- import { isIgnored } from './dom-ignore.js';
6
+ import { isIgnored, isReticleOverlay } from './dom-ignore.js';
7
7
  const INTERACTIVE = new Set([
8
8
  'button',
9
9
  'link',
@@ -55,6 +55,16 @@ function directText(el) {
55
55
  const collapsed = out.replace(/\s+/g, ' ').trim();
56
56
  return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
57
57
  }
58
+ /**
59
+ * Cap on how many cut branches one snapshot names.
60
+ *
61
+ * A hostile or merely huge DOM can cut thousands of siblings at once (a non-virtualized grid does it
62
+ * routinely), and a frontier that large is neither affordable to send nor useful to re-read one at a
63
+ * time. Fifty is well above what a real page's cut produces — the frontier is the branches at the
64
+ * node cap, not the nodes themselves — and small enough that the list stays a hint rather than a
65
+ * second snapshot. Past it the read declares `unreadOverflow` instead of quietly sending fewer.
66
+ */
67
+ export const MAX_UNREAD_BRANCHES = 50;
58
68
  /** Cheap skips that need no computed style (tags, overlays, aria-hidden, [hidden]). */
59
69
  function skipEarly(el) {
60
70
  if (SKIP_TAGS.has(el.tagName.toLowerCase()))
@@ -77,8 +87,12 @@ function stateSuffix(el) {
77
87
  function formatLine(el, depth, role, name, layout) {
78
88
  const indent = ' '.repeat(depth);
79
89
  const value = getValue(el);
80
- const namePart = name.length > 0 ? ` "${name}"` : '';
81
- const refPart = INTERACTIVE.has(role) || name.length > 0 ? ` (ref=${refs.refFor(el)})` : '';
90
+ // A testid stands in for the accessible name when there is none. Including a role-less control and
91
+ // then printing it as a bare `- generic` is worse than leaving it out: it is visible and it cannot
92
+ // be addressed. This is the name its author gave it.
93
+ const label = name;
94
+ const namePart = label.length > 0 ? ` "${label}"` : '';
95
+ const refPart = INTERACTIVE.has(role) || label.length > 0 ? ` (ref=${refs.refFor(el)})` : '';
82
96
  const valuePart = value !== undefined && value.length > 0 ? ` [value="${value}"]` : '';
83
97
  const layoutPart = layout.length > 0 ? ` [${layout}]` : '';
84
98
  return `${indent}- ${role}${namePart}${refPart}${valuePart}${layoutPart}${stateSuffix(el)}`;
@@ -105,6 +119,16 @@ function layoutSignature(style) {
105
119
  }
106
120
  return '';
107
121
  }
122
+ /** Record a cut branch on the frontier, declaring an overflow rather than shortening it silently. */
123
+ function recordUnread(branches, ctx) {
124
+ for (const branch of branches) {
125
+ if (ctx.unread.length >= MAX_UNREAD_BRANCHES) {
126
+ ctx.unreadOverflow = true;
127
+ return;
128
+ }
129
+ ctx.unread.push(refs.refFor(branch));
130
+ }
131
+ }
108
132
  /**
109
133
  * The children to walk under `parent`, piercing boundaries a plain `.children` misses: an OPEN shadow
110
134
  * root renders as part of the element (web components / design systems), and a SAME-ORIGIN iframe's
@@ -134,52 +158,118 @@ function pierceChildren(parent) {
134
158
  }
135
159
  return out;
136
160
  }
161
+ /** Emit one element (if it earns a line) and descend into it. Split out of `walk` so the completion
162
+ * re-read can start AT a branch root — the node the truncated walk stopped just before emitting. */
163
+ function visit(child, depth, ctx, inLive) {
164
+ if (skipEarly(child))
165
+ return;
166
+ // Resolve computed style ONCE per node (the dominant snapshot cost) and thread it into both the
167
+ // display-none skip and the layout signature — was two forced style resolutions per node.
168
+ const view = child.ownerDocument.defaultView;
169
+ const style = view !== null ? view.getComputedStyle(child) : null;
170
+ if (style !== null && 'none' === style.display)
171
+ return;
172
+ const role = getRole(child);
173
+ const name = getAccessibleName(child);
174
+ const interactive = INTERACTIVE.has(role);
175
+ // Announcements are exempt from leanness, and so is everything inside one: a live region whose
176
+ // message sits in a child element would otherwise be included as a contentless `- generic`.
177
+ const announce = inLive || announces(child, role);
178
+ const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
179
+ // A generic, unnamed container's own text content — only consulted outside INTERACTIVE mode,
180
+ // so the actionable-only view stays lean while FULL/meaningful views see content regressions.
181
+ const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
182
+ // Layout signature for grid/flex containers — makes CLS/layout regressions visible.
183
+ const layout = lean ? '' : layoutSignature(style);
184
+ // Actionable WITHOUT an interactive role, which is most of the real web.
185
+ //
186
+ // `interactive` is a role test, and a large share of production UI carries no roles at all.
187
+ // Measured on MarkText, a production Electron editor: `mode:"interactive"` returned an EMPTY tree
188
+ // where `mode:"full"` found 47 nodes, because its whole block picker is `<div>`s. An empty tree is
189
+ // indistinguishable from an empty page, and the tool description recommends this mode as the
190
+ // default — so the cheaper view told an agent there was nothing to drive.
191
+ //
192
+ // A `data-testid` ONLY. It is a handle its author put there to be driven, and it marks one element
193
+ // rather than a subtree.
194
+ //
195
+ // `cursor: pointer` was tried here as a second signal — it is how an app tells a HUMAN that a
196
+ // thing is clickable — and measured on MarkText it was a disaster: interactive went from 0 nodes
197
+ // and 72 tokens to 180 nodes and 870, against a FULL snapshot of 47 nodes and 424. The pointer
198
+ // cursor is inherited, so every wrapper div inside a clickable row matched, and the lean mode
199
+ // became twice the size of the complete one while adding nothing but nameless `- generic` lines.
200
+ // The mode's whole claim is that it is smaller; a signal that cannot tell a control from its
201
+ // ancestors cannot be used to decide what a control is.
202
+ // `data-testid` was tried here as a second signal, on the premise that it is a handle its author
203
+ // put there to be driven. Measured against our own instrumented bench app, the premise is false:
204
+ // the lean tree on its dashboard came to 16 nodes and 175 tokens, and EIGHT were display elements
205
+ // — kpi-deploys, kpi-success, kpi-p95, kpi-services, area-chart, activity-feed, brand. Half the
206
+ // "actionable" view was things nothing can act on, and the mode roughly doubled to carry them; the
207
+ // benchmark read it as a 4% verification-efficiency regression. A testid marks what a TEST cares
208
+ // about, which is a superset of what a driver can use.
209
+ //
210
+ // It did not even help the case it shipped for: MarkText's controls carry no testid. `leanSkipped`
211
+ // is what fixed that — an empty tree that says how many it passed over is no longer an empty page.
212
+ //
213
+ // `cursor: pointer` was tried too and was worse: interactive went from 0 nodes and 72 tokens to
214
+ // 180 and 870 against a FULL snapshot of 47/424, because the pointer cursor is inherited and every
215
+ // wrapper div inside a clickable row matched. A signal that cannot tell a control from its
216
+ // ancestors cannot decide what a control is.
217
+ const actionable = interactive;
218
+ // A testid still makes an element MEANINGFUL, so `full` keeps it and can name and address it. It
219
+ // just is not evidence that the element is ACTIONABLE, which is the only question lean mode asks.
220
+ const meaningful = actionable || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
221
+ const include = lean ? actionable : meaningful;
222
+ // Counted, not just skipped. An empty INTERACTIVE tree is indistinguishable from an empty page,
223
+ // and on a real app the difference is everything: MarkText's block picker is 20+ live controls
224
+ // that carry no role, no testid and no pointer cursor, so the lean view is empty while the app is
225
+ // full of things to drive. Knowing how many were passed over is what turns "" into "look again in
226
+ // full mode" — see the note the snapshot tool builds from this.
227
+ if (lean && !include && meaningful)
228
+ ctx.leanSkipped += 1;
229
+ if (include) {
230
+ ctx.nodes += 1;
231
+ ctx.lines.push(text.length > 0 && 0 === name.length && 0 === layout.length
232
+ ? formatTextLine(depth, text)
233
+ : formatLine(child, depth, role, name, layout));
234
+ walk(child, depth + 1, ctx, announce);
235
+ }
236
+ else {
237
+ walk(child, depth, ctx, announce);
238
+ }
239
+ }
137
240
  function walk(parent, depth, ctx, inLive = false) {
138
241
  if (depth > ctx.maxDepth)
139
242
  return;
140
- for (const child of pierceChildren(parent)) {
243
+ const children = pierceChildren(parent);
244
+ for (let index = 0; index < children.length; index += 1) {
141
245
  if (ctx.nodes >= ctx.maxNodes) {
142
246
  ctx.truncated = true;
247
+ // Everything from here on is unread, not absent. Naming the frontier is what makes the cut
248
+ // recoverable — see `unread` on SnapshotResult.
249
+ recordUnread(children.slice(index), ctx);
143
250
  return;
144
251
  }
145
- if (skipEarly(child))
146
- continue;
147
- // Resolve computed style ONCE per node (the dominant snapshot cost) and thread it into both the
148
- // display-none skip and the layout signature — was two forced style resolutions per node.
149
- const view = child.ownerDocument.defaultView;
150
- const style = view !== null ? view.getComputedStyle(child) : null;
151
- if (style !== null && 'none' === style.display)
152
- continue;
153
- const role = getRole(child);
154
- const name = getAccessibleName(child);
155
- const interactive = INTERACTIVE.has(role);
156
- // Announcements are exempt from leanness, and so is everything inside one: a live region whose
157
- // message sits in a child element would otherwise be included as a contentless `- generic`.
158
- const announce = inLive || announces(child, role);
159
- const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
160
- // A generic, unnamed container's own text content — only consulted outside INTERACTIVE mode,
161
- // so the actionable-only view stays lean while FULL/meaningful views see content regressions.
162
- const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
163
- // Layout signature for grid/flex containers — makes CLS/layout regressions visible.
164
- const layout = lean ? '' : layoutSignature(style);
165
- const meaningful = interactive || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
166
- const include = lean ? interactive : meaningful;
167
- if (include) {
168
- ctx.nodes += 1;
169
- ctx.lines.push(text.length > 0 && 0 === name.length && 0 === layout.length
170
- ? formatTextLine(depth, text)
171
- : formatLine(child, depth, role, name, layout));
172
- walk(child, depth + 1, ctx, announce);
173
- }
174
- else {
175
- walk(child, depth, ctx, announce);
176
- }
252
+ const child = children[index];
253
+ if (child !== undefined)
254
+ visit(child, depth, ctx, inLive);
177
255
  }
178
256
  }
257
+ /**
258
+ * The APP's open dialogs. Reticle's own panels are excluded, always.
259
+ *
260
+ * The HUD's chat and report panels carry `role="dialog"` because that is the correct role for what
261
+ * they are — but they are OUR surface, not the application's. Once the presenter became visible to
262
+ * the tool surface (so that Reticle can be used to check its own HUD), every snapshot of every page
263
+ * started reporting `visibleDialogs: ["Reticle agent chat"]`, telling the agent a modal was up when
264
+ * the app had none. An agent that believes a dialog is open dismisses it before doing anything else,
265
+ * which is a wasted action at best and a dismissed REAL dialog at worst.
266
+ */
179
267
  function collectDialogs(root) {
180
268
  const nodes = root.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
181
269
  const names = [];
182
270
  for (const node of nodes) {
271
+ if (isReticleOverlay(node))
272
+ continue;
183
273
  if (isVisible(node))
184
274
  names.push(getAccessibleName(node) || '(unnamed dialog)');
185
275
  }
@@ -209,6 +299,11 @@ function overlayHidingPage(root) {
209
299
  const dialogs = document.body.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
210
300
  let modal;
211
301
  for (const d of dialogs) {
302
+ // Never OUR overlay. Reticle's HUD must not be able to explain the app's absence with itself:
303
+ // that turns "the page did not render" into "an overlay is covering it", which sends the reader
304
+ // to dismiss a panel that was never the problem.
305
+ if (isReticleOverlay(d))
306
+ continue;
212
307
  if (isVisible(d)) {
213
308
  modal = d;
214
309
  break;
@@ -255,16 +350,25 @@ export function buildSnapshot(options = {}) {
255
350
  const ctx = {
256
351
  lines: [],
257
352
  nodes: 0,
353
+ leanSkipped: 0,
258
354
  truncated: false,
259
355
  mode,
260
356
  maxNodes: options.maxNodes ?? 400,
261
357
  maxDepth: options.maxDepth ?? 20,
358
+ unread: [],
359
+ unreadOverflow: false,
262
360
  };
263
- walk(root, 0, ctx);
361
+ if (true === options.includeRoot)
362
+ visit(root, 0, ctx, false);
363
+ else
364
+ walk(root, 0, ctx);
264
365
  return {
265
366
  tree: ctx.lines.join('\n'),
266
367
  status,
267
368
  nodes: ctx.nodes,
268
369
  truncated: ctx.truncated,
370
+ ...(ctx.leanSkipped > 0 ? { leanSkipped: ctx.leanSkipped } : {}),
371
+ ...(ctx.unread.length > 0 ? { unread: ctx.unread } : {}),
372
+ ...(ctx.unreadOverflow ? { unreadOverflow: true } : {}),
269
373
  };
270
374
  }
@@ -0,0 +1,32 @@
1
+ import { type RefRegistry } from './dom/refs.js';
2
+ export declare class EditEpoch {
3
+ #private;
4
+ /** The registry is injected for the same reason the clock is: the interesting property is the
5
+ * RELATION between when a ref was minted and when an edit landed, and a global has no seam. */
6
+ constructor(registry: RefRegistry);
7
+ /**
8
+ * How many hot updates this document has applied.
9
+ *
10
+ * `NO_EDITS_OBSERVED` means exactly that and never "none happened": most pages Reticle instruments
11
+ * have no channel that could report one, so absence here is UNKNOWN.
12
+ */
13
+ get current(): number;
14
+ /** Subscribe to a hot-update channel if one was handed in. Anything else is silently ignored —
15
+ * no channel is the normal case, not an error. */
16
+ observe(hot: unknown): void;
17
+ /** Record that an update was applied, naming the files it changed (which may be none). */
18
+ applied(files: readonly string[]): void;
19
+ /**
20
+ * The refusal for a ref that no longer resolves — saying WHY when the page can tell us.
21
+ *
22
+ * The generic wording is untouched and stays at the front: it is already tuned, and the server's
23
+ * recovery table matches on it. When the ref predates the last hot update the diagnosis is
24
+ * appended, because "you edited TripCard.tsx and it re-rendered" is a next step and "that ref is
25
+ * stale" is a dead end. A ref minted AFTER the last update gets the generic message unchanged —
26
+ * blaming an edit for an ordinary post-click stale ref would be a confident wrong answer, which is
27
+ * worse than the vague right one.
28
+ */
29
+ staleRefMessage(ref: string): string;
30
+ }
31
+ /** The epoch of the page, paired with the process-wide ref registry it has to reason against. */
32
+ export declare const editEpoch: EditEpoch;
@@ -0,0 +1,108 @@
1
+ import { NO_EDITS_OBSERVED } from '@reticlehq/core';
2
+ import { echoRef, refs } from './dom/refs.js';
3
+ /**
4
+ * What the page can tell us about its own source being edited underneath it.
5
+ *
6
+ * The agent driving Reticle edits source and re-verifies in a loop. When an edit lands, the dev
7
+ * server hot-updates the module and the framework re-renders: DOM nodes are REPLACED, every ref the
8
+ * agent holds points at a detached node — and there was no navigation, so the document is the same
9
+ * one and `documentId` never moves. The agent got a generic "that ref is stale" and could not tell
10
+ * its own edit landing from a bug in the app it was verifying.
11
+ *
12
+ * DELIBERATELY NOT A BUILD INTEGRATION. Nothing here watches files, reads the user's repo or knows
13
+ * what a module graph is. The page is told by its own dev server that it hot-updated; this records
14
+ * that it happened and what it named, and that is the whole feature.
15
+ */
16
+ /**
17
+ * Vite's "the update has been applied" event, on a hot context.
18
+ *
19
+ * `vite:afterUpdate` rather than `vite:beforeUpdate`: the point of the epoch is that the DOM has
20
+ * been replaced, and before the update it has not been yet.
21
+ *
22
+ * The SDK does NOT depend on Vite — it ships to Next, Electron, Tauri and plain pages, and it cannot
23
+ * read `import.meta.hot` of its own module in any of them (a dependency is pre-bundled by Vite's
24
+ * optimizer and gets no hot context at all). The channel is handed IN by whoever has one, and this
25
+ * name is the only Vite-shaped thing in the SDK.
26
+ */
27
+ const HOT_UPDATE_APPLIED = 'vite:afterUpdate';
28
+ /** How many changed files a refusal names before it stops being a diagnosis and starts being a list. */
29
+ const MAX_NAMED_FILES = 3;
30
+ /** The exact substring the server's recovery table keys off. Changing it silently unclassifies. */
31
+ const STALE_REF_REFUSAL = 'no longer resolves to an element';
32
+ /** The clause that turns a dead end into a diagnosis, and the one the server's table matches on. */
33
+ const CODE_CHANGED = 'the code changed underneath it';
34
+ /** Anything object-shaped, as a bag of unknowns. The payload comes from the page, so nothing about
35
+ * its shape is guaranteed and every field has to be checked before it is read. */
36
+ function fields(value) {
37
+ return null !== value && 'object' === typeof value
38
+ ? value
39
+ : undefined;
40
+ }
41
+ function isHotLike(value) {
42
+ return 'function' === typeof fields(value)?.['on'];
43
+ }
44
+ /** The file paths a hot-update payload named, narrowed from `unknown`. */
45
+ function updatedPaths(payload) {
46
+ const updates = fields(payload)?.['updates'];
47
+ if (!Array.isArray(updates))
48
+ return [];
49
+ const paths = [];
50
+ for (const update of updates) {
51
+ const path = fields(update)?.['path'];
52
+ if ('string' === typeof path && path.length > 0 && !paths.includes(path))
53
+ paths.push(path);
54
+ }
55
+ return paths;
56
+ }
57
+ export class EditEpoch {
58
+ #epoch = NO_EDITS_OBSERVED;
59
+ #files = [];
60
+ #refs;
61
+ /** The registry is injected for the same reason the clock is: the interesting property is the
62
+ * RELATION between when a ref was minted and when an edit landed, and a global has no seam. */
63
+ constructor(registry) {
64
+ this.#refs = registry;
65
+ }
66
+ /**
67
+ * How many hot updates this document has applied.
68
+ *
69
+ * `NO_EDITS_OBSERVED` means exactly that and never "none happened": most pages Reticle instruments
70
+ * have no channel that could report one, so absence here is UNKNOWN.
71
+ */
72
+ get current() {
73
+ return this.#epoch;
74
+ }
75
+ /** Subscribe to a hot-update channel if one was handed in. Anything else is silently ignored —
76
+ * no channel is the normal case, not an error. */
77
+ observe(hot) {
78
+ if (!isHotLike(hot))
79
+ return;
80
+ hot.on(HOT_UPDATE_APPLIED, (payload) => this.applied(updatedPaths(payload)));
81
+ }
82
+ /** Record that an update was applied, naming the files it changed (which may be none). */
83
+ applied(files) {
84
+ this.#epoch += 1;
85
+ this.#files = files.slice(0, MAX_NAMED_FILES);
86
+ this.#refs.markEdited();
87
+ }
88
+ /**
89
+ * The refusal for a ref that no longer resolves — saying WHY when the page can tell us.
90
+ *
91
+ * The generic wording is untouched and stays at the front: it is already tuned, and the server's
92
+ * recovery table matches on it. When the ref predates the last hot update the diagnosis is
93
+ * appended, because "you edited TripCard.tsx and it re-rendered" is a next step and "that ref is
94
+ * stale" is a dead end. A ref minted AFTER the last update gets the generic message unchanged —
95
+ * blaming an edit for an ordinary post-click stale ref would be a confident wrong answer, which is
96
+ * worse than the vague right one.
97
+ */
98
+ staleRefMessage(ref) {
99
+ const generic = `ref '${echoRef(ref)}' ${STALE_REF_REFUSAL}`;
100
+ if (!this.#refs.mintedBeforeLastEdit(ref))
101
+ return generic;
102
+ const named = 0 === this.#files.length ? 'the page hot-updated' : `${this.#files.join(', ')} hot-updated`;
103
+ return (`${generic} — ${named} after this ref was taken, so ${CODE_CHANGED} and the framework ` +
104
+ 'replaced the node. Query again for a fresh ref: this is your own edit landing, not the app failing.');
105
+ }
106
+ }
107
+ /** The epoch of the page, paired with the process-wide ref registry it has to reason against. */
108
+ export const editEpoch = new EditEpoch(refs);
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { SESSION_AUTO } from '@reticlehq/core';
6
6
  export { setIgnoreSelectors } from './dom/dom-ignore.js';
7
7
  export { registerAdapter, identifyComponent, readComponentState, elementHasHoverHandlers, adapterNames, } from './registry/adapters.js';
8
8
  export type { ReticleAdapter, ComponentInfo, ComponentSource } from './registry/adapters.js';
9
- export { registerStore, unregisterStore, storeNames, readStores, readStoresWithTruncation, } from './registry/stores.js';
9
+ export { registerStore, unregisterStore, storeNames, readStores, readStoresWithTruncation, sourceOwner, markAdapterSource, } from './registry/stores.js';
10
10
  export type { StoreGetter, StoreLike, StoreSubscribe } from './registry/stores.js';
11
11
  export { tanstackQueryStore, jotaiStore, xstateStore, valtioStore, mobxStore, recoilStore, svelteStore, piniaStore, pushStore, } from './registry/store-adapters.js';
12
12
  export type { QuerySnapshot, RecoilAtomSnapshot } from './registry/store-adapters.js';
package/dist/index.js CHANGED
@@ -14,7 +14,11 @@ export { setIgnoreSelectors } from './dom/dom-ignore.js';
14
14
  // Adapter API (used by @reticlehq/react and other framework adapters).
15
15
  export { registerAdapter, identifyComponent, readComponentState, elementHasHoverHandlers, adapterNames, } from './registry/adapters.js';
16
16
  // Store registry: pull live framework/store state on demand.
17
- export { registerStore, unregisterStore, storeNames, readStores, readStoresWithTruncation, } from './registry/stores.js';
17
+ export { registerStore, unregisterStore, storeNames, readStores, readStoresWithTruncation,
18
+ // Which name a store SOURCE is registered under. The React adapter discovers stores from the fiber
19
+ // tree and needs this to tell "the app already wired this exact store" from "this is another one" —
20
+ // a question neither the name nor the read value can answer.
21
+ sourceOwner, markAdapterSource, } from './registry/stores.js';
18
22
  // Adapters for state libraries that do not natively expose `{getState, subscribe}`. zustand and Redux
19
23
  // need none of these; TanStack Query is the one that matters most, because a stale-cache bug fires no
20
24
  // request and renders a plausible value, so the cache is the only witness.
@@ -3,6 +3,8 @@ import { refs } from '../dom/refs.js';
3
3
  import { isReticleOverlay } from '../dom/dom-ignore.js';
4
4
  /** Observe CSS animations + transitions and emit anim.start / anim.end. */
5
5
  export function installAnimation(emit) {
6
+ const ac = new AbortController();
7
+ const { signal } = ac;
6
8
  const onStart = (event) => {
7
9
  const target = event.target;
8
10
  // Skip Reticle's own HUD keyframes (reticle-pulse/reticle-shimmer/…) so observe/record never
@@ -23,12 +25,8 @@ export function installAnimation(emit) {
23
25
  emit(EventType.ANIM_END, { name: event.propertyName, kind: 'transition' }, refs.refFor(target));
24
26
  }
25
27
  };
26
- document.addEventListener('animationstart', onStart, true);
27
- document.addEventListener('animationend', onEnd, true);
28
- document.addEventListener('transitionend', onTransitionEnd, true);
29
- return () => {
30
- document.removeEventListener('animationstart', onStart, true);
31
- document.removeEventListener('animationend', onEnd, true);
32
- document.removeEventListener('transitionend', onTransitionEnd, true);
33
- };
28
+ document.addEventListener('animationstart', onStart, { capture: true, signal });
29
+ document.addEventListener('animationend', onEnd, { capture: true, signal });
30
+ document.addEventListener('transitionend', onTransitionEnd, { capture: true, signal });
31
+ return () => ac.abort();
34
32
  }
@@ -0,0 +1,12 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ /**
3
+ * The page asked for ANOTHER browsing context — `window.open` — so the consequence of whatever was
4
+ * just clicked may live where this document's SDK cannot follow. An OAuth sign-in is the archetype:
5
+ * the POST succeeds, the popup carries the whole flow, and the original tab legitimately never
6
+ * changes (#508). Without this event the contradiction layer reads that shape as response-ignored,
7
+ * accusing the client of ignoring a response it handed to a window nobody can see.
8
+ *
9
+ * The wrap preserves the call exactly: same arguments, same return value, and a null/undefined
10
+ * first argument (the "open a blank tab" form) still emits, since a blank tab can navigate anywhere.
11
+ */
12
+ export declare function installContextOpen(emit: Emit): Teardown;
@@ -0,0 +1,25 @@
1
+ import { EventType } from '@reticlehq/core';
2
+ import { captureMethod } from '../patching/capture-method.js';
3
+ /**
4
+ * The page asked for ANOTHER browsing context — `window.open` — so the consequence of whatever was
5
+ * just clicked may live where this document's SDK cannot follow. An OAuth sign-in is the archetype:
6
+ * the POST succeeds, the popup carries the whole flow, and the original tab legitimately never
7
+ * changes (#508). Without this event the contradiction layer reads that shape as response-ignored,
8
+ * accusing the client of ignoring a response it handed to a window nobody can see.
9
+ *
10
+ * The wrap preserves the call exactly: same arguments, same return value, and a null/undefined
11
+ * first argument (the "open a blank tab" form) still emits, since a blank tab can navigate anywhere.
12
+ */
13
+ export function installContextOpen(emit) {
14
+ const originalOpen = captureMethod(window, 'open');
15
+ if (originalOpen === undefined)
16
+ return () => { };
17
+ const openPatch = function (href, target, features) {
18
+ emit(EventType.CONTEXT_OPENED, { ...(href === undefined ? {} : { href }) });
19
+ return originalOpen.call(this, href, target, features);
20
+ };
21
+ window.open = openPatch;
22
+ return () => {
23
+ window.open = originalOpen;
24
+ };
25
+ }
@@ -17,6 +17,8 @@ function toBody(el) {
17
17
  * FOCUS_CHANGE {to, from, toBody}. Reversible.
18
18
  */
19
19
  export function installFocus(emit) {
20
+ const ac = new AbortController();
21
+ const { signal } = ac;
20
22
  let last = null;
21
23
  const onFocusIn = (event) => {
22
24
  const to = event.target;
@@ -39,10 +41,7 @@ export function installFocus(emit) {
39
41
  toBody: true,
40
42
  });
41
43
  };
42
- document.addEventListener('focusin', onFocusIn);
43
- document.addEventListener('focusout', onFocusOut);
44
- return () => {
45
- document.removeEventListener('focusin', onFocusIn);
46
- document.removeEventListener('focusout', onFocusOut);
47
- };
44
+ document.addEventListener('focusin', onFocusIn, { signal });
45
+ document.addEventListener('focusout', onFocusOut, { signal });
46
+ return () => ac.abort();
48
47
  }
@@ -109,21 +109,18 @@ function snapshotHealth() {
109
109
  * tab blind. Uses a native (pre-bound) timer so a frozen app clock (reticle_clock) never stalls it.
110
110
  */
111
111
  export function installHealth(emit) {
112
+ const ac = new AbortController();
113
+ const { signal } = ac;
112
114
  const report = (reason) => {
113
115
  emit(EventType.PAGE_HEALTH, { ...snapshotHealth(), reason });
114
116
  };
115
- const onVisibility = () => report(HealthReason.VISIBILITY);
116
- const onFocus = () => report(HealthReason.FOCUS);
117
- const onBlur = () => report(HealthReason.BLUR);
118
- document.addEventListener('visibilitychange', onVisibility);
119
- window.addEventListener('focus', onFocus);
120
- window.addEventListener('blur', onBlur);
117
+ document.addEventListener('visibilitychange', () => report(HealthReason.VISIBILITY), { signal });
118
+ window.addEventListener('focus', () => report(HealthReason.FOCUS), { signal });
119
+ window.addEventListener('blur', () => report(HealthReason.BLUR), { signal });
121
120
  report(HealthReason.INITIAL); // baseline so the server knows state before the first change
122
121
  const stopHeartbeat = nativeSetInterval(() => report(HealthReason.HEARTBEAT), SESSION_HEALTH.HEARTBEAT_MS);
123
122
  return () => {
124
123
  stopHeartbeat();
125
- document.removeEventListener('visibilitychange', onVisibility);
126
- window.removeEventListener('focus', onFocus);
127
- window.removeEventListener('blur', onBlur);
124
+ ac.abort();
128
125
  };
129
126
  }
@@ -17,6 +17,7 @@ import { installStoreState } from './state.js';
17
17
  import { installFocus } from './focus.js';
18
18
  import { installBlindSpots } from './blind-spots.js';
19
19
  import { installDownload } from './download.js';
20
+ import { installContextOpen } from './context-open.js';
20
21
  import { installNetwork } from './network.js';
21
22
  import { installIpc, ipcNetOverrides, isReticleOwnIpc } from './ipc.js';
22
23
  import { installPerf } from './perf.js';
@@ -62,5 +63,6 @@ export function installAllObservers(emit, options) {
62
63
  guard(emit, SdkSite.NETWORK_OBSERVER, () => installDownload(emit, { capturePreview: options.captureBodies })),
63
64
  guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)), // cross-origin iframes the SDK can't see → BLIND_SPOT (coverage: partial)
64
65
  guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)), // page visibility/focus health + heartbeat
66
+ guard(emit, SdkSite.DOM_OBSERVER, () => installContextOpen(emit)), // window.open → CONTEXT_OPENED (the consequence may live in another context)
65
67
  ];
66
68
  }
@@ -5,3 +5,16 @@
5
5
  * The hash is preserved and the URL is returned byte-for-byte when nothing matched.
6
6
  */
7
7
  export declare function redactUrl(raw: string): string;
8
+ /**
9
+ * Displayed URL plus, when redaction rewrote it, the raw request for graders.
10
+ *
11
+ * `url` is what the agent reads. `urlRaw` exists only so `urlContains` can still match a public
12
+ * path segment that the heuristic rewrote (`/auth/token/refresh-context`). Omitted when nothing
13
+ * changed, so an ordinary request pays nothing.
14
+ */
15
+ export declare function netUrlFields(raw: string): {
16
+ url: string;
17
+ } | {
18
+ url: string;
19
+ urlRaw: string;
20
+ };
@@ -1,4 +1,4 @@
1
- import { REDACTED_VALUE } from '@reticlehq/core';
1
+ import { REDACTED_VALUE, URL_RAW } from '@reticlehq/core';
2
2
  import { isSensitiveKey } from '../security/serialization.js';
3
3
  /** A path segment name that is typically followed by a single-use secret token in the NEXT segment. */
4
4
  const SENSITIVE_PATH_SEGMENT = /^(reset|verify|verification|confirm|activate|invite|magic|magiclink|token|key|oauth|unsubscribe|password)$/i;
@@ -67,3 +67,14 @@ export function redactUrl(raw) {
67
67
  const queryOut = -1 === queryStart ? '' : `?${newQuery}`;
68
68
  return `${segments.join('/')}${queryOut}${newHash}`;
69
69
  }
70
+ /**
71
+ * Displayed URL plus, when redaction rewrote it, the raw request for graders.
72
+ *
73
+ * `url` is what the agent reads. `urlRaw` exists only so `urlContains` can still match a public
74
+ * path segment that the heuristic rewrote (`/auth/token/refresh-context`). Omitted when nothing
75
+ * changed, so an ordinary request pays nothing.
76
+ */
77
+ export function netUrlFields(raw) {
78
+ const url = redactUrl(raw);
79
+ return url === raw ? { url } : { url, [URL_RAW]: raw };
80
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
- import { redactUrl } from './network-redact.js';
3
- export { redactUrl };
2
+ import { redactUrl, netUrlFields } from './network-redact.js';
3
+ export { redactUrl, netUrlFields };
4
4
  /** Config for the network observer. Body capture is OFF by default and dev-only opt-in. */
5
5
  /**
6
6
  * Reinterpret a completed request's fields from its response.