Skip to content

Commit 48c7780

Browse files
fix(app-shell): a hidden tab polls the bell and approvals feeds every three minutes (#28133)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ ## 维护者速读 - 改了什么:控制台标签页转到后台后,铃铛(收件箱)和审批角标改为每 3 分钟查一次(原来与前台一样每 1 分钟);后台满 10 分钟后全停、回到标签页时每个数据源立刻读一次,这两条不变。 - 为什么改:#27907 把前台轮询改成 60 秒后,后台的 60 秒不再比前台慢,前 10 分钟里一个后台标签页和前台一样频繁地请求。按裁决 B(6522899149)把后台节奏调回明显慢于前台。 - 风险与回滚:只改一个常量和注释;后台标签页的桌面通知最晚约 3 分钟到达(原来约 1 分钟)。回滚即 revert 本 PR。 - 先看:`sharedUserFeeds.ts` 的 `HIDDEN_POLL_MS` 及其注释;`sharedUserFeeds.hiddenStop-12439.test.tsx` 新增的 "reads at most 4 times" 用例;`.changeset/27626-record-open-requests.md` 那一句的改正。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/hooks/__tests__/sharedUserFeeds.hiddenStop-12439.test.tsx`,预期 5 条全部通过。 - 需要你做的:无,席位落地。 Fixes #28084 Clause-②: no Metadata-protocol: none Ruling-ref: 6522899149 (maintainer, letter B) ## What changes All in `ui/packages/app-shell/src/hooks/` (app-shell is private; it ships inside `@object-ui/console`), plus changesets: - **`HIDDEN_POLL_MS` 60_000 to 180_000** in `sharedUserFeeds.ts`. A hidden tab now polls the inbox and approvals feeds every 3 minutes against the 1-minute foreground. Its docblock is rewritten: slower-when-hidden is the rule since #4225; the scheduler reads visibility when it arms, so the foreground tick already armed at the moment of hiding still fires once; the objectui#12439 full stop after `HIDDEN_POLL_STOP_MS` (10 minutes) is unchanged. The module header's "A forgotten tab goes quiet" section names the new cadence. - **What is now true, stated where it was wrong.** `useInboxArrivalNotifier.ts` header: a desktop notification can be "up to about three minutes late" (was "about a minute"). `.changeset/27626-record-open-requests.md` (unreleased): "still stop in a hidden tab" is corrected in place to "every three minutes while the tab is hidden (and not at all once it has been hidden for ten minutes)". - **Unchanged, per the ruling:** the 10-minute window and the full stop after it, the one immediate read per feed on return, the desktop notification and its toggle. No new knob, no per-feed rule. - New changeset `.changeset/28084-hidden-tab-bell-cadence.md` (`@object-ui/app-shell: patch`). No i18n key, no export from a published package, no metadata change. ## Tests - New case in `sharedUserFeeds.hiddenStop-12439.test.tsx`, fake timers plus `visibilitychange`: both feeds mounted in a visible tab, the tab hidden 30 s in. Each feed reads once at the already-armed foreground tick, then nothing until each full 180 s has passed, then one read: 4 reads per feed in the 10 hidden minutes (10 at the old cadence), nothing after the 10th minute, no timer left armed, and exactly one read per feed on return. - The existing hidden-cadence cases restate `HIDDEN_POLL_MS` as 180_000 (`sharedUserFeeds.hiddenStop-12439`, `sharedInboxFeed.cadence-7249`); their arithmetic already derives from the constant. <details><summary>Readings (head e802b06)</summary> - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build`: lock VERDICT command-exit 0. - The three hidden-tab files (`sharedUserFeeds.hiddenStop-12439`, `hiddenTabPollStop-12439`, `sharedInboxFeed.cadence-7249`): Test Files 3 passed (3), Tests 15 passed (15), VERDICT command-exit 0. - Ablation through `scripts/ablation-replace.mjs`, anchor `const HIDDEN_POLL_MS = 180_000;` replaced by `60_000` (anchor 1 to 0, blob 3ebb502 to 74dfdfe3): Tests 3 failed, 7 passed of the two feed files, VERDICT command-exit 1. Red: the new case ('expected { approvals: 3, inbox: 3 } to deeply equal { approvals: 1, inbox: 1 }'), the cut-off case ('expected { approvals: 10, inbox: 10 } to deeply equal { approvals: 4, inbox: 4 }') and the 7249 hidden throttle ('expected 4 to be 2'). Restored: blob == HEAD 3ebb502, `git diff HEAD` empty. Tests import `src`, so no dist rebuild. - `pnpm --dir ui exec vitest run packages/app-shell/src/hooks/`: Test Files 84 passed (84), Tests 715 passed (715). - `pnpm --filter @object-ui/app-shell run type-check` (tsc --noEmit and tsconfig.test.json): exit 0. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 34 families; all 34 run and recorded, 33 exit 0 (including `check-sdui-manifest`, `vitest run scripts/`, `check:type-check-coverage`, `check:type-check-debt`, `check:nul-bytes`); `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED. One red, by design: `check-empty-changeset --base origin/main` exit 1 on `.changeset/27626-record-open-requests.md` (see the note below). `pnpm --dir ui run check:new-line-citations`: 0 new citations. </details> ## Pending release note corrected in place (confirmation for `check-empty-changeset`) `check-empty-changeset` stays red on this PR, and that is the intended outcome. This PR rewrites one sentence of `.changeset/27626-record-open-requests.md`, a pending note that another PR (#27907) added. The sentence said the feeds "still stop in a hidden tab". On `main` they stop only after 10 continuous hidden minutes, and after this PR they poll every three minutes before that. This is the gate's DELIBERATE CORRECTION class, not a filename collision. Restoring the base text would publish a false sentence. The correction was ordered by the maintainer's ruling 6522899149 on #28084 (execution parameter 3). Only that one clause changed. The note's other bullets and its front matter are unchanged. ## Acceptance notes - [x] **With the tab hidden, the feeds poll at the agreed hidden cadence (180 s) from the moment it hides.** Pinned by the new case: one already-armed foreground tick, then 180 s spacing, at most 4 reads per feed before the 10-minute stop, none after. - [x] **On becoming visible, one read per feed.** Pinned by the same case and by the existing return case. - An inbox tick is two requests (messages, then receipts when there is a window to join); an approvals tick is one. Per hidden tab, the first 10 hidden minutes go from up to 10 ticks per feed to at most 4. - The push-channel seam of #27626 (`setUserFeedPushChannelOpen`) is untouched: while a host reports an open channel neither feed polls, hidden or not. No host opens one today. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXAofd4m4ymS3HqKt5)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d85ff19 commit 48c7780

6 files changed

Lines changed: 93 additions & 25 deletions

File tree

‎.changeset/27626-record-open-requests.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ Measured in the console on app-crm, opening one record from a list and closing i
1212

1313
- **The list under the drawer is not read again.** The first record opened replaced the list with the route skeleton (the drawer made the route read the whole object registry instead of the app's object scope, and the route waited for it), so the list mounted again and re-read its rows; every later open and close handed the list a different document for its object, which rebuilt its columns and re-ran the list query. The drawer now reads the whole registry beside the route's scope, without holding the route, and the registry joins the scope once it lands with the scope's own documents kept as they are. The list page also holds `columns` and `searchableFields` by value, as it holds the view-type blocks. The record form overlay (`?form=`) still reads the whole registry.
1414
- **One field-access request per record.** The record's sections, highlights, form and page each asked `GET /data/:object/:id/field-access` at once; `ObjectStackAdapter.getRecordFieldAccess` now shares one request between concurrent asks for the same record, remembers a 404 for that record until a write to its object, and stops asking a server that answers 501. A settled answer is not cached: the next ask after a save reaches the server.
15-
- **The bell polls once a minute.** The inbox and approvals feeds polled every 10 s and 30 s on every console page. With no realtime channel in the platform, they now poll every 60 s in the foreground, still stop in a hidden tab, and stop polling altogether while a host reports an open push channel (a pushed change reads the feed it names). A new inbox message can show up to about a minute later.
15+
- **The bell polls once a minute.** The inbox and approvals feeds polled every 10 s and 30 s on every console page. With no realtime channel in the platform, they now poll every 60 s in the foreground, every three minutes while the tab is hidden (and not at all once it has been hidden for ten minutes), and stop polling altogether while a host reports an open push channel (a pushed change reads the feed it names). A new inbox message can show up to about a minute later.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
perf(console): a background tab checks the bell and the approvals badge every three minutes instead of every minute
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Since the bell's foreground poll moved to once a minute, a hidden console tab polled just as often as a visible one for its first ten minutes. A hidden tab now polls every three minutes (the foreground tick already armed when the tab hides still fires once), still stops polling altogether after ten hidden minutes, and reads each feed once, at once, when the user comes back. A desktop notification for a new inbox message in a background tab can now arrive up to about three minutes late instead of one.

‎ui/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.cadence-7249.test.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ import { useHomeInbox } from '../useHomeInbox';
148148
* channel; the 10 s measured in the header above is the cadence of its day.
149149
*/
150150
const INBOX_POLL_MS = 60_000;
151-
/** The declared backgrounded cadence (`HIDDEN_POLL_MS`). */
152-
const HIDDEN_POLL_MS = 60_000;
151+
/** The declared backgrounded cadence (`HIDDEN_POLL_MS`), 3 minutes since objectstack#28084. */
152+
const HIDDEN_POLL_MS = 180_000;
153153
/** The measurement window both counting cases use. */
154154
const WINDOW_MS = 60_000;
155155
/**
@@ -266,7 +266,8 @@ describe('objectui#7249 — the inbox feed holds ONE declared cadence', () => {
266266
});
267267

268268
it('throttles to HIDDEN_POLL_MS while the tab is backgrounded', async () => {
269-
// The hidden cadence measured on the real page (1 pair per 60 s), pinned so
269+
// The hidden cadence (1 pair per HIDDEN_POLL_MS; 60 s when measured on the
270+
// real page, 3 minutes since objectstack#28084), pinned so
270271
// a future change cannot let a backgrounded tab poll at the foreground rate
271272
// — the regression #4225 explicitly refused to let ride along.
272273
setHidden(true);

‎ui/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.hiddenStop-12439.test.tsx‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
* the requests both polled feeds actually send, under fake timers and a
1111
* `document.hidden` driver (the `sharedInboxFeed.cadence-7249` technique):
1212
*
13-
* - under the cut-off, the hidden cadence is today's 60 s;
13+
* - under the cut-off, the hidden cadence is `HIDDEN_POLL_MS`, 3 minutes —
14+
* clearly slower than the 1-minute foreground (objectstack#28084);
15+
* - a tab hidden while visible sends its already-armed foreground tick, then
16+
* reads at the hidden spacing: at most 4 reads per feed before the cut-off;
1417
* - past it, nothing goes out, however long the tab stays hidden;
1518
* - the return sends exactly one read per feed, then the foreground cadence;
1619
* - a visible spell restarts the clock — "continuously hidden" is the rule;
@@ -63,11 +66,12 @@ import { HIDDEN_POLL_STOP_MS } from '../hiddenTabPollStop';
6366

6467
/**
6568
* The declared cadences, restated as the pins' units — both the 60 s fallback
66-
* of a push channel since objectstack#27626 (they were 10 s and 30 s).
69+
* of a push channel since objectstack#27626 (they were 10 s and 30 s), and the
70+
* hidden cadence, three times slower since objectstack#28084.
6771
*/
6872
const INBOX_POLL_MS = 60_000;
6973
const APPROVALS_POLL_MS = 60_000;
70-
const HIDDEN_POLL_MS = 60_000;
74+
const HIDDEN_POLL_MS = 180_000;
7175

7276
const approvalsReads = () =>
7377
(globalThis.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls.filter((c) =>
@@ -125,14 +129,14 @@ afterEach(() => {
125129
});
126130

127131
describe('objectui#12439 — a forgotten tab stops polling', () => {
128-
it('keeps the 60 s hidden cadence under the cut-off, then sends nothing at all', async () => {
132+
it('keeps the hidden cadence under the cut-off, then sends nothing at all', async () => {
129133
setHidden(true);
130134
mountBothFeeds();
131135
await settle();
132136
expect(reads()).toEqual({ approvals: 1, inbox: 1 });
133137

134-
// Under the cut-off: today's hidden cadence, unchanged — the mount read
135-
// plus one per HIDDEN_POLL_MS tick that comes due before HIDDEN_POLL_STOP_MS.
138+
// Under the cut-off: the hidden cadence — the mount read plus one per
139+
// HIDDEN_POLL_MS tick that comes due before HIDDEN_POLL_STOP_MS.
136140
await advance(HIDDEN_POLL_STOP_MS - 1);
137141
const ticksBeforeStop = Math.ceil(HIDDEN_POLL_STOP_MS / HIDDEN_POLL_MS) - 1;
138142
expect(reads()).toEqual({ approvals: 1 + ticksBeforeStop, inbox: 1 + ticksBeforeStop });
@@ -166,6 +170,52 @@ describe('objectui#12439 — a forgotten tab stops polling', () => {
166170
});
167171
});
168172

173+
it('a tab hidden while visible reads at most 4 times per feed in its 10 hidden minutes (objectstack#28084)', async () => {
174+
mountBothFeeds();
175+
await settle();
176+
expect(reads()).toEqual({ approvals: 1, inbox: 1 });
177+
178+
// Hide half-way to the first foreground tick: the tick armed while the tab
179+
// was visible keeps its foreground due time.
180+
const hiddenAt = APPROVALS_POLL_MS / 2;
181+
await advance(hiddenAt);
182+
await switchTab(true);
183+
const atHide = reads();
184+
const sinceHide = () => ({
185+
approvals: reads().approvals - atHide.approvals,
186+
inbox: reads().inbox - atHide.inbox,
187+
});
188+
expect(sinceHide()).toEqual({ approvals: 0, inbox: 0 });
189+
190+
// The already-armed foreground tick fires once, after the hide.
191+
await advance(APPROVALS_POLL_MS - hiddenAt);
192+
expect(sinceHide()).toEqual({ approvals: 1, inbox: 1 });
193+
194+
// Then the hidden spacing: nothing until a full HIDDEN_POLL_MS has passed.
195+
let expected = 1;
196+
let hiddenFor = APPROVALS_POLL_MS - hiddenAt;
197+
while (hiddenFor + HIDDEN_POLL_MS < HIDDEN_POLL_STOP_MS) {
198+
await advance(HIDDEN_POLL_MS - 1);
199+
expect(sinceHide()).toEqual({ approvals: expected, inbox: expected });
200+
await advance(1);
201+
expected += 1;
202+
hiddenFor += HIDDEN_POLL_MS;
203+
expect(sinceHide()).toEqual({ approvals: expected, inbox: expected });
204+
}
205+
// At most 4 reads per feed in the first 10 hidden minutes — at the
206+
// foreground rate it would have been 10.
207+
expect(expected).toBe(4);
208+
209+
// Nothing after the 10th hidden minute, and nothing left armed.
210+
await advance(6 * HIDDEN_POLL_STOP_MS);
211+
expect(sinceHide()).toEqual({ approvals: 4, inbox: 4 });
212+
expect(vi.getTimerCount()).toBe(0);
213+
214+
// Coming back: exactly one read per feed.
215+
await switchTab(false);
216+
expect(sinceHide()).toEqual({ approvals: 5, inbox: 5 });
217+
});
218+
169219
it('restarts the clock on each hide — a visible spell resets it', async () => {
170220
setHidden(true);
171221
mountBothFeeds();

‎ui/packages/app-shell/src/hooks/sharedUserFeeds.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@
4646
*
4747
* ## A forgotten tab goes quiet (objectui#12439)
4848
*
49-
* A backgrounded tab polls at `HIDDEN_POLL_MS`, but only for a while: once it
50-
* has been hidden continuously for `HIDDEN_POLL_STOP_MS` (10 minutes), every
51-
* feed here stops arming its timer, and a tick that comes due after that point
49+
* A backgrounded tab polls at `HIDDEN_POLL_MS` (every 3 minutes, slower than
50+
* the foreground's 1 minute), but only for a while: once it has been hidden
51+
* continuously for `HIDDEN_POLL_STOP_MS` (10 minutes), every feed here stops
52+
* arming its timer, and a tick that comes due after that point
5253
* sends nothing. The reason is the backend, not the browser: wherever the
5354
* server scales to zero, a poll inside its idle window keeps it awake, and a
5455
* 60 s poll is always inside it — one tab left open on a console page kept two
@@ -122,18 +123,23 @@ const APPROVALS_POLL_MS = 60_000;
122123
*/
123124
const INBOX_POLL_MS = 60_000;
124125
/**
125-
* Cadence while the tab is backgrounded. The bell's own poller carried this
126-
* (60s hidden against 10s foregrounded) plus an immediate refetch when the
127-
* user comes back, and consolidating the poll into the store had to bring
128-
* both along — a shared feed that polled a hidden tab at its foreground rate
129-
* would have been a regression riding in on a de-duplication fix (#4225).
126+
* Cadence while the tab is backgrounded: 3 minutes, against the foreground's
127+
* 1 minute. A hidden tab polls clearly slower than a visible one — the rule
128+
* since the bell's own poller (#4225), which consolidating the poll into the
129+
* store kept, and which objectstack#28084 restored once the foreground itself
130+
* moved to 60 s (objectstack#27626). Coming back to the tab refetches each feed
131+
* at once, so the slower cadence costs nothing the user can see.
132+
*
133+
* The scheduler reads the tab's visibility when it ARMS a timer, so the
134+
* foreground tick already armed at the moment of hiding still fires once; the
135+
* hidden ticks follow it at this spacing.
130136
*
131137
* It holds only for the first `HIDDEN_POLL_STOP_MS` of a continuous hide
132138
* (`hiddenTabPollStop.ts`, objectui#12439): after that the feeds stop arming
133139
* their timers altogether, so a forgotten tab stops keeping a scale-to-zero
134-
* backend awake. The immediate refetch on return is what makes that cheap.
140+
* backend awake.
135141
*/
136-
const HIDDEN_POLL_MS = 60_000;
142+
const HIDDEN_POLL_MS = 180_000;
137143
/**
138144
* Ceiling for the failure backoff, likewise lifted from the bell's poller and
139145
* now applied to every feed: a feed whose read keeps failing must not keep

‎ui/packages/app-shell/src/hooks/useInboxArrivalNotifier.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@
2323
* Presentation only. Nothing here touches the transport: no cadence is changed,
2424
* no request is added, and no push channel (WebSocket / SSE) is opened — those
2525
* are framework-side platform work with their own project. The consequence is
26-
* accepted rather than worked around: a backgrounded tab polls at 60 s, so a
27-
* desktop notification can be up to about a minute late. Shaving that by
28-
* speeding the poll up would trade a server-wide cost for one surface's
29-
* latency, which is the trade the card refused.
26+
* accepted rather than worked around: a backgrounded tab polls every 3 minutes
27+
* (`HIDDEN_POLL_MS`, objectstack#28084), so a desktop notification can be up
28+
* to about three minutes late. Shaving that by speeding the poll up would
29+
* trade a server-wide cost for one surface's latency, which is the trade the
30+
* card refused.
3031
*
31-
* And the 60 s holds only for the first 10 minutes of a continuous hide
32+
* And that cadence holds only for the first 10 minutes of a continuous hide
3233
* (`HIDDEN_POLL_STOP_MS`, `hiddenTabPollStop.ts`, objectui#12439). After that
3334
* the shared feed stops polling, so a message that arrives later raises NO
3435
* desktop notification while the tab stays hidden: nothing reads it until the

0 commit comments

Comments
 (0)