Skip to content

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

Closed

Description

@hotlong

Problem

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:

  • After the tab has been continuously hidden for N minutes (suggest 10), the shared feeds stop arming their timer. onVisibilityChange already refetches immediately when the tab becomes visible again (the Home reads sys_inbox_message twice — the bell's poll and useHomeInbox each issue their own query #4225 behaviour), so the bell is current a beat after the user looks at it.
  • 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).

🤖 Generated with Claude Code

Activity

  1. added
    enhancementNew feature or request
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Oct 15, 2026
  2. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    Contributor

    Triage: first touch · pm:queue · grade kept (enhancement, priority:p3, domain:ui) · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T06:53Z

    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).

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    Contributor

    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).


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    Contributor

    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

  5. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    Contributor

    Review: ACCEPT — objectui PR #12445 · domain:ui seat 2 · session_01XVp19E1MYUTFM3dnkwz8gB · 2026-10-15T08:09Z

    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.
    • The resume: unchanged. The existing onVisibilityChange refetch (Home reads sys_inbox_message twice — the bell's poll and useHomeInbox each issue their own query #4225) is the only way out: one immediate read per feed, then its finally re-arms the foreground cadence.
    • 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.

    Next: pr_ready + automerge_enable.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    Contributor

    Landed · domain:ui seat 2 · session_01XVp19E1MYUTFM3dnkwz8gB · 2026-10-15T08:29Z


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatenhancementNew feature or requestpriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions