diff --git a/.changeset/27626-record-open-requests.md b/.changeset/27626-record-open-requests.md index 97b8ea6f350..1fe08fb97fd 100644 --- a/.changeset/27626-record-open-requests.md +++ b/.changeset/27626-record-open-requests.md @@ -12,4 +12,4 @@ Measured in the console on app-crm, opening one record from a list and closing i - **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. - **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. -- **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. +- **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. diff --git a/.changeset/28084-hidden-tab-bell-cadence.md b/.changeset/28084-hidden-tab-bell-cadence.md new file mode 100644 index 00000000000..bfefef8cae1 --- /dev/null +++ b/.changeset/28084-hidden-tab-bell-cadence.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +perf(console): a background tab checks the bell and the approvals badge every three minutes instead of every minute + +Clause-②: no +Metadata-protocol: none + +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. diff --git a/ui/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.cadence-7249.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.cadence-7249.test.tsx index c87eafa7b64..68bda373bff 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.cadence-7249.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.cadence-7249.test.tsx @@ -148,8 +148,8 @@ import { useHomeInbox } from '../useHomeInbox'; * channel; the 10 s measured in the header above is the cadence of its day. */ const INBOX_POLL_MS = 60_000; -/** The declared backgrounded cadence (`HIDDEN_POLL_MS`). */ -const HIDDEN_POLL_MS = 60_000; +/** The declared backgrounded cadence (`HIDDEN_POLL_MS`), 3 minutes since objectstack#28084. */ +const HIDDEN_POLL_MS = 180_000; /** The measurement window both counting cases use. */ const WINDOW_MS = 60_000; /** @@ -266,7 +266,8 @@ describe('objectui#7249 — the inbox feed holds ONE declared cadence', () => { }); it('throttles to HIDDEN_POLL_MS while the tab is backgrounded', async () => { - // The hidden cadence measured on the real page (1 pair per 60 s), pinned so + // The hidden cadence (1 pair per HIDDEN_POLL_MS; 60 s when measured on the + // real page, 3 minutes since objectstack#28084), pinned so // a future change cannot let a backgrounded tab poll at the foreground rate // — the regression #4225 explicitly refused to let ride along. setHidden(true); diff --git a/ui/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.hiddenStop-12439.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.hiddenStop-12439.test.tsx index f6fac7536f3..f8ee340fde6 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.hiddenStop-12439.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.hiddenStop-12439.test.tsx @@ -10,7 +10,10 @@ * the requests both polled feeds actually send, under fake timers and a * `document.hidden` driver (the `sharedInboxFeed.cadence-7249` technique): * - * - under the cut-off, the hidden cadence is today's 60 s; + * - under the cut-off, the hidden cadence is `HIDDEN_POLL_MS`, 3 minutes — + * clearly slower than the 1-minute foreground (objectstack#28084); + * - a tab hidden while visible sends its already-armed foreground tick, then + * reads at the hidden spacing: at most 4 reads per feed before the cut-off; * - past it, nothing goes out, however long the tab stays hidden; * - the return sends exactly one read per feed, then the foreground cadence; * - a visible spell restarts the clock — "continuously hidden" is the rule; @@ -63,11 +66,12 @@ import { HIDDEN_POLL_STOP_MS } from '../hiddenTabPollStop'; /** * The declared cadences, restated as the pins' units — both the 60 s fallback - * of a push channel since objectstack#27626 (they were 10 s and 30 s). + * of a push channel since objectstack#27626 (they were 10 s and 30 s), and the + * hidden cadence, three times slower since objectstack#28084. */ const INBOX_POLL_MS = 60_000; const APPROVALS_POLL_MS = 60_000; -const HIDDEN_POLL_MS = 60_000; +const HIDDEN_POLL_MS = 180_000; const approvalsReads = () => (globalThis.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls.filter((c) => @@ -125,14 +129,14 @@ afterEach(() => { }); describe('objectui#12439 — a forgotten tab stops polling', () => { - it('keeps the 60 s hidden cadence under the cut-off, then sends nothing at all', async () => { + it('keeps the hidden cadence under the cut-off, then sends nothing at all', async () => { setHidden(true); mountBothFeeds(); await settle(); expect(reads()).toEqual({ approvals: 1, inbox: 1 }); - // Under the cut-off: today's hidden cadence, unchanged — the mount read - // plus one per HIDDEN_POLL_MS tick that comes due before HIDDEN_POLL_STOP_MS. + // Under the cut-off: the hidden cadence — the mount read plus one per + // HIDDEN_POLL_MS tick that comes due before HIDDEN_POLL_STOP_MS. await advance(HIDDEN_POLL_STOP_MS - 1); const ticksBeforeStop = Math.ceil(HIDDEN_POLL_STOP_MS / HIDDEN_POLL_MS) - 1; expect(reads()).toEqual({ approvals: 1 + ticksBeforeStop, inbox: 1 + ticksBeforeStop }); @@ -166,6 +170,52 @@ describe('objectui#12439 — a forgotten tab stops polling', () => { }); }); + it('a tab hidden while visible reads at most 4 times per feed in its 10 hidden minutes (objectstack#28084)', async () => { + mountBothFeeds(); + await settle(); + expect(reads()).toEqual({ approvals: 1, inbox: 1 }); + + // Hide half-way to the first foreground tick: the tick armed while the tab + // was visible keeps its foreground due time. + const hiddenAt = APPROVALS_POLL_MS / 2; + await advance(hiddenAt); + await switchTab(true); + const atHide = reads(); + const sinceHide = () => ({ + approvals: reads().approvals - atHide.approvals, + inbox: reads().inbox - atHide.inbox, + }); + expect(sinceHide()).toEqual({ approvals: 0, inbox: 0 }); + + // The already-armed foreground tick fires once, after the hide. + await advance(APPROVALS_POLL_MS - hiddenAt); + expect(sinceHide()).toEqual({ approvals: 1, inbox: 1 }); + + // Then the hidden spacing: nothing until a full HIDDEN_POLL_MS has passed. + let expected = 1; + let hiddenFor = APPROVALS_POLL_MS - hiddenAt; + while (hiddenFor + HIDDEN_POLL_MS < HIDDEN_POLL_STOP_MS) { + await advance(HIDDEN_POLL_MS - 1); + expect(sinceHide()).toEqual({ approvals: expected, inbox: expected }); + await advance(1); + expected += 1; + hiddenFor += HIDDEN_POLL_MS; + expect(sinceHide()).toEqual({ approvals: expected, inbox: expected }); + } + // At most 4 reads per feed in the first 10 hidden minutes — at the + // foreground rate it would have been 10. + expect(expected).toBe(4); + + // Nothing after the 10th hidden minute, and nothing left armed. + await advance(6 * HIDDEN_POLL_STOP_MS); + expect(sinceHide()).toEqual({ approvals: 4, inbox: 4 }); + expect(vi.getTimerCount()).toBe(0); + + // Coming back: exactly one read per feed. + await switchTab(false); + expect(sinceHide()).toEqual({ approvals: 5, inbox: 5 }); + }); + it('restarts the clock on each hide — a visible spell resets it', async () => { setHidden(true); mountBothFeeds(); diff --git a/ui/packages/app-shell/src/hooks/sharedUserFeeds.ts b/ui/packages/app-shell/src/hooks/sharedUserFeeds.ts index fa340bcd75a..3ebb502c03d 100644 --- a/ui/packages/app-shell/src/hooks/sharedUserFeeds.ts +++ b/ui/packages/app-shell/src/hooks/sharedUserFeeds.ts @@ -46,9 +46,10 @@ * * ## A forgotten tab goes quiet (objectui#12439) * - * A backgrounded tab polls at `HIDDEN_POLL_MS`, but only for a while: once it - * has been hidden continuously for `HIDDEN_POLL_STOP_MS` (10 minutes), every - * feed here stops arming its timer, and a tick that comes due after that point + * A backgrounded tab polls at `HIDDEN_POLL_MS` (every 3 minutes, slower than + * the foreground's 1 minute), but only for a while: once it has been hidden + * continuously for `HIDDEN_POLL_STOP_MS` (10 minutes), every feed here stops + * arming its timer, and a tick that comes due after that point * sends nothing. The reason is the backend, not the browser: wherever the * server scales to zero, a poll inside its idle window keeps it awake, and a * 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; */ const INBOX_POLL_MS = 60_000; /** - * Cadence while the tab is backgrounded. The bell's own poller carried this - * (60s hidden against 10s foregrounded) plus an immediate refetch when the - * user comes back, and consolidating the poll into the store had to bring - * both along — a shared feed that polled a hidden tab at its foreground rate - * would have been a regression riding in on a de-duplication fix (#4225). + * Cadence while the tab is backgrounded: 3 minutes, against the foreground's + * 1 minute. A hidden tab polls clearly slower than a visible one — the rule + * since the bell's own poller (#4225), which consolidating the poll into the + * store kept, and which objectstack#28084 restored once the foreground itself + * moved to 60 s (objectstack#27626). Coming back to the tab refetches each feed + * at once, so the slower cadence costs nothing the user can see. + * + * The scheduler reads the tab's visibility when it ARMS a timer, so the + * foreground tick already armed at the moment of hiding still fires once; the + * hidden ticks follow it at this spacing. * * It holds only for the first `HIDDEN_POLL_STOP_MS` of a continuous hide * (`hiddenTabPollStop.ts`, objectui#12439): after that the feeds stop arming * their timers altogether, so a forgotten tab stops keeping a scale-to-zero - * backend awake. The immediate refetch on return is what makes that cheap. + * backend awake. */ -const HIDDEN_POLL_MS = 60_000; +const HIDDEN_POLL_MS = 180_000; /** * Ceiling for the failure backoff, likewise lifted from the bell's poller and * now applied to every feed: a feed whose read keeps failing must not keep diff --git a/ui/packages/app-shell/src/hooks/useInboxArrivalNotifier.ts b/ui/packages/app-shell/src/hooks/useInboxArrivalNotifier.ts index 92de9d9cfff..decbbf6d680 100644 --- a/ui/packages/app-shell/src/hooks/useInboxArrivalNotifier.ts +++ b/ui/packages/app-shell/src/hooks/useInboxArrivalNotifier.ts @@ -23,12 +23,13 @@ * Presentation only. Nothing here touches the transport: no cadence is changed, * no request is added, and no push channel (WebSocket / SSE) is opened — those * are framework-side platform work with their own project. The consequence is - * accepted rather than worked around: a backgrounded tab polls at 60 s, so a - * desktop notification can be up to about a minute late. Shaving that by - * speeding the poll up would trade a server-wide cost for one surface's - * latency, which is the trade the card refused. + * accepted rather than worked around: a backgrounded tab polls every 3 minutes + * (`HIDDEN_POLL_MS`, objectstack#28084), so a desktop notification can be up + * to about three minutes late. Shaving that by speeding the poll up would + * trade a server-wide cost for one surface's latency, which is the trade the + * card refused. * - * And the 60 s holds only for the first 10 minutes of a continuous hide + * And that cadence holds only for the first 10 minutes of a continuous hide * (`HIDDEN_POLL_STOP_MS`, `hiddenTabPollStop.ts`, objectui#12439). After that * the shared feed stops polling, so a message that arrives later raises NO * desktop notification while the tab stays hidden: nothing reads it until the