@reticlehq/browser 2.12.0 → 2.13.1

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 (41) hide show
  1. package/dist/actions/actions.js +6 -6
  2. package/dist/connect-options.d.ts +18 -1
  3. package/dist/dom/dom-ignore.d.ts +9 -0
  4. package/dist/dom/dom-ignore.js +32 -3
  5. package/dist/dom/query.js +55 -3
  6. package/dist/dom/snapshot.d.ts +8 -0
  7. package/dist/dom/snapshot.js +71 -5
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +5 -1
  10. package/dist/observers/animation.js +6 -8
  11. package/dist/observers/focus.js +5 -6
  12. package/dist/observers/health.js +6 -9
  13. package/dist/observers/network-redact.d.ts +13 -0
  14. package/dist/observers/network-redact.js +12 -1
  15. package/dist/observers/network.d.ts +2 -2
  16. package/dist/observers/network.js +24 -17
  17. package/dist/observers/scroll.js +4 -2
  18. package/dist/presenter/presenter-config.d.ts +11 -2
  19. package/dist/presenter/presenter-config.js +9 -0
  20. package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
  21. package/dist/presenter/presenter-report-copy.d.ts +30 -1
  22. package/dist/presenter/presenter-report-copy.js +31 -2
  23. package/dist/presenter/presenter-report-styles.js +24 -0
  24. package/dist/presenter/presenter-report.d.ts +2 -2
  25. package/dist/presenter/presenter-report.js +95 -3
  26. package/dist/presenter/presenter-shell-styles.js +20 -1
  27. package/dist/presenter/presenter-shell.js +0 -1
  28. package/dist/presenter/presenter.d.ts +13 -0
  29. package/dist/presenter/presenter.js +37 -2
  30. package/dist/registry/auto-testids.d.ts +21 -0
  31. package/dist/registry/auto-testids.js +48 -0
  32. package/dist/registry/capabilities.d.ts +28 -1
  33. package/dist/registry/capabilities.js +59 -7
  34. package/dist/registry/store-adapters.d.ts +1 -1
  35. package/dist/registry/store-adapters.js +6 -1
  36. package/dist/registry/stores.d.ts +4 -0
  37. package/dist/registry/stores.js +35 -0
  38. package/dist/reticle.js +8 -0
  39. package/dist/transport/unreachable-message.d.ts +7 -0
  40. package/dist/transport/unreachable-message.js +9 -0
  41. package/package.json +4 -4
@@ -1,4 +1,33 @@
1
1
  /** Self-describing capability registry — the testable surface the app advertises. */
2
+ import { RETICLE_RENDERS_STORE } from '@reticlehq/core';
3
+ import { isPresenterVisible } from '../dom/dom-ignore.js';
4
+ import { domTestids } from './auto-testids.js';
5
+ import { storeNames } from './stores.js';
6
+ /**
7
+ * The two halves of the capability report the app no longer has to write down.
8
+ *
9
+ * `testids` and `stores` used to be *declarations* — strings copied into a generated file by `init`
10
+ * and then expected to stay true. Both are now read from the thing they describe: the testids from
11
+ * the DOM, the store names from the registry (which the React adapter fills by discovery). What the
12
+ * app declares is still merged in and still wins nothing away: `signals` and `flows` cannot be
13
+ * observed and remain the app's to state.
14
+ */
15
+ function liveTestids() {
16
+ if ('undefined' === typeof document)
17
+ return [];
18
+ return domTestids(document);
19
+ }
20
+ /** Registered stores, minus the ones Reticle registered on the app's behalf. */
21
+ function liveStores() {
22
+ return storeNames().filter((name) => RETICLE_RENDERS_STORE !== name);
23
+ }
24
+ function union(declared, observed) {
25
+ const out = [...declared];
26
+ for (const value of observed)
27
+ if (!out.includes(value))
28
+ out.push(value);
29
+ return out;
30
+ }
2
31
  // Persist on a global so the registry survives HMR module re-evaluation (matches __reticleAdapters).
3
32
  const globalStore = globalThis;
4
33
  function empty() {
@@ -48,16 +77,39 @@ export function registerCapabilities(input) {
48
77
  /** Snapshot copy of the registered capabilities (defensive — never hand out the live arrays). */
49
78
  export function getCapabilities() {
50
79
  return {
51
- testids: [...capabilities.testids],
80
+ testids: union(capabilities.testids, liveTestids()),
52
81
  signals: [...capabilities.signals],
53
- stores: [...capabilities.stores],
82
+ stores: union(capabilities.stores, liveStores()),
54
83
  flows: capabilities.flows.map((f) => ({ name: f.name, steps: [...f.steps] })),
84
+ // Only when open — an absent field is the ordinary case and should not cost a line in every
85
+ // capabilities payload ever sent.
86
+ ...(isPresenterVisible() ? { presenterExposed: true } : {}),
55
87
  };
56
88
  }
57
- /** Whether the app has advertised any capabilities (used in the HELLO flag). */
89
+ /**
90
+ * Testids the app DECLARED, without the ones merely observed in the DOM.
91
+ *
92
+ * The difference carries meaning the merged list cannot. `knownEmptyState` asks "did the app NAME
93
+ * this element as part of its testable surface?", which is how a zero-match query against
94
+ * `cart-empty-region` is told apart from a broken selector. Every testid on the page is observed, so
95
+ * answering that over the merged list makes it true whenever the page has any testid at all — the
96
+ * same as not asking.
97
+ */
98
+ export function declaredTestids() {
99
+ return [...capabilities.testids];
100
+ }
101
+ /**
102
+ * Whether the app has a testable surface at all (used in the HELLO flag).
103
+ *
104
+ * Answered over the OBSERVED surface as well as the declared one. It used to be answerable only by
105
+ * declaration, so an app with two hundred testids in its DOM and a live Redux store reported
106
+ * `hasCapabilities: false` until somebody typed those facts into a config file — and the agent
107
+ * reading that flag concluded, correctly by the flag and wrongly in fact, that there was nothing to
108
+ * drive.
109
+ */
58
110
  export function hasCapabilities() {
59
- return (capabilities.testids.length > 0 ||
60
- capabilities.signals.length > 0 ||
61
- capabilities.stores.length > 0 ||
62
- capabilities.flows.length > 0);
111
+ return (capabilities.signals.length > 0 ||
112
+ capabilities.flows.length > 0 ||
113
+ union(capabilities.testids, liveTestids()).length > 0 ||
114
+ union(capabilities.stores, liveStores()).length > 0);
63
115
  }
@@ -1,4 +1,4 @@
1
- import type { StoreLike, StoreSubscribe } from './stores.js';
1
+ import { type StoreLike, type StoreSubscribe } from './stores.js';
2
2
  /**
3
3
  * Adapters that give non-`{getState, subscribe}` state libraries the shape `registerStore` wants.
4
4
  *
@@ -1,4 +1,5 @@
1
1
  import { nativeWarn } from '../timers/native-console.js';
2
+ import { markAdapterSource } from './stores.js';
2
3
  /**
3
4
  * Expose a TanStack Query cache as a Reticle store, keyed by query key.
4
5
  *
@@ -16,7 +17,7 @@ export function tanstackQueryStore(client) {
16
17
  // effects, a provider remount, HMR — and an adapter holding the old cache would keep answering
17
18
  // from a store the app no longer reads, which is a stale-data bug inside the tool whose job is
18
19
  // catching stale data.
19
- return {
20
+ const store = {
20
21
  getState: () => {
21
22
  const out = {};
22
23
  for (const query of client.getQueryCache().getAll()) {
@@ -34,6 +35,10 @@ export function tanstackQueryStore(client) {
34
35
  },
35
36
  subscribe: (listener) => client.getQueryCache().subscribe(listener),
36
37
  };
38
+ // Registering this adapter claims the CLIENT too, so fiber-tree discovery can tell that the app
39
+ // already wired this exact QueryClient and must not add a second entry for it.
40
+ markAdapterSource(store, client);
41
+ return store;
37
42
  }
38
43
  /**
39
44
  * Expose a chosen set of Jotai atoms as one Reticle store.
@@ -48,3 +48,7 @@ export declare function readStoresWithTruncation(only?: string): {
48
48
  stores: Record<string, unknown>;
49
49
  truncation?: Record<string, TruncationReport>;
50
50
  };
51
+ /** Called by an adapter to record what it was built from. See `sourceOwner`. */
52
+ export declare function markAdapterSource(store: object, source: object): void;
53
+ /** The name this store source is registered under, or undefined if nothing has claimed it. */
54
+ export declare function sourceOwner(value: unknown): string | undefined;
@@ -34,6 +34,7 @@ function isStoreLike(source) {
34
34
  * leaves the store on pull-only reads: no change events, and an empty `stateDiffs` in every act summary.
35
35
  */
36
36
  export function registerStore(name, source, subscribe) {
37
+ claimSource(name, source);
37
38
  if (isStoreLike(source)) {
38
39
  const getter = () => source.getState();
39
40
  const sub = (listener) => source.subscribe(listener);
@@ -149,3 +150,37 @@ function warnSilentStoreOnce(name) {
149
150
  `changes are invisible: no STATE_CHANGE events, no state diffs in causal summaries, and a state ` +
150
151
  `predicate will never see it update. Pass the store object (or a subscribe callback) to fix.`);
151
152
  }
153
+ /**
154
+ * Who owns a given store SOURCE — the object the app (or an adapter) was built from.
155
+ *
156
+ * Registration is keyed by name, which cannot answer "is this same store already readable?". The
157
+ * question matters because the React adapter discovers stores from the fiber tree, and an app that
158
+ * has already registered the very same store by hand must not get a second entry for it under a
159
+ * second name. Comparing the READ VALUE cannot answer it either: `tanstackQueryStore` builds a fresh
160
+ * snapshot object on every `getState`, so identity never matches. Measured on bench-app, which
161
+ * registers `queries` itself and got an auto-discovered `query` beside it — one cache, listed twice,
162
+ * in every state read.
163
+ *
164
+ * A WeakMap so nothing here keeps an app's store alive.
165
+ */
166
+ const sourceOwners = new WeakMap();
167
+ /** Adapter instance → the object it wraps, so registering an adapter also claims its source. */
168
+ const adapterSources = new WeakMap();
169
+ /** Called by an adapter to record what it was built from. See `sourceOwner`. */
170
+ export function markAdapterSource(store, source) {
171
+ adapterSources.set(store, source);
172
+ }
173
+ function claimSource(name, source) {
174
+ if (null === source || ('object' !== typeof source && 'function' !== typeof source))
175
+ return;
176
+ sourceOwners.set(source, name);
177
+ const wrapped = adapterSources.get(source);
178
+ if (wrapped !== undefined)
179
+ sourceOwners.set(wrapped, name);
180
+ }
181
+ /** The name this store source is registered under, or undefined if nothing has claimed it. */
182
+ export function sourceOwner(value) {
183
+ if (null === value || ('object' !== typeof value && 'function' !== typeof value))
184
+ return undefined;
185
+ return sourceOwners.get(value);
186
+ }
package/dist/reticle.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { setPresenterVisible } from './dom/dom-ignore.js';
1
2
  import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, buildRedactionPolicy, isLoopbackHostname, isLocalPage, setActiveRedactionPolicy, wireRedactionKeys, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, CONTRACT_FINGERPRINT, newDocumentId, NO_EDITS_OBSERVED, } from '@reticlehq/core';
2
3
  import { rememberSessionLabel } from './session-continuity.js';
3
4
  import { editEpoch } from './edit-epoch.js';
@@ -251,11 +252,18 @@ export class Reticle {
251
252
  // page observed rather than guessing at the daemon, which it cannot see from in here.
252
253
  onUnreachable: ({ url: tried, attempts }) => {
253
254
  nativeWarn(unreachableMessage(tried, attempts));
255
+ // And on screen, not only in a console nobody has open. An instrumented page with a dead
256
+ // bridge looked exactly like a page with no Reticle in it, so the user could not tell a
257
+ // daemon they forgot to start from an install that did not work.
258
+ this.#presenter?.showUnreachable(tried, attempts);
254
259
  },
255
260
  });
256
261
  // Capabilities registered AFTER connect (which is when they are registered, by design) must
257
262
  // reach the bridge, or an app that declared its whole testable surface reads as having none.
258
263
  setCapabilitiesListener(() => this.#transport?.reannounce());
264
+ // Contributors only, and reported in capabilities so a verdict drawn with it open is never
265
+ // mistaken for an ordinary one. See connect-options.ts.
266
+ setPresenterVisible(true === options.exposePresenter);
259
267
  const emit = this.#emit;
260
268
  this.#teardowns = installAllObservers(emit, {
261
269
  captureBodies: true === options.captureNetworkBodies,
@@ -10,3 +10,10 @@
10
10
  * diagnose the daemon, and lists the checks in the order that actually resolves the ambiguity.
11
11
  */
12
12
  export declare function unreachableMessage(url: string, attempts: number): string;
13
+ /**
14
+ * The same fact, sized for the HUD's one-line status row.
15
+ *
16
+ * The console warning explains; this one only has room to name the URL, which is the answer in
17
+ * most cases anyway — a bridge on a port the page is not dialling.
18
+ */
19
+ export declare function unreachableStripText(url: string, attempts: number): string;
@@ -19,3 +19,12 @@ export function unreachableMessage(url, attempts) {
19
19
  `different host, so set the URL explicitly (Vite: VITE_RETICLE_WS_URL, or ` +
20
20
  `reticle.connect({ url })). An https page cannot open a ws:// socket at all. Still retrying…`);
21
21
  }
22
+ /**
23
+ * The same fact, sized for the HUD's one-line status row.
24
+ *
25
+ * The console warning explains; this one only has room to name the URL, which is the answer in
26
+ * most cases anyway — a bridge on a port the page is not dialling.
27
+ */
28
+ export function unreachableStripText(url, attempts) {
29
+ return `no bridge at ${url} — ${String(attempts)} attempt${1 === attempts ? '' : 's'}`;
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/browser",
3
- "version": "2.12.0",
3
+ "version": "2.13.1",
4
4
  "description": "Reticle browser SDK — installs observers, builds semantic snapshots, executes actions, talks to the bridge.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,7 +13,7 @@
13
13
  "./package.json": "./package.json"
14
14
  },
15
15
  "dependencies": {
16
- "@reticlehq/core": "2.12.0"
16
+ "@reticlehq/core": "2.13.1"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@iconify-json/heroicons-outline": "^1.2.1",
@@ -21,11 +21,11 @@
21
21
  "@tanstack/query-core": "^5.101.4",
22
22
  "jotai": "^2.20.2",
23
23
  "jsdom": "^30.0.1",
24
- "mobx": "^7.0.0",
24
+ "mobx": "^7.0.3",
25
25
  "pinia": "^4.0.3",
26
26
  "react": "^19.2.8",
27
27
  "recoil": "^0.7.7",
28
- "svelte": "^5.56.8",
28
+ "svelte": "^5.56.10",
29
29
  "valtio": "^2.3.2",
30
30
  "vue": "^3.5.41",
31
31
  "xstate": "^5.32.5"