You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
app-shell: a backgrounded tab polls approvals + inbox every 60 s forever, so a forgotten tab keeps scale-to-zero servers (staging) awake 24/7: stop polling after N minutes hidden #12439
A tab left open on a console page never lets its server go idle. packages/app-shell/src/hooks/sharedUserFeeds.ts keeps polling while the tab is hidden: HIDDEN_POLL_MS = 60_000 against APPROVALS_POLL_MS = 30_000 / INBOX_POLL_MS = 10_000 in the foreground. So a backgrounded tab still sends GET /api/v1/approvals/requests and GET /api/v1/data/sys_inbox_message every 60 s, forever.
That matters wherever the backend scales to zero. Since cloud#2766 (2026-10-12), the staging runtime and control-plane containers sleep after 15 / 30 idle minutes, and a 60 s poll is always inside that window. Measured with Cloudflare HTTP analytics:
window (UTC)
host
approvals/requests
sys_inbox_message
cadence
10-13 15:00–20:00
cloud.objectos.app (staging console)
607
588
~30 s, foreground
10-14 12:00–18:00
os-4x2mxc.objectos.app (a staging environment)
345
355
~60 s, hidden
Both came from a single Chrome-on-macOS tab. The staging runtime also calls the control plane on each request (objectos-runtime → /api/v1/cloud/environments/…), so that one tab kept both staging containers awake around the clock. Staging container cost stayed at about $1.5/day where it should be close to zero. In the hours with no tab open (10-13 14:00–20:00), the runtime did sleep, with 0 GiB allocated.
Proposal
Stop polling once the tab has been hidden for a while, and keep the immediate refetch when the user comes back:
While hidden but under N minutes, keep today's 60 s cadence, so a tab switched away briefly still behaves exactly as now.
Apply the same rule to any other poller that reads document.hidden / visibilityState (for example useAiUsage), so one stray poller cannot keep the server awake by itself.
The trade-off (needs an explicit yes)
useInboxArrivalNotifier raises a desktop notification for new inbox messages while the tab is hidden, and its header accepts "up to about a minute late" as the cost of the 60 s hidden cadence. With this change, a message that arrives after the tab has been hidden for more than N minutes gets no desktop notification until the user returns to the tab. At that point it appears as the normal in-page toast or bell badge. The maintainer asked for this card knowing the trade-off. Pick N, or make it configurable, with that cost in mind.
Done when
A hidden tab issues no feed requests after N minutes (test: fake timers plus a document.hidden driver, as sharedInboxFeed.cadence-7249.test.tsx already does).
Returning to the tab triggers exactly one immediate refetch per feed, then the foreground cadence.
The useInboxArrivalNotifier module header and the HIDDEN_POLL_MS comment are updated to describe the new behaviour.
Related: #4225 (consolidated the bell's poller and kept the 60 s hidden cadence), #7249 (cadence test), #7011 (desktop notifications), cloud#2766 (staging scales to zero).
Read fresh. sharedUserFeeds.ts keeps polling approvals and the inbox every 60 s while a tab is hidden, forever. One forgotten tab kept both scale-to-zero staging containers awake around the clock (Cloudflare analytics, about $1.5 a day, against close to zero with no tab open).
The grade and lane are kept as filed: p3, domain:ui (packages/app-shell).
Scope: the card's proposal. Stop arming the timer after N minutes (10 suggested) continuously hidden, and keep the immediate refetch on visibility (Home reads sys_inbox_message twice — the bell's poll and useHomeInbox each issue their own query #4225). Apply the same rule to every poller that reads document.hidden / visibilityState, from one shared helper (⛔ not a copy per poller). Pin both the stop and the resume.
Claim: PM loop round 1
Session: session_01XVp19E1MYUTFM3dnkwz8gB
Account: baozhoutao (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-12439-hidden-poll-stop
Worktree: objectui-issue-12439
Domain: domain:ui
Seat: domain:ui#2
File surface: packages/app-shell/src/hooks/sharedUserFeeds.ts (the hidden-tab cadence and the visibility refetch), one shared hidden-tab helper under packages/app-shell/src/hooks/ or packages/app-shell/src/utils/, any other timer poller in packages/app-shell/src that reads document.hidden / visibilityState (on main at 2026-10-15T07:25Z, that is only sharedUserFeeds.ts; useAiUsage.ts and useInboxArrivalNotifier.ts read visibility but arm no timer, so they change only if the dev measures otherwise), the useInboxArrivalNotifier.ts module header (the hidden-tab trade-off), their tests, and one .changeset/12439-*.md (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus (default tier; dispatch-gates --tier --repo objectstack-ai/objectui reads no path-derived mandate)
Clause-②: no
Responsibility: n/a — not a defect card
Thread-read: 6186333502
Serial constraints cleared: no in-flight domain:ui claim touches sharedUserFeeds.ts or packages/app-shell/src/hooks/. objectui#12423 (plugin-list / plugin-view / components / app-shell PageHeader.tsx, ObjectView.tsx), objectui#12427 (app-shell UnifiedSidebar.tsx, useNavPins.ts, FavoritesProvider.tsx, views) and objectui#12433 (plugin-detail) are disjoint. The last change to sharedUserFeeds.ts on main is 5039e81 (#12250).
{
"issue": 12439,
"status": "done",
"branch": "claude/issue-12439-hidden-poll-stop",
"pr": "https://github.com/objectstack-ai/objectui/pull/12445",
"session": "session_01XVp19E1MYUTFM3dnkwz8gB (subagent; the parent seat's id)",
"premise_still_valid": true,
"summary": "New internal module packages/app-shell/src/hooks/hiddenTabPollStop.ts holds the one hidden-tab clock: HIDDEN_POLL_STOP_MS = 10 min (a constant), hiddenForMs() and isPollingSuspended(). It reads document.hidden at call time and keeps one visibilitychange listener, so hide, show, hide restarts the clock. In sharedUserFeeds.ts, SharedFeed.schedule() arms nothing while suspended, and a poll or probe tick that comes due after the cut-off sends nothing and re-arms nothing. The existing onVisibilityChange refetch (objectui#4225) is the only way out and re-arms the foreground cadence. Module headers of sharedUserFeeds.ts (section, HIDDEN_POLL_MS, onVisibilityChange) and useInboxArrivalNotifier.ts (no desktop notification after 10 min hidden) are updated. Changeset: app-shell patch. A1 holds: the only visibility-reading timer poller in packages/ and apps/ is sharedUserFeeds.ts. useAiUsage, useInboxArrivalNotifier and apps/console inbox-arrival-preview read visibility but arm no timer. The helper is not exported from the package entry, so Clause-② stays no. No labels were written: the dispatch named none, and objectui labels are labeler-owned.",
"tests": "At head d7a0d5845, from the repo root, under os-verify-lock. (1) The new sharedUserFeeds.hiddenStop-12439.test.tsx and hiddenTabPollStop-12439.test.tsx plus sharedInboxFeed.cadence-7249.test.tsx: Test Files 3 passed (3), Tests 14 passed (14). (2) pnpm exec vitest run packages/app-shell/src/hooks/: Test Files 64 passed (64), Tests 536 passed (536). (3) The 13 feed-consumer test files outside hooks/: Test Files 13 passed (13), Tests 117 passed (117). (4) Full packages/app-shell/ run: exit 124 at the 10-min foreground cap, no verdict line, so NOT MEASURED: app-shell full project, reason: foreground cap; declared to CI. (5) Closure build pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build exit 0, then pnpm --filter @object-ui/app-shell type-check exit 0 (tsc --noEmit plus tsconfig.test.json, whose include src/**/*.test.ts(x) covers both new tests). Ablation (implementation committed first, ablation-replace.mjs): the helper import in sharedUserFeeds.ts was replaced by a local isPollingSuspended stub returning false. Anchor 1 to 0, blob 4df0de315421 to cf3189e7ca51. Result: 3 failed of 9, red on the stop case ('expected { approvals: 70, inbox: 70 } to deeply equal { approvals: 10, inbox: 10 }'), the restart case and the shared-helper case. The return case and the cadence-7249 cases stayed green (expected direction: the return path is the pre-existing visibility handler). Restored: blob == HEAD 4df0de315421 and git diff HEAD empty. The tests resolve app-shell src through the vitest alias, so no dist rebuild was involved. Lint narrowed: eslint --format json over the 5 changed files (root eslint.config.js, none ignored) gives files 5, errors 0, warnings 0. The config has no parserOptions.project/projectService, so linting is not type-aware and the verdict on untouched files is invariant. Repo-wide pnpm lint is left to CI. Gates: check:phantom-deps, self-import, unreferenced-sources, doc-example-readers, handler-key-reads, side-effects-array, element-data-source-declaration, spec-symbols, action-forward-parity, designer-field-key-parity, icon-record-names, i18n-keys, i18n-drift, i18n-designer-parity, control-bytes and new-line-citations all exit 0. check-changeset-fixed.mjs and check-changeset-presence.mjs exit 0. check:spec-floors exits 1 with no-artifact (needs a whole-workspace build): known red on main (objectui#12334), recorded and not fixed. Raw control-byte self-scan of the new files: 0 hits.",
"mcp_calls": "0 — no MCP GitHub tools used",
"api_writes": "2 relay strokes: (1) fleet-write pr_create to objectstack-ai/objectui (POST /repos/objectstack-ai/objectui/pulls draft, plus POST /repos/objectstack-ai/objectui/issues/12445/assignees [baozhoutao]), read back 6931 bytes sent = 6931 stored; (2) fleet-write comment POST /repos/objectstack-ai/objectui/issues/12439/comments (this report). Plus git pushes of the branch, which are not REST writes.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (owner: none) · noted, not filed. packages/plugin-dashboard/src/useDashboardAutoRefresh.ts runs setInterval at the authored refreshIntervalSeconds and ignores tab visibility, so a dashboard that opts into auto-refresh keeps polling in a hidden tab, which is the same scale-to-zero cost on author-opted dashboards. It reads no visibility, so it is outside this card's scope, and no reach was measured. Dedupe words: dashboard auto-refresh hidden tab, refreshIntervalSeconds visibility, scale-to-zero poll."
]
}
Reviewed on GitHub at head d7a0d58, against triage's scope (6186333502) and the claim (6186836402), not against the dev's report.
Shape: the PR is a draft against main. Its body opens Fixes #12439, the only closing keyword, followed by Clause-②: no. The governed sweep reads NOT governed, 454 lines (+454 / −0), 6 files.
One shared helper, ⛔ not a copy per poller (triage's ruling), met:
New internal module hooks/hiddenTabPollStop.ts holds the one clock: HIDDEN_POLL_STOP_MS (10 minutes, a constant, as the claim ruled), hiddenForMs() and isPollingSuspended(). It reads document.hidden at call time and keeps one visibilitychange listener, so hide → show → hide restarts the clock.
It is not re-exported from any package entry, so Clause-②: no holds.
The dev re-grepped packages/ and apps/: sharedUserFeeds.ts is the only timer poller that reads visibility. useAiUsage, useInboxArrivalNotifier and the console's inbox-arrival preview read visibility but arm no timer. This matches the seat's own reading at claim time.
The stop:SharedFeed.schedule() arms nothing while suspended, and a poll or probe tick that comes due after the cut-off sends nothing and re-arms nothing. So the last request a hidden tab makes falls inside the 10-minute window.
Headers:sharedUserFeeds.ts (a new section, plus the HIDDEN_POLL_MS and onVisibilityChange docs) and useInboxArrivalNotifier.ts (no desktop notification after 10 minutes hidden; the message arrives as the in-page toast on return) both say the trade-off.
Pins and ablation: 4 feed pins (the 60 s hidden cadence under the cut-off, then zero requests and zero armed timers; exactly one read per feed on return, then the foreground cadence; the clock restarting on each hide; the feeds asking the shared clock) plus the helper's own tests. Stubbing the helper to false in sharedUserFeeds.ts fails 3 of the 9 (stop, restart, shared clock). The return pin stays green, as expected: the return path is the pre-existing handler. Restore was proven by blob hash.
Changeset:@object-ui/app-shell: patch.
Evidence: the closure build and type-check exit 0. src/hooks/ gives 536/536 and the 13 feed-consumer files outside it give 117/117, under the lock. The battery exits 0. The full app-shell project is declared to CI; its shards are read below.
Acceptance note (not filed):plugin-dashboard's useDashboardAutoRefresh polls at an author-set refreshIntervalSeconds and ignores visibility. It reads no visibility, so it is outside this card's rule, and it runs only where an author opted a dashboard into auto-refresh; no reach was measured.
CI at head: 34 success, 3 skipped, 0 failures (filter=latest, 37 runs), all 12 test shards included.
Activity
objectstack-fleet commentedon Oct 15, 2026
Triage: first touch ·
pm:queue· grade kept (enhancement,priority:p3,domain:ui) · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-15T06:53ZRead fresh.
sharedUserFeeds.tskeeps polling approvals and the inbox every 60 s while a tab is hidden, forever. One forgotten tab kept both scale-to-zero staging containers awake around the clock (Cloudflare analytics, about $1.5 a day, against close to zero with no tab open).domain:ui(packages/app-shell).sys_inbox_messagetwice — the bell's poll anduseHomeInboxeach issue their own query #4225). Apply the same rule to every poller that readsdocument.hidden/visibilityState, from one shared helper (⛔ not a copy per poller). Pin both the stop and the resume.objectstack-fleet commentedon Oct 15, 2026
Claim: PM loop round 1
Session:
session_01XVp19E1MYUTFM3dnkwz8gBAccount:
baozhoutao(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-12439-hidden-poll-stopWorktree:
objectui-issue-12439Domain:
domain:uiSeat:
domain:ui#2File surface:
packages/app-shell/src/hooks/sharedUserFeeds.ts(the hidden-tab cadence and the visibility refetch), one shared hidden-tab helper underpackages/app-shell/src/hooks/orpackages/app-shell/src/utils/, any other timer poller inpackages/app-shell/srcthat readsdocument.hidden/visibilityState(on main at 2026-10-15T07:25Z, that is onlysharedUserFeeds.ts;useAiUsage.tsanduseInboxArrivalNotifier.tsread visibility but arm no timer, so they change only if the dev measures otherwise), theuseInboxArrivalNotifier.tsmodule header (the hidden-tab trade-off), their tests, and one.changeset/12439-*.md(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opus(default tier;dispatch-gates --tier --repo objectstack-ai/objectuireads no path-derived mandate)Clause-②: no
Responsibility:
n/a — not a defect cardThread-read: 6186333502
Serial constraints cleared: no in-flight
domain:uiclaim touchessharedUserFeeds.tsorpackages/app-shell/src/hooks/. objectui#12423 (plugin-list / plugin-view / components / app-shellPageHeader.tsx,ObjectView.tsx), objectui#12427 (app-shellUnifiedSidebar.tsx,useNavPins.ts,FavoritesProvider.tsx, views) and objectui#12433 (plugin-detail) are disjoint. The last change tosharedUserFeeds.tson main is 5039e81 (#12250).Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
os-dev-report
{ "issue": 12439, "status": "done", "branch": "claude/issue-12439-hidden-poll-stop", "pr": "https://github.com/objectstack-ai/objectui/pull/12445", "session": "session_01XVp19E1MYUTFM3dnkwz8gB (subagent; the parent seat's id)", "premise_still_valid": true, "summary": "New internal module packages/app-shell/src/hooks/hiddenTabPollStop.ts holds the one hidden-tab clock: HIDDEN_POLL_STOP_MS = 10 min (a constant), hiddenForMs() and isPollingSuspended(). It reads document.hidden at call time and keeps one visibilitychange listener, so hide, show, hide restarts the clock. In sharedUserFeeds.ts, SharedFeed.schedule() arms nothing while suspended, and a poll or probe tick that comes due after the cut-off sends nothing and re-arms nothing. The existing onVisibilityChange refetch (objectui#4225) is the only way out and re-arms the foreground cadence. Module headers of sharedUserFeeds.ts (section, HIDDEN_POLL_MS, onVisibilityChange) and useInboxArrivalNotifier.ts (no desktop notification after 10 min hidden) are updated. Changeset: app-shell patch. A1 holds: the only visibility-reading timer poller in packages/ and apps/ is sharedUserFeeds.ts. useAiUsage, useInboxArrivalNotifier and apps/console inbox-arrival-preview read visibility but arm no timer. The helper is not exported from the package entry, so Clause-② stays no. No labels were written: the dispatch named none, and objectui labels are labeler-owned.", "tests": "At head d7a0d5845, from the repo root, under os-verify-lock. (1) The new sharedUserFeeds.hiddenStop-12439.test.tsx and hiddenTabPollStop-12439.test.tsx plus sharedInboxFeed.cadence-7249.test.tsx: Test Files 3 passed (3), Tests 14 passed (14). (2) pnpm exec vitest run packages/app-shell/src/hooks/: Test Files 64 passed (64), Tests 536 passed (536). (3) The 13 feed-consumer test files outside hooks/: Test Files 13 passed (13), Tests 117 passed (117). (4) Full packages/app-shell/ run: exit 124 at the 10-min foreground cap, no verdict line, so NOT MEASURED: app-shell full project, reason: foreground cap; declared to CI. (5) Closure build pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build exit 0, then pnpm --filter @object-ui/app-shell type-check exit 0 (tsc --noEmit plus tsconfig.test.json, whose include src/**/*.test.ts(x) covers both new tests). Ablation (implementation committed first, ablation-replace.mjs): the helper import in sharedUserFeeds.ts was replaced by a local isPollingSuspended stub returning false. Anchor 1 to 0, blob 4df0de315421 to cf3189e7ca51. Result: 3 failed of 9, red on the stop case ('expected { approvals: 70, inbox: 70 } to deeply equal { approvals: 10, inbox: 10 }'), the restart case and the shared-helper case. The return case and the cadence-7249 cases stayed green (expected direction: the return path is the pre-existing visibility handler). Restored: blob == HEAD 4df0de315421 and git diff HEAD empty. The tests resolve app-shell src through the vitest alias, so no dist rebuild was involved. Lint narrowed: eslint --format json over the 5 changed files (root eslint.config.js, none ignored) gives files 5, errors 0, warnings 0. The config has no parserOptions.project/projectService, so linting is not type-aware and the verdict on untouched files is invariant. Repo-wide pnpm lint is left to CI. Gates: check:phantom-deps, self-import, unreferenced-sources, doc-example-readers, handler-key-reads, side-effects-array, element-data-source-declaration, spec-symbols, action-forward-parity, designer-field-key-parity, icon-record-names, i18n-keys, i18n-drift, i18n-designer-parity, control-bytes and new-line-citations all exit 0. check-changeset-fixed.mjs and check-changeset-presence.mjs exit 0. check:spec-floors exits 1 with no-artifact (needs a whole-workspace build): known red on main (objectui#12334), recorded and not fixed. Raw control-byte self-scan of the new files: 0 hits.", "mcp_calls": "0 — no MCP GitHub tools used", "api_writes": "2 relay strokes: (1) fleet-write pr_create to objectstack-ai/objectui (POST /repos/objectstack-ai/objectui/pulls draft, plus POST /repos/objectstack-ai/objectui/issues/12445/assignees [baozhoutao]), read back 6931 bytes sent = 6931 stored; (2) fleet-write comment POST /repos/objectstack-ai/objectui/issues/12439/comments (this report). Plus git pushes of the branch, which are not REST writes.", "open_questions": [], "out_of_scope_findings": [ "carrier: none (owner: none) · noted, not filed. packages/plugin-dashboard/src/useDashboardAutoRefresh.ts runs setInterval at the authored refreshIntervalSeconds and ignores tab visibility, so a dashboard that opts into auto-refresh keeps polling in a hidden tab, which is the same scale-to-zero cost on author-opted dashboards. It reads no visibility, so it is outside this card's scope, and no reach was measured. Dedupe words: dashboard auto-refresh hidden tab, refreshIntervalSeconds visibility, scale-to-zero poll." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Review: ACCEPT — objectui PR #12445 ·
domain:uiseat 2 ·session_01XVp19E1MYUTFM3dnkwz8gB· 2026-10-15T08:09ZReviewed on GitHub at head
d7a0d58, against triage's scope (6186333502) and the claim (6186836402), not against the dev's report.main. Its body opensFixes #12439, the only closing keyword, followed byClause-②: no. The governed sweep reads NOT governed, 454 lines (+454 / −0), 6 files.hooks/hiddenTabPollStop.tsholds the one clock:HIDDEN_POLL_STOP_MS(10 minutes, a constant, as the claim ruled),hiddenForMs()andisPollingSuspended(). It readsdocument.hiddenat call time and keeps onevisibilitychangelistener, so hide → show → hide restarts the clock.Clause-②: noholds.packages/andapps/:sharedUserFeeds.tsis the only timer poller that reads visibility.useAiUsage,useInboxArrivalNotifierand the console's inbox-arrival preview read visibility but arm no timer. This matches the seat's own reading at claim time.SharedFeed.schedule()arms nothing while suspended, and a poll or probe tick that comes due after the cut-off sends nothing and re-arms nothing. So the last request a hidden tab makes falls inside the 10-minute window.onVisibilityChangerefetch (Home readssys_inbox_messagetwice — the bell's poll anduseHomeInboxeach issue their own query #4225) is the only way out: one immediate read per feed, then itsfinallyre-arms the foreground cadence.sharedUserFeeds.ts(a new section, plus theHIDDEN_POLL_MSandonVisibilityChangedocs) anduseInboxArrivalNotifier.ts(no desktop notification after 10 minutes hidden; the message arrives as the in-page toast on return) both say the trade-off.falseinsharedUserFeeds.tsfails 3 of the 9 (stop, restart, shared clock). The return pin stays green, as expected: the return path is the pre-existing handler. Restore was proven by blob hash.@object-ui/app-shell: patch.src/hooks/gives 536/536 and the 13 feed-consumer files outside it give 117/117, under the lock. The battery exits 0. The full app-shell project is declared to CI; its shards are read below.plugin-dashboard'suseDashboardAutoRefreshpolls at an author-setrefreshIntervalSecondsand ignores visibility. It reads no visibility, so it is outside this card's rule, and it runs only where an author opted a dashboard into auto-refresh; no reach was measured.filter=latest, 37 runs), all 12 test shards included.Next:
pr_ready+automerge_enable.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Landed ·
domain:uiseat 2 ·session_01XVp19E1MYUTFM3dnkwz8gB· 2026-10-15T08:29Zmain27411ea. The card closedcompletedby the PR'sFixes #12439, which is the only closing keyword. No other card closed with it.origin/main, all 6 files the PR touches are blob-identical to the accepted headd7a0d58. They arehooks/hiddenTabPollStop.ts,hooks/sharedUserFeeds.ts,hooks/useInboxArrivalNotifier.ts, the two app-shell: a backgrounded tab polls approvals + inbox every 60 s forever, so a forgotten tab keeps scale-to-zero servers (staging) awake 24/7: stop polling after N minutes hidden #12439 pins and.changeset/12439-hidden-poll-stop.md.pm:dispatchedis removed in the same stroke as this comment.Generated by Claude Code