@quiesco/agent 0.0.0-stage → 0.1.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -2
  3. package/metro.cjs +144 -0
  4. package/package.json +54 -3
  5. package/src/actions/hit-test.ts +42 -0
  6. package/src/actions/host.ts +194 -0
  7. package/src/actions/js.ts +245 -0
  8. package/src/actions/multitouch.ts +179 -0
  9. package/src/actions/native.ts +500 -0
  10. package/src/actions/performer.ts +87 -0
  11. package/src/actions/value.ts +460 -0
  12. package/src/actions/wheel.ts +260 -0
  13. package/src/api-types.ts +32 -0
  14. package/src/api.ts +11 -0
  15. package/src/assertions/index.ts +160 -0
  16. package/src/backdoor.ts +54 -0
  17. package/src/build-config.ts +6 -0
  18. package/src/connection/client.ts +196 -0
  19. package/src/connection/params.ts +63 -0
  20. package/src/early.ts +10 -0
  21. package/src/errors.ts +23 -0
  22. package/src/events.ts +136 -0
  23. package/src/expo-fetch.js +13 -0
  24. package/src/fiber-adapter/default-deps.native.ts +36 -0
  25. package/src/fiber-adapter/default-deps.ts +38 -0
  26. package/src/fiber-adapter/fiber.ts +81 -0
  27. package/src/fiber-adapter/hook.ts +154 -0
  28. package/src/fiber-adapter/host.ts +210 -0
  29. package/src/fiber-adapter/index.ts +221 -0
  30. package/src/fiber-adapter/scheduler.ts +70 -0
  31. package/src/fiber-adapter/traverse.ts +260 -0
  32. package/src/fiber-adapter/types.ts +142 -0
  33. package/src/index.ts +7 -0
  34. package/src/install.ts +6 -0
  35. package/src/json-check.ts +43 -0
  36. package/src/platform.ts +155 -0
  37. package/src/query/index.ts +5 -0
  38. package/src/query/matcher.ts +121 -0
  39. package/src/query/semantic.ts +266 -0
  40. package/src/query/tree.ts +116 -0
  41. package/src/query/visibility.ts +60 -0
  42. package/src/reset/index.ts +59 -0
  43. package/src/reset/storage-defaults.native.ts +26 -0
  44. package/src/reset/storage-defaults.ts +39 -0
  45. package/src/root/QuiescoRoot.tsx +28 -0
  46. package/src/runtime.ts +1003 -0
  47. package/src/singleton.ts +18 -0
  48. package/src/stub.ts +28 -0
  49. package/src/sync/frame-stable.ts +142 -0
  50. package/src/sync/idle-callback.ts +142 -0
  51. package/src/sync/index.ts +17 -0
  52. package/src/sync/native-settle.ts +41 -0
  53. package/src/sync/network.ts +299 -0
  54. package/src/sync/offline.ts +121 -0
  55. package/src/sync/react.ts +28 -0
  56. package/src/sync/timer.ts +124 -0
  57. package/src/sync/tracker.ts +51 -0
  58. package/src/sync/wait-for-idle.ts +141 -0
  59. package/src/version.ts +2 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `__REACT_DEVTOOLS_GLOBAL_HOOK__` integration (docs/spikes/01-fiber-query.md §1).
3
+ *
4
+ * - A hook already exists (debug builds: react-devtools-core installs it in InitializeCore; Expo web dev):
5
+ * WRAP its methods and call the originals, so React DevTools keeps working.
6
+ * - No hook (release builds, web production): install a minimal one.
7
+ *
8
+ * Renderers look the hook up once, at module init. React Native 0.88 loads its renderer from
9
+ * `setup-env` (run by Metro before the app entry), so `withQuiesco()` makes Metro run the agent's
10
+ * early module (`src/early.ts`) before that. When the renderer is already registered on an existing
11
+ * hook anyway, its roots are taken over from `getFiberRoots`; only roots rendered before the agent
12
+ * (app code that ran first) are reported as a problem.
13
+ */
14
+ import type { DevToolsHook, FiberRoot, RendererInternals } from './fiber';
15
+ import { isObject } from './fiber';
16
+ import type { RendererInfo, RendererKind } from './types';
17
+
18
+ export const HOOK_KEY = '__REACT_DEVTOOLS_GLOBAL_HOOK__';
19
+ const MARK = '__quiescoWrapped';
20
+
21
+ const SUPPORTED: Record<string, RendererKind> = {
22
+ 'react-native-renderer': 'native',
23
+ 'react-dom': 'web',
24
+ };
25
+ const SUPPORTED_MAJOR = 19;
26
+
27
+ export type CommitListener = (rendererID: number, root: FiberRoot) => void;
28
+
29
+ export interface HookState {
30
+ source: 'existing' | 'installed';
31
+ renderers: Map<number, RendererInfo>;
32
+ roots: Map<number, Set<FiberRoot>>;
33
+ /** Renderers that had already rendered roots when we installed (state set up before may be missed). */
34
+ lateRenderers: RendererInfo[];
35
+ commitListeners: Set<CommitListener>;
36
+ }
37
+
38
+ export function describeRenderer(id: number, internals: RendererInternals): RendererInfo {
39
+ const packageName = internals.rendererPackageName ?? 'unknown';
40
+ const version = internals.version ?? internals.reconcilerVersion ?? 'unknown';
41
+ const kind = SUPPORTED[packageName];
42
+ const major = Number.parseInt(version, 10);
43
+ return {
44
+ id,
45
+ packageName,
46
+ version,
47
+ bundleType: internals.bundleType ?? 0,
48
+ kind: kind ?? 'native',
49
+ supported: kind !== undefined && major === SUPPORTED_MAJOR,
50
+ };
51
+ }
52
+
53
+ function addRoot(state: HookState, rendererID: unknown, root: unknown): void {
54
+ if (typeof rendererID !== 'number' || !isObject(root) || !('current' in root)) return;
55
+ let set = state.roots.get(rendererID);
56
+ if (set === undefined) state.roots.set(rendererID, (set = new Set()));
57
+ set.add(root as unknown as FiberRoot);
58
+ }
59
+
60
+ function onCommit(state: HookState, rendererID: unknown, root: unknown): void {
61
+ addRoot(state, rendererID, root);
62
+ if (typeof rendererID !== 'number' || !isObject(root)) return;
63
+ for (const l of state.commitListeners) {
64
+ try {
65
+ l(rendererID, root as unknown as FiberRoot);
66
+ } catch {
67
+ // A listener must never break the app's commit.
68
+ }
69
+ }
70
+ }
71
+
72
+ type HookMethod = (this: unknown, ...args: unknown[]) => unknown;
73
+
74
+ function wrapMethod(hook: DevToolsHook, name: string, before: (args: unknown[]) => void): void {
75
+ const original = hook[name] as HookMethod | undefined;
76
+ hook[name] = function (this: unknown, ...args: unknown[]) {
77
+ try {
78
+ before(args);
79
+ } catch {
80
+ // never break the app
81
+ }
82
+ return typeof original === 'function' ? original.apply(this, args) : undefined;
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Install (or wrap) the DevTools hook on `target`. Idempotent per target: a second call returns the
88
+ * state created by the first one (Fast Refresh re-evaluates modules).
89
+ */
90
+ export function installHook(target: Record<string, unknown>): HookState {
91
+ const existing = target[HOOK_KEY];
92
+ if (isObject(existing) && isObject(existing[MARK])) return existing[MARK] as unknown as HookState;
93
+
94
+ const state: HookState = {
95
+ source: 'installed',
96
+ renderers: new Map(),
97
+ roots: new Map(),
98
+ lateRenderers: [],
99
+ commitListeners: new Set(),
100
+ };
101
+
102
+ if (isObject(existing) && typeof existing['inject'] === 'function') {
103
+ const hook = existing as DevToolsHook;
104
+ state.source = 'existing';
105
+ const originalInject = hook.inject;
106
+ hook.inject = function (this: unknown, internals: RendererInternals) {
107
+ const id = originalInject.call(this, internals);
108
+ state.renderers.set(id, describeRenderer(id, internals));
109
+ return id;
110
+ };
111
+ if (hook.renderers instanceof Map) {
112
+ for (const [id, internals] of hook.renderers) {
113
+ const info = describeRenderer(id, internals);
114
+ state.renderers.set(id, info);
115
+ const roots = typeof hook.getFiberRoots === 'function' ? hook.getFiberRoots(id) : undefined;
116
+ if (roots !== undefined && roots.size > 0) {
117
+ for (const root of roots) addRoot(state, id, root);
118
+ state.lateRenderers.push(info);
119
+ }
120
+ }
121
+ }
122
+ wrapMethod(hook, 'onScheduleFiberRoot', (a) => addRoot(state, a[0], a[1]));
123
+ wrapMethod(hook, 'onCommitFiberRoot', (a) => onCommit(state, a[0], a[1]));
124
+ hook[MARK] = state;
125
+ return state;
126
+ }
127
+
128
+ let nextID = 1;
129
+ const renderers = new Map<number, RendererInternals>();
130
+ const hook: DevToolsHook = {
131
+ supportsFiber: true,
132
+ isDisabled: false,
133
+ renderers,
134
+ inject(internals) {
135
+ const id = nextID++;
136
+ renderers.set(id, internals);
137
+ state.renderers.set(id, describeRenderer(id, internals));
138
+ return id;
139
+ },
140
+ onScheduleFiberRoot(id, root) {
141
+ addRoot(state, id, root);
142
+ },
143
+ onCommitFiberRoot(id, root) {
144
+ onCommit(state, id, root);
145
+ },
146
+ onPostCommitFiberRoot() {},
147
+ onCommitFiberUnmount() {},
148
+ setStrictMode() {},
149
+ checkDCE() {},
150
+ [MARK]: state,
151
+ };
152
+ target[HOOK_KEY] = hook;
153
+ return state;
154
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Renderer-specific details. These are the only three differences between Fabric and
3
+ * react-dom + react-native-web that the query layer needs (docs/spikes/07-web-fiber-query.md),
4
+ * plus style-based hiding and the viewport size.
5
+ */
6
+ import type { Rect } from '@quiesco/protocol';
7
+
8
+ import type { Fiber } from './fiber';
9
+ import { isObject } from './fiber';
10
+ import type { RendererKind, Viewport } from './types';
11
+
12
+ export interface HostPlatform {
13
+ kind: RendererKind;
14
+ testID(props: Record<string, unknown>): string | undefined;
15
+ label(props: Record<string, unknown>): string | undefined;
16
+ role(props: Record<string, unknown>): string | undefined;
17
+ hiddenByStyle(fiber: Fiber, props: Record<string, unknown>): boolean;
18
+ /** Whether the host clips its descendants (scroll views, `overflow` other than `visible`). */
19
+ clipsContent(type: string, fiber: Fiber, props: Record<string, unknown>): boolean;
20
+ /** Whether the host has the input focus. */
21
+ isFocused(fiber: Fiber): boolean;
22
+ /** Focus the host (text inputs); false when it has no way to be focused. */
23
+ focus(fiber: Fiber): boolean;
24
+ /** Live text of a text input host, undefined for other hosts. */
25
+ inputValue(type: string, fiber: Fiber, props: Record<string, unknown>): string | undefined;
26
+ measure(fiber: Fiber): Rect | null;
27
+ /** React view tag of the native view (Fabric), for native touch injection. Null on web. */
28
+ nativeTag(fiber: Fiber): number | null;
29
+ viewport(): Viewport | null;
30
+ }
31
+
32
+ /** Lazily loaded platform modules. Injected so the adapter can be unit-tested without React Native. */
33
+ export interface PlatformDeps {
34
+ /**
35
+ * Fabric's `getPublicInstanceFromInternalInstanceHandle`, from
36
+ * `react-native/Libraries/Renderer/shims/ReactFabric` (private path; docs/spikes/01 §3).
37
+ */
38
+ getPublicInstance?: () => ((fiber: Fiber) => unknown) | null;
39
+ /** `Dimensions.get('window')` on native; `{ innerWidth, innerHeight }` on web. */
40
+ viewport?: () => Viewport | null;
41
+ /** `window.getComputedStyle` on web. */
42
+ getComputedStyle?: (el: unknown) => ComputedStyleLike | null;
43
+ /** The focused element: `TextInput.State.currentlyFocusedInput()` on native, `document.activeElement` on web. */
44
+ focusedElement?: () => unknown;
45
+ }
46
+
47
+ export interface ComputedStyleLike {
48
+ display?: string;
49
+ opacity?: string;
50
+ overflowX?: string;
51
+ overflowY?: string;
52
+ }
53
+
54
+ /** Native host types that scroll (and therefore clip) their content. RN 0.88 host names. */
55
+ const NATIVE_SCROLL_HOSTS = new Set(['RCTScrollView', 'AndroidHorizontalScrollView']);
56
+ /** Native text input hosts (RN 0.88). TextInput passes its current text as the `text` prop. */
57
+ const NATIVE_TEXT_INPUTS = new Set(['RCTSinglelineTextInputView', 'RCTMultilineTextInputView', 'AndroidTextInput']);
58
+ const WEB_TEXT_INPUTS = new Set(['input', 'textarea', 'select']);
59
+ const WEB_NON_TEXT_INPUT_TYPES = new Set(['checkbox', 'radio', 'button', 'submit', 'reset', 'image', 'file', 'range', 'color', 'hidden']);
60
+
61
+ function str(v: unknown): string | undefined {
62
+ return typeof v === 'string' ? v : undefined;
63
+ }
64
+
65
+ function toRect(r: unknown): Rect | null {
66
+ if (!isObject(r)) return null;
67
+ const { x, y, width, height } = r;
68
+ if (typeof x !== 'number' || typeof y !== 'number' || typeof width !== 'number' || typeof height !== 'number') return null;
69
+ return { x, y, width, height };
70
+ }
71
+
72
+ function callRect(el: unknown): Rect | null {
73
+ if (!isObject(el) || typeof el['getBoundingClientRect'] !== 'function') return null;
74
+ try {
75
+ return toRect((el['getBoundingClientRect'] as () => unknown).call(el));
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** Flatten an RN style prop (object, nested arrays, falsy entries). Registered numeric styles are ignored. */
82
+ export function flattenStyle(style: unknown): Record<string, unknown> {
83
+ if (Array.isArray(style)) {
84
+ const out: Record<string, unknown> = {};
85
+ for (const s of style) Object.assign(out, flattenStyle(s));
86
+ return out;
87
+ }
88
+ return isObject(style) ? { ...style } : {};
89
+ }
90
+
91
+ function hiddenStyle(display: unknown, opacity: unknown): boolean {
92
+ return display === 'none' || opacity === 0 || opacity === '0';
93
+ }
94
+
95
+ function clips(overflow: unknown): boolean {
96
+ return typeof overflow === 'string' && overflow !== '' && overflow !== 'visible';
97
+ }
98
+
99
+ function focusedElement(deps: PlatformDeps): unknown {
100
+ try {
101
+ return deps.focusedElement?.() ?? null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ export function nativePlatform(deps: PlatformDeps): HostPlatform {
108
+ let getPublic: ((fiber: Fiber) => unknown) | null | undefined;
109
+ return {
110
+ kind: 'native',
111
+ testID: (p) => str(p['testID']),
112
+ label: (p) => str(p['accessibilityLabel']) ?? str(p['aria-label']),
113
+ role: (p) => str(p['accessibilityRole']) ?? str(p['role']),
114
+ hiddenByStyle: (_fiber, p) => {
115
+ const s = flattenStyle(p['style']);
116
+ return hiddenStyle(s['display'], s['opacity']);
117
+ },
118
+ clipsContent: (type, _fiber, p) => NATIVE_SCROLL_HOSTS.has(type) || clips(flattenStyle(p['style'])['overflow']),
119
+ isFocused(fiber) {
120
+ const focused = focusedElement(deps);
121
+ return focused !== null && focused !== undefined && focused === publicInstance(fiber);
122
+ },
123
+ focus(fiber) {
124
+ const el = publicInstance(fiber);
125
+ if (!isObject(el) || typeof el['focus'] !== 'function') return false;
126
+ (el['focus'] as () => void).call(el);
127
+ return true;
128
+ },
129
+ inputValue(type, _fiber, p) {
130
+ if (!NATIVE_TEXT_INPUTS.has(type)) return undefined;
131
+ return str(p['text']) ?? str(p['value']) ?? str(p['defaultValue']) ?? '';
132
+ },
133
+ measure(fiber) {
134
+ return callRect(publicInstance(fiber));
135
+ },
136
+ nativeTag(fiber) {
137
+ const el = publicInstance(fiber);
138
+ const tag = isObject(el) ? el['__nativeTag'] : undefined;
139
+ return typeof tag === 'number' ? tag : null;
140
+ },
141
+ viewport: () => deps.viewport?.() ?? null,
142
+ };
143
+
144
+ function publicInstance(fiber: Fiber): unknown {
145
+ if (getPublic === undefined) {
146
+ try {
147
+ getPublic = deps.getPublicInstance?.() ?? null;
148
+ } catch {
149
+ getPublic = null;
150
+ }
151
+ }
152
+ if (getPublic === null) return null;
153
+ try {
154
+ return getPublic(fiber);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+ }
160
+
161
+ export function webPlatform(deps: PlatformDeps): HostPlatform {
162
+ return {
163
+ kind: 'web',
164
+ // react-native-web forwards testID to the DOM element as data-testid; testID itself stays on the RNW composite.
165
+ testID: (p) => str(p['data-testid']) ?? str(p['testID']),
166
+ label: (p) => str(p['aria-label']) ?? str(p['accessibilityLabel']),
167
+ role: (p) => str(p['role']) ?? str(p['accessibilityRole']),
168
+ hiddenByStyle(fiber, p) {
169
+ // RNW compiles StyleSheet styles to class names, so the computed style is the reliable source.
170
+ const computed = deps.getComputedStyle?.(fiber.stateNode) ?? null;
171
+ if (computed !== null) return hiddenStyle(computed.display, computed.opacity);
172
+ const s = flattenStyle(p['style']);
173
+ return hiddenStyle(s['display'], s['opacity']);
174
+ },
175
+ clipsContent(_type, fiber, p) {
176
+ const computed = deps.getComputedStyle?.(fiber.stateNode) ?? null;
177
+ if (computed !== null) return clips(computed.overflowX) || clips(computed.overflowY);
178
+ return clips(flattenStyle(p['style'])['overflow']);
179
+ },
180
+ isFocused(fiber) {
181
+ const focused = focusedElement(deps);
182
+ return focused !== null && focused !== undefined && focused === fiber.stateNode;
183
+ },
184
+ focus(fiber) {
185
+ const el = fiber.stateNode;
186
+ if (!isObject(el) || typeof el['focus'] !== 'function') return false;
187
+ (el['focus'] as () => void).call(el);
188
+ // Keys act at the end of the text, like after a user tapped into the field.
189
+ const value = el['value'];
190
+ if (typeof value === 'string' && typeof el['setSelectionRange'] === 'function') {
191
+ try {
192
+ (el['setSelectionRange'] as (a: number, b: number) => void).call(el, value.length, value.length);
193
+ } catch {
194
+ // Inputs like type="email" do not support selection.
195
+ }
196
+ }
197
+ return true;
198
+ },
199
+ inputValue(type, fiber, p) {
200
+ if (!WEB_TEXT_INPUTS.has(type)) return undefined;
201
+ if (type === 'input' && WEB_NON_TEXT_INPUT_TYPES.has(String(p['type'] ?? ''))) return undefined;
202
+ const el = fiber.stateNode;
203
+ const live = isObject(el) ? el['value'] : undefined;
204
+ return str(live) ?? str(p['value']) ?? str(p['defaultValue']) ?? '';
205
+ },
206
+ measure: (fiber) => callRect(fiber.stateNode),
207
+ nativeTag: () => null,
208
+ viewport: () => deps.viewport?.() ?? null,
209
+ };
210
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The only module allowed to touch React internals (requirements.md §3-3, §12).
3
+ * Everything above it works with `HostNode` and plain strings.
4
+ */
5
+ import { defaultPlatformDeps, loadScheduler } from './default-deps';
6
+ import type { Fiber, FiberRoot } from './fiber';
7
+ import type { HookState } from './hook';
8
+ import { installHook } from './hook';
9
+ import type { HostPlatform, PlatformDeps } from './host';
10
+ import { nativePlatform, webPlatform } from './host';
11
+ import type { SchedulerState } from './scheduler';
12
+ import { patchScheduler } from './scheduler';
13
+ import { buildHostTree, findFiberByType, findHostlessComposites } from './traverse';
14
+ import type { AdapterDiagnostics, CompositeNode, HostNode, RendererKind, Viewport } from './types';
15
+
16
+ export type { AdapterDiagnostics, CompositeNode, HandlerRef, HostNode, InstanceRef, PropsRef, RendererInfo, RendererKind, Viewport } from './types';
17
+ export { flattenStyle } from './host';
18
+
19
+ /** React Native's `noTimeout` / react-dom's `noTimeout`: no pending suspended-commit timeout. */
20
+ const NO_TIMEOUT = -1;
21
+
22
+ export interface FiberAdapterOptions {
23
+ /** Where `__REACT_DEVTOOLS_GLOBAL_HOOK__` lives. Default: `globalThis`. */
24
+ target?: Record<string, unknown>;
25
+ /** Returns the `scheduler` module object. Default: `require('scheduler')`. */
26
+ loadScheduler?: () => unknown;
27
+ platformDeps?: PlatformDeps;
28
+ now?: () => number;
29
+ }
30
+
31
+ /**
32
+ * Install the DevTools hook and patch the scheduler on `target`, before any renderer loads. Run by
33
+ * `src/early.ts`, which `withQuiesco()` makes Metro execute ahead of React Native's own setup.
34
+ * Idempotent: FiberAdapter.install() later picks up the same hook and scheduler state.
35
+ */
36
+ export function installEarly(target: Record<string, unknown> = globalThis as unknown as Record<string, unknown>, load: () => unknown = loadScheduler): void {
37
+ installHook(target);
38
+ try {
39
+ patchScheduler(load());
40
+ } catch {
41
+ // FiberAdapter.install() reports scheduler problems.
42
+ }
43
+ }
44
+
45
+ export class FiberAdapter {
46
+ private hook: HookState | null = null;
47
+ private scheduler: SchedulerState | null = null;
48
+ private schedulerError: string | null = null;
49
+ private scopeType: unknown = null;
50
+ private readonly platforms = new Map<RendererKind, HostPlatform>();
51
+ private readonly now: () => number;
52
+
53
+ constructor(private readonly options: FiberAdapterOptions = {}) {
54
+ this.now = options.now ?? Date.now;
55
+ }
56
+
57
+ /**
58
+ * Install the DevTools hook and patch the scheduler. Must run before the renderer module loads,
59
+ * i.e. from the first import of the app entry (docs/spikes/01 §1, 02 §2). Idempotent.
60
+ */
61
+ install(): void {
62
+ if (this.hook !== null) return;
63
+ this.hook = installHook(this.options.target ?? (globalThis as unknown as Record<string, unknown>));
64
+ try {
65
+ this.scheduler = patchScheduler((this.options.loadScheduler ?? loadScheduler)(), this.now);
66
+ if (!this.scheduler.patched) this.schedulerError = 'scheduler module has no unstable_scheduleCallback';
67
+ } catch (e) {
68
+ this.schedulerError = `cannot load scheduler: ${String(e)}`;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Restrict queries to the subtree of the first fiber whose component type is `componentType`
74
+ * (e.g. `QuiescoRoot`), excluding dev-client overlays (docs/spikes/01 §2). `null` = whole app.
75
+ */
76
+ setScope(componentType: unknown): void {
77
+ this.scopeType = componentType;
78
+ }
79
+
80
+ /** Top-level host nodes within the scope, across all roots. Built fresh on every call. */
81
+ getHostRoots(): HostNode[] {
82
+ const out: HostNode[] = [];
83
+ const hook = this.requireHook();
84
+ for (const [rendererID, roots] of hook.roots) {
85
+ const platform = this.platformFor(rendererID);
86
+ for (const root of roots) {
87
+ let start = root.current;
88
+ if (this.scopeType !== null) {
89
+ const scoped = findFiberByType(start, this.scopeType);
90
+ if (scoped === null) continue;
91
+ start = scoped;
92
+ }
93
+ out.push(...buildHostTree(start, platform));
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * Components within the scope that render no host element and satisfy `test` (value actions on e.g.
101
+ * Android's DateTimePicker, which renders null and opens a system dialog).
102
+ */
103
+ findHostlessComposites(test: (c: CompositeNode) => boolean): CompositeNode[] {
104
+ const out: CompositeNode[] = [];
105
+ for (const start of this.scopedStarts()) out.push(...findHostlessComposites(start, test));
106
+ return out;
107
+ }
108
+
109
+ /** Window size of the (first) renderer in use, in the same units as `HostNode.measure()`. */
110
+ viewport(): Viewport | null {
111
+ const first = this.requireHook().roots.keys().next();
112
+ return first.done === true ? null : this.platformFor(first.value).viewport();
113
+ }
114
+
115
+ /** Detector A (docs/spikes/02): pending work recorded on FiberRoot fields. Empty = idle. */
116
+ pendingRootWork(): string[] {
117
+ const busy: string[] = [];
118
+ for (const root of this.allRoots()) {
119
+ if (root.pendingLanes !== 0) busy.push(`pendingLanes=0b${root.pendingLanes.toString(2)}`);
120
+ if (root.callbackNode != null) busy.push('callbackNode');
121
+ if (root.cancelPendingCommit != null) busy.push('cancelPendingCommit');
122
+ if (root.timeoutHandle !== NO_TIMEOUT && root.timeoutHandle !== undefined) busy.push(`timeoutHandle=${String(root.timeoutHandle)}`);
123
+ }
124
+ return busy;
125
+ }
126
+
127
+ /** Detector B (docs/spikes/02): scheduler tasks not yet finished. Empty = idle. */
128
+ pendingSchedulerTasks(): string[] {
129
+ const out: string[] = [];
130
+ if (this.scheduler === null) return out;
131
+ const t = this.now();
132
+ for (const task of this.scheduler.pending.values()) {
133
+ out.push(`task#${task.id} p${task.priority} ${t - task.since}ms${task.continued > 0 ? ` (continued ×${task.continued})` : ''}`);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /** Called after every commit of any root. Returns an unsubscribe function. */
139
+ onCommit(listener: () => void): () => void {
140
+ const hook = this.requireHook();
141
+ const l = (): void => listener();
142
+ hook.commitListeners.add(l);
143
+ return () => hook.commitListeners.delete(l);
144
+ }
145
+
146
+ diagnostics(): AdapterDiagnostics {
147
+ const hook = this.hook;
148
+ const problems: string[] = [];
149
+ const warnings: string[] = [];
150
+ if (hook === null) {
151
+ problems.push('fiber adapter not installed');
152
+ return { hookSource: 'none', schedulerPatched: false, renderers: [], rootCount: 0, problems, warnings };
153
+ }
154
+ const renderers = [...hook.renderers.values()];
155
+ if (hook.lateRenderers.length > 0) {
156
+ problems.push(
157
+ `The app rendered before @quiesco/agent was installed (${hook.lateRenderers.map((r) => r.packageName).join(', ')}); ` +
158
+ 'work started before that is not tracked. Import @quiesco/agent on the first line of the app entry file.',
159
+ );
160
+ }
161
+ if (this.schedulerError !== null) problems.push(this.schedulerError);
162
+ for (const r of renderers) {
163
+ if (!r.supported) warnings.push(`untested renderer ${r.packageName}@${r.version}; queries may not work`);
164
+ }
165
+ return {
166
+ hookSource: hook.source,
167
+ schedulerPatched: this.scheduler?.patched ?? false,
168
+ renderers,
169
+ rootCount: this.allRoots().length,
170
+ problems,
171
+ warnings,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Call once the app has rendered (e.g. when QuiescoRoot mounts). If no renderer was injected by
177
+ * then, the renderer loaded before the hook existed and never saw it.
178
+ */
179
+ checkRendererSeen(): string | null {
180
+ const hook = this.requireHook();
181
+ if (hook.renderers.size > 0) return null;
182
+ return (
183
+ 'No React renderer registered with the Quiesco DevTools hook: the renderer loaded before the hook existed. ' +
184
+ 'Wrap metro.config.js with withQuiesco() from @quiesco/agent/metro (it runs the hook before React Native ' +
185
+ 'starts) and import @quiesco/agent on the first line of the app entry file.'
186
+ );
187
+ }
188
+
189
+ private scopedStarts(): Fiber[] {
190
+ const out: Fiber[] = [];
191
+ for (const roots of this.requireHook().roots.values()) {
192
+ for (const root of roots) {
193
+ const start = this.scopeType === null ? root.current : findFiberByType(root.current, this.scopeType);
194
+ if (start !== null) out.push(start);
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ private allRoots(): FiberRoot[] {
201
+ const out: FiberRoot[] = [];
202
+ for (const set of this.requireHook().roots.values()) out.push(...set);
203
+ return out;
204
+ }
205
+
206
+ private platformFor(rendererID: number): HostPlatform {
207
+ const kind = this.hook?.renderers.get(rendererID)?.kind ?? 'native';
208
+ let p = this.platforms.get(kind);
209
+ if (p === undefined) {
210
+ const deps = this.options.platformDeps ?? defaultPlatformDeps;
211
+ p = kind === 'web' ? webPlatform(deps) : nativePlatform(deps);
212
+ this.platforms.set(kind, p);
213
+ }
214
+ return p;
215
+ }
216
+
217
+ private requireHook(): HookState {
218
+ if (this.hook === null) throw new Error('FiberAdapter.install() has not been called');
219
+ return this.hook;
220
+ }
221
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Track pending React scheduler tasks (docs/spikes/02-react-idle.md §2, detector B).
3
+ *
4
+ * The renderer reads `unstable_scheduleCallback` / `unstable_cancelCallback` from the `scheduler`
5
+ * module object at its own module init, so the exports must be replaced BEFORE the renderer loads.
6
+ * Render work and `flushPassiveEffects` are both queued through it.
7
+ */
8
+ import { isObject } from './fiber';
9
+
10
+ const MARK = '__quiescoSchedulerState';
11
+
12
+ export interface PendingTask {
13
+ id: number;
14
+ priority: number;
15
+ since: number;
16
+ /** Number of continuations returned so far (a continuation keeps the task pending). */
17
+ continued: number;
18
+ }
19
+
20
+ export interface SchedulerState {
21
+ patched: boolean;
22
+ pending: Map<unknown, PendingTask>;
23
+ scheduledTotal: number;
24
+ }
25
+
26
+ type Callback = (this: unknown, didTimeout: boolean) => unknown;
27
+ type ScheduleFn = (this: unknown, priority: number, callback: Callback, options?: unknown) => unknown;
28
+ type CancelFn = (this: unknown, task: unknown) => unknown;
29
+
30
+ /** Patch a `scheduler` module object in place. Idempotent per module object. */
31
+ export function patchScheduler(scheduler: unknown, now: () => number = Date.now): SchedulerState {
32
+ if (!isObject(scheduler)) return { patched: false, pending: new Map(), scheduledTotal: 0 };
33
+ const existing = scheduler[MARK];
34
+ if (isObject(existing)) return existing as unknown as SchedulerState;
35
+
36
+ const origSchedule = scheduler['unstable_scheduleCallback'] as ScheduleFn | undefined;
37
+ const origCancel = scheduler['unstable_cancelCallback'] as CancelFn | undefined;
38
+ const state: SchedulerState = { patched: false, pending: new Map(), scheduledTotal: 0 };
39
+ if (typeof origSchedule !== 'function' || typeof origCancel !== 'function') return state;
40
+
41
+ let nextId = 1;
42
+ scheduler['unstable_scheduleCallback'] = function (this: unknown, priority: number, callback: Callback, options?: unknown) {
43
+ const info: PendingTask = { id: nextId++, priority, since: now(), continued: 0 };
44
+ const wrap = (cb: Callback): Callback =>
45
+ function (this: unknown, didTimeout: boolean) {
46
+ let result: unknown;
47
+ try {
48
+ result = cb.call(this, didTimeout);
49
+ } finally {
50
+ if (typeof result !== 'function') state.pending.delete(task);
51
+ }
52
+ if (typeof result === 'function') {
53
+ info.continued++;
54
+ return wrap(result as Callback);
55
+ }
56
+ return result;
57
+ };
58
+ const task: unknown = origSchedule.call(this, priority, wrap(callback), options);
59
+ state.pending.set(task, info);
60
+ state.scheduledTotal++;
61
+ return task;
62
+ };
63
+ scheduler['unstable_cancelCallback'] = function (this: unknown, task: unknown) {
64
+ state.pending.delete(task);
65
+ return origCancel.call(this, task);
66
+ };
67
+ scheduler[MARK] = state;
68
+ state.patched = true;
69
+ return state;
70
+ }