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
42 changes: 42 additions & 0 deletions .changeset/10105-pull-to-refresh-late-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@object-ui/mobile': patch
---

fix(mobile): `usePullToRefresh` arms on a host that mounts after the first render, and one pull has one owner (objectui#10105)

The hook returns an object ref, so it cannot see when a consumer attaches it.
It used to bind its touch listeners in an effect keyed only on its handlers and
`enabled`, which read `ref.current` once. A view whose first render is a
loading screen has no host at that moment. When the host appeared, none of the
effect's keys had changed, so it never ran again and the pull gesture did
nothing, with no error. The listeners now follow the element the ref points at
after each commit: they bind when it appears, move when it is replaced, and are
released when it goes away or the consumer unmounts.

Measured on this branch, on the internal-fetch path (the view fetches its own
rows): the gesture was dead on `ObjectCalendar`, `ObjectGrid` and
`ObjectTimeline` and now arms and refetches. `ListView` renders its host on the
first render and was not affected.

Nested pull hosts now have one owner. `ListView` has its own pull host, and
so does the view it renders inside itself. A touch on the inner view bubbles
to `ListView`, so both used to respond to one pull. Before this change, the
calendar and timeline views inside `ListView` drew two "Pull to refresh"
strips for one pull, and arming the grid would have made its view do the
same. The gesture now belongs to the OUTERMOST armed pull host: an inner host
lets a pull go when an armed pull host is its ancestor in the DOM. So one pull
inside `ListView` draws one indicator, `ListView`'s, and runs one refetch,
which reloads the rows `ListView` hands the inner view. A view with no pull
host above it keeps its own pull. While it is armed, the hook marks its
element with a `data-pull-to-refresh-host` attribute; that attribute is how one
hook instance sees another, including across two copies of this package.

Product-visible:

- Nothing changes in what renders while loading.
- A pull inside `ListView` (calendar, grid or timeline view) shows one
indicator (calendar and timeline used to show two; the grid view keeps its
one), and still triggers one refetch.
- The pull gesture now works on those views when they fetch their own rows.

The exported signature and return type of `usePullToRefresh` are unchanged.
227 changes: 227 additions & 0 deletions apps/console/src/__tests__/pullToRefreshOwner-10105.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* 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#10105 — one pull, one indicator, one refetch, on the real views.
*
* `usePullToRefresh` (`@object-ui/mobile`) is used by `ListView` and by the
* views `ListView` renders inside itself. A touch on the inner view's host
* bubbles to `ListView`'s host, so both hooks receive the same gesture. The
* hook now gives that gesture to the OUTERMOST armed host: `ListView` shows
* the one indicator and runs the one refetch, which reloads the rows it hands
* the inner view. The hook's own pin covers the rule on a minimal consumer.
* This file pins it on the real compositions.
*
* Without this rule, one pull drew two indicators. The per-view counts from
* before and after are a historical reading recorded on objectui#10105; this
* file does not re-derive them. It asserts only the rule.
*
* ## Both halves, so a rule that disarms everything cannot pass
*
* - NESTED: `ListView` over calendar, grid and timeline. Before pulling, the
* case requires TWO armed hosts, one inside the other. Without that, "one
* indicator" would also be what a dead inner listener produces.
* - STANDALONE: grid and timeline fetching their own rows, with no pull host
* above them. Each must still arm and refetch. The standalone calendar is
* pinned in `plugin-calendar` (`ObjectCalendar.pullToRefresh-10105.test.tsx`).
*
* ## Why this file lives in `apps/console`
*
* It renders `ListView` together with the calendar, grid and timeline plugins,
* so it must live in a package that declares all four (the objectui#4409
* dependency-direction method). `plugin-list` declares none of the other three,
* and `app-shell` declares every one except `plugin-timeline`. This app declares
* all four, and `timelineAxisRefusalReach-7070.test.tsx` beside this file already
* renders `ListView` over the real timeline for the same reason.
*
* ## How it reads the DOM
*
* - Armed hosts are found by the attribute the hook sets while it is bound
* (`data-pull-to-refresh-host`). That attribute is how one hook instance
* sees another, so reading it here tests the same fact the rule relies on.
* - An indicator is a `div` whose inline height is the pull distance, 100px
* here. Every consumer draws its indicator that way, and each case checks
* that nothing on screen is 100px tall before the pull.
* - The gesture is the hook's own `touchstart` → `touchmove` → `touchend`,
* with `touches` attached by hand because happy-dom builds no `TouchList`.
* Effects are flushed first because the hook binds in a passive effect.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup, fireEvent, act } from '@testing-library/react';
import '@object-ui/plugin-calendar';
import { ObjectGrid } from '@object-ui/plugin-grid';
import { ObjectTimeline } from '@object-ui/plugin-timeline';
import { ListView } from '@object-ui/plugin-list';
import { ActionProvider, SchemaRendererProvider } from '@object-ui/react';

afterEach(() => {
cleanup();
});

const ARMED_HOST = '[data-pull-to-refresh-host]';

const today = new Date();
const START = new Date(today.getFullYear(), today.getMonth(), 10, 9, 0, 0, 0).toISOString();
const ROWS = [{ id: 'r-0', name: 'Task 0', subject: 'Task 0', start_date: START }];

const makeDataSource = () => ({
find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => ({
name: 'duly_task',
fields: {
id: { name: 'id', type: 'text' },
name: { name: 'name', type: 'text' },
subject: { name: 'subject', type: 'text' },
start_date: { name: 'start_date', type: 'datetime' },
},
})),
});

const touch = (type: string, clientY: number) => {
const event = new Event(type, { bubbles: true });
(event as unknown as { touches: Array<{ clientY: number }> }).touches = [{ clientY }];
return event;
};

const indicators = () =>
[...document.querySelectorAll<HTMLElement>('div')].filter((d) => d.style.height === '100px');

/**
* Pull 100px on `el`, wait for the indicators to settle, hand them to
* `inspect` WHILE they are still on screen, then release. The release clears
* the indicator synchronously (`fireEvent` runs inside `act`), so anything
* that reads where an indicator sits has to run before it.
*/
async function pull(el: HTMLElement, inspect: (shown: HTMLElement[]) => void): Promise<void> {
await act(async () => {});
expect(indicators(), 'control: nothing on screen is 100px tall before the pull').toHaveLength(0);
fireEvent(el, touch('touchstart', 10));
fireEvent(el, touch('touchmove', 110));
await waitFor(() => expect(indicators().length).toBeGreaterThan(0));
await act(async () => {});
inspect(indicators());
fireEvent(el, touch('touchend', 110));
}

/** The release refetches exactly once, and nothing fetches again after it. */
async function expectOneRefetch(find: { mock: { calls: unknown[] } }, before: number) {
await waitFor(() => expect(find.mock.calls.length).toBe(before + 1));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(find.mock.calls.length, 'one pull must refetch exactly once').toBe(before + 1);
}

async function renderInListView(schema: Record<string, unknown>) {
const dataSource = makeDataSource();
const { container } = render(
<SchemaRendererProvider dataSource={dataSource}>
<ListView
schema={{ type: 'list-view', objectName: 'duly_task', ...schema } as never}
dataSource={dataSource as never}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(screen.getAllByText('Task 0').length).toBeGreaterThan(0));
await act(async () => {});
return { container, dataSource };
}

async function expectListViewOwnsThePull(schema: Record<string, unknown>) {
const { container, dataSource } = await renderInListView(schema);
const hosts = [...container.querySelectorAll<HTMLElement>(ARMED_HOST)];
// ListView's host and the inner view's host, both armed, one inside the
// other. querySelectorAll returns document order, so the outer comes first.
expect(hosts, 'expected ListView and its inner view both armed').toHaveLength(2);
const [outer, inner] = hosts;
expect(outer.contains(inner)).toBe(true);

const before = dataSource.find.mock.calls.length;
await pull(inner, (shown) => {
expect(shown, 'one pull on a nested view must draw exactly one indicator').toHaveLength(1);
expect(outer.contains(shown[0]), 'the indicator must be inside ListView\'s host').toBe(true);
expect(inner.contains(shown[0]), 'the indicator must be the OUTER host\'s').toBe(false);
});
await expectOneRefetch(dataSource.find, before);
}

describe('objectui#10105 — a pull inside ListView belongs to ListView: one indicator, one refetch', () => {
it('ListView over ObjectCalendar', async () => {
await expectListViewOwnsThePull({
viewType: 'calendar',
columns: ['name'],
calendar: { startDateField: 'start_date', titleField: 'name' },
});
});

it('ListView over ObjectGrid', async () => {
await expectListViewOwnsThePull({ viewType: 'grid', columns: ['subject'] });
});

it('ListView over ObjectTimeline', async () => {
await expectListViewOwnsThePull({
viewType: 'timeline',
columns: ['subject'],
timeline: { startDateField: 'start_date', titleField: 'subject' },
});
});
});

describe('objectui#10105 — a view with no pull host above it still owns its own pull', () => {
async function expectStandaloneArmsAndRefetches(
container: HTMLElement,
find: { mock: { calls: unknown[] } },
) {
await waitFor(() => expect(screen.getAllByText('Task 0').length).toBeGreaterThan(0));
await act(async () => {});
const hosts = [...container.querySelectorAll<HTMLElement>(ARMED_HOST)];
expect(hosts, 'expected exactly one armed host').toHaveLength(1);
const before = find.mock.calls.length;
await pull(hosts[0], (shown) => {
expect(shown).toHaveLength(1);
expect(hosts[0].contains(shown[0])).toBe(true);
});
await expectOneRefetch(find, before);
}

it('ObjectGrid fetching its own rows', async () => {
const dataSource = makeDataSource();
const { container } = render(
<ActionProvider>
<ObjectGrid
schema={{ type: 'object-grid', objectName: 'duly_task', columns: ['subject'] } as never}
dataSource={dataSource as never}
/>
</ActionProvider>,
);
await expectStandaloneArmsAndRefetches(container, dataSource.find);
});

it('ObjectTimeline fetching its own rows', async () => {
const dataSource = makeDataSource();
const { container } = render(
<ObjectTimeline
schema={
{
type: 'timeline',
objectName: 'duly_task',
titleField: 'subject',
startDateField: 'start_date',
} as never
}
dataSource={dataSource as never}
/>,
);
await expectStandaloneArmsAndRefetches(container, dataSource.find);
});
});
10 changes: 10 additions & 0 deletions packages/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ export function Feed() {
}
```

The element does not have to exist on the first render. A component that shows
a loading screen first and attaches `ref` once its data arrives works, and so
does one that later swaps in a new element. The hook binds its touch listeners
to whatever element `ref` points at after each render.

Pull hosts can nest, for example a list view that renders a grid view inside
it. One pull belongs to the outermost armed host: an inner host lets the
gesture go when an armed pull host sits above it in the DOM, so a single pull
draws one indicator and runs one `onRefresh`.

### useTouchTarget

Hook for ensuring minimum touch target sizes. It returns the `style` and `className`
Expand Down
Loading
Loading