Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/10663-calendar-error-clears.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions .changeset/10663-kanban-error-clears.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions .changeset/10663-timeline-error-clears.md
Original file line number Diff line number Diff line change
@@ -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.
213 changes: 213 additions & 0 deletions packages/plugin-calendar/src/ObjectCalendar.errorClears-10663.test.tsx
Original file line number Diff line number Diff line change
@@ -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<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
}

function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((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<any>[] = [];
const dataSource = {
find: vi.fn(() => {
const d = deferred<any>();
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<any>, name: string) {
await act(async () => {
d.resolve({ data: rows(name), total: 1 });
await Promise.resolve();
});
}

async function fail(d: Deferred<any>, 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<typeof makeDeferredDataSource>['dataSource']) {
return render(
<SchemaRendererProvider dataSource={dataSource as any}>
<SchemaRenderer schema={BLOCK as any} />
</SchemaRendererProvider>,
);
}

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(<ObjectCalendar schema={schema} dataSource={dataSource as any} />);
await waitFor(() => expect(finds).toHaveLength(1));
await fail(finds[0], 'own fetch failed');
expect(errorShown('own fetch failed')).toBe(true);

await act(async () => {
rerender(<ObjectCalendar schema={schema} dataSource={dataSource as any} data={rows('Handed over')} />);
});

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();
});
});
22 changes: 21 additions & 1 deletion packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,9 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
// 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]);

Expand Down Expand Up @@ -675,6 +678,9 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
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;
Expand Down Expand Up @@ -752,10 +758,24 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
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);
Expand Down
Loading
Loading