@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.
- package/LICENSE +21 -0
- package/README.md +23 -2
- package/metro.cjs +144 -0
- package/package.json +54 -3
- package/src/actions/hit-test.ts +42 -0
- package/src/actions/host.ts +194 -0
- package/src/actions/js.ts +245 -0
- package/src/actions/multitouch.ts +179 -0
- package/src/actions/native.ts +500 -0
- package/src/actions/performer.ts +87 -0
- package/src/actions/value.ts +460 -0
- package/src/actions/wheel.ts +260 -0
- package/src/api-types.ts +32 -0
- package/src/api.ts +11 -0
- package/src/assertions/index.ts +160 -0
- package/src/backdoor.ts +54 -0
- package/src/build-config.ts +6 -0
- package/src/connection/client.ts +196 -0
- package/src/connection/params.ts +63 -0
- package/src/early.ts +10 -0
- package/src/errors.ts +23 -0
- package/src/events.ts +136 -0
- package/src/expo-fetch.js +13 -0
- package/src/fiber-adapter/default-deps.native.ts +36 -0
- package/src/fiber-adapter/default-deps.ts +38 -0
- package/src/fiber-adapter/fiber.ts +81 -0
- package/src/fiber-adapter/hook.ts +154 -0
- package/src/fiber-adapter/host.ts +210 -0
- package/src/fiber-adapter/index.ts +221 -0
- package/src/fiber-adapter/scheduler.ts +70 -0
- package/src/fiber-adapter/traverse.ts +260 -0
- package/src/fiber-adapter/types.ts +142 -0
- package/src/index.ts +7 -0
- package/src/install.ts +6 -0
- package/src/json-check.ts +43 -0
- package/src/platform.ts +155 -0
- package/src/query/index.ts +5 -0
- package/src/query/matcher.ts +121 -0
- package/src/query/semantic.ts +266 -0
- package/src/query/tree.ts +116 -0
- package/src/query/visibility.ts +60 -0
- package/src/reset/index.ts +59 -0
- package/src/reset/storage-defaults.native.ts +26 -0
- package/src/reset/storage-defaults.ts +39 -0
- package/src/root/QuiescoRoot.tsx +28 -0
- package/src/runtime.ts +1003 -0
- package/src/singleton.ts +18 -0
- package/src/stub.ts +28 -0
- package/src/sync/frame-stable.ts +142 -0
- package/src/sync/idle-callback.ts +142 -0
- package/src/sync/index.ts +17 -0
- package/src/sync/native-settle.ts +41 -0
- package/src/sync/network.ts +299 -0
- package/src/sync/offline.ts +121 -0
- package/src/sync/react.ts +28 -0
- package/src/sync/timer.ts +124 -0
- package/src/sync/tracker.ts +51 -0
- package/src/sync/wait-for-idle.ts +141 -0
- package/src/version.ts +2 -0
package/src/singleton.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One runtime per JS context, kept on globalThis so Fast Refresh re-evaluating modules reuses it
|
|
3
|
+
* (the DevTools hook and the patched globals must not be installed twice).
|
|
4
|
+
*/
|
|
5
|
+
import { AgentRuntime } from './runtime';
|
|
6
|
+
|
|
7
|
+
const KEY = '__quiescoRuntime';
|
|
8
|
+
|
|
9
|
+
export function getRuntime(): AgentRuntime {
|
|
10
|
+
const g = globalThis as unknown as Record<string, AgentRuntime | undefined>;
|
|
11
|
+
let rt = g[KEY];
|
|
12
|
+
if (rt === undefined) {
|
|
13
|
+
rt = new AgentRuntime();
|
|
14
|
+
rt.installEarly();
|
|
15
|
+
g[KEY] = rt;
|
|
16
|
+
}
|
|
17
|
+
return rt;
|
|
18
|
+
}
|
package/src/stub.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production stub: withQuiesco() (metro.cjs) resolves `@quiesco/agent` to this file in non-e2e builds
|
|
3
|
+
* (docs/spikes/06-prod-strip.md). It must export the same API as index.ts and pull in nothing else.
|
|
4
|
+
*/
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
import type { QuiescoApi } from './api-types';
|
|
8
|
+
|
|
9
|
+
export type { Backdoor, QuiescoApi, StorageAdapter } from './api-types';
|
|
10
|
+
|
|
11
|
+
export interface QuiescoRootProps {
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function QuiescoRoot({ children }: QuiescoRootProps): ReactNode {
|
|
16
|
+
return children ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const noop = (): void => {};
|
|
20
|
+
|
|
21
|
+
/** No-ops in production: registrations are accepted and ignored. */
|
|
22
|
+
export const quiesco: QuiescoApi = {
|
|
23
|
+
backdoor: () => noop,
|
|
24
|
+
onReset: () => noop,
|
|
25
|
+
registerStorage: () => noop,
|
|
26
|
+
launchArgs: () => ({}),
|
|
27
|
+
workerIndex: () => null,
|
|
28
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame-stable tracker (requirements.md §6.2-5): the element tree's structure snapshot (type, testID,
|
|
3
|
+
* text, rounded rect of every host node) is unchanged for N consecutive frames (default 2). Catches
|
|
4
|
+
* UI-thread animations (e.g. Reanimated) that are invisible to the JS-side trackers.
|
|
5
|
+
*
|
|
6
|
+
* Sampling runs only while a waitForIdle is in progress (beginWait / endWait), so idle apps do not
|
|
7
|
+
* pay for snapshotting the tree every frame. Outside a wait it reports idle.
|
|
8
|
+
*
|
|
9
|
+
* When the tree never settles, describeBusy() says how many frames were sampled in how long (very few
|
|
10
|
+
* frames means a starved UI thread, not an animation) and which node differed between the last two
|
|
11
|
+
* different frames, so a sync timeout names the element that keeps changing.
|
|
12
|
+
*/
|
|
13
|
+
import { SyncDefaults } from '@quiesco/protocol';
|
|
14
|
+
|
|
15
|
+
import type { IdleTracker } from './tracker';
|
|
16
|
+
|
|
17
|
+
export interface FrameStableTrackerOptions {
|
|
18
|
+
/** Structure snapshot of the current element tree, one line per host node. */
|
|
19
|
+
snapshot: () => readonly string[];
|
|
20
|
+
frames?: number;
|
|
21
|
+
/** Default: `globalThis.requestAnimationFrame`. */
|
|
22
|
+
requestFrame?: (cb: () => void) => unknown;
|
|
23
|
+
cancelFrame?: (id: unknown) => void;
|
|
24
|
+
/** Clock for the diagnostics. Default: `Date.now`. */
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_LINE = 120;
|
|
29
|
+
|
|
30
|
+
function sameLines(a: readonly string[], b: readonly string[]): boolean {
|
|
31
|
+
if (a.length !== b.length) return false;
|
|
32
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const clip = (line: string | undefined): string =>
|
|
37
|
+
line === undefined ? '(none)' : line.length > MAX_LINE ? `${line.slice(0, MAX_LINE - 1)}…` : line;
|
|
38
|
+
|
|
39
|
+
/** Where two snapshots first differ, as a short human-readable line. */
|
|
40
|
+
export function describeChange(before: readonly string[], after: readonly string[]): string {
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < before.length && i < after.length && before[i] === after[i]) i++;
|
|
43
|
+
const count = before.length === after.length ? '' : `node count ${before.length} → ${after.length}; `;
|
|
44
|
+
return `${count}first difference at node ${i}: ${clip(before[i])} → ${clip(after[i])}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class FrameStableTracker implements IdleTracker {
|
|
48
|
+
readonly name = 'frame-stable';
|
|
49
|
+
private frames: number;
|
|
50
|
+
private waiting = 0;
|
|
51
|
+
private frameId: unknown = null;
|
|
52
|
+
private last: readonly string[] | null = null;
|
|
53
|
+
/** The snapshot before the most recent change (for describeBusy). */
|
|
54
|
+
private beforeChange: readonly string[] | null = null;
|
|
55
|
+
/** Consecutive sampled frames with the same snapshot, including the first. */
|
|
56
|
+
private same = 0;
|
|
57
|
+
private sampled = 0;
|
|
58
|
+
private startedAt = 0;
|
|
59
|
+
|
|
60
|
+
constructor(private readonly options: FrameStableTrackerOptions) {
|
|
61
|
+
this.frames = Math.max(1, options.frames ?? SyncDefaults.stableFrames);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setFrames(frames: number): void {
|
|
65
|
+
this.frames = Math.max(1, frames);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isIdle(): boolean {
|
|
69
|
+
return this.waiting === 0 || this.same >= this.frames;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describeBusy(): string[] {
|
|
73
|
+
if (this.isIdle()) return [];
|
|
74
|
+
if (this.sampled === 0) return ['no frame rendered yet (is the app in the foreground?)'];
|
|
75
|
+
const elapsed = this.now() - this.startedAt;
|
|
76
|
+
const lines = [`element tree changing: stable for ${this.same}/${this.frames} frames (${this.sampled} frames sampled in ${elapsed}ms)`];
|
|
77
|
+
if (this.beforeChange !== null && this.last !== null) lines.push(`last change: ${describeChange(this.beforeChange, this.last)}`);
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
install(): void {}
|
|
82
|
+
|
|
83
|
+
uninstall(): void {
|
|
84
|
+
this.waiting = 0;
|
|
85
|
+
this.stop();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
beginWait(): void {
|
|
89
|
+
if (this.waiting++ > 0) return;
|
|
90
|
+
this.last = null;
|
|
91
|
+
this.beforeChange = null;
|
|
92
|
+
this.same = 0;
|
|
93
|
+
this.sampled = 0;
|
|
94
|
+
this.startedAt = this.now();
|
|
95
|
+
this.schedule();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
endWait(): void {
|
|
99
|
+
if (this.waiting === 0 || --this.waiting > 0) return;
|
|
100
|
+
this.stop();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private now(): number {
|
|
104
|
+
return (this.options.now ?? Date.now)();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private schedule(): void {
|
|
108
|
+
const raf = this.options.requestFrame ?? (globalThis as { requestAnimationFrame?: (cb: () => void) => unknown }).requestAnimationFrame;
|
|
109
|
+
if (raf === undefined) return;
|
|
110
|
+
this.frameId = raf(() => {
|
|
111
|
+
this.frameId = null;
|
|
112
|
+
if (this.waiting === 0) return;
|
|
113
|
+
this.sample();
|
|
114
|
+
this.schedule();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private sample(): void {
|
|
119
|
+
let snap: readonly string[];
|
|
120
|
+
try {
|
|
121
|
+
snap = this.options.snapshot();
|
|
122
|
+
} catch (e) {
|
|
123
|
+
snap = [`error: ${String(e)}`];
|
|
124
|
+
}
|
|
125
|
+
this.sampled++;
|
|
126
|
+
if (this.last !== null && sameLines(snap, this.last)) {
|
|
127
|
+
this.same++;
|
|
128
|
+
} else {
|
|
129
|
+
if (this.last !== null) this.beforeChange = this.last;
|
|
130
|
+
this.same = 1;
|
|
131
|
+
}
|
|
132
|
+
this.last = snap;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private stop(): void {
|
|
136
|
+
if (this.frameId !== null) {
|
|
137
|
+
const cancel = this.options.cancelFrame ?? (globalThis as { cancelAnimationFrame?: (id: unknown) => void }).cancelAnimationFrame;
|
|
138
|
+
cancel?.(this.frameId);
|
|
139
|
+
this.frameId = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending idle-callback tracker (requirements.md §6.2-4, tracker name `idle-callback`):
|
|
3
|
+
* - `requestIdleCallback` callbacks not yet run (RN 0.88 removed InteractionManager and recommends it).
|
|
4
|
+
* - On older React Native versions that still have `InteractionManager`, its open interaction
|
|
5
|
+
* handles and not-yet-run `runAfterInteractions` tasks.
|
|
6
|
+
*/
|
|
7
|
+
import type { IdleTracker } from './tracker';
|
|
8
|
+
import { isFunction, patch } from './tracker';
|
|
9
|
+
|
|
10
|
+
type RicFn = (this: unknown, cb: unknown, options?: unknown) => unknown;
|
|
11
|
+
type CancelFn = (this: unknown, id: unknown) => void;
|
|
12
|
+
|
|
13
|
+
export interface IdleCallbackGlobals {
|
|
14
|
+
requestIdleCallback?: RicFn;
|
|
15
|
+
cancelIdleCallback?: CancelFn;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The parts of the legacy `InteractionManager` API that are tracked. */
|
|
19
|
+
export interface InteractionManagerLike {
|
|
20
|
+
createInteractionHandle: (this: unknown) => unknown;
|
|
21
|
+
clearInteractionHandle: (this: unknown, handle: unknown) => void;
|
|
22
|
+
runAfterInteractions: (this: unknown, task?: unknown) => { then?: unknown; cancel?: unknown } | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IdleCallbackTrackerOptions {
|
|
26
|
+
target?: IdleCallbackGlobals;
|
|
27
|
+
/** Pass React Native's `InteractionManager` when it exists (RN < 0.88). */
|
|
28
|
+
interactionManager?: InteractionManagerLike | null;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class IdleCallbackTracker implements IdleTracker {
|
|
33
|
+
readonly name = 'idle-callback';
|
|
34
|
+
private readonly callbacks = new Map<unknown, { since: number; name: string }>();
|
|
35
|
+
private readonly handles = new Set<unknown>();
|
|
36
|
+
private readonly tasks = new Map<number, number>();
|
|
37
|
+
private readonly restores: (() => void)[] = [];
|
|
38
|
+
private readonly target: IdleCallbackGlobals;
|
|
39
|
+
private readonly now: () => number;
|
|
40
|
+
private nextTask = 1;
|
|
41
|
+
private readonly attached = new Set<InteractionManagerLike>();
|
|
42
|
+
|
|
43
|
+
constructor(private readonly options: IdleCallbackTrackerOptions = {}) {
|
|
44
|
+
this.target = options.target ?? (globalThis as unknown as IdleCallbackGlobals);
|
|
45
|
+
this.now = options.now ?? Date.now;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
isIdle(): boolean {
|
|
49
|
+
return this.callbacks.size === 0 && this.handles.size === 0 && this.tasks.size === 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describeBusy(): string[] {
|
|
53
|
+
const t = this.now();
|
|
54
|
+
const out = [...this.callbacks.values()].map((c) => `requestIdleCallback(${c.name}) ${t - c.since}ms`);
|
|
55
|
+
if (this.handles.size > 0) out.push(`InteractionManager: ${this.handles.size} open interaction handle(s)`);
|
|
56
|
+
for (const since of this.tasks.values()) out.push(`InteractionManager.runAfterInteractions task ${t - since}ms`);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
install(): void {
|
|
61
|
+
if (this.restores.length > 0) return;
|
|
62
|
+
const self = this;
|
|
63
|
+
const g = this.target;
|
|
64
|
+
if (isFunction(g.requestIdleCallback) && isFunction(g.cancelIdleCallback)) {
|
|
65
|
+
const tg = g as Required<IdleCallbackGlobals>;
|
|
66
|
+
this.restores.push(
|
|
67
|
+
patch(tg, 'requestIdleCallback', (original) =>
|
|
68
|
+
function (this: unknown, cb: unknown, options?: unknown) {
|
|
69
|
+
if (!isFunction(cb)) return original.call(this, cb, options);
|
|
70
|
+
const wrapped = function (this: unknown, ...a: unknown[]) {
|
|
71
|
+
self.callbacks.delete(id);
|
|
72
|
+
return (cb as (...x: unknown[]) => unknown).apply(this, a);
|
|
73
|
+
};
|
|
74
|
+
const id: unknown = original.call(this, wrapped, options);
|
|
75
|
+
self.callbacks.set(id, { since: self.now(), name: cb.name || 'anonymous' });
|
|
76
|
+
return id;
|
|
77
|
+
},
|
|
78
|
+
),
|
|
79
|
+
patch(tg, 'cancelIdleCallback', (original) =>
|
|
80
|
+
function (this: unknown, id: unknown) {
|
|
81
|
+
self.callbacks.delete(id);
|
|
82
|
+
return original.call(this, id);
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (this.options.interactionManager != null) this.attachInteractionManager(this.options.interactionManager);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Track a legacy InteractionManager. Separate from install() so the runtime can look it up after the
|
|
92
|
+
* app has started (requiring react-native at agent import time would be too early).
|
|
93
|
+
*/
|
|
94
|
+
attachInteractionManager(im: InteractionManagerLike): void {
|
|
95
|
+
if (this.attached.has(im)) return;
|
|
96
|
+
this.attached.add(im);
|
|
97
|
+
const self = this;
|
|
98
|
+
this.restores.push(
|
|
99
|
+
patch(im, 'createInteractionHandle', (original) =>
|
|
100
|
+
function (this: unknown) {
|
|
101
|
+
const h = original.call(this);
|
|
102
|
+
self.handles.add(h);
|
|
103
|
+
return h;
|
|
104
|
+
},
|
|
105
|
+
),
|
|
106
|
+
patch(im, 'clearInteractionHandle', (original) =>
|
|
107
|
+
function (this: unknown, h: unknown) {
|
|
108
|
+
self.handles.delete(h);
|
|
109
|
+
return original.call(this, h);
|
|
110
|
+
},
|
|
111
|
+
),
|
|
112
|
+
patch(im, 'runAfterInteractions', (original) =>
|
|
113
|
+
function (this: unknown, task?: unknown) {
|
|
114
|
+
const result = original.call(this, task);
|
|
115
|
+
const id = self.nextTask++;
|
|
116
|
+
self.tasks.set(id, self.now());
|
|
117
|
+
const done = (): void => {
|
|
118
|
+
self.tasks.delete(id);
|
|
119
|
+
};
|
|
120
|
+
if (result != null && isFunction(result.then)) (result.then as (a: () => void, b: () => void) => unknown).call(result, done, done);
|
|
121
|
+
else done();
|
|
122
|
+
if (result != null && isFunction(result.cancel)) {
|
|
123
|
+
const cancel = result.cancel;
|
|
124
|
+
result.cancel = function (this: unknown, ...a: never[]) {
|
|
125
|
+
done();
|
|
126
|
+
return cancel.apply(this, a);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
uninstall(): void {
|
|
136
|
+
while (this.restores.length > 0) this.restores.pop()?.();
|
|
137
|
+
this.attached.clear();
|
|
138
|
+
this.callbacks.clear();
|
|
139
|
+
this.handles.clear();
|
|
140
|
+
this.tasks.clear();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type { Clock, IdleTracker } from './tracker';
|
|
2
|
+
export { realClock } from './tracker';
|
|
3
|
+
export { NetworkTracker } from './network';
|
|
4
|
+
export type { FetchFn, NetworkTrackerOptions, UrlPattern } from './network';
|
|
5
|
+
export { TimerTracker } from './timer';
|
|
6
|
+
export type { TimerGlobals, TimerTrackerOptions } from './timer';
|
|
7
|
+
export { ReactTracker } from './react';
|
|
8
|
+
export type { ReactWorkSource } from './react';
|
|
9
|
+
export { IdleCallbackTracker } from './idle-callback';
|
|
10
|
+
export type { IdleCallbackTrackerOptions, InteractionManagerLike } from './idle-callback';
|
|
11
|
+
export { FrameStableTracker } from './frame-stable';
|
|
12
|
+
export type { FrameStableTrackerOptions } from './frame-stable';
|
|
13
|
+
export { SyncEngine } from './wait-for-idle';
|
|
14
|
+
export type { WaitForIdleOptions } from './wait-for-idle';
|
|
15
|
+
export { NativeSettleTracker } from './native-settle';
|
|
16
|
+
export type { NativeSettleSource } from './native-settle';
|
|
17
|
+
export { OfflineGate, ownConnection } from './offline';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native settle tracker (tracker name `native`): native UI that JS cannot see is still moving —
|
|
3
|
+
* a view controller transition (navigation push / pop, Modal presentation), the software keyboard
|
|
4
|
+
* moving or just used. Read synchronously from @quiesco/native (`idleState`, sampled every frame on
|
|
5
|
+
* the UI thread). Present only when the native module offers it (iOS); otherwise always idle.
|
|
6
|
+
*
|
|
7
|
+
* Why: UIKit drops a navigation update or a Modal presentation that arrives during a transition, and
|
|
8
|
+
* touches during keyboard activity; the JS-side trackers report idle meanwhile (Phase 4.5 findings).
|
|
9
|
+
*/
|
|
10
|
+
import type { IdleTracker } from './tracker';
|
|
11
|
+
|
|
12
|
+
export interface NativeSettleSource {
|
|
13
|
+
idleState?(): { settling: boolean; reasons: string[] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class NativeSettleTracker implements IdleTracker {
|
|
17
|
+
readonly name = 'native';
|
|
18
|
+
|
|
19
|
+
/** `source` is resolved lazily: the native module may load after the trackers are created. */
|
|
20
|
+
constructor(private readonly source: () => NativeSettleSource | null) {}
|
|
21
|
+
|
|
22
|
+
private state(): { settling: boolean; reasons: string[] } | null {
|
|
23
|
+
const s = this.source();
|
|
24
|
+
if (s === null || typeof s.idleState !== 'function') return null;
|
|
25
|
+
return s.idleState();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
isIdle(): boolean {
|
|
29
|
+
return this.state()?.settling !== true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describeBusy(): string[] {
|
|
33
|
+
const s = this.state();
|
|
34
|
+
if (s === null || !s.settling) return [];
|
|
35
|
+
return s.reasons.length > 0 ? s.reasons : ['native UI settling'];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
install(): void {}
|
|
39
|
+
|
|
40
|
+
uninstall(): void {}
|
|
41
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-flight network tracker (requirements.md §6.2-1; Phase 6.10 hardening, findings「6.10 网络同步 spike」).
|
|
3
|
+
*
|
|
4
|
+
* - `fetch` (the global one, and `expo/fetch` imported directly through `wrapFetch`, see
|
|
5
|
+
* src/expo-fetch.js): busy until the response arrives, then — rule A (HU 2026-11-10) — until the body is
|
|
6
|
+
* read: a body read (`json()` / `text()` / …) started before the next macrotask keeps the request busy
|
|
7
|
+
* until it settles; a response nobody reads ends there. A body read started later counts again, as its
|
|
8
|
+
* own entry. `expo/fetch` (the native global fetch since SDK 56) resolves on the headers and streams the
|
|
9
|
+
* body, so without this the app looked idle while `await res.json()` was still waiting.
|
|
10
|
+
* - `XMLHttpRequest`: until `loadend`. On React Native's whatwg-fetch, XHRs sent synchronously from
|
|
11
|
+
* inside a tracked `fetch()` call are not counted a second time.
|
|
12
|
+
* - `WebSocket` / `EventSource`: only while connecting (until open, error or close); an open connection is
|
|
13
|
+
* long-lived and never counts.
|
|
14
|
+
*
|
|
15
|
+
* `ignoreUrls` and the exempt predicate (the agent's own connection) apply to all of them.
|
|
16
|
+
*/
|
|
17
|
+
import type { IdleTracker } from './tracker';
|
|
18
|
+
import { isFunction, patch, realClock } from './tracker';
|
|
19
|
+
|
|
20
|
+
export type UrlPattern = RegExp | string;
|
|
21
|
+
|
|
22
|
+
interface XhrLike {
|
|
23
|
+
addEventListener(type: string, listener: () => void): void;
|
|
24
|
+
}
|
|
25
|
+
interface XhrCtor {
|
|
26
|
+
prototype: {
|
|
27
|
+
open: (this: XhrLike, method: string, url: string | URL, ...rest: unknown[]) => unknown;
|
|
28
|
+
send: (this: XhrLike, body?: unknown) => unknown;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export type FetchFn = (this: unknown, input: unknown, init?: unknown) => Promise<unknown>;
|
|
32
|
+
type SocketCtor = new (url: string | URL, ...rest: unknown[]) => { addEventListener(type: string, l: () => void): void };
|
|
33
|
+
|
|
34
|
+
export interface NetworkGlobals {
|
|
35
|
+
fetch?: FetchFn;
|
|
36
|
+
XMLHttpRequest?: XhrCtor;
|
|
37
|
+
WebSocket?: SocketCtor;
|
|
38
|
+
EventSource?: SocketCtor;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NetworkTrackerOptions {
|
|
42
|
+
target?: NetworkGlobals;
|
|
43
|
+
/** Requests whose URL matches are ignored (long polling, SSE, analytics). Strings match as substrings. */
|
|
44
|
+
ignoreUrls?: readonly UrlPattern[];
|
|
45
|
+
now?: () => number;
|
|
46
|
+
/** Runs `cb` on the next macrotask with an untracked timer (rule A's window for starting a body read). */
|
|
47
|
+
defer?: (cb: () => void) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Request {
|
|
51
|
+
method: string;
|
|
52
|
+
url: string;
|
|
53
|
+
since: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Response methods that read the body. */
|
|
57
|
+
const BODY_READERS = ['json', 'text', 'arrayBuffer', 'blob', 'bytes', 'formData'] as const;
|
|
58
|
+
const PATCHED = Symbol.for('quiesco.trackedResponse');
|
|
59
|
+
|
|
60
|
+
function urlOf(input: unknown): string {
|
|
61
|
+
if (typeof input === 'string') return input;
|
|
62
|
+
if (typeof input === 'object' && input !== null) {
|
|
63
|
+
const u = (input as { url?: unknown; href?: unknown }).url ?? (input as { href?: unknown }).href;
|
|
64
|
+
if (typeof u === 'string') return u;
|
|
65
|
+
}
|
|
66
|
+
return String(input);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function methodOf(input: unknown, init: unknown): string {
|
|
70
|
+
const m = (init as { method?: unknown } | undefined)?.method ?? (input as { method?: unknown } | null)?.method;
|
|
71
|
+
return typeof m === 'string' ? m.toUpperCase() : 'GET';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class NetworkTracker implements IdleTracker {
|
|
75
|
+
readonly name = 'network';
|
|
76
|
+
private readonly pending = new Map<number, Request>();
|
|
77
|
+
private readonly xhrInfo = new WeakMap<object, { method: string; url: string }>();
|
|
78
|
+
private readonly restores: (() => void)[] = [];
|
|
79
|
+
private nextId = 1;
|
|
80
|
+
private insideFetch = 0;
|
|
81
|
+
private readonly target: NetworkGlobals;
|
|
82
|
+
private readonly now: () => number;
|
|
83
|
+
private readonly defer: (cb: () => void) => void;
|
|
84
|
+
private ignoreUrls: readonly UrlPattern[];
|
|
85
|
+
private exempt: (url: string) => boolean = () => false;
|
|
86
|
+
|
|
87
|
+
constructor(options: NetworkTrackerOptions = {}) {
|
|
88
|
+
this.target = options.target ?? (globalThis as unknown as NetworkGlobals);
|
|
89
|
+
this.now = options.now ?? Date.now;
|
|
90
|
+
this.defer = options.defer ?? ((cb) => void realClock.setTimeout(cb, 0));
|
|
91
|
+
this.ignoreUrls = options.ignoreUrls ?? [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Applies to requests started from now on. */
|
|
95
|
+
setIgnoreUrls(patterns: readonly UrlPattern[]): void {
|
|
96
|
+
this.ignoreUrls = patterns;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Connections never tracked (the agent's own WebSocket). */
|
|
100
|
+
setExempt(exempt: (url: string) => boolean): void {
|
|
101
|
+
this.exempt = exempt;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
isIdle(): boolean {
|
|
105
|
+
return this.pending.size === 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describeBusy(): string[] {
|
|
109
|
+
const t = this.now();
|
|
110
|
+
return [...this.pending.values()].map((r) => `${r.method} ${r.url} (${t - r.since}ms)`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A fetch function that is tracked like the global one (used for `expo/fetch` imported directly). */
|
|
114
|
+
wrapFetch(original: FetchFn): FetchFn {
|
|
115
|
+
const self = this;
|
|
116
|
+
return function (this: unknown, input: unknown, init?: unknown) {
|
|
117
|
+
const url = urlOf(input);
|
|
118
|
+
if (self.ignored(url)) return original.call(this, input, init);
|
|
119
|
+
const method = methodOf(input, init);
|
|
120
|
+
const id = self.begin(method, url);
|
|
121
|
+
let p: Promise<unknown>;
|
|
122
|
+
self.insideFetch++;
|
|
123
|
+
try {
|
|
124
|
+
p = original.call(this, input, init);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
self.end(id);
|
|
127
|
+
throw e;
|
|
128
|
+
} finally {
|
|
129
|
+
self.insideFetch--;
|
|
130
|
+
}
|
|
131
|
+
// Registered before the caller's handlers, so the response is wrapped before the app sees it.
|
|
132
|
+
p.then(
|
|
133
|
+
(res) => self.awaitBody(id, method, url, res),
|
|
134
|
+
() => self.end(id),
|
|
135
|
+
);
|
|
136
|
+
return p;
|
|
137
|
+
} as FetchFn;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
install(): void {
|
|
141
|
+
if (this.restores.length > 0) return;
|
|
142
|
+
const self = this;
|
|
143
|
+
if (isFunction(this.target.fetch)) {
|
|
144
|
+
this.restores.push(patch(this.target, 'fetch', (original) => this.wrapFetch(original as FetchFn)));
|
|
145
|
+
}
|
|
146
|
+
const proto = this.target.XMLHttpRequest?.prototype;
|
|
147
|
+
if (proto !== undefined) {
|
|
148
|
+
this.restores.push(
|
|
149
|
+
patch(proto, 'open', (original) =>
|
|
150
|
+
function (this: XhrLike, method: string, url: string | URL, ...rest: unknown[]) {
|
|
151
|
+
self.xhrInfo.set(this, { method: String(method).toUpperCase(), url: urlOf(url) });
|
|
152
|
+
return original.call(this, method, url, ...rest);
|
|
153
|
+
},
|
|
154
|
+
),
|
|
155
|
+
patch(proto, 'send', (original) =>
|
|
156
|
+
function (this: XhrLike, body?: unknown) {
|
|
157
|
+
const info = self.xhrInfo.get(this);
|
|
158
|
+
if (info === undefined || self.insideFetch > 0 || self.ignored(info.url)) return original.call(this, body);
|
|
159
|
+
const id = self.begin(info.method, info.url);
|
|
160
|
+
this.addEventListener('loadend', () => self.end(id));
|
|
161
|
+
try {
|
|
162
|
+
return original.call(this, body);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
self.end(id);
|
|
165
|
+
throw e;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
this.trackConnecting('WebSocket', 'WS');
|
|
172
|
+
this.trackConnecting('EventSource', 'SSE');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
uninstall(): void {
|
|
176
|
+
while (this.restores.length > 0) this.restores.pop()?.();
|
|
177
|
+
this.pending.clear();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A subclass that counts the connection until it opens, fails or closes. Subclassing keeps
|
|
182
|
+
* `instanceof`, the static constants and the app's own handlers.
|
|
183
|
+
*/
|
|
184
|
+
private trackConnecting(key: 'WebSocket' | 'EventSource', label: string): void {
|
|
185
|
+
const Base = this.target[key];
|
|
186
|
+
if (!isFunction(Base)) return;
|
|
187
|
+
const self = this;
|
|
188
|
+
const Tracked = class extends Base {
|
|
189
|
+
constructor(url: string | URL, ...rest: unknown[]) {
|
|
190
|
+
super(url, ...rest);
|
|
191
|
+
const u = urlOf(url);
|
|
192
|
+
if (self.ignored(u)) return;
|
|
193
|
+
const id = self.begin(label, u);
|
|
194
|
+
const done = (): void => self.end(id);
|
|
195
|
+
try {
|
|
196
|
+
for (const type of ['open', 'error', 'close']) this.addEventListener(type, done);
|
|
197
|
+
} catch {
|
|
198
|
+
done(); // no event API: never leave the app busy
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
Object.defineProperty(Tracked, 'name', { value: Base.name });
|
|
203
|
+
this.restores.push(patch(this.target, key, () => Tracked as SocketCtor));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Rule A: keep the request busy until the next macrotask; a body read started by then keeps it busy
|
|
208
|
+
* until the read settles. Reads started later are tracked on their own.
|
|
209
|
+
*/
|
|
210
|
+
private awaitBody(id: number, method: string, url: string, res: unknown): void {
|
|
211
|
+
let reading = false;
|
|
212
|
+
const patched = this.wrapBody(res, method, url, () => {
|
|
213
|
+
if (reading || !this.pending.has(id)) return null;
|
|
214
|
+
reading = true;
|
|
215
|
+
return id;
|
|
216
|
+
});
|
|
217
|
+
if (!patched) {
|
|
218
|
+
this.end(id);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.defer(() => {
|
|
222
|
+
if (!reading) this.end(id);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Wraps the body readers of a response (and of its clones). `claim()` returns the pending request id
|
|
228
|
+
* the read continues, or null to track the read as a new entry. False when nothing could be wrapped.
|
|
229
|
+
*/
|
|
230
|
+
private wrapBody(res: unknown, method: string, url: string, claim: () => number | null): boolean {
|
|
231
|
+
if (typeof res !== 'object' || res === null) return false;
|
|
232
|
+
const r = res as Record<string | symbol, unknown>;
|
|
233
|
+
if (r[PATCHED] === true) return true;
|
|
234
|
+
const self = this;
|
|
235
|
+
let wrapped = false;
|
|
236
|
+
// whatwg-fetch's json() calls this.text(): a read made from inside another one is the same read.
|
|
237
|
+
let inside = 0;
|
|
238
|
+
try {
|
|
239
|
+
for (const m of BODY_READERS) {
|
|
240
|
+
const read = r[m];
|
|
241
|
+
if (!isFunction(read)) continue;
|
|
242
|
+
r[m] = function (this: unknown, ...args: unknown[]) {
|
|
243
|
+
if (inside > 0) return (read as (...a: unknown[]) => unknown).apply(this, args);
|
|
244
|
+
const id = claim() ?? self.begin(method, `${url} (body)`);
|
|
245
|
+
let p: unknown;
|
|
246
|
+
inside++;
|
|
247
|
+
try {
|
|
248
|
+
p = (read as (...a: unknown[]) => unknown).apply(this, args);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
self.end(id);
|
|
251
|
+
throw e;
|
|
252
|
+
} finally {
|
|
253
|
+
inside--;
|
|
254
|
+
}
|
|
255
|
+
if (p instanceof Promise || (typeof p === 'object' && p !== null && isFunction((p as { then?: unknown }).then))) {
|
|
256
|
+
(p as Promise<unknown>).then(
|
|
257
|
+
() => self.end(id),
|
|
258
|
+
() => self.end(id),
|
|
259
|
+
);
|
|
260
|
+
} else {
|
|
261
|
+
self.end(id);
|
|
262
|
+
}
|
|
263
|
+
return p;
|
|
264
|
+
};
|
|
265
|
+
wrapped = true;
|
|
266
|
+
}
|
|
267
|
+
const clone = r['clone'];
|
|
268
|
+
if (isFunction(clone)) {
|
|
269
|
+
r['clone'] = function (this: unknown, ...args: unknown[]) {
|
|
270
|
+
const c = (clone as (...a: unknown[]) => unknown).apply(this, args);
|
|
271
|
+
self.wrapBody(c, method, url, () => null);
|
|
272
|
+
return c;
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
Object.defineProperty(r, PATCHED, { value: true });
|
|
276
|
+
} catch {
|
|
277
|
+
// Frozen or exotic response: fall back to ending at the headers.
|
|
278
|
+
}
|
|
279
|
+
return wrapped;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private ignored(url: string): boolean {
|
|
283
|
+
if (this.exempt(url)) return true;
|
|
284
|
+
for (const p of this.ignoreUrls) {
|
|
285
|
+
if (typeof p === 'string' ? url.includes(p) : ((p.lastIndex = 0), p.test(url))) return true;
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private begin(method: string, url: string): number {
|
|
291
|
+
const id = this.nextId++;
|
|
292
|
+
this.pending.set(id, { method, url, since: this.now() });
|
|
293
|
+
return id;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private end(id: number): void {
|
|
297
|
+
this.pending.delete(id);
|
|
298
|
+
}
|
|
299
|
+
}
|