Skip to content

fix(app-shell): the bell polls the inbox on every console surface, not only inside an app (#4110) - #4199

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4110-inbox-popover-filter
Aug 10, 2026
Merged

fix(app-shell): the bell polls the inbox on every console surface, not only inside an app (#4110)#4199
yinlianghui merged 1 commit into
mainfrom
claude/issue-4110-inbox-popover-filter

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4110

Root cause

Not the popover. InboxPopover never filters a canonical inbox row away — its (topic, title) coalescing (#2765) and its topic/source handling are innocent, and the "All" sub-filter applies no predicate at all, which is why "All" reading "No notifications" was the tell: the component was handed an empty array.

The array is empty because the bell's poller never ran. AppHeader's inbox read

sys_inbox_message   $filter { user_id }  $orderby { created_at: desc }  $top 20
sys_notification_receipt   $filter { user_id, channel: 'inbox' }  $top 200

was gated on the header's isApp variant flag:

useEffect(() => {
  if (!dataSource || !isApp || !user?.id) return;   // ← the predicate that emptied the tab

isApp is variant === 'app', true only under ConsoleLayout (/apps/:appName/*). HomeLayout renders AppHeader variant="home", OrganizationsLayout renders variant="orgs", and AiChatPage renders variant="home" — the bell renders in all of them, but on all of them the effect returns before issuing a request. So the tab was empty forever, independent of row count, exactly as reported.

Meanwhile Home's To-do card reads the same object through useHomeInbox, which carries no such gate — hence one page showing the notification in a card and nothing in the bell.

This matches the reporter's own network trace. The only inbox request observed was top=5 — that is useHomeInbox(5), the card. The bell's read is $top: 20 plus a sys_notification_receipt read; both are absent from the trace because the effect returned early. "The popover issues no request on open" was correct and, it turns out, load-bearing: nothing ever issued the bell's request.

Origin: 20255e5411feat(app-shell): notifications bell in AppHeader (M10.8), 2026-05-18. The gate arrived with the feature (copied from the presence/connection-status block above it, which is genuinely app-only chrome) and has never changed since.

⚠️ The rc.3 to rc.5 regression window is not supported by this repo's history

Reported as "worked under 17.0.0-rc.3 on 2026-07-31, broken under rc.5". The predicate that empties the tab is ~3 months old and untouched in that window: git log -S finds it entering at 20255e5411 (2026-05-18) and nowhere else, and git blame on the line agrees. The Home bell has never polled.

The most likely reading of the passing 2026-07-31 e2e is that it asserted the popover on an app page (/apps/...), where this bug does not reproduce — the variant="app" control case in the new test passes both before and after this change. Worth confirming on the reporting side, because if the bell was genuinely working on Home under rc.3 then there is a second, platform-side cause that this PR does not address. Nothing found here explains it.

Fix

Drop isApp from the inbox poller's gate (and its dependency array). The inbox is scoped to the signed-in user, not to the app in the URL; user?.id remains the scope. Nothing else changes — no tab restructuring, no fetch-on-open (the shared-data architecture is untouched), no change to the badge logic from #4073.

Reproduction and reverse verification

New test packages/app-shell/src/layout/__tests__/AppHeader.inboxVariant.test.tsx renders the header against a fake adapter holding one canonical MessagingService-materialized row (user_id, topic, title, body_md, action_url, notification_id, created_at) plus its state: 'delivered' receipt, and asserts the bell lists it per variant.

On origin/main, before the fix — the defect and its exoneration in one run:

× lists a canonical inbox row on Home, where the To-do card already shows it
× scopes that read to the signed-in user, newest first (ADR-0030 `mine`)
× counts a `delivered` receipt as unread, so the badge shows the row
× lists it on the Organizations variant too
✓ still lists it inside an app (the control — this half never broke)

After the fix: 6 passed.

Reverse verification restored !isApp into the gate; predicted direction was 5 red / 1 green with the app control surviving, and that is what ran. Note the sixth case, "shows the empty state only when the inbox is genuinely empty": it asserts that the read happened as well as that the copy appears, so it goes red with the gate restored. Without the read assertion it would have stayed green for the wrong reason — the empty-state copy is present either way.

Local gates

npx vitest run packages/app-shell --maxWorkers=2
  Test Files  325 passed (325)
       Tests  3044 passed | 1 skipped (3045)

pnpm --filter '@object-ui/app-shell' type-check          → tsc --noEmit clean
pnpm --filter '@object-ui/app-shell^...' build           → dependency closure built first
npx eslint packages/app-shell/src/layout/AppHeader.tsx AppHeader.inboxVariant.test.tsx
  ✖ 48 problems (0 errors, 48 warnings)                  → warnings pre-existing / mock-shape `any`
pnpm check:control-bytes                                 → OK (3875 tracked text files)

No user-visible copy added or changed, so no i18n keys move.

Out of scope, filed separately

#4197 — the same isApp gate still sits on the approvals-count poll and the activity fetch, so the bell's other two tabs stay dead off-app, and the badge (unreadTopics + pendingApprovalsCount) is now variant-dependent. Filed rather than fixed here to keep this PR to the reported tab; it also carries a real trade-off (duplicate reads with useHomeInbox on Home) that deserves its own decision.

Changeset

.changeset/inbox-popover-bell-polls-off-app-4110.md@object-ui/app-shell patch.


Generated by Claude Code

…t only inside an app (#4110)

The top-bar bell's `sys_inbox_message` + `sys_notification_receipt` poll was
gated on the header's `isApp` variant flag — the flag that exists to hide the
app-only presence avatars and connection dot. The bell itself renders in every
variant, so on Home, Organizations and the full-page AI screen its Notifications
tab held `[]` forever: "Unread" read "You're all caught up" and "All" — which
applies no predicate at all — read "No notifications", on the very page whose
To-do card (`useHomeInbox`, ungated) was listing the same row from the same
object.

The popover's own filter and its (topic, title) coalescing (#2765) are innocent:
given the row they render it. The inbox is scoped to the signed-in user, not to
the app in the URL, so the poll is now scoped by `user?.id` only.

Regression test renders the header against a fake adapter holding one canonical
MessagingService-emitted row and asserts the bell lists it in every variant; the
`variant="app"` case is the control that never broke.
@github-actions github-actions Bot added the tests label Aug 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.3 KB 350 KB
Entry file index-C_wGxo_p.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.88KB 3.25KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 486.12KB 107.45KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 140.66KB 36.25KB
fields (index.js) 226.96KB 56.30KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.87KB 10.80KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.52KB 17.49KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.52KB 30.68KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 162.81KB 39.67KB
plugin-grid (index.js) 188.04KB 49.91KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.04KB 26.67KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.95KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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

Labels

Projects

None yet

2 participants