Skip to content

fix(app-shell): give missing-resource retirement a bounded recovery, so one transient 404 stops killing both inbox panels for the page (#4289) - #4360

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4289-inbox-feed-404-latch
Aug 11, 2026
Merged

fix(app-shell): give missing-resource retirement a bounded recovery, so one transient 404 stops killing both inbox panels for the page (#4289)#4360
yinlianghui merged 1 commit into
mainfrom
claude/issue-4289-inbox-feed-404-latch

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4289

The premise moved, and it closed in the wrong direction

#4289 was filed against AppHeader's own inbox poll: a notificationsUnavailableRef that latched on one 404 / OBJECT_NOT_FOUND and was cleared by nothing, while useHomeInbox — reading the same object with the same filter — simply retried. The card was explicit that reachability of a transient 404 was unverified, and named that as step one for whoever picked it up.

That poll no longer exists. #4225 / PR #4327 deleted it and pointed both surfaces at sharedUserFeeds' inbox feed. So the asymmetry the card describes is gone — but it closed in the wrong direction: the surviving reader is the latching one, and the sibling that used to retry is now a consumer of it.

Measured on origin/main @ de627792e, markUnavailable() is still a one-way door. unavailable is set, and refresh(), schedule() and onVisibilityChange() all return early on it, so the only exits are a key change (a different user or adapter) or a page reload. A consumer remounting does not re-arm it; returning to the tab does not; a later successful read never happens because no later read is issued.

The blast radius grew rather than shrank. One missing-resource answer now takes the bell and Home's action centre together — which also removes the "Home's card still works" contrast that made the #4110 / #4230 signature diagnosable the first two times.

Why a transient 404 is reachable — the predicate is STATUS-shaped

The reachability question the card left open does not need a server race to answer. isMissingResource is

e?.httpStatus === 404 || e?.status === 404 || errorCodeIs(err, 'OBJECT_NOT_FOUND')

and its two status arms are already pinned true with no error code at allsharedUserFeeds.isMissingResource.test.ts, "is true for a plain status 404 as well". On the producer side, @objectstack/client stamps error.httpStatus = res.status on every non-ok response, before it inspects the body (packages/client/src/index.ts).

So the 404 population reaching this seam is not one population but two, and they are indistinguishable here:

population source correct handling
permanent the registry's considered answer — assertObjectRegistered throws OBJECT_NOT_FOUND + 404 for a community build with no service-messaging an ANSWER (#4315): ready, empty, "You're all caught up", no error line
transient any 404 elsewhere in the transport — an edge router or reverse proxy with no healthy upstream mid-deploy, a host answering its catch-all for /api/v1/data/... before the API is mounted a lost race; today it costs the page its inbox until reload

Only the first is the answer #4315 ruled on. The second used to be given the first's treatment, permanently.

The change

packages/app-shell/src/hooks/sharedUserFeeds.ts only. Retirement keeps its meaning and gains a bound:

  • a retired feed re-probes at most UNAVAILABLE_PROBE_LIMIT (3) times, no more often than UNAVAILABLE_PROBE_MS (60s);
  • the cadence gates the timer and visibilitychange alike, so tab-flipping cannot spend the budget faster than the clock;
  • a probe that answers with rows revives the feed, restores its normal cadence and refills the budget — that is the only exit;
  • a probe that fails for any other reason spends one and changes nothing else: it does not drag a retired feed into error, so a 404-then-500 sequence never starts telling a community build its inbox is broken;
  • schedule()'s retired branch is not gated on pollMs > 0, so the non-polled sys_activity feed — whose only other re-read point was a consumer attaching, and which the retirement silenced just as permanently — gets the same bounded recovery.

Cost on a deployment that genuinely has no messaging pipeline: 3 extra reads across the whole page lifetime, then silence — against the ~360/hour an un-retired 10s poll would issue. The retired state is untouched: status stays ready, the value stays empty, no error is ever rendered.

Pins, and what the reverse verification measured

New: packages/app-shell/src/hooks/__tests__/sharedInboxFeed.transient404.test.tsx (8 cases).

Directions were predicted in the file header before running, and one of them is not the template's shape — the ceiling case fails downward on main (main issues fewer reads than the bound, not more), which is why it is written as an exact count rather than a <=.

Direction 1 — the fix absent (constants present, behaviour not): 6 red / 2 green, exactly the predicted split.

× re-probes after retiring, and comes back when the read answers   expected [] to have a length of 1
× brings BOTH surfaces back — the bell and Home read one feed      expected 1 to be 2
× resumes its normal cadence once revived                          expected 1 to be greater than 2
× spends its probe budget and then stops for good                  expected 1 to be 4      ← downward
✓ CONTROL: a permanently-missing object answers `ready`-and-empty, never an error
✓ does not let tab-flipping burn the budget faster than the cadence

Direction 2 — the retirement deleted (markUnavailable routed to markFailed), to prove the CONTROL is not vacuous:

× CONTROL: a permanently-missing object answers `ready`-and-empty  expected 'error' to be 'ready'
× does not let tab-flipping burn the budget                        expected 21 to be 1     ← the storm
× spends its probe budget and then stops for good                  expected 6 to be 4      ← upward

So the ceiling is a two-sided pin: it can fail upward (retry storm) and downward (no probes at all), and the #4315 control turns red the moment "404 is an answer" stops holding.

Amended pins

None. The #4315 ruling is asserted, not amended: HomeActionCenter.unansweredInbox.test.tsx's "a deployment with no inbox object at all is an ANSWER, not an error" and sharedInboxFeed.twoSurfaces.test.tsx's "still treats a MISSING inbox object as an answer, for both surfaces" both stay green with their assertions untouched, because the probes are invisible at those surfaces by design — they publish nothing. The new file's CONTROL case restates the same rule one layer down, at the store, where the probe count is observable.

Verification

Related: #4225, #4230, #4315, #4327, #4110.


Generated by Claude Code

…4289)

`sharedUserFeeds.markUnavailable()` was a one-way door — `refresh`,
`schedule` and `onVisibilityChange` all returned early on `unavailable`,
so one missing-resource answer retired the feed until reload or an
identity change. Since #4225 pointed the bell and Home's action centre
at one inbox feed, that took both panels together.

A retired feed now re-probes at most UNAVAILABLE_PROBE_LIMIT (3) times,
no more often than UNAVAILABLE_PROBE_MS (60s), on the timer and on
visibilitychange alike; a probe that answers with rows revives the feed
and restores its cadence. The retired STATE is unchanged: status stays
`ready`, value stays empty, no error is ever rendered (#4315).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 11, 2026 9:18pm

Request Review

@github-actions github-actions Bot added the tests label Aug 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
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) 489.12KB 108.41KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 150.04KB 39.79KB
fields (index.js) 228.37KB 56.62KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.98KB 10.85KB
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) 62.18KB 17.67KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 121.60KB 31.63KB
plugin-designer (index.js) 210.91KB 42.67KB
plugin-detail (index.js) 238.95KB 59.76KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.00KB 49.94KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.10KB 26.74KB
plugin-map (index.js) 18.05KB 5.80KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.60KB 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.96KB
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 (dashboard-filter-alias.js) 6.23KB 2.74KB
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) 3.05KB 1.52KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 21:32
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit ca269fe Aug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4289-inbox-feed-404-latch branch August 11, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The bell's inbox poll latches itself off permanently on one 404, while Home's card reading the same object does not

2 participants