Skip to content

fix(app-shell): an unloaded attachment list is not an empty one - #4691

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4684-attachments-unavailable-state
Aug 15, 2026
Merged

fix(app-shell): an unloaded attachment list is not an empty one#4691
yinlianghui merged 1 commit into
mainfrom
claude/issue-4684-attachments-unavailable-state

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4684

The defect

RecordAttachmentsPanel.refresh() split authorization out of its catch in PR #4685 and folded everything else back into setRows([]). So three different failures — a network failure (server unreachable, DNS, aborted request), a 5xx, and a 401 / AUTH_REQUIRED — all rendered:

No attachments yet. Upload a file to get started.

That is an affirmative claim about the record's contents, made by a panel that never got an answer, over a record that may hold thousands of files. Same defect class as #4269, one status over. The 401 is the sharpest case: an expired session is authentication, not authorization, so isPermissionError deliberately does not claim it (verified at source — it matches 403 / PERMISSION_DENIED / FORBIDDEN / an RLS denial only), which left it landing in the empty state.

The fix

The panel now carries the sibling four-way status vocabulary — loading / loaded / denied / unavailable — replacing the loading boolean and PR #4685's listDenied boolean with one enum. The render chain is the assertion discipline: every state meaning "the panel does not know" is answered before rows.length === 0 is allowed to mean "the record holds nothing".

  • unavailable (new) — any non-authz failure. Renders "We couldn't load the attachments for this record." with a Retry, and withdraws the Upload affordance.
  • denied — unchanged from PR fix(app-shell): a denied attachment list is not an empty one #4685. Its JSX block is untouched; only the condition it hangs on changed from listDenied to status === 'denied', so the rendered output is byte-identical.
  • loaded + zero rows — now the only branch entitled to the empty state, reached only after a read that came back.

Why unavailable keeps a retry and denied does not: a denial is permanent for this caller and retrying just re-earns the same 403, while an outage or a lapsed session are exactly what a second attempt fixes. Why Upload is withdrawn under unavailable too: offering an upload against a list the panel could not reach is the same over-assertion as the empty state it replaces — and the upload's own three-step presigned flow would fail on the same outage.

No leaking, same bar as PR #4685: the unavailable state renders the i18n sentence and nothing sourced from the error — no status code, no server message, no host — and setError is not called, because a failed list is a state of the panel rather than an error banner about something the user did.

This restores a house rule that had already landed twice as a bug fix: HomeActionCenter (#4235) may only say "You're all caught up" once the inbox has answered, and an unloadable app list (#4300) is UNKNOWN rather than "no default app".

The deliberate pin from PR #4685, rewritten not deleted

PR #4685 planted pins today's behaviour for a NON-authz failure: still the empty state precisely so this change would be conscious. It is rewritten in place as a NON-authz failure no longer reaches the DENIED state (that split is authz-only) — the same TypeError('Failed to fetch') input, now asserting the unavailable state and that the denied state has not crept outward to claim outages. What the pin protected (nobody widens this branch silently) survives as a stricter assertion.

Premises verified before building

  • The catch folds every non-authz failure into the empty state on current main — confirmed at source.
  • 401 is not claimed by isPermissionError — confirmed by reading the predicate.
  • Retry is safe, no double-fetch.refresh() sets status to loading synchronously and rows is already empty in this state, so the loading branch wins on the very next render and the Retry button — the only handler that calls refresh() — unmounts for the duration of the read. Covered by a test that counts find calls.
  • The "table not provisioned on an older stack" 404 is unaffected, and never depended on the catch swallowing it: the ObjectStack adapter's find() degrades a bare 404 to { data: [], total: 0 } (is404Error, A list request that 404s with OBJECT_API_DISABLED renders as the generic empty state — "this page cannot work" reads as "you have no records" #4408), so it resolves through the success path and still renders the empty state.

Two notes on the edges, both deliberate:

  • A panel rendered with no dataSource / recordId issues no read, so status stays loading (a spinner) instead of showing the empty state. Not forced to loaded (that would assert an empty record from zero evidence) nor to unavailable (nothing failed). Every guard dep is in the callback's dependency list, so a late-arriving prop re-runs the read on its own.
  • An OBJECT_API_DISABLED / OBJECT_API_METHOD_NOT_ALLOWED refusal folds into unavailable rather than getting its own fifth state. Honest (the panel still asserts nothing) but less precise than ListView.classifyLoadError's api-disabled, since the retry can never succeed. Filed separately as an observation rather than widened here.

Classifier reuse

No third classifier was written. isPermissionError from @object-ui/react is reused exactly as PR #4685 imported it. ListView.classifyLoadError was checked and is not importable — it is a module-local function in packages/plugin-list/src/ListView.tsx, not exported from the file or the package — and app-shell does not depend on plugin-list. Its five-way split is also finer than this panel's render needs: the panel only needs assert-vs-don't-assert, plus the denied/retryable distinction that decides the affordance.

Verification

Local gate union run after the final commit, at 37d57c64c (working tree clean, so the union ran on the tree that is HEAD):

GateResult
vitest run packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx24 passed (24)
vitest run packages/app-shell/ (full package)401 files, 3810 passed, 1 skipped, 0 failed
vitest run packages/i18n/45 files, 804 passed
type-check for app-shell + i18nScope: 2 of 47 workspace projects, both Done — filters matched, not a silent zero-match
eslint --quiet on all changed filesclean
pnpm run check:i18n-keys0 — every call-site key resolves; both inline defaults match their en value
pnpm run check:i18n-drift0 — 2 keys added, 0 en values changed
pnpm run check:control-bytesOK (4215 tracked text files)
node scripts/check-changeset-presence.mjsOK — 12 source files of 2 released packages, 1 changeset

Reverse verification, direction predicted before running: reverting only the component (tests kept) should turn red exactly the 8 tests that require record-attachments-unavailable and leave the other 16 green. Observed exactly that — 8 failed | 16 passed (24), with PR #4685's five denied tests, the empty-state test, the loaded test and the #2755 / #2970 suites all still green, so the new assertions are load-bearing and nothing else was disturbed. The fix was committed first; restoring it left the tree byte-identical to HEAD (git diff HEAD empty), and the suite was re-run green from that restored state.

Scope

RecordAttachmentsPanel.tsx + its test file + two new i18n keys (detail.attachmentsLoadFailed, detail.retryLoadAttachments) in all ten locale packs + a changeset. Nothing else touched.


Generated by Claude Code

RecordAttachmentsPanel's refresh() catch split authorization out in #4685 and
folded every other failure back into setRows([]), so a network failure, a 5xx
and a 401/AUTH_REQUIRED all rendered "No attachments yet. Upload a file to get
started." — an affirmative claim about the record's contents from a panel that
never got an answer.
The panel now carries the sibling four-way status vocabulary (loading / loaded
/ denied / unavailable): every state meaning "the panel does not know" is
answered before rows.length === 0 is allowed to mean "the record holds
nothing". A non-authz failure renders a distinct unavailable state with a
retry (unlike denied, an outage and a lapsed session are fixable by a second
attempt) and withdraws the Upload affordance. It leaks nothing from the error.
The empty state is now reserved for a genuine 200-with-zero-rows; the denied
state is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnQd8iMMUwXQEV1crFmQiQ
@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 15, 2026 9:34am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-DQyZRdlB.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)25.13KB5.40KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)38.46KB10.17KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.91KB108.67KB
core (index.js)3.79KB1.52KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)163.56KB44.83KB
fields (index.js)230.37KB57.17KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.84KB1.45KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.86KB12.91KB
plugin-charts (index.js)62.54KB17.83KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)121.87KB31.76KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)239.93KB60.01KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.72KB27.70KB
plugin-gantt (index.js)164.30KB40.02KB
plugin-grid (index.js)192.10KB51.24KB
plugin-kanban (index.js)52.74KB14.53KB
plugin-list (index.js)111.82KB27.23KB
plugin-map (index.js)18.16KB5.81KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.38KB11.09KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)83.81KB20.49KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)27.64KB9.44KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.26KB0.67KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.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 15, 2026 09:44
@yinlianghui
yinlianghui added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit 1eaf0a1Aug 15, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4684-attachments-unavailable-state branch August 15, 2026 09:44
Sign up for freeto 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.

RecordAttachmentsPanel renders "No attachments yet" for a network/5xx failure it never loaded

2 participants

@yinlianghui@claude