From 525e1385e0608a18068fda89e661f36f6586d233 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:49:28 +0000 Subject: [PATCH 1/3] fix(app-shell): a failed comments or activity read on a record page shows a load-failure notice with a Retry, not the empty state A 5xx or a request that never got an answer used to settle the discussion panel on its empty state and the timeline tab's badge on 0. The feed reads now record answered | refused | failed per source; a failed one draws a notice with a Retry that re-reads only the failed sources, directly. Refusals, empty answers and 404s keep today's handling. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jT854xvoBjntpsRE1F --- .changeset/29188-feed-load-failure.md | 19 + ...dDetailView.feedLoadFailure-29188.test.tsx | 362 ++++++++++++++++++ ...ecordDetailView.feedRefusal-11195.test.tsx | 11 +- .../app-shell/src/views/RecordDetailView.tsx | 130 +++++-- ui/packages/i18n/src/locales/en.ts | 3 + ui/packages/i18n/src/locales/zh.ts | 3 + .../src/RecordActivityTimeline.tsx | 76 +++- .../plugin-detail/src/RecordChatterPanel.tsx | 23 +- ...ActivityTimeline.loadFailed-29188.test.tsx | 157 ++++++++ .../src/renderers/record-activity.tsx | 15 +- .../src/renderers/record-chatter.tsx | 5 + .../plugin-detail/src/useDetailTranslation.ts | 5 + .../react/src/context/DiscussionContext.tsx | 19 + 13 files changed, 775 insertions(+), 53 deletions(-) create mode 100644 .changeset/29188-feed-load-failure.md create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.feedLoadFailure-29188.test.tsx create mode 100644 ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.loadFailed-29188.test.tsx diff --git a/.changeset/29188-feed-load-failure.md b/.changeset/29188-feed-load-failure.md new file mode 100644 index 00000000000..bff7f8c3a8e --- /dev/null +++ b/.changeset/29188-feed-load-failure.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/plugin-detail': patch +'@object-ui/react': patch +'@object-ui/i18n': patch +--- + +fix(app-shell): a record page whose comments or activity read fails (5xx, network) says it could not load them, with a Retry, instead of showing the empty state + +Clause-②: yes (widening) +Metadata-protocol: none + +On a record page, a comments read or an activity read that answered a server error used to settle the discussion panel on its empty state (「暂无评论」, "No activity recorded"), and the 动态 tab's badge on 0. A comment users wrote looked lost, and an outage was visible only in developer tools. + +- **A failed read has its own state.** A 5xx, or a request that never got an answer, now shows "We couldn't load the comments for this record." (or the activity) with a Retry, in place of the empty state, on the 动态 panel, the 动态 tab and the discussion panel. Rows the other read answered stay on screen with the notice above them, and the count is unknown (no number) until the read answers. +- **Retry reads only what failed**, directly, never the page payload again. A retry that fails again keeps the notice. +- **Unchanged:** a refusal (401 / 403) keeps its no-permission state and is never retried; an empty 200, a 404 (a table the deployment does not serve), an object that withholds its API and a 400 keep the empty state. +- **New public members.** `DiscussionContextValue` gains the optional `activityFailed`, `commentsFailed` and `onRetryFeed`; `RecordActivityTimeline` and `RecordChatterPanel` gain the same three optional props; the `detail` locale namespace gains `activityLoadFailed`, `commentsLoadFailed` and `retryLoadFeed` (en, zh). +- The attachments panel already showed its own load-failure state with a Retry for a failed read; that is now pinned for the record page payload's attachments part too. diff --git a/ui/packages/app-shell/src/views/RecordDetailView.feedLoadFailure-29188.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.feedLoadFailure-29188.test.tsx new file mode 100644 index 00000000000..8909e3ce3ee --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.feedLoadFailure-29188.test.tsx @@ -0,0 +1,362 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#29188 — a FAILED feed read is not an empty feed. + * + * Measured on main before this change (this rig, the real adapter): a 500 on + * the comments read, whether the page payload's `discussion` part or a direct + * `sys_comment` GET, settled the 动态 panel on "No activity recorded" and the + * 动态 tab's badge on 0. On staging that hid a `DATABASE_ERROR` on every + * hosted environment (cloud#3485), so comments users wrote looked lost. + * + * Pinned here, on the rendered page: + * 1. a 500 on the comments read (payload part, and direct GET) shows the + * load-failure notice with a Retry, never the empty state, and the 动态 + * tab names no count; + * 2. the same for the activity read; + * 3. Retry re-reads only the failed source, directly, and the comments + * replace the notice once the server answers; + * 4. control: an empty 200 still shows the empty state, with no notice; + * 5. a refusal (403) keeps its no-permission state and offers no Retry; + * 6. attachments: a 500 on the payload's attachments part shows the panel's + * own load-failure state with a Retry and leaves the badge's count + * unknown (main already did; pinned, no attachments code changed). + * + * The rig is `RecordDetailView.recordOpenOneRequest-28470.test.tsx`'s. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +import { MetadataCtx } from '@object-ui/react'; +import { RelatedCountStore } from '@object-ui/components'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; +import { __resetRecordApprovalsStore } from '../hooks/useRecordApprovals'; +import { RecordDetailView } from './RecordDetailView'; +import { recordAttachmentsCountField } from './recordPagePayload'; +// The `record:attachments` block the synthesized page places for `enable.files` +// (registered by the package entry, which this file does not import). +import './record-attachments-renderer'; + +const BASE = 'http://test.local'; +const OBJECT = 'os_29188_contact'; +const CHILD = 'os_29188_task'; + +const contactDef = { + name: OBJECT, + label: 'Contact', + enable: { files: true }, + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + account: { label: 'Account', type: 'lookup', reference: 'os_29188_account' }, + }, +}; +const accountDef = { name: 'os_29188_account', label: 'Account', fields: { id: { label: 'Id', type: 'text' }, name: { label: 'Name', type: 'text' } } }; +const childDef = { + name: CHILD, + label: 'Task', + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + contact: { label: 'Contact', type: 'lookup', reference: OBJECT }, + owner: { label: 'Owner', type: 'lookup', reference: 'sys_user' }, + }, +}; +const ALL = [contactDef, accountDef, childDef]; + +const COMMENT = { id: 'cm1', thread_id: `${OBJECT}:c1`, author_id: 'u2', author_name: 'Grace', body: 'hello from the payload', created_at: '2026-10-01T10:00:00.000Z' }; +const ACTIVITY = { id: 'ac1', type: 'created', object_name: OBJECT, record_id: 'c1', actor_name: 'Grace', summary: 'created the contact', timestamp: '2026-09-30T10:00:00.000Z' }; +const ATTACHMENT = { id: 'f1', parent_object: OBJECT, parent_id: 'c1', file_id: 'file-1', file_name: 'contract.pdf', created_at: '2026-10-02T10:00:00.000Z' }; + +interface Answers { + /** Answer the related rows' owner as a bare id even when the request expands it. */ + ignoreExpand?: boolean; + activity?: unknown; + discussion?: unknown; + attachments?: unknown; + failGet?: string[]; +} + +interface Held { method: string; path: string; body: any; released: boolean; release: () => void } + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + +function pageAnswer(id: string, body: any, answers: Answers) { + return { + success: true, + data: { + object: OBJECT, + id, + record: { id, name: `Contact ${id}`, account: 'a1' }, + related: (body.related ?? []).map((r: any) => ({ + object: r.object, + field: r.field, + total: 2, + records: [0, 1].slice(0, r.top).map((i) => ({ + id: `t${i}`, + name: `Task ${i}`, + contact: id, + owner: !answers.ignoreExpand && r.expand?.includes('owner') ? { id: 'u9', name: 'Una Owner' } : 'u9', + })), + })), + ...(body.fieldAccess ? { fieldAccess: { object: OBJECT, recordId: id, readable: ['name', 'account'], editable: ['name'], writable: true } } : {}), + ...(body.explain ? { explain: Object.fromEntries(body.explain.map((op: string) => [op, { allowed: true, record: { recordId: id, visible: true } }])) } : {}), + ...(body.approvals ? { approvals: { requests: [], lock: { locked: false } } } : {}), + ...(body.discussion ? { discussion: answers.discussion ?? { comments: [COMMENT], reactions: { records: [] } } } : {}), + ...(body.activity ? { activity: answers.activity ?? { total: 1, records: [ACTIVITY] } } : {}), + ...(body.attachments ? { attachments: answers.attachments ?? { total: 3, records: [ATTACHMENT] } } : {}), + }, + }; +} + +function answerFor(h: Held, answers: Answers): unknown { + const meta = /\/api\/v1\/meta\/object\/([^/?]+)/.exec(h.path); + if (meta) return { success: true, data: ALL.find((o) => o.name === meta[1]) ?? null }; + const page = /\/api\/v1\/data\/[^/?]+\/([^/?]+)\/page$/.exec(h.path); + if (page) return pageAnswer(page[1], h.body, answers); + const one = new RegExp(`/api/v1/data/${OBJECT}\\?.*\\["id","=","([^"]+)"\\]`).exec(h.path); + if (one) return { success: true, data: { records: [{ id: one[1], name: `Contact ${one[1]}`, account: 'a1' }], total: 1 } }; + if (h.path.startsWith('/api/v1/data/sys_user?')) return { success: true, data: { records: [{ id: 'u9', name: 'Una Owner' }], total: 1 } }; + return { success: true, data: { records: [], total: 0 } }; +} + +function heldTransport(answers: Answers) { + const held: Held[] = []; + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? 'GET').toUpperCase(); + const path = decodeURIComponent(url.replace(BASE, '')); + if (path.endsWith('/api/v1/discovery')) return Promise.resolve(json({ success: true, data: { capabilities: {}, routes: {} } })); + return new Promise((resolve) => { + const h: Held = { + method, path, + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + released: false, + release: () => { if (h.released) return; h.released = true; const fail = (answers.failGet ?? []).some((o) => method === 'GET' && path.startsWith(`/api/v1/data/${o}?`)); resolve(fail ? new Response(JSON.stringify({ success: false, error: { code: 'DATABASE_ERROR', message: 'db down' } }), { status: 500, headers: { 'content-type': 'application/json' } }) : json(answerFor(h, answers))); }, + }; + held.push(h); + }); + }); + return { fetch, held }; +} + +async function idle() { + await act(async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); }); +} +async function settle(held: Held[]) { + await idle(); + for (let round = 0; round < 12; round++) { + const pending = held.filter((h) => !h.released); + if (pending.length === 0) return; + await act(async () => { for (const h of pending) h.release(); await new Promise((r) => setTimeout(r, 0)); }); + await idle(); + } +} + +const readsOf = (held: Held[], object: string) => held.filter((h) => h.method === 'GET' && h.path.startsWith(`/api/v1/data/${object}?`)); +/** The retired stream reads, by object. */ +const STREAMS = ['sys_comment', 'sys_comment_reaction', 'sys_activity', 'sys_attachment']; +const streamReads = (held: Held[]) => STREAMS.flatMap((o) => readsOf(held, o).map(() => o)); + +beforeEach(() => { + cleanup(); + __clearRecordEditableCache(); + __resetRecordApprovalsStore(); + RelatedCountStore._reset(); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 }); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +async function boot(answers: Answers = {}) { + const { fetch, held } = heldTransport(answers); + vi.stubGlobal('fetch', fetch); + const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch, autoReconnect: false }); + await ds.connect(); + const metadata = { + objects: ALL, pages: [], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: async () => [], getItem: async () => null, getItemsByType: () => [], + } as never; + const open = () => render( + + + + {}} objectNameOverride={OBJECT} recordIdOverride="c1" /> + + + , + ); + return { held, open, ds }; +} + +const FAIL = { error: { code: 'DATABASE_ERROR', status: 500, message: 'db down' } }; +const EMPTY_ACTIVITY = 'No activity recorded'; +const COMMENTS_FAILED = "We couldn't load the comments for this record."; +const ACTIVITY_FAILED = "We couldn't load the activity for this record."; +const NO_COMMENTS = { comments: [], reactions: { records: [] } }; +const NO_ACTIVITY = { total: 0, records: [] }; + +const peekOf = (c: HTMLElement) => c.querySelector('[data-record-timeline-peek]') as HTMLElement; +const failedSources = (c: HTMLElement) => + Array.from(c.querySelectorAll('[data-failed-source]')).map((e) => e.getAttribute('data-failed-source')); +const retryButton = (c: HTMLElement) => c.querySelector('[data-feed-retry]') as HTMLButtonElement | null; +/** The 动态 tab's label, badge included. */ +const timelineTab = (c: HTMLElement) => + Array.from(c.querySelectorAll('[role="tab"]')).find((t) => /^Activity/.test(t.textContent ?? ''))?.textContent; +/** Open the record over an adapter that reads every stream on its own. */ +async function bootDirect(answers: Answers) { + const booted = await boot(answers); + Object.assign(booted.ds, { getRecordPage: undefined, prefetchRecordPage: undefined }); + return booted; +} + +describe('objectstack#29188 — a failed comments read shows the load-failure notice', () => { + it('a 500 discussion part shows the notice with a Retry, not the empty state, and no count', async () => { + const { held, open } = await boot({ discussion: FAIL, activity: NO_ACTIVITY, attachments: { total: 0, records: [] } }); + const view = open(); + await settle(held); + const peek = peekOf(view.container); + expect(failedSources(peek)).toEqual(['comments']); + expect(peek.textContent).toContain(COMMENTS_FAILED); + expect(peek.textContent).not.toContain(EMPTY_ACTIVITY); + expect(retryButton(peek)).not.toBeNull(); + // Only the sentence: no status code, no server text, no table name. + expect(peek.textContent).not.toMatch(/500|DATABASE_ERROR|db down|sys_comment/); + // The stream's count is unknown, not 0. + expect(timelineTab(view.container)).toBe('Activity'); + }); + + it('a 500 on a direct sys_comment read shows the same notice', async () => { + const { held, open } = await bootDirect({ failGet: ['sys_comment'] }); + const view = open(); + await settle(held); + const peek = peekOf(view.container); + expect(failedSources(peek)).toEqual(['comments']); + expect(peek.textContent).not.toContain(EMPTY_ACTIVITY); + expect(timelineTab(view.container)).toBe('Activity'); + }); + + it('Retry re-reads only the failed comments, directly, and draws them once the server answers', async () => { + const answers: Answers = { discussion: FAIL, activity: NO_ACTIVITY }; + const { held, open } = await boot(answers); + const view = open(); + await settle(held); + expect(streamReads(held)).toEqual([]); + expect(retryButton(peekOf(view.container))).not.toBeNull(); + + await act(async () => { fireEvent.click(retryButton(peekOf(view.container))!); }); + await settle(held); + + // One direct comment read (and its reactions), never the activity again. + expect(readsOf(held, 'sys_comment')).toHaveLength(1); + expect(readsOf(held, 'sys_activity')).toHaveLength(0); + // The direct read answers an empty thread in this rig: the notice goes and + // the empty state may speak now. + const peek = peekOf(view.container); + expect(failedSources(peek)).toEqual([]); + expect(retryButton(peek)).toBeNull(); + expect(peek.textContent).toContain(EMPTY_ACTIVITY); + }); + + it('a retry that fails again keeps the notice', async () => { + const { held, open } = await bootDirect({ failGet: ['sys_comment'] }); + const view = open(); + await settle(held); + await act(async () => { fireEvent.click(retryButton(peekOf(view.container))!); }); + await settle(held); + expect(readsOf(held, 'sys_comment').length).toBeGreaterThanOrEqual(2); + expect(failedSources(peekOf(view.container))).toEqual(['comments']); + expect(peekOf(view.container).textContent).not.toContain(EMPTY_ACTIVITY); + }); +}); + +describe('objectstack#29188 — a failed activity read shows the load-failure notice', () => { + it('a 500 activity part shows the notice beside the comments that answered', async () => { + const { held, open } = await boot({ activity: FAIL }); + const view = open(); + await settle(held); + const peek = peekOf(view.container); + expect(failedSources(peek)).toEqual(['activity']); + expect(peek.textContent).toContain(ACTIVITY_FAILED); + // The comment the discussion part carried still draws. + expect(peek.textContent).toContain('hello from the payload'); + }); + + it('a 500 on a direct sys_activity read shows the notice', async () => { + const { held, open } = await bootDirect({ failGet: ['sys_activity'] }); + const view = open(); + await settle(held); + expect(failedSources(peekOf(view.container))).toEqual(['activity']); + expect(peekOf(view.container).textContent).not.toContain(EMPTY_ACTIVITY); + }); +}); + +describe('objectstack#29188 — controls', () => { + it('an empty 200 payload still shows the empty state, with no notice', async () => { + const { held, open } = await boot({ discussion: NO_COMMENTS, activity: NO_ACTIVITY }); + const view = open(); + await settle(held); + const peek = peekOf(view.container); + expect(peek.textContent).toContain(EMPTY_ACTIVITY); + expect(failedSources(peek)).toEqual([]); + expect(retryButton(peek)).toBeNull(); + }); + + it('an empty 200 on direct reads still shows the empty state, with no notice', async () => { + const { held, open } = await bootDirect({}); + const view = open(); + await settle(held); + expect(peekOf(view.container).textContent).toContain(EMPTY_ACTIVITY); + expect(failedSources(peekOf(view.container))).toEqual([]); + }); + + it('a refused (403) discussion part keeps the no-permission state and offers no Retry', async () => { + const { held, open } = await boot({ + discussion: { error: { code: 'PERMISSION_DENIED', status: 403, message: 'No.' } }, + activity: NO_ACTIVITY, + }); + const view = open(); + await settle(held); + const peek = peekOf(view.container); + expect(peek.querySelector('[data-denied-source="comments"]')).not.toBeNull(); + expect(failedSources(peek)).toEqual([]); + expect(retryButton(peek)).toBeNull(); + }); +}); + +describe('objectstack#29188 — attachments (pin: main already shows the failure)', () => { + it('a 500 attachments part shows the panel\'s load-failure state with a Retry, and no count', async () => { + const { held, open } = await boot({ attachments: FAIL }); + const view = open(); + await settle(held); + expect(view.container.querySelector('[data-testid="record-attachments-unavailable"]')).not.toBeNull(); + expect(view.container.textContent).not.toContain('No attachments yet'); + expect(RelatedCountStore.get('sys_attachment', recordAttachmentsCountField(OBJECT), 'c1')).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.feedRefusal-11195.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.feedRefusal-11195.test.tsx index a9e05ecb7eb..429375aa802 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.feedRefusal-11195.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.feedRefusal-11195.test.tsx @@ -17,7 +17,8 @@ * 2. the control: a 200 with zero rows shows the empty state, unchanged; * 3. a 404 is not a refusal, so the empty state stays; * 4. a 500 is not a refusal either, so the panel never says "no permission" - * for it; + * for it; since objectstack#29188 it shows the load-failure notice, not + * the empty state; * 5. a refused `sys_comment` read beside an answered `sys_activity` read * keeps the activity rows and says the comments are withheld. * @@ -260,9 +261,11 @@ describe('RecordDetailView: a refused sys_activity read shows the no-permission renderDetail(dataSource); - // The panel has no error state of its own, so a failure that is not a - // refusal still settles on the empty state. - expect(await screen.findByText(EMPTY_COMMENTS)).toBeTruthy(); + // objectstack#29188: a failure that is not a refusal shows the panel's + // load-failure notice, never the empty state and never "no permission". + const notice = await screen.findByTestId('activity-load-failed'); + expect(notice.querySelector('[data-failed-source="activity"]')).not.toBeNull(); + expect(screen.queryByText(EMPTY_COMMENTS)).toBeNull(); expect(screen.queryByTestId('activity-access-denied')).toBeNull(); expect(screen.queryByText(ACTIVITY_DENIED)).toBeNull(); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index cd273d1e20d..81483e431a3 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -396,15 +396,40 @@ function pageShowsNodeOnOpen(page: unknown, types: ReadonlySet, activeTa } /** - * Which of a record's two feed reads the server REFUSED (objectui#11195): - * 401 / 403, or a permission envelope, judged by plugin-detail's - * `isRefusedFeedRead`. A read that answered, even with zero rows, and a read - * that failed for any other reason are both `false` here. Only a refusal is a - * different answer from "nothing to show". + * How each of a record's two feed reads ended, per source. + * + * - `answered`: the read answered, even with zero rows (a bare 404, a table + * this deployment does not provision, answers empty), or it was refused + * for a reason no retry or grant changes (the object withholds its API, a + * 400). The panel draws the rows, or its empty state. + * - `refused`: the server REFUSED it (objectui#11195): 401 / 403, or a + * permission envelope, judged by plugin-detail's `isRefusedFeedRead`. The + * panel says the reader may not see that half. Never retried. + * - `failed`: it FAILED (objectstack#29188): a 5xx or a network failure + * (`feedReadVerdictOf`). The panel says it could not load that + * half and offers a Retry, which re-reads only the failed sources. */ -interface FeedRefusal { - activity: boolean; - comments: boolean; +type FeedReadVerdict = 'answered' | 'refused' | 'failed'; +type FeedSource = 'activity' | 'comments'; +type FeedReadVerdicts = Record; + +/** + * A feed read's rejection, as a {@link FeedReadVerdict}. `failed` is a 5xx or + * a rejection with no status at all (the request never got an answer): + * `classifyLoadError`'s `network`, less the 4xx statuses it also folds there. + * A 404 (a system table this deployment does not serve) stays the empty + * answer it has always been. + */ +function feedReadVerdictOf(err: unknown): FeedReadVerdict { + if (isRefusedFeedRead(err)) return 'refused'; + if (classifyLoadError(err) !== 'network') return 'answered'; + const e = err as { httpStatus?: unknown; status?: unknown; statusCode?: unknown; message?: unknown } | null; + let status = [e?.httpStatus, e?.status, e?.statusCode].find((v): v is number => typeof v === 'number'); + if (status === undefined) { + const m = /HTTP (\d{3})\b/.exec(String(e?.message ?? '')); + if (m) status = Number(m[1]); + } + return status === undefined || status >= 500 ? 'failed' : 'answered'; } /** @@ -888,10 +913,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // The discussion feed, stored PER RECORD (objectui#3268). See the // `feedRecordKey` block below for why this is a map and not a `FeedItem[]`. const [feedItemsByRecord, setFeedItemsByRecord] = useState>({}); - // Which feed reads were refused, stored under the same per-record key as the - // rows (objectui#11195), so a refusal on record A never reaches record B's - // panel. - const [feedRefusalByRecord, setFeedRefusalByRecord] = useState>({}); + // How each feed read ended (refused, objectui#11195; failed, + // objectstack#29188), stored under the same per-record key as the rows, so a + // refusal or a failure on record A never reaches record B's panel. + const [feedVerdictByRecord, setFeedVerdictByRecord] = useState>({}); + // objectstack#29188 — the sources a Retry asked to read again, per record. + // The feed effect takes them when it runs on the bumped nonce, and reads only + // those, directly (never from the page payload, which already answered). + const feedRetryRef = useRef(new Map>()); + const [feedRetryNonce, setFeedRetryNonce] = useState(0); // objectstack#28737 — the 动态 stream's count the page payload answered, per // record (`recordPageTimeline`): `null` when it carried none. const [timelineCountByRecord, setTimelineCountByRecord] = useState>({}); @@ -2973,7 +3003,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // a spinner while the re-read confirms them (#3205). const feedRecordKey = objectName && pureRecordId ? `${objectName}:${pureRecordId}` : null; const feedItems = (feedRecordKey ? feedItemsByRecord[feedRecordKey] : undefined) ?? EMPTY_FEED; - const feedRefusal = feedRecordKey ? feedRefusalByRecord[feedRecordKey] : undefined; + const feedVerdict = feedRecordKey ? feedVerdictByRecord[feedRecordKey] : undefined; const feedFetchKey = dataSource && feedRecordKey && (feedsEnabled || activitiesEnabled) ? feedRecordKey : null; const [settledFeedKey, setSettledFeedKey] = useState(null); @@ -3031,28 +3061,35 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // The payload this open asked, when it is this record's: the discussion // and the activity it carries are taken from it, never read again. A read // it was not asked for is this effect's own, as before. + // objectstack#29188 — a Retry of this record's failed reads: only those + // sources are read, each directly, and the page's stream count is left as + // it stands. + const retrying = feedRetryRef.current.get(threadId) ?? null; + if (retrying) feedRetryRef.current.delete(threadId); const opened = recordPageRead.current; - const fromPage = opened && opened.feedKey === threadId ? opened : null; + const fromPage = !retrying && opened && opened.feedKey === threadId ? opened : null; // The two reads below run in PARALLEL and are collected here so the // loading flag can close over BOTH of them — see the `allSettled` at the // end of this effect. const inFlight: Promise[] = []; - // objectui#11195: each read's verdict on REFUSAL, recorded under THIS - // record's key when it settles. An answered read clears it, so a grant - // the member receives shows on the next read. Returning `prev` for an - // unchanged verdict keeps the map's identity when nothing moved. Nothing - // here re-issues a read: a refusal is recorded, never retried. - const recordRefusal = (source: keyof FeedRefusal, refused: boolean) => { - setFeedRefusalByRecord(prev => { + // objectui#11195 / objectstack#29188: each read's verdict, recorded under + // THIS record's key when it settles. An answered read clears a refusal or + // a failure, so a grant the member receives, or a server that recovered, + // shows on the next read. Returning `prev` for an unchanged verdict keeps + // the map's identity when nothing moved. Nothing here re-issues a read: a + // refusal is recorded, never retried, and a failure waits for the panel's + // Retry. + const recordVerdict = (source: FeedSource, verdict: FeedReadVerdict) => { + setFeedVerdictByRecord(prev => { const current = prev[threadId]; - if ((current?.[source] ?? false) === refused) return prev; + if ((current?.[source] ?? 'answered') === verdict) return prev; return { ...prev, [threadId]: { - activity: current?.activity ?? false, - comments: current?.comments ?? false, - [source]: refused, + activity: current?.activity ?? 'answered', + comments: current?.comments ?? 'answered', + [source]: verdict, }, }; }); @@ -3089,14 +3126,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri return toReactions(parsed); }; - const commentsRead: Promise<{ data?: any[]; reactionRows?: Array> | null }> | null = !feedsEnabled + const commentsRead: Promise<{ data?: any[]; reactionRows?: Array> | null }> | null = !feedsEnabled || (retrying && !retrying.has('comments')) ? null : fromPage?.discussion ? recordPageComments(fromPage.payload) : dataSource.find('sys_comment', { $filter: { thread_id: threadId }, $orderby: { created_at: 'asc' } }); if (commentsRead) inFlight.push(commentsRead .then(async (res: any) => { - recordRefusal('comments', false); + recordVerdict('comments', 'answered'); if (!res?.data?.length) return; // The `records` store (objectui#12078): ONE batched read of the // comments' reaction rows (paged only past REACTION_READ_COMMENT_IDS @@ -3171,8 +3208,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }) // A refused comment read is the same panel's other half (objectui#11195): // the panel says the member may not see the comments, instead of "No - // comments yet". Any other failure still lands on the empty state. - .catch((err: unknown) => { recordRefusal('comments', isRefusedFeedRead(err)); })); + // comments yet". A failed one (5xx, network) says the comments could not + // be loaded, with a Retry (objectstack#29188). + .catch((err: unknown) => { recordVerdict('comments', feedReadVerdictOf(err)); })); // M10.11: Fetch sys_activity rows for this record and merge into the // timeline. plugin-audit's writers populate sys_activity on every @@ -3208,7 +3246,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // activity to sees the panel say so, not "no activity". The verdict is // `isRefusedFeedRead`, the one the `record:activity` block's self-fetch // uses, so the two surfaces cannot disagree about what a refusal is. - const activityRead: Promise | null = !activitiesEnabled + const activityRead: Promise | null = !activitiesEnabled || (retrying && !retrying.has('activity')) ? null : fromPage?.activity // objectstack#28470 — the payload's activity: the newest rows it @@ -3220,7 +3258,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // Collected with the reads, so the panel leaves its loading state with the // count it names. A read without the payload holds the whole window and // counts its own rows. - if (fromPage?.timeline) { + if (retrying) { + // A Retry keeps the stream's count as it stands. + } else if (fromPage?.timeline) { inFlight.push(recordPageTimeline(fromPage.payload).then((count) => { setTimelineCountByRecord(prev => ({ ...prev, [threadId]: count })); })); @@ -3229,7 +3269,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } if (activityRead) inFlight.push(activityRead .then((res: any) => { - recordRefusal('activity', false); + recordVerdict('activity', 'answered'); // objectstack#28737 — the payload asked only the 动态 panel's rows and // the record holds more: the 动态 tab reads the window when it opens. const peek = !!fromPage?.activity @@ -3248,7 +3288,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri [threadId]: mergeFeedRows(prev[threadId] ?? EMPTY_FEED, mapped), })); }) - .catch((err: unknown) => { recordRefusal('activity', isRefusedFeedRead(err)); })); + .catch((err: unknown) => { recordVerdict('activity', feedReadVerdictOf(err)); })); // The panel leaves the loading state exactly once, when BOTH reads have // answered. `allSettled` over promises that already carry their own @@ -3264,7 +3304,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }); return () => { cancelled = true; }; - }, [dataSource, objectName, pureRecordId, currentUser, feedsEnabled, activitiesEnabled, feedFetchKey, reactionStore, feedWanted, feedPlanReady]); + }, [dataSource, objectName, pureRecordId, currentUser, feedsEnabled, activitiesEnabled, feedFetchKey, reactionStore, feedWanted, feedPlanReady, feedRetryNonce]); + + // objectstack#29188 — the load-failure notice's Retry: read this record's + // FAILED feed reads again, and only those. The feed shows its loading state + // until they settle (a panel with rows keeps them), and each read records + // its new verdict as it lands. + const handleRetryFeed = useCallback(() => { + if (!feedRecordKey || !feedVerdict) return; + const failed = new Set(); + if (feedVerdict.activity === 'failed') failed.add('activity'); + if (feedVerdict.comments === 'failed') failed.add('comments'); + if (failed.size === 0) return; + feedRetryRef.current.set(feedRecordKey, failed); + setSettledFeedKey(null); + setFeedRetryNonce(n => n + 1); + }, [feedRecordKey, feedVerdict]); // ── The 动态 tab's activity window (objectstack#28737) ───────────────── // @@ -4867,8 +4922,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri void; /** * objectstack#27055, Target 3 — `stream` is the record page's 动态 stream * in full (the 动态 tab): the filter chips 全部 / 活动 / 讨论 / 变更 on the @@ -321,6 +334,9 @@ export const RecordActivityTimeline: React.FC = ({ emptyLabel, activityDenied = false, commentsDenied = false, + activityFailed = false, + commentsFailed = false, + onRetryFeed, variant = 'panel', streamFilter = 'all', onStreamFilterChange, @@ -372,6 +388,48 @@ export const RecordActivityTimeline: React.FC = ({ ) : null; + // objectstack#29188: which of the feed's reads FAILED (a 5xx or a network + // failure), one line per failed source, and a Retry when the host offers + // one. Placed where the refusal notice is: in place of the empty state with + // no rows, above the rows with some. A failed read is not "nothing here", + // and only the i18n sentence is rendered: no status code, no server text. + const failedNotice = + activityFailed || commentsFailed ? ( +
+ {activityFailed && ( +

+

+ )} + {commentsFailed && ( +

+

+ )} + {onRetryFeed && ( + + )} +
+ ) : null; + // A read that did not answer, refused or failed: the rows on screen (if + // any) are not the whole story, and the count is unknown. + const readUnanswered = activityDenied || commentsDenied || activityFailed || commentsFailed; + const readNotice = readUnanswered ? ( + <> + {deniedNotice} + {failedNotice} + + ) : null; + const activeFilter = controlledFilter ?? internalFilter; const showFilter = config?.showFilterToggle !== false; const showCommentInput = config?.showCommentInput !== false && !!onAddComment; @@ -835,7 +893,7 @@ export const RecordActivityTimeline: React.FC = ({ )} )} - {filtered.length > 0 && deniedNotice &&
{deniedNotice}
} + {filtered.length > 0 && readNotice &&
{readNotice}
} {loading && filtered.length === 0 ? (
= ({ {t('detail.loading')}
- ) : (activityDenied || commentsDenied) && filtered.length === 0 ? ( -
{deniedNotice}
+ ) : readUnanswered && filtered.length === 0 ? ( +
{readNotice}
) : filtered.length === 0 ? (
{emptyLabel ?? t('detail.noActivity')} @@ -907,7 +965,7 @@ export const RecordActivityTimeline: React.FC = ({ {/* A refused read leaves the count unknown, not zero: the rows the reader may not see are not in `filtered`, so a number here would state what the server withheld. */} - {!activityDenied && !commentsDenied && ( + {!readUnanswered && ( ({filtered.length}) @@ -980,7 +1038,7 @@ export const RecordActivityTimeline: React.FC = ({ The rows stay, and the notice above them says which half the reader may not see. Without it the rows would read as the whole story. With no rows, the notice is the branch below instead. */} - {filtered.length > 0 && deniedNotice} + {filtered.length > 0 && readNotice} {/* Timeline. The loading branch comes FIRST because "still fetching" and "no @@ -1005,15 +1063,17 @@ export const RecordActivityTimeline: React.FC = ({ {t('detail.loading')}
- ) : (activityDenied || commentsDenied) && filtered.length === 0 ? ( + ) : readUnanswered && filtered.length === 0 ? ( // objectui#11195: a REFUSED read is not an empty one. This branch // comes before the empty state because "you may not see this" and // "there is nothing here" are different answers, and only the first // is true when the server refused the read. The empty state's copy // is a claim about the record, and a refusal gives no evidence for // it. Only the i18n sentence is rendered: no status code, no server - // message, no object name. - deniedNotice + // message, no object name. objectstack#29188: a FAILED read (5xx, + // network) is not an empty one either, for the same reason; it + // says the read failed and offers a Retry. + readNotice ) : filtered.length === 0 ? ( collapseWhenEmpty ? null : ( void; className?: string; } @@ -99,6 +107,9 @@ export const RecordChatterPanel: React.FC = ({ onUploadAttachments, activityDenied, commentsDenied, + activityFailed, + commentsFailed, + onRetryFeed, className, }) => { const position = config?.position ?? 'right'; @@ -125,10 +136,10 @@ export const RecordChatterPanel: React.FC = ({ */ const feedAria = useRecordAriaProps(config?.feed?.aria); - // A refused read leaves the count unknown, not zero (see the timeline's - // header), so the collapsed toggle names the panel without one. + // A refused or failed read leaves the count unknown, not zero (see the + // timeline's header), so the collapsed toggle names the panel without one. const showLabel = - activityDenied || commentsDenied + activityDenied || commentsDenied || activityFailed || commentsFailed ? t('detail.openDiscussion') : t('detail.showDiscussion', { count: items.length }); @@ -213,6 +224,9 @@ export const RecordChatterPanel: React.FC = ({ emptyLabel={t('detail.noCommentsYet')} activityDenied={activityDenied} commentsDenied={commentsDenied} + activityFailed={activityFailed} + commentsFailed={commentsFailed} + onRetryFeed={onRetryFeed} className="border-0 shadow-none" /> @@ -277,6 +291,9 @@ export const RecordChatterPanel: React.FC = ({ emptyLabel={t('detail.noCommentsYet')} activityDenied={activityDenied} commentsDenied={commentsDenied} + activityFailed={activityFailed} + commentsFailed={commentsFailed} + onRetryFeed={onRetryFeed} /> diff --git a/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.loadFailed-29188.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.loadFailed-29188.test.tsx new file mode 100644 index 00000000000..940022dc28e --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.loadFailed-29188.test.tsx @@ -0,0 +1,157 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * A FAILED feed read is not an empty feed (objectstack#29188). + * + * The record page's comments read answered a 500 (`DATABASE_ERROR`) on every + * hosted environment for days, and the discussion panel said 「暂无评论」 / + * "No comments yet": comments users wrote looked lost. The host now reports a + * failed read (`commentsFailed` / `activityFailed` on the DiscussionContext, + * beside the refusal flags of objectui#11195) and offers a re-read + * (`onRetryFeed`). The timeline renders a load-failure notice with a Retry in + * place of the empty state, keeps the rows it holds, and names no count. + * + * Read through the real `en` and `zh` packs and the `record:chatter` block + * under a host `DiscussionContext`, the way the record page mounts it; the + * `peek` face (the 动态 panel) directly. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { DiscussionContextProvider, RecordContextProvider } from '@object-ui/react'; +import type { FeedItem } from '@object-ui/types'; +import { RecordChatterRenderer } from '../renderers/record-chatter'; +import { RecordActivityTimeline } from '../RecordActivityTimeline'; + +const COMMENTS_FAILED = "We couldn't load the comments for this record."; +const ACTIVITY_FAILED = "We couldn't load the activity for this record."; +const NO_COMMENTS = 'No comments yet'; + +type HostItems = React.ComponentProps['items']; + +function mountChatter( + ctx: { + items: HostItems; + commentsFailed?: boolean; + activityFailed?: boolean; + commentsDenied?: boolean; + onRetryFeed?: () => void; + loading?: boolean; + }, + language: 'en' | 'zh' = 'en', +) { + return render( + + + + + + + , + ); +} + +const failedSources = () => + Array.from(document.querySelectorAll('[data-failed-source]')).map((e) => e.getAttribute('data-failed-source')); +const retry = () => document.querySelector('[data-feed-retry]') as HTMLButtonElement | null; +const heading = () => screen.getByRole('heading', { level: 2 }); + +afterEach(() => cleanup()); + +describe('a failed comments read shows the load-failure notice (objectstack#29188)', () => { + it('shows the notice with a Retry in place of "No comments yet", and no count', () => { + const onRetryFeed = vi.fn(); + mountChatter({ items: [], commentsFailed: true, onRetryFeed }); + + const notice = screen.getByTestId('activity-load-failed'); + expect(notice.textContent).toContain(COMMENTS_FAILED); + expect(failedSources()).toEqual(['comments']); + expect(screen.queryByText(NO_COMMENTS)).toBeNull(); + expect(heading().textContent).not.toMatch(/\(\d+\)/); + // Not a refusal: no lock line. + expect(screen.queryByTestId('activity-access-denied')).toBeNull(); + + fireEvent.click(retry()!); + expect(onRetryFeed).toHaveBeenCalledTimes(1); + }); + + it('renders the zh sentence, never 「暂无评论」', () => { + mountChatter({ items: [], commentsFailed: true, onRetryFeed: () => {} }, 'zh'); + const notice = screen.getByTestId('activity-load-failed'); + expect(notice.textContent).toContain('无法加载此记录的评论。'); + expect(retry()!.textContent).toContain('重试'); + expect(document.body.textContent).not.toContain('暂无评论'); + }); + + it('keeps the rows that answered and puts the notice above them', () => { + const row = { + id: 'a-1', + type: 'field_change', + actor: 'Grace', + body: 'Stage moved to qualified', + createdAt: '2026-01-02T00:00:00.000Z', + } satisfies FeedItem; + mountChatter({ items: [row], commentsFailed: true, onRetryFeed: () => {} }); + expect(screen.getByText('Stage moved to qualified')).toBeTruthy(); + expect(failedSources()).toEqual(['comments']); + expect(heading().textContent).not.toMatch(/\(\d+\)/); + }); + + it('a failed activity read says so for the activity', () => { + mountChatter({ items: [], activityFailed: true, onRetryFeed: () => {} }); + expect(screen.getByTestId('activity-load-failed').textContent).toContain(ACTIVITY_FAILED); + expect(failedSources()).toEqual(['activity']); + }); + + it('offers no Retry when the host offers no re-read', () => { + mountChatter({ items: [], commentsFailed: true }); + expect(failedSources()).toEqual(['comments']); + expect(retry()).toBeNull(); + }); + + it('a refusal and a failure side by side show both lines', () => { + mountChatter({ items: [], commentsDenied: true, activityFailed: true, onRetryFeed: () => {} }); + expect(document.querySelector('[data-denied-source="comments"]')).not.toBeNull(); + expect(failedSources()).toEqual(['activity']); + }); + + it('control: an answered empty read still says "No comments yet", with no notice', () => { + mountChatter({ items: [] }); + expect(screen.getByText(NO_COMMENTS)).toBeTruthy(); + expect(screen.queryByTestId('activity-load-failed')).toBeNull(); + expect(heading().textContent).toMatch(/\(0\)/); + }); +}); + +describe('the 动态 panel (peek) shows the same notice (objectstack#29188)', () => { + const renderPeek = (props: Partial>) => + render( + + + , + ); + + it('shows the notice with a Retry instead of the empty line', () => { + const onRetryFeed = vi.fn(); + renderPeek({ commentsFailed: true, onRetryFeed }); + expect(screen.getByTestId('activity-load-failed').textContent).toContain(COMMENTS_FAILED); + expect(screen.queryByText('No activity recorded')).toBeNull(); + fireEvent.click(retry()!); + expect(onRetryFeed).toHaveBeenCalledTimes(1); + }); + + it('the Retry is disabled while the re-read is in flight over rows on screen', () => { + const row = { + id: 'a-1', + type: 'created', + actor: 'Grace', + body: 'created the account', + createdAt: '2026-01-02T00:00:00.000Z', + } satisfies FeedItem; + renderPeek({ items: [row], commentsFailed: true, onRetryFeed: () => {}, loading: true }); + expect(retry()!.disabled).toBe(true); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/record-activity.tsx b/ui/packages/plugin-detail/src/renderers/record-activity.tsx index eee4455023f..7a4f958230e 100644 --- a/ui/packages/plugin-detail/src/renderers/record-activity.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-activity.tsx @@ -323,6 +323,11 @@ export const RecordActivityRenderer: React.FC = ({ const activityDenied = hostItems !== undefined ? false : discussion ? !!discussion.activityDenied : canSelfFetch && selfDenied; const commentsDenied = hostItems === undefined && !!discussion?.commentsDenied; + // objectstack#29188: which of the host's reads FAILED (5xx, network). Only a + // mounted DiscussionContext reports it; host `items` and the self-fetch + // carry no failure verdict, as before. + const activityFailed = hostItems === undefined && !!discussion?.activityFailed; + const commentsFailed = hostItems === undefined && !!discussion?.commentsFailed; // The timeline takes `filterMode` as a CONTROLLED prop, so the authored // `filterMode` becomes the initial state here and the dropdown stays usable — @@ -346,7 +351,7 @@ export const RecordActivityRenderer: React.FC = ({ const wholeTotal = asStream && hostTotal !== null ? hostTotal : applied.total; const reportCount = asPeek ? timelinePanel?.onCount : asStream ? (countReporter ?? undefined) : undefined; const reportsWhole = asPeek || streamFilter === 'all'; - const streamCount = !loading && !activityDenied && !commentsDenied ? wholeTotal : null; + const streamCount = !loading && !activityDenied && !commentsDenied && !activityFailed && !commentsFailed ? wholeTotal : null; React.useEffect(() => { if (reportsWhole) reportCount?.(streamCount); }, [reportCount, reportsWhole, streamCount]); @@ -379,6 +384,9 @@ export const RecordActivityRenderer: React.FC = ({ onUploadAttachments={discussion?.onUploadAttachments as any} activityDenied={activityDenied} commentsDenied={commentsDenied} + activityFailed={activityFailed} + commentsFailed={commentsFailed} + onRetryFeed={hostItems === undefined ? discussion?.onRetryFeed : undefined} {...(asPeek ? { variant: 'peek' as const, @@ -443,7 +451,10 @@ export function RecordTimelineCountProbe({ const read = (key: string) => node?.[key] ?? bag[key]; const hostItems = Array.isArray(read('items')) ? (read('items') as FeedItem[]) : undefined; const loading: boolean = read('loading') ?? discussion?.loading ?? false; - const denied = hostItems !== undefined ? false : !!discussion?.activityDenied || !!discussion?.commentsDenied; + // objectstack#29188: a failed read leaves the count as unknown as a refused one. + const denied = hostItems !== undefined + ? false + : !!discussion?.activityDenied || !!discussion?.commentsDenied || !!discussion?.activityFailed || !!discussion?.commentsFailed; const sourceItems = hostItems ?? (discussion?.items as FeedItem[] | undefined); const types = read('types'); const showCompleted = read('showCompleted'); diff --git a/ui/packages/plugin-detail/src/renderers/record-chatter.tsx b/ui/packages/plugin-detail/src/renderers/record-chatter.tsx index 17650ac196f..f7e50562c78 100644 --- a/ui/packages/plugin-detail/src/renderers/record-chatter.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-chatter.tsx @@ -302,6 +302,11 @@ export const RecordChatterRenderer: React.FC = ({ // decides `loading` above; the panel only renders it. activityDenied={discussion?.activityDenied} commentsDenied={discussion?.commentsDenied} + // objectstack#29188: which of them FAILED (5xx, network), and the + // host's re-read of those; the panel only renders it. + activityFailed={discussion?.activityFailed} + commentsFailed={discussion?.commentsFailed} + onRetryFeed={discussion?.onRetryFeed} /> ); diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index f196c4e4ac0..a141837f0b0 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -201,6 +201,11 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { // a provider-less host would render the raw key where the refusal belongs. 'detail.activityAccessDenied': "You don't have permission to view activity on this record.", 'detail.commentsAccessDenied': "You don't have permission to view comments on this record.", + // objectstack#29188 — the timeline's load-failure state for a feed read that + // FAILED (5xx, network), read bare as the refusal rows above are. + 'detail.activityLoadFailed': "We couldn't load the activity for this record.", + 'detail.commentsLoadFailed': "We couldn't load the comments for this record.", + 'detail.retryLoadFeed': 'Retry', // objectui#7149 — the rest of `ActivityTimeline`'s copy. Byte-identical to // the `en` pack rows (this map mirrors it; `defaults-maps-mirror-en-pack` // enforces that), so a provider-less host reads the same English the console diff --git a/ui/packages/react/src/context/DiscussionContext.tsx b/ui/packages/react/src/context/DiscussionContext.tsx index f118265bf91..9703364a1c2 100644 --- a/ui/packages/react/src/context/DiscussionContext.tsx +++ b/ui/packages/react/src/context/DiscussionContext.tsx @@ -89,6 +89,22 @@ export interface DiscussionContextValue { activityDenied?: boolean; /** The same, for the host's `sys_comment` read. */ commentsDenied?: boolean; + /** + * The host's `sys_activity` read FAILED: a 5xx or a network failure, not a + * refusal (objectstack#29188). The feed renderers then show a load-failure + * notice with a Retry instead of claiming the record has no activity. Set by + * the host that owns the fetch; a refusal, an answered read and a 404 leave + * it unset. + */ + activityFailed?: boolean; + /** The same, for the host's `sys_comment` read. */ + commentsFailed?: boolean; + /** + * Read the failed feed reads again (objectstack#29188). Offered by the host + * alongside {@link activityFailed} / {@link commentsFailed}; the notice's + * Retry calls it. Re-reads only the reads that failed. + */ + onRetryFeed?: () => void; } const DiscussionContext = React.createContext(null); @@ -115,6 +131,9 @@ export const DiscussionContextProvider: React.FC value.error, value.activityDenied, value.commentsDenied, + value.activityFailed, + value.commentsFailed, + value.onRetryFeed, ]); return ( {children} From e4f8f8035ab376093b9a5b130b366aea80e29f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:04:27 +0000 Subject: [PATCH 2/3] test(app-shell): the feed-loading rows reject with the status their message names A rejection with no status now reads as a network failure and shows the load-failure notice, so the rows that meant a 403 or a 404 carry it, as the ObjectStack client does. A refused comment read settles on its no-permission line. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jT854xvoBjntpsRE1F --- .../RecordDetailView.feedLoading.test.tsx | 36 +++++++++++++------ ...ActivityTimeline.loadFailed-29188.test.tsx | 2 +- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/ui/packages/app-shell/src/views/RecordDetailView.feedLoading.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.feedLoading.test.tsx index a93b4b85b07..54aec7c480d 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.feedLoading.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.feedLoading.test.tsx @@ -37,9 +37,13 @@ * 1. during the fetch → loading row, no empty copy; * 2. after BOTH parallel reads settle with nothing → empty copy, no * spinner (one settled read is not an answer); - * 3. after a read REJECTS → still the empty copy. A rejected fetch is an - * answer too; the alternative is a panel that spins forever, which is - * strictly worse than the bug being fixed. + * 3. after a read REJECTS → no spinner. A rejected fetch is an answer too; + * the alternative is a panel that spins forever, which is strictly worse + * than the bug being fixed. A 404 settles on the empty copy; a refused + * read (403) on its no-permission line (objectui#11195). Each rejection + * carries the status the ObjectStack client puts on it: a rejection with + * no status is a network failure, which shows the load-failure notice + * (objectstack#29188). */ import * as React from 'react'; @@ -106,6 +110,12 @@ const OBJECTS = [ /** A promise that never settles — "the fetch is still in flight". */ const pending = () => new Promise(() => {}); +/** + * A rejected read in the shape the ObjectStack client throws it: the HTTP + * status on `httpStatus` and the ADR-0112 code on `code`. + */ +const rejection = (httpStatus: number, code: string) => + Object.assign(new Error(`${httpStatus} ${code}`), { httpStatus, code }); interface FeedResponses { sys_comment: () => Promise; @@ -294,20 +304,23 @@ describe('RecordDetailView — the feed fetch produces a loading signal (#3209)' // this fix would trade "wrongly says empty" for "spins forever", which is // the worse bug. const dataSource = makeDataSource({ - sys_comment: () => Promise.reject(new Error('403 FEEDS_DISABLED')), - sys_activity: () => Promise.reject(new Error('404 sys_activity')), + sys_comment: () => Promise.reject(rejection(403, 'FEEDS_DISABLED')), + sys_activity: () => Promise.reject(rejection(404, 'OBJECT_NOT_FOUND')), }); renderDetail(dataSource); - expect(await screen.findByText(EMPTY_COMMENTS)).toBeTruthy(); + // The refused comment read settles on its no-permission line, the 404 on + // nothing: an answer either way, never a spinner. + const notice = await screen.findByTestId('activity-access-denied'); + expect(notice.querySelector('[data-denied-source="comments"]')).not.toBeNull(); expect(screen.queryByTestId('activity-loading')).toBeNull(); }); it('leaves the loading state when only one read rejects and the other resolves empty', async () => { const dataSource = makeDataSource({ sys_comment: () => Promise.resolve({ data: [] }), - sys_activity: () => Promise.reject(new Error('404 sys_activity')), + sys_activity: () => Promise.reject(rejection(404, 'OBJECT_NOT_FOUND')), }); renderDetail(dataSource); @@ -329,7 +342,7 @@ describe('RecordDetailView — the feed fetch produces a loading signal (#3209)' }, ], }), - sys_activity: () => Promise.reject(new Error('404 sys_activity')), + sys_activity: () => Promise.reject(rejection(404, 'OBJECT_NOT_FOUND')), }); renderDetail(dataSource); @@ -368,13 +381,14 @@ describe('RecordDetailView — an AUTHORED discussion node is on the same chain it('leaves the loading state when both reads reject', async () => { const dataSource = makeDataSource({ - sys_comment: () => Promise.reject(new Error('403 FEEDS_DISABLED')), - sys_activity: () => Promise.reject(new Error('404 sys_activity')), + sys_comment: () => Promise.reject(rejection(403, 'FEEDS_DISABLED')), + sys_activity: () => Promise.reject(rejection(404, 'OBJECT_NOT_FOUND')), }); renderDetail(dataSource, [AUTHORED_PAGE_WITH_DISCUSSION]); - expect(await screen.findByText(AUTHORED_EMPTY_COMMENTS)).toBeTruthy(); + const notice = await screen.findByTestId('activity-access-denied'); + expect(notice.querySelector('[data-denied-source="comments"]')).not.toBeNull(); expect(screen.queryByTestId('activity-loading')).toBeNull(); }); }); diff --git a/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.loadFailed-29188.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.loadFailed-29188.test.tsx index 940022dc28e..8c95ced585c 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.loadFailed-29188.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.loadFailed-29188.test.tsx @@ -146,7 +146,7 @@ describe('the 动态 panel (peek) shows the same notice (objectstack#29188)', () it('the Retry is disabled while the re-read is in flight over rows on screen', () => { const row = { id: 'a-1', - type: 'created', + type: 'record_create', actor: 'Grace', body: 'created the account', createdAt: '2026-01-02T00:00:00.000Z', From 53d0b7741902e701809c9bd5311117fc811be05f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:12:55 +0000 Subject: [PATCH 3/3] fix(i18n): the feed load-failure strings in the other eight locale packs ar, de, es, fr, ja, ko, pt and ru carry activityLoadFailed, commentsLoadFailed and retryLoadFeed at the en place, worded as each pack's attachmentsLoadFailed and retryLoadAttachments already are. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jT854xvoBjntpsRE1F --- .changeset/29188-feed-load-failure.md | 2 +- ui/packages/i18n/src/locales/ar.ts | 3 +++ ui/packages/i18n/src/locales/de.ts | 3 +++ ui/packages/i18n/src/locales/es.ts | 3 +++ ui/packages/i18n/src/locales/fr.ts | 3 +++ ui/packages/i18n/src/locales/ja.ts | 3 +++ ui/packages/i18n/src/locales/ko.ts | 3 +++ ui/packages/i18n/src/locales/pt.ts | 3 +++ ui/packages/i18n/src/locales/ru.ts | 3 +++ 9 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.changeset/29188-feed-load-failure.md b/.changeset/29188-feed-load-failure.md index bff7f8c3a8e..eeb5477e8a6 100644 --- a/.changeset/29188-feed-load-failure.md +++ b/.changeset/29188-feed-load-failure.md @@ -15,5 +15,5 @@ On a record page, a comments read or an activity read that answered a server err - **A failed read has its own state.** A 5xx, or a request that never got an answer, now shows "We couldn't load the comments for this record." (or the activity) with a Retry, in place of the empty state, on the 动态 panel, the 动态 tab and the discussion panel. Rows the other read answered stay on screen with the notice above them, and the count is unknown (no number) until the read answers. - **Retry reads only what failed**, directly, never the page payload again. A retry that fails again keeps the notice. - **Unchanged:** a refusal (401 / 403) keeps its no-permission state and is never retried; an empty 200, a 404 (a table the deployment does not serve), an object that withholds its API and a 400 keep the empty state. -- **New public members.** `DiscussionContextValue` gains the optional `activityFailed`, `commentsFailed` and `onRetryFeed`; `RecordActivityTimeline` and `RecordChatterPanel` gain the same three optional props; the `detail` locale namespace gains `activityLoadFailed`, `commentsLoadFailed` and `retryLoadFeed` (en, zh). +- **New public members.** `DiscussionContextValue` gains the optional `activityFailed`, `commentsFailed` and `onRetryFeed`; `RecordActivityTimeline` and `RecordChatterPanel` gain the same three optional props; the `detail` locale namespace gains `activityLoadFailed`, `commentsLoadFailed` and `retryLoadFeed` in all ten packs. - The attachments panel already showed its own load-failure state with a Retry for a failed read; that is now pinned for the record page payload's attachments part too. diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 42a36805c49..80157841715 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1409,6 +1409,9 @@ const ar = { noActivity: "لا يوجد نشاط مسجل", activityAccessDenied: "ليس لديك صلاحية عرض نشاط هذا السجل.", commentsAccessDenied: "ليس لديك صلاحية عرض التعليقات على هذا السجل.", + activityLoadFailed: "تعذر تحميل نشاط هذا السجل.", + commentsLoadFailed: "تعذر تحميل تعليقات هذا السجل.", + retryLoadFeed: "إعادة المحاولة", // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the // `detail.*` pack did not already name, and the `formatFieldChange` // sentences, which are assembled in code and so need interpolation holes. diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index a128b462c23..5d39cc0a7c8 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1259,6 +1259,9 @@ const de = { noActivity: "Keine Aktivitäten aufgezeichnet", activityAccessDenied: "Sie haben keine Berechtigung, die Aktivitäten dieses Datensatzes anzuzeigen.", commentsAccessDenied: "Sie haben keine Berechtigung, die Kommentare zu diesem Datensatz anzuzeigen.", + activityLoadFailed: "Die Aktivitäten dieses Datensatzes konnten nicht geladen werden.", + commentsLoadFailed: "Die Kommentare zu diesem Datensatz konnten nicht geladen werden.", + retryLoadFeed: "Erneut versuchen", // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the // `detail.*` pack did not already name, and the `formatFieldChange` // sentences, which are assembled in code and so need interpolation holes. diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 2f0f81ca839..6cbaf8861de 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1298,6 +1298,9 @@ const es = { noActivity: "Sin actividad registrada", activityAccessDenied: "No tiene permiso para ver la actividad de este registro.", commentsAccessDenied: "No tiene permiso para ver los comentarios de este registro.", + activityLoadFailed: "No se pudo cargar la actividad de este registro.", + commentsLoadFailed: "No se pudieron cargar los comentarios de este registro.", + retryLoadFeed: "Reintentar", // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the // `detail.*` pack did not already name, and the `formatFieldChange` // sentences, which are assembled in code and so need interpolation holes. diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index d8cbdfb3788..9fc5aff9d89 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1295,6 +1295,9 @@ const fr = { noActivity: "Aucune activité enregistrée", activityAccessDenied: "Vous n'avez pas l'autorisation de consulter l'activité de cet enregistrement.", commentsAccessDenied: "Vous n'avez pas l'autorisation de consulter les commentaires de cet enregistrement.", + activityLoadFailed: "Impossible de charger l'activité de cet enregistrement.", + commentsLoadFailed: "Impossible de charger les commentaires de cet enregistrement.", + retryLoadFeed: "Réessayer", // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the // `detail.*` pack did not already name, and the `formatFieldChange` // sentences, which are assembled in code and so need interpolation holes. diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 081fba23289..0b062f8a212 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1319,6 +1319,9 @@ const ja = { noActivity: "アクティビティの記録なし", activityAccessDenied: "このレコードのアクティビティを表示する権限がありません。", commentsAccessDenied: "このレコードのコメントを表示する権限がありません。", + activityLoadFailed: "このレコードのアクティビティを読み込めませんでした。", + commentsLoadFailed: "このレコードのコメントを読み込めませんでした。", + retryLoadFeed: "再試行", // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the // `detail.*` pack did not already name, and the `formatFieldChange` // sentences, which are assembled in code and so need interpolation holes. diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 572c616c225..9133fcef72b 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1259,6 +1259,9 @@ const ko = { noActivity: "기록된 활동 없음", activityAccessDenied: "이 레코드의 활동을 볼 권한이 없습니다.", commentsAccessDenied: "이 레코드의 댓글을 볼 권한이 없습니다.", + activityLoadFailed: "이 레코드의 활동을 불러오지 못했습니다.", + commentsLoadFailed: "이 레코드의 댓글을 불러오지 못했습니다.", + retryLoadFeed: "다시 시도", // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the // `detail.*` pack did not already name, and the `formatFieldChange` // sentences, which are assembled in code and so need interpolation holes. diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index a6cc30eb6fa..fc175e26045 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1295,6 +1295,9 @@ const pt = { noActivity: "Nenhuma atividade registrada", activityAccessDenied: "Você não tem permissão para ver a atividade deste registro.", commentsAccessDenied: "Você não tem permissão para ver os comentários deste registro.", + activityLoadFailed: "Não foi possível carregar a atividade deste registro.", + commentsLoadFailed: "Não foi possível carregar os comentários deste registro.", + retryLoadFeed: "Tentar novamente", // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the // `detail.*` pack did not already name, and the `formatFieldChange` // sentences, which are assembled in code and so need interpolation holes. diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 7c26bb68b6a..cc4c5e51cca 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1410,6 +1410,9 @@ const ru = { noActivity: "Нет зарегистрированной активности", activityAccessDenied: "У вас нет разрешения на просмотр активности этой записи.", commentsAccessDenied: "У вас нет разрешения на просмотр комментариев к этой записи.", + activityLoadFailed: "Не удалось загрузить активность этой записи.", + commentsLoadFailed: "Не удалось загрузить комментарии к этой записи.", + retryLoadFeed: "Повторить", // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the // `detail.*` pack did not already name, and the `formatFieldChange` // sentences, which are assembled in code and so need interpolation holes.