From c338fc4ab2f74d237831ea67deaf3f4d460143fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:31:37 +0000 Subject: [PATCH 1/3] fix(plugin-timeline): clear the error when the current run commits rows (objectui#10663) ObjectTimeline set `error` only in its fetch catch and cleared it nowhere, so one failed load kept the error screen up after later loads succeeded. Since objectui#10623 a failed data-invalidation re-read was enough to get there. The current run of the fetch effect now clears `error` when it commits rows, and only the current run writes `error` at all (a run sequence, the objectui#10578 shape). A failed background re-read is still reported: this block has no silent mode. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .../ObjectTimeline.errorClears-10663.test.tsx | 209 ++++++++++++++++++ .../plugin-timeline/src/ObjectTimeline.tsx | 26 ++- 2 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 packages/plugin-timeline/src/ObjectTimeline.errorClears-10663.test.tsx diff --git a/packages/plugin-timeline/src/ObjectTimeline.errorClears-10663.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.errorClears-10663.test.tsx new file mode 100644 index 0000000000..92823a7639 --- /dev/null +++ b/packages/plugin-timeline/src/ObjectTimeline.errorClears-10663.test.tsx @@ -0,0 +1,209 @@ +/** + * 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. + */ + +/** + * objectui#10663 — one failed load must not keep an `object-timeline` block on + * its error screen after a later load succeeds (the objectui#10578 shape, + * landed on `ObjectGantt`). + * + * The timeline renders its error screen with an early return, so once `error` + * is set the canvas is gone. The fetch effect wrote `error` in ONE place, its + * `catch`, and cleared it nowhere. Since objectui#10623 every data-invalidation + * event re-reads this block, so one failed background re-read put a healthy + * timeline on a permanent error screen. + * + * What clears it, and which run may touch it: + * + * - The CURRENT run clears it when it COMMITS rows. Those rows answer the + * current query, so no earlier failure describes the screen any more. + * - Only the CURRENT run writes it at all. A run that a newer one has + * superseded may neither clear the current run's error nor raise its own + * over the current run's rows. + * - A failed background re-read does NOT keep the last good rows on screen. + * This block has no silent mode (`ObjectGantt` and `ObjectMap` have one), + * so that failure is reported like any other, and the next re-read that + * succeeds takes the screen back. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration. Every `find` is held open by hand, so each in-flight + * assertion is made while the query really is in flight. The error screen is + * read through the message the adapter threw; its English prefix is not what + * this card is about. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged } from '@object-ui/react'; +// Registers `object-timeline` through this package's own entry. +import './index'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Every `find` returns a promise the test settles by hand. */ +function makeDeferredDataSource() { + const finds: Deferred[] = []; + const dataSource = { + find: vi.fn(() => { + const d = deferred(); + finds.push(d); + return d.promise; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'task', fields: { name: { type: 'text' }, due: { type: 'date' } } })), + }; + return { dataSource, finds }; +} + +const rows = (name: string) => [{ id: name, name, due: '2026-01-05' }]; + +async function answer(d: Deferred, name: string) { + await act(async () => { + d.resolve({ data: rows(name), total: 1 }); + await Promise.resolve(); + }); +} + +async function fail(d: Deferred, message: string) { + await act(async () => { + d.reject(new Error(message)); + await Promise.resolve(); + }); +} + +async function invalidate() { + await act(async () => { + notifyDataChanged({ objectName: 'task' }); + }); +} + +const BLOCK = { type: 'object-timeline', objectName: 'task', timeline: { titleField: 'name', startDateField: 'due' } }; + +function mount(dataSource: ReturnType['dataSource']) { + return render( + + + , + ); +} + +const errorScreen = () => screen.queryByTestId('timeline-error'); +const errorShown = (message: string) => (errorScreen()?.textContent ?? '').includes(message); + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); + // Each failure below is logged by the component; keep the run readable. + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + cleanup(); +}); + +describe('object-timeline clears its error when a later load commits rows (objectui#10663)', () => { + it('a failed load, then a data-invalidation re-read that succeeds: the error screen is gone and the rows are drawn', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + + await fail(finds[0], 'backend down'); + expect(errorShown('backend down'), 'the first failure was not reported').toBe(true); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + await answer(finds[1], 'Recovered'); + + expect(errorScreen(), 'the error screen outlived a re-read that succeeded').toBeNull(); + expect(screen.getByTestId('timeline-canvas')).toBeTruthy(); + expect(screen.getByText('Recovered')).toBeTruthy(); + }); + + it('a failed BACKGROUND re-read over good rows is reported, and does not keep those rows on screen; the next re-read that succeeds takes the screen back', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + await answer(finds[0], 'First rows'); + expect(screen.getByText('First rows')).toBeTruthy(); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + await fail(finds[1], 're-read failed'); + // No silent mode on this block: the failure is reported, and the last good + // rows stay in state but are not drawn. + expect(errorShown('re-read failed')).toBe(true); + expect(screen.queryByText('First rows')).toBeNull(); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(3)); + await answer(finds[2], 'Second rows'); + expect(errorScreen()).toBeNull(); + expect(screen.getByText('Second rows')).toBeTruthy(); + }); + + it('control: a failure, then another failure, shows the newer failure', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + await fail(finds[0], 'first failure'); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + await fail(finds[1], 'second failure'); + + expect(errorShown('second failure')).toBe(true); + expect(errorShown('first failure')).toBe(false); + }); + + it('a SUPERSEDED run that succeeds does not clear the error the current run reported', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + // A re-read starts while the first read is still in flight. + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + + await fail(finds[1], 'current run failed'); + expect(errorShown('current run failed')).toBe(true); + await answer(finds[0], 'Superseded rows'); + + expect(errorShown('current run failed'), 'a superseded answer cleared the current error').toBe(true); + }); + + it('a SUPERSEDED run that fails does not put the error screen over the current run\'s rows', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + + await answer(finds[1], 'Current rows'); + expect(screen.getByText('Current rows')).toBeTruthy(); + await fail(finds[0], 'superseded run failed'); + + expect(errorScreen(), 'a superseded failure took the canvas off screen').toBeNull(); + expect(screen.getByText('Current rows')).toBeTruthy(); + }); +}); diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index 9309e2e844..337ad8e898 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import type { DataSource, TimelineSchema, ListViewTimelineConfig } from '@object-ui/types'; import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation } from '@object-ui/react'; import { NavigationOverlay } from '@object-ui/components'; @@ -256,6 +256,13 @@ export const ObjectTimeline: React.FC = ({ return !hasInlineItems && !hasInlineData && !!schema.objectName; }); const [error, setError] = useState(null); + /** + * objectui#10663 — which run of the fetch effect below is the CURRENT one. + * Every run takes the next number, so a run a newer one has superseded can + * tell, and it may then neither clear the current run's `error` nor raise its + * own. Read only by the two `error` writes; nothing renders from it. + */ + const fetchSeqRef = useRef(0); const [refreshKey, setRefreshKey] = useState(0); // Resolve nested TimelineConfig (spec-compliant) @@ -353,6 +360,8 @@ export const ObjectTimeline: React.FC = ({ const invalidationNonce = useDataInvalidation(fetchesForItself ? schema.objectName : undefined); useEffect(() => { + const seq = ++fetchSeqRef.current; + const isCurrent = () => fetchSeqRef.current === seq; const fetchData = async () => { if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) { // Can't fetch — clear loading so we don't sit in skeleton forever. @@ -420,9 +429,22 @@ export const ObjectTimeline: React.FC = ({ }); const data = extractRecords(results); setFetchedData(data); + // objectui#10663 — `error` is an early return in the render, so a + // report nothing clears kept the canvas off screen until a remount, + // and since objectui#10623 one failed data-invalidation re-read was + // enough to get there. It is cleared HERE, when the current run + // commits rows: those rows answer the current query, so no earlier + // failure describes the screen any more (objectui#10578's rule on + // `ObjectGantt`). ⛔ Not when a run starts: until rows land, the + // report stays. + if (isCurrent()) setError(null); } catch (e) { console.error(e); - setError(e as Error); + // A superseded run's failure no longer describes the screen, so it + // may not raise the error screen over the current run's rows. A + // failed background re-read is reported like any other: this + // block has no silent mode. + if (isCurrent()) setError(e as Error); } finally { setLoading(false); } From 4f1d532a877c3acdeba2d9ef796ad603ad03d20d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:33:59 +0000 Subject: [PATCH 2/3] fix(plugin-calendar,plugin-kanban): clear the error when the current run commits rows (objectui#10663) ObjectCalendar and ObjectKanban set `error` only in their fetch catch and cleared it nowhere, the same defect objectui#10578 fixed on ObjectGantt. Since objectui#10572 a failed data-invalidation re-read was enough to keep either block on its error screen until a remount. Each now clears `error` when the current run commits rows, inside the fetch effect's existing `isMounted` guard. The calendar also clears it where host `data` is committed, beside the ceiling reset that sits there for the same reason. A failed background re-read is still reported: neither block has a silent mode. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .../ObjectCalendar.errorClears-10663.test.tsx | 213 ++++++++++++++++++ .../plugin-calendar/src/ObjectCalendar.tsx | 22 +- .../ObjectKanban.errorClears-10663.test.tsx | 193 ++++++++++++++++ packages/plugin-kanban/src/ObjectKanban.tsx | 11 + 4 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-calendar/src/ObjectCalendar.errorClears-10663.test.tsx create mode 100644 packages/plugin-kanban/src/ObjectKanban.errorClears-10663.test.tsx diff --git a/packages/plugin-calendar/src/ObjectCalendar.errorClears-10663.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.errorClears-10663.test.tsx new file mode 100644 index 0000000000..97d0c2e2c9 --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.errorClears-10663.test.tsx @@ -0,0 +1,213 @@ +/** + * 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. + */ + +/** + * objectui#10663 — one failed load must not keep an `object-calendar` block on + * its error screen after a later load succeeds (the objectui#10578 shape, + * landed on `ObjectGantt`). + * + * The calendar renders its error screen with an early return. Its fetch + * effect wrote `error` in ONE place, its `catch`, and cleared it nowhere, so + * every later answer reached `data` while the component kept returning the + * error screen until it remounted. Since objectui#10572 every data-invalidation + * event re-reads this block, so one failed background re-read was enough. + * + * What clears it: + * + * - The CURRENT run clears it when it COMMITS rows, on every commit branch + * of the fetch effect, under the effect's existing `isMounted` guard. A run + * that a newer one superseded is discarded, and its clear with it. + * - Rows a parent hands over (`data`) clear it too, where the calendar + * already drops a ceiling left over from its own fetch: those rows are not + * this component's query, so no failure of that query describes them. + * - A failed background re-read does NOT keep the last good rows on screen. + * This block has no silent mode (`ObjectGantt` and `ObjectMap` have one), + * so the failure is reported, and the next re-read that succeeds takes the + * screen back. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration. Every `find` is held open by hand. The error screen is read + * through the message the adapter threw. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged } from '@object-ui/react'; +import { ObjectCalendar } from './ObjectCalendar'; +// Registers `object-calendar`. +import './index'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Every `find` returns a promise the test settles by hand. */ +function makeDeferredDataSource() { + const finds: Deferred[] = []; + const dataSource = { + find: vi.fn(() => { + const d = deferred(); + finds.push(d); + return d.promise; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'event', fields: { name: { type: 'text' }, due: { type: 'datetime' } } })), + }; + return { dataSource, finds }; +} + +// A day inside the month the calendar opens on, so a committed event is drawn. +const today = new Date(); +const inThisMonth = new Date(today.getFullYear(), today.getMonth(), 10, 9, 0, 0, 0).toISOString(); +const rows = (name: string) => [{ id: name, name, due: inThisMonth }]; + +async function answer(d: Deferred, name: string) { + await act(async () => { + d.resolve({ data: rows(name), total: 1 }); + await Promise.resolve(); + }); +} + +async function fail(d: Deferred, message: string) { + await act(async () => { + d.reject(new Error(message)); + await Promise.resolve(); + }); +} + +async function invalidate() { + await act(async () => { + notifyDataChanged({ objectName: 'event' }); + }); +} + +const CALENDAR = { startDateField: 'due', titleField: 'name' }; +const BLOCK = { type: 'object-calendar', objectName: 'event', calendar: CALENDAR }; + +function mount(dataSource: ReturnType['dataSource']) { + return render( + + + , + ); +} + +const errorShown = (message: string) => screen.queryByText(`Error: ${message}`) !== null; +const anyErrorShown = () => screen.queryByText(/^Error: /) !== null; + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); + // Each failure below is logged by the component; keep the run readable. + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + cleanup(); +}); + +describe('object-calendar clears its error when a later load commits rows (objectui#10663)', () => { + it('a failed load, then a data-invalidation re-read that succeeds: the error screen is gone and the events are drawn', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + + await fail(finds[0], 'backend down'); + expect(errorShown('backend down'), 'the first failure was not reported').toBe(true); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + await answer(finds[1], 'Recovered'); + + expect(anyErrorShown(), 'the error screen outlived a re-read that succeeded').toBe(false); + expect(screen.getByText('Recovered')).toBeTruthy(); + }); + + it('a failed BACKGROUND re-read over good rows is reported, and does not keep those rows on screen; the next re-read that succeeds takes the screen back', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + await answer(finds[0], 'First rows'); + expect(screen.getByText('First rows')).toBeTruthy(); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + await fail(finds[1], 're-read failed'); + // No silent mode on this block: the failure is reported, and the last good + // rows stay in state but are not drawn. + expect(errorShown('re-read failed')).toBe(true); + expect(screen.queryByText('First rows')).toBeNull(); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(3)); + await answer(finds[2], 'Second rows'); + expect(anyErrorShown()).toBe(false); + expect(screen.getByText('Second rows')).toBeTruthy(); + }); + + it('control: a failure, then another failure, shows the newer failure', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + await fail(finds[0], 'first failure'); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + await fail(finds[1], 'second failure'); + + expect(errorShown('second failure')).toBe(true); + expect(errorShown('first failure')).toBe(false); + }); + + it('a SUPERSEDED run that succeeds does not clear the error the current run reported', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + // A re-read starts while the first read is still in flight. + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + + await fail(finds[1], 'current run failed'); + expect(errorShown('current run failed')).toBe(true); + await answer(finds[0], 'Superseded rows'); + + expect(errorShown('current run failed'), 'a superseded answer cleared the current error').toBe(true); + }); + + it('rows a parent hands over after a failed fetch take the calendar off its error screen', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + const schema: any = { type: 'object-calendar', objectName: 'event', calendar: CALENDAR }; + const { rerender } = render(); + await waitFor(() => expect(finds).toHaveLength(1)); + await fail(finds[0], 'own fetch failed'); + expect(errorShown('own fetch failed')).toBe(true); + + await act(async () => { + rerender(); + }); + + expect(anyErrorShown(), 'the error of a fetch whose rows are not drawn outlived the handed-over rows').toBe(false); + expect(screen.getByText('Handed over')).toBeTruthy(); + }); +}); diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index e1f8c0f2d4..77400ebf4d 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -568,6 +568,9 @@ export const ObjectCalendar: React.FC = ({ // set that is not being drawn. Every other `setData` path here already // resets it — this was the one that did not. setRowCeiling(null); + // ...and an error from that fetch, for the same reason (objectui#10663): + // the rows now on screen are not the query that failed. + setError(null); } }, [externalData, hasExternalData]); @@ -675,6 +678,9 @@ export const ObjectCalendar: React.FC = ({ if (isMounted) { setData(capped.rows); setRowCeiling(capped); + // Committed rows clear an earlier failure (objectui#10663); the + // reasoning sits on the `object` arm's commit below. + setError(null); setLoading(false); } return; @@ -752,10 +758,24 @@ export const ObjectCalendar: React.FC = ({ if (isMounted) { setData(capped.rows); setRowCeiling(capped); + // objectui#10663 — `error` is an early return in the render, so a + // report nothing clears kept the calendar off screen until a + // remount, and since objectui#10572 one failed data-invalidation + // re-read was enough to get there. It is cleared HERE, when the + // current run commits rows: those rows answer the current query, + // so no earlier failure describes the screen any more + // (objectui#10578's rule on `ObjectGantt`). `isMounted` is this + // run's own flag, false once a newer run has started, so a + // superseded run's clear is discarded with its answer. ⛔ Not when + // a run starts: until rows land, the report stays. + setError(null); } } else if (dataProvider === 'api') { console.warn('API provider not yet implemented for ObjectCalendar'); - if (isMounted) setData([]); + if (isMounted) { + setData([]); + setError(null); + } } if (isMounted) setLoading(false); diff --git a/packages/plugin-kanban/src/ObjectKanban.errorClears-10663.test.tsx b/packages/plugin-kanban/src/ObjectKanban.errorClears-10663.test.tsx new file mode 100644 index 0000000000..ec2095b097 --- /dev/null +++ b/packages/plugin-kanban/src/ObjectKanban.errorClears-10663.test.tsx @@ -0,0 +1,193 @@ +/** + * 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. + */ + +/** + * objectui#10663 — one failed load must not keep an `object-kanban` block on + * its error screen after a later load succeeds (the objectui#10578 shape, + * landed on `ObjectGantt`). + * + * The board renders its error screen with an early return. Its fetch effect + * wrote `error` in ONE place, its `catch`, and cleared it nowhere, so every + * later answer reached the cards while the component kept returning the error + * screen until it remounted. Since objectui#10572 every data-invalidation event + * re-reads this block, so one failed background re-read was enough. + * + * What clears it: + * + * - The CURRENT run clears it when it COMMITS cards, under the effect's + * existing `isMounted` guard. A run that a newer one superseded is + * discarded, and its clear with it. + * - A failed background re-read does NOT keep the last good cards on screen. + * This block has no silent mode (`ObjectGantt` and `ObjectMap` have one), + * so the failure is reported, and the next re-read that succeeds takes the + * screen back. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration. Every `find` is held open by hand. The error screen is read + * through the message the adapter threw. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged } from '@object-ui/react'; +// Registers `object-kanban`. +import './index'; +// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing +// the chunk at module scope keeps the cold transform out of the assertion +// window (the objectui#3010 rule, same specifier as `index.tsx`'s factory). +import './KanbanImpl'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Every `find` returns a promise the test settles by hand. */ +function makeDeferredDataSource() { + const finds: Deferred[] = []; + const dataSource = { + find: vi.fn(() => { + const d = deferred(); + finds.push(d); + return d.promise; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'deal', fields: { name: { type: 'text' }, status: { type: 'text' } } })), + }; + return { dataSource, finds }; +} + +const rows = (name: string) => [{ id: name, name, status: 'open' }]; + +async function answer(d: Deferred, name: string) { + await act(async () => { + d.resolve({ data: rows(name), total: 1 }); + await Promise.resolve(); + }); +} + +async function fail(d: Deferred, message: string) { + await act(async () => { + d.reject(new Error(message)); + await Promise.resolve(); + }); +} + +async function invalidate() { + await act(async () => { + notifyDataChanged({ objectName: 'deal' }); + }); +} + +const BLOCK = { type: 'object-kanban', objectName: 'deal', groupBy: 'status', columns: [{ id: 'open', title: 'Open' }] }; + +function mount(dataSource: ReturnType['dataSource']) { + return render( + + + , + ); +} + +const errorShown = (message: string) => screen.queryByText(new RegExp(`: ${message}$`)) !== null; +const anyErrorShown = () => screen.queryByText(/^Error loading kanban data/) !== null; + +beforeEach(() => { + // Best-effort metadata probes are not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); + // Each failure below is logged by the component; keep the run readable. + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + cleanup(); +}); + +describe('object-kanban clears its error when a later load commits cards (objectui#10663)', () => { + it('a failed load, then a data-invalidation re-read that succeeds: the error screen is gone and the cards are drawn', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + + await fail(finds[0], 'backend down'); + expect(errorShown('backend down'), 'the first failure was not reported').toBe(true); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + await answer(finds[1], 'Recovered'); + + expect(anyErrorShown(), 'the error screen outlived a re-read that succeeded').toBe(false); + await waitFor(() => expect(screen.getByText('Recovered')).toBeTruthy()); + }); + + it('a failed BACKGROUND re-read over good cards is reported, and does not keep those cards on screen; the next re-read that succeeds takes the screen back', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + await answer(finds[0], 'First cards'); + await waitFor(() => expect(screen.getByText('First cards')).toBeTruthy()); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + await fail(finds[1], 're-read failed'); + // No silent mode on this block: the failure is reported, and the last good + // cards stay in state but are not drawn. + expect(errorShown('re-read failed')).toBe(true); + expect(screen.queryByText('First cards')).toBeNull(); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(3)); + await answer(finds[2], 'Second cards'); + expect(anyErrorShown()).toBe(false); + await waitFor(() => expect(screen.getByText('Second cards')).toBeTruthy()); + }); + + it('control: a failure, then another failure, shows the newer failure', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + await fail(finds[0], 'first failure'); + + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + await fail(finds[1], 'second failure'); + + expect(errorShown('second failure')).toBe(true); + expect(errorShown('first failure')).toBe(false); + }); + + it('a SUPERSEDED run that succeeds does not clear the error the current run reported', async () => { + const { dataSource, finds } = makeDeferredDataSource(); + mount(dataSource); + await waitFor(() => expect(finds).toHaveLength(1)); + // A re-read starts while the first read is still in flight. + await invalidate(); + await waitFor(() => expect(finds).toHaveLength(2)); + + await fail(finds[1], 'current run failed'); + expect(errorShown('current run failed')).toBe(true); + await answer(finds[0], 'Superseded cards'); + + expect(errorShown('current run failed'), 'a superseded answer cleared the current error').toBe(true); + }); +}); diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index e9ef966402..210a3aa3a6 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -710,6 +710,17 @@ export const ObjectKanban: React.FC = ({ // against the window THIS request carried, because that is the // only point where the two numbers are both in hand. setFetchWindowSaturated(data.length >= query.$top); + // objectui#10663 — `error` is an early return in the render, so + // a report nothing clears kept the board off screen until a + // remount, and since objectui#10572 one failed data-invalidation + // re-read was enough to get there. It is cleared HERE, when the + // current run commits cards: those cards answer the current + // query, so no earlier failure describes the screen any more + // (objectui#10578's rule on `ObjectGantt`). `isMounted` is this + // run's own flag, false once a newer run has started, so a + // superseded run's clear is discarded with its answer. ⛔ Not + // when a run starts: until cards land, the report stays. + setError(null); } } catch (e) { console.error('[ObjectKanban] Fetch error:', e); From 65d698ad74a629fa4a95c935cb208bbf8317ec8d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:39:48 +0000 Subject: [PATCH 3/3] chore(changeset): declare the timeline, calendar and kanban error clears (objectui#10663) Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .changeset/10663-calendar-error-clears.md | 26 +++++++++++++++++++++++ .changeset/10663-kanban-error-clears.md | 22 +++++++++++++++++++ .changeset/10663-timeline-error-clears.md | 24 +++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 .changeset/10663-calendar-error-clears.md create mode 100644 .changeset/10663-kanban-error-clears.md create mode 100644 .changeset/10663-timeline-error-clears.md diff --git a/.changeset/10663-calendar-error-clears.md b/.changeset/10663-calendar-error-clears.md new file mode 100644 index 0000000000..a1b05ea7f5 --- /dev/null +++ b/.changeset/10663-calendar-error-clears.md @@ -0,0 +1,26 @@ +--- +'@object-ui/plugin-calendar': patch +--- + +fix(plugin-calendar): a failed load no longer keeps `ObjectCalendar` on its error screen after a later load succeeds (objectui#10663) + +`ObjectCalendar` set `error` when its fetch failed, and the render returns the +error screen early whenever `error` is set. Nothing ever cleared it. Every later +load that succeeded still wrote its events, but the calendar stayed on the error +screen until it remounted. Since objectui#10572 the calendar re-reads on every +data-invalidation event, so one failed background re-read was enough. + +The current run of the fetch now clears the error when it commits rows, on each +of its commit branches: those rows answer the current query, so the earlier +failure no longer describes the screen. This is the rule objectui#10578 set for +`ObjectGantt`. The clear sits inside the run's existing `isMounted` guard, so a +superseded run cannot clear the current run's error. The error is not cleared +when a run starts; it stays until rows land. + +Rows a parent hands over through `data` clear it too, beside the row-ceiling +reset that already sits there for the same reason: those rows are not the query +that failed. + +A failed background re-read is still reported: the calendar has no silent mode, +so it shows the error screen rather than keeping the last good events, and the +next re-read that succeeds takes the screen back. diff --git a/.changeset/10663-kanban-error-clears.md b/.changeset/10663-kanban-error-clears.md new file mode 100644 index 0000000000..38a78d4ba0 --- /dev/null +++ b/.changeset/10663-kanban-error-clears.md @@ -0,0 +1,22 @@ +--- +'@object-ui/plugin-kanban': patch +--- + +fix(plugin-kanban): a failed load no longer keeps `ObjectKanban` on its error screen after a later load succeeds (objectui#10663) + +`ObjectKanban` set `error` when its fetch failed, and the render returns the +error screen early whenever `error` is set. Nothing ever cleared it. Every later +load that succeeded still wrote its cards, but the board stayed on the error +screen until it remounted. Since objectui#10572 the board re-reads on every +data-invalidation event, so one failed background re-read was enough. + +The current run of the fetch now clears the error when it commits cards: those +cards answer the current query, so the earlier failure no longer describes the +screen. This is the rule objectui#10578 set for `ObjectGantt`. The clear sits +inside the run's existing `isMounted` guard, so a superseded run cannot clear +the current run's error. The error is not cleared when a run starts; it stays +until cards land. + +A failed background re-read is still reported: the board has no silent mode, so +it shows the error screen rather than keeping the last good cards, and the next +re-read that succeeds takes the screen back. diff --git a/.changeset/10663-timeline-error-clears.md b/.changeset/10663-timeline-error-clears.md new file mode 100644 index 0000000000..0d5e9b5684 --- /dev/null +++ b/.changeset/10663-timeline-error-clears.md @@ -0,0 +1,24 @@ +--- +'@object-ui/plugin-timeline': patch +--- + +fix(plugin-timeline): a failed load no longer keeps `ObjectTimeline` on its error screen after a later load succeeds (objectui#10663) + +`ObjectTimeline` set `error` when its fetch failed, and the render returns the +error screen early whenever `error` is set. Nothing ever cleared it. Every later +load that succeeded still wrote its rows, but the timeline stayed on the error +screen until it remounted. Since objectui#10623 the timeline re-reads on every +data-invalidation event, so one failed background re-read was enough. + +The current run of the fetch now clears the error when it commits rows: those +rows answer the current query, so the earlier failure no longer describes the +screen. This is the rule objectui#10578 set for `ObjectGantt`. The error is not +cleared when a run starts; it stays until rows land. + +Only the current run writes the error at all. A run that a newer one has +superseded can no longer clear the current run's error, or put the error screen +over the current run's rows. + +A failed background re-read is still reported: the timeline has no silent mode, +so it shows the error screen rather than keeping the last good rows, and the +next re-read that succeeds takes the screen back.