Skip to content

Commit 70bd87b

Browse files
authored
perf: smart-wait — waitForCapture, wait({ selector }), daemon backoff
- waitForCapture(): polls window.__opencli_xhr instead of DOM-stable; fixes INTERCEPT adapters returning empty after smart-wait refactor - wait({ selector }): MutationObserver-based wait; resolves instantly on element insertion - CDPPage.wait(N): smart DOM-stable wait (matches Page.wait behavior) - Daemon cold-start: exponential backoff [50..3000ms] - README: simplified to 50-line overview
1 parent ea0cf4d commit 70bd87b

41 files changed

Lines changed: 1562 additions & 400 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 21 additions & 362 deletions
Large diffs are not rendered by default.

docs/superpowers/plans/2026-03-28-perf-smart-wait.md

Lines changed: 1143 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Performance: Smart Wait & INTERCEPT Fix
2+
3+
**Date**: 2026-03-28
4+
**Status**: Approved
5+
6+
## Problem
7+
8+
Three distinct performance/correctness issues:
9+
10+
1. **INTERCEPT strategy semantic bug**: After `installInterceptor()` + `goto()`, adapters call `wait(N)` — which now uses `waitForDomStableJs` and returns early when the DOM settles. But DOM-settle != network capture. The API response may arrive *after* DOM is stable, causing `getInterceptedRequests()` to return an empty array.
11+
12+
2. **Blind `wait(N)` in adapters**: ~30 high-traffic adapters (Twitter family, Medium, Substack, etc.) call `wait(5)` waiting for React/Vue to hydrate. These should wait for a specific DOM element to appear, not a fixed cap.
13+
14+
3. **Daemon cold-start polling**: Fixed 300ms poll loop means ~600ms before first successful `isExtensionConnected()` check, even though the daemon is typically ready in 500–800ms.
15+
16+
## Design
17+
18+
### Layer 1 — `waitForCapture()` (correctness fix + perf)
19+
20+
Add `waitForCapture(timeout?: number): Promise<void>` to `IPage`.
21+
22+
Polls `window.__opencli_xhr.length > 0` every 100ms inside the browser tab. Resolves as soon as ≥1 capture arrives; rejects after `timeout` seconds.
23+
24+
```typescript
25+
// dom-helpers.ts
26+
export function waitForCaptureJs(maxMs: number): string {
27+
return `
28+
new Promise((resolve, reject) => {
29+
const deadline = Date.now() + ${maxMs};
30+
const check = () => {
31+
if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
32+
if (Date.now() > deadline) return reject(new Error('No capture within ${maxMs / 1000}s'));
33+
setTimeout(check, 100);
34+
};
35+
check();
36+
})
37+
`;
38+
}
39+
```
40+
41+
`page.ts` and `cdp.ts` implement `waitForCapture()` by calling `waitForCaptureJs`.
42+
43+
**All INTERCEPT adapters** replace `wait(N)``waitForCapture(N+2)` (slightly longer timeout as safety margin).
44+
45+
`stepIntercept` in `pipeline/steps/intercept.ts` replaces its internal `wait(timeout)` with `waitForCapture(timeout)`.
46+
47+
**Expected gain**: 36kr hot/search: 6s → ~1–2s. Twitter search/followers: 5–8s → ~1–3s.
48+
49+
### Layer 2 — `wait({ selector })` (semantic precision)
50+
51+
Extend `WaitOptions` with `selector?: string`.
52+
53+
Add `waitForSelectorJs(selector, timeoutMs)` to `dom-helpers.ts` — polls `document.querySelector(selector)` every 100ms, resolves on first match, rejects on timeout.
54+
55+
```typescript
56+
// types.ts
57+
export interface WaitOptions {
58+
text?: string;
59+
selector?: string; // NEW
60+
time?: number;
61+
timeout?: number;
62+
}
63+
```
64+
65+
```typescript
66+
// dom-helpers.ts
67+
export function waitForSelectorJs(selector: string, timeoutMs: number): string {
68+
return `
69+
new Promise((resolve, reject) => {
70+
const deadline = Date.now() + ${timeoutMs};
71+
const check = () => {
72+
if (document.querySelector(${JSON.stringify(selector)})) return resolve('found');
73+
if (Date.now() > deadline) return reject(new Error('Selector not found: ' + ${JSON.stringify(selector)}));
74+
setTimeout(check, 100);
75+
};
76+
check();
77+
})
78+
`;
79+
}
80+
```
81+
82+
`page.ts` and `cdp.ts` handle `selector` branch in `wait()`.
83+
84+
**High-impact adapter changes**:
85+
86+
| Adapter | Old | New |
87+
|---------|-----|-----|
88+
| `twitter/*` (15 adapters) | `wait(5)` | `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
89+
| `twitter/reply.ts` | `wait(5)` | `wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 8 })` |
90+
| `medium/utils.ts` | `wait(5)` + inline 3s setTimeout | `wait({ selector: 'article', timeout: 8 })` + remove inline sleep |
91+
| `substack/utils.ts` | `wait(5)` × 2 | `wait({ selector: 'article', timeout: 8 })` |
92+
| `bloomberg/news.ts` | `wait(5)` | `wait({ selector: 'article', timeout: 6 })` |
93+
| `sinablog/utils.ts` | `wait(5)` | `wait({ selector: 'article, .article', timeout: 6 })` |
94+
| `producthunt` (already covered by layer 1) |||
95+
96+
**Expected gain**: Twitter commands: 5s → ~0.5–2s. Medium: 8s → ~1–3s.
97+
98+
### Layer 3 — Daemon exponential backoff (cold-start)
99+
100+
Replace fixed 300ms poll in `_ensureDaemon()` (`browser/mcp.ts`) with exponential backoff:
101+
102+
```typescript
103+
// before
104+
while (Date.now() < deadline) {
105+
await new Promise(resolve => setTimeout(resolve, 300));
106+
if (await isExtensionConnected()) return;
107+
}
108+
109+
// after
110+
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
111+
let i = 0;
112+
while (Date.now() < deadline) {
113+
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i++, backoffs.length - 1)]));
114+
if (await isExtensionConnected()) return;
115+
}
116+
```
117+
118+
**Expected gain**: First cold-start check succeeds at ~150ms instead of ~600ms.
119+
120+
## Files Changed
121+
122+
### New / Modified (framework)
123+
- `src/types.ts``WaitOptions.selector`, `IPage.waitForCapture()`
124+
- `src/browser/dom-helpers.ts``waitForCaptureJs()`, `waitForSelectorJs()`
125+
- `src/browser/page.ts``waitForCapture()`, `wait()` selector branch
126+
- `src/browser/cdp.ts``waitForCapture()`, `wait()` selector branch
127+
- `src/browser/mcp.ts` — exponential backoff in `_ensureDaemon()`
128+
- `src/pipeline/steps/intercept.ts` — use `waitForCapture()`
129+
130+
### Modified (adapters — Layer 1, INTERCEPT)
131+
- `src/clis/36kr/hot.ts`
132+
- `src/clis/36kr/search.ts`
133+
- `src/clis/twitter/search.ts`
134+
- `src/clis/twitter/followers.ts`
135+
- `src/clis/twitter/following.ts`
136+
- `src/clis/producthunt/hot.ts`
137+
- `src/clis/producthunt/browse.ts`
138+
139+
### Modified (adapters — Layer 2, selector)
140+
- `src/clis/twitter/reply.ts`
141+
- `src/clis/twitter/follow.ts`
142+
- `src/clis/twitter/unfollow.ts`
143+
- `src/clis/twitter/like.ts`
144+
- `src/clis/twitter/bookmark.ts`
145+
- `src/clis/twitter/unbookmark.ts`
146+
- `src/clis/twitter/block.ts`
147+
- `src/clis/twitter/unblock.ts`
148+
- `src/clis/twitter/hide-reply.ts`
149+
- `src/clis/twitter/notifications.ts`
150+
- `src/clis/twitter/profile.ts`
151+
- `src/clis/twitter/thread.ts`
152+
- `src/clis/twitter/timeline.ts`
153+
- `src/clis/twitter/delete.ts`
154+
- `src/clis/twitter/reply-dm.ts`
155+
- `src/clis/medium/utils.ts`
156+
- `src/clis/substack/utils.ts`
157+
- `src/clis/bloomberg/news.ts`
158+
- `src/clis/sinablog/utils.ts`
159+
160+
## Delivery Order
161+
162+
1. Layer 1 (`waitForCapture`) — correctness fix, highest ROI
163+
2. Layer 3 (backoff) — 3-line change, zero risk
164+
3. Layer 2 (`wait({ selector })`) — largest adapter surface, can be done per-site
165+
166+
## Testing
167+
168+
- Unit tests: `waitForCaptureJs`, `waitForSelectorJs` exported and tested in `dom-helpers.test.ts` (if exists) or new test file
169+
- Adapter tests: existing tests must continue to pass (mock `page.wait` / `page.waitForCapture`)
170+
- Run: `npx vitest run --project unit --project adapter`

src/browser/cdp.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
autoScrollJs,
2626
networkRequestsJs,
2727
waitForDomStableJs,
28+
waitForCaptureJs,
29+
waitForSelectorJs,
2830
} from './dom-helpers.js';
2931
import { isRecord, saveBase64ToFile } from '../utils.js';
3032

@@ -247,6 +249,15 @@ class CDPPage implements IPage {
247249

248250
async wait(options: number | WaitOptions): Promise<void> {
249251
if (typeof options === 'number') {
252+
if (options >= 1) {
253+
try {
254+
const maxMs = options * 1000;
255+
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
256+
return;
257+
} catch {
258+
// Fallback: fixed sleep
259+
}
260+
}
250261
await new Promise((resolve) => setTimeout(resolve, options * 1000));
251262
return;
252263
}
@@ -255,6 +266,11 @@ class CDPPage implements IPage {
255266
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
256267
return;
257268
}
269+
if (options.selector) {
270+
const timeout = (options.timeout ?? 10) * 1000;
271+
await this.evaluate(waitForSelectorJs(options.selector, timeout));
272+
return;
273+
}
258274
if (options.text) {
259275
const timeout = (options.timeout ?? 30) * 1000;
260276
await this.evaluate(waitForTextJs(options.text, timeout));
@@ -326,6 +342,11 @@ class CDPPage implements IPage {
326342
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
327343
return Array.isArray(result) ? result : [];
328344
}
345+
346+
async waitForCapture(timeout: number = 10): Promise<void> {
347+
const maxMs = timeout * 1000;
348+
await this.evaluate(waitForCaptureJs(maxMs));
349+
}
329350
}
330351

331352
function isCookie(value: unknown): value is BrowserCookie {

src/browser/dom-helpers.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
3+
4+
describe('waitForCaptureJs', () => {
5+
it('returns a non-empty string', () => {
6+
const code = waitForCaptureJs(1000);
7+
expect(typeof code).toBe('string');
8+
expect(code.length).toBeGreaterThan(0);
9+
expect(code).toContain('__opencli_xhr');
10+
expect(code).toContain('resolve');
11+
expect(code).toContain('reject');
12+
});
13+
14+
it('resolves "captured" when __opencli_xhr is populated before deadline', async () => {
15+
const g = globalThis as any;
16+
g.__opencli_xhr = [];
17+
g.window = g; // stub window for Node eval
18+
const code = waitForCaptureJs(1000);
19+
const promise = eval(code) as Promise<string>;
20+
g.__opencli_xhr.push({ data: 'test' });
21+
await expect(promise).resolves.toBe('captured');
22+
delete g.__opencli_xhr;
23+
delete g.window;
24+
});
25+
26+
it('rejects when __opencli_xhr stays empty past deadline', async () => {
27+
const g = globalThis as any;
28+
g.__opencli_xhr = [];
29+
g.window = g;
30+
const code = waitForCaptureJs(50); // 50ms timeout
31+
const promise = eval(code) as Promise<string>;
32+
await expect(promise).rejects.toThrow('No network capture within 0.05s');
33+
delete g.__opencli_xhr;
34+
delete g.window;
35+
});
36+
37+
it('resolves immediately when __opencli_xhr already has data', async () => {
38+
const g = globalThis as any;
39+
g.__opencli_xhr = [{ data: 'already here' }];
40+
g.window = g;
41+
const code = waitForCaptureJs(1000);
42+
await expect(eval(code) as Promise<string>).resolves.toBe('captured');
43+
delete g.__opencli_xhr;
44+
delete g.window;
45+
});
46+
});
47+
48+
describe('waitForSelectorJs', () => {
49+
it('returns a non-empty string', () => {
50+
const code = waitForSelectorJs('#app', 1000);
51+
expect(typeof code).toBe('string');
52+
expect(code).toContain('#app');
53+
expect(code).toContain('querySelector');
54+
expect(code).toContain('MutationObserver');
55+
});
56+
57+
it('resolves "found" immediately when selector already present', async () => {
58+
const g = globalThis as any;
59+
const fakeEl = { tagName: 'DIV' };
60+
g.document = { querySelector: (_: string) => fakeEl };
61+
const code = waitForSelectorJs('[data-testid="primaryColumn"]', 1000);
62+
await expect(eval(code) as Promise<string>).resolves.toBe('found');
63+
delete g.document;
64+
});
65+
66+
it('resolves "found" when selector appears after DOM mutation', async () => {
67+
const g = globalThis as any;
68+
let mutationCallback!: () => void;
69+
g.MutationObserver = class {
70+
constructor(cb: () => void) { mutationCallback = cb; }
71+
observe() {}
72+
disconnect() {}
73+
};
74+
let calls = 0;
75+
g.document = {
76+
querySelector: (_: string) => (calls++ > 0 ? { tagName: 'DIV' } : null),
77+
body: {},
78+
};
79+
const code = waitForSelectorJs('#app', 1000);
80+
const promise = eval(code) as Promise<string>;
81+
mutationCallback(); // simulate DOM mutation
82+
await expect(promise).resolves.toBe('found');
83+
delete g.document;
84+
delete g.MutationObserver;
85+
});
86+
87+
it('rejects when selector never appears within timeout', async () => {
88+
const g = globalThis as any;
89+
g.MutationObserver = class {
90+
constructor(_cb: () => void) {}
91+
observe() {}
92+
disconnect() {}
93+
};
94+
g.document = { querySelector: (_: string) => null, body: {} };
95+
const code = waitForSelectorJs('#missing', 50);
96+
await expect(eval(code) as Promise<string>).rejects.toThrow('Selector not found: #missing');
97+
delete g.document;
98+
delete g.MutationObserver;
99+
});
100+
});

src/browser/dom-helpers.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,47 @@ export function waitForDomStableJs(maxMs: number, quietMs: number): string {
179179
})
180180
`;
181181
}
182+
183+
/**
184+
* Generate JS to wait until window.__opencli_xhr has ≥1 captured response.
185+
* Polls every 100ms. Resolves 'captured' on success; rejects after maxMs.
186+
* Used after installInterceptor() + goto() instead of a fixed sleep.
187+
*/
188+
export function waitForCaptureJs(maxMs: number): string {
189+
return `
190+
new Promise((resolve, reject) => {
191+
const deadline = Date.now() + ${maxMs};
192+
const check = () => {
193+
if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
194+
if (Date.now() > deadline) return reject(new Error('No network capture within ${maxMs / 1000}s'));
195+
setTimeout(check, 100);
196+
};
197+
check();
198+
})
199+
`;
200+
}
201+
202+
/**
203+
* Generate JS to wait until document.querySelector(selector) returns a match.
204+
* Uses MutationObserver for near-instant resolution; falls back to reject after timeoutMs.
205+
*/
206+
export function waitForSelectorJs(selector: string, timeoutMs: number): string {
207+
return `
208+
new Promise((resolve, reject) => {
209+
const sel = ${JSON.stringify(selector)};
210+
if (document.querySelector(sel)) return resolve('found');
211+
const cap = setTimeout(() => {
212+
obs.disconnect();
213+
reject(new Error('Selector not found: ' + sel));
214+
}, ${timeoutMs});
215+
const obs = new MutationObserver(() => {
216+
if (document.querySelector(sel)) {
217+
clearTimeout(cap);
218+
obs.disconnect();
219+
resolve('found');
220+
}
221+
});
222+
obs.observe(document.body || document.documentElement, { childList: true, subtree: true });
223+
})
224+
`;
225+
}

src/browser/mcp.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,11 @@ export class BrowserBridge implements IBrowserFactory {
9696
});
9797
this._daemonProc.unref();
9898

99-
// Wait for daemon to be ready AND extension to connect
99+
// Wait for daemon to be ready AND extension to connect (exponential backoff)
100+
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
100101
const deadline = Date.now() + timeoutMs;
101-
while (Date.now() < deadline) {
102-
await new Promise(resolve => setTimeout(resolve, 300));
102+
for (let i = 0; Date.now() < deadline; i++) {
103+
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i, backoffs.length - 1)]));
103104
if (await isExtensionConnected()) return;
104105
}
105106

0 commit comments

Comments
 (0)