Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for embedding and prefetching #10270

Merged
merged 20 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from 7 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
105 changes: 105 additions & 0 deletions docs/DataProviderWriting.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,111 @@ dataProvider.getList('posts', {

React-admin's `<Pagination>` component will automatically handle the `pageInfo` object and display the appropriate pagination controls.

## Embedded Data

The response for `getList`, `getOne`, `getMany`, and `getManyReference` can include embedded data. This is useful when you want to avoid additional requests to fetch related records (e.g., when using [`<ReferenceField>`](./ReferenceField.md#prefetching)). The embedded data should be included in the `meta._embed` property of the response.

```jsx
const { data, meta } = dataProvider.getOne('posts', { id: 123 }, { meta: { _embed: ['author'] } });
console.log(data); // { id: 123, title: "Hello, world", author_id: 456 }
console.log(meta._embed); // { authors: [{ id: 456, name: "John Doe" }] }
```

By convention, the `meta._embed` response key must be an object where each key is the name of the embedded resource, and each value is an array of records.

```jsx
// example _embed value
{
authors: [
{ id: 1, name: 'John Doe' }
],
comments: [
{ id: 1, post_id: 1, body: 'Nice post!' },
{ id: 2, post_id: 1, body: 'I agree!' }
],
}
```

It's the Data Provider's job to build the `meta._embed` object based on the API response.

For example, the [JSON server](https://github.com/typicode/json-server?tab=readme-ov-file#embed) backend supports embedded data using the `_embed` query parameter:

```txt
GET /posts/123?_embed=author
```

```json
{
"id": 123,
"title": "Hello, world",
"author_id": 456,
"author": {
"id": 456,
"name": "John Doe"
}
}
```

To add support for embedded records in the response, the JSON Server Data Provider extracts the embedded data from the response, and puts them in the `meta._embed` property:

```jsx
const dataProvider = {
getOne: async (resource, params) => {
let query = `${apiUrl}/${resource}/${params.id}`;
if (params.embed) {
query += `?_embed=${params._embed.join(',')}`;
}
const { json: record } = await httpClient(query);
const embeds = {};
if (params._embed) {
params._embed.forEach(embed => {
if (record[embed]) {
const embedKey = embed.endsWith('s') ? embed : `${embed}s`;
if (!embeds[embedKey]) {
embeds[embedKey] = [];
}
if (!embeds[embedKey].find(r => r.id === record[embed].id)) {
embeds[embedKey].push(record[embed]);
}
delete record[embed];
}
});
}
return { data: record, meta: { _embed: embeds } };
},
getList: async (resource, params) => {
let query = `${apiUrl}/${resource}`;
if (params._embed) {
query += `?_embed=${params._embed}`;
}
const { json: records, headers } = await httpClient(query);
const embeds = {};
if (params._embed) {
records.forEach(record => {
params._embed.forEach(embed => {
if (record[embed]) {
const embedKey = embed.endsWith('s') ? embed : `${embed}s`;
if (!embeds[embedKey]) {
embeds[embedKey] = [];
}
if (!embeds[embedKey].find(r => r.id === record[embed].id)) {
embeds[embedKey].push(record[embed]);
}
delete record[embed];
}
});
});
}
return {
data: records,
total: parseInt(headers.get('content-range').split('/').pop(), 10),
meta: { _embed: embeds }
};
},
// ...
}
```

## Error Format

When the API backend returns an error, the Data Provider should return a rejected Promise containing an `Error` object. This object should contain a `status` property with the HTTP response code (404, 500, etc.). React-admin inspects this error code, and uses it for [authentication](./Authentication.md) (in case of 401 or 403 errors). Besides, react-admin displays the error `message` on screen in a temporary notification.
Expand Down
57 changes: 57 additions & 0 deletions docs/DataProviders.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,63 @@ Access-Control-Expose-Headers: X-Custom-Header

This must be done on the server side.

## Embedding Relationships

Some API backends can embed related records in the response to avoid multiple requests.

For instance, JSON Server can return a post and its author in a single response:

```txt
GET /posts/123?embed=author
```

```json
{
"id": 123,
"title": "Hello, world",
"author_id": 456,
"author": {
"id": 456,
"name": "John Doe"
}
}
```

In such cases, the data provider response should put the related record(s) in the `meta._embed` key of the response, using the resource name as key (here, `authors`):

```jsx
const { data, meta } = dataProvider.getOne('posts', { id: 123 })
console.log(data); // { id: 123, title: "Hello, world", author_id: 456 }
console.log(meta._embed); // { authors: [{ id: 456, name: "John Doe" }] }
```

When seeing a `meta._embed` in a data provider response, react-admin populates the react-query cache with the related records, so they don't need to be fetched separately.

```jsx
const { data } = useGetOne('authors', { id: 456 });
// will return immediately with the author data, without making a network request
```

This feature allow you to prefetch related records by passing a custom query parameter:

{% raw %}
```jsx
const PostList = () => (
<List queryOptions={{ meta: { embed: 'author' } }}>
<Datagrid>
<TextField source="title" />
{/** renders without an additional request */}
<ReferenceField source="author_id" />
</Datagrid>
</List>
);
```
{% endraw %}

The way to *ask* for embedded resources isn't normalized and depends on the API. For example, `ra-data-fakerest` uses a `meta: { embed }` key in the query to indicate that the author must be embedded.

Refer to your data provider's documentation to verify if this feature is supported. If you're writing your own data provider, check the [Writing a Data Provider](./DataProviderWriting.md#embedded-data) documentation for more details.

## Adding Lifecycle Callbacks

<iframe src="https://www.youtube-nocookie.com/embed/o8U-wjfUwGk" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen style="aspect-ratio: 16 / 9;width:100%;margin-bottom:1em;"></iframe>
Expand Down
24 changes: 24 additions & 0 deletions docs/ReferenceField.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,30 @@ React-admin accumulates and deduplicates the ids of the referenced records to ma

Then react-admin renders the `<PostList>` with a loader for the `<ReferenceField>`, fetches the API for the related users in one call (`dataProvider.getMany('users', { ids: [789,735] }`), and re-renders the list once the data arrives. This accelerates the rendering and minimizes network load.

## Prefetching

When you know that a page will contain a `<ReferenceField>`, you can configure the main page query to prefetch the referenced records to avoid a flicker when the data arrives. To do so, pass a `meta` parameter to the page query to enable relationship embedding.

For example, the following code prefetches the authors referenced by the posts:

{% raw %}
```jsx
const PostList = () => (
<List queryOptions={{ meta: { embed: 'author' } }}>
<Datagrid>
<TextField source="title" />
{/** renders without an additional request */}
<ReferenceField source="author_id" />
</Datagrid>
</List>
);
```
{% endraw %}

**Note**: For prefetching to function correctly, your data provider must support [Relationships Embedding](./DataProviders.md#embedding-relationships). Refer to your data provider's documentation to verify if this feature is supported.

**Note**: Prefetching is a frontend performance feature, designed to avoid flickers and repaints. It doesn't always prevent `<ReferenceField>` to fetch the data. For instance, when coming to a show view from a list view, the main record is already in the cache, so the page renders immediately, and both the page controller and the `<ReferenceField>` controller fetch the data in parallel. The embedded data from the page controller arrives after the first render of the `<ReferenceField>`, so the data provider fetches the related data anyway. But from a user perspective, the page displays immediately, including the `<ReferenceField>`.
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

## Rendering More Than One Field

You often need to render more than one field of the reference table (e.g. if the `users` table has a `first_name` and a `last_name` field).
Expand Down
6 changes: 5 additions & 1 deletion examples/simple/src/comments/CommentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ const CommentMobileList = () => (
);

const CommentList = () => (
<ListBase perPage={6} exporter={exporter}>
<ListBase
perPage={6}
exporter={exporter}
queryOptions={{ meta: { embed: 'post' } }}
>
<ListView />
</ListBase>
);
Expand Down
2 changes: 1 addition & 1 deletion examples/simple/src/comments/CommentShow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from 'react-admin'; // eslint-disable-line import/no-unresolved

const CommentShow = () => (
<Show>
<Show queryOptions={{ meta: { embed: 'post' } }}>
<SimpleShowLayout>
<TextField source="id" />
<ReferenceField source="post_id" reference="posts">
Expand Down
3 changes: 2 additions & 1 deletion examples/simple/src/dataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ const addTagsSearchSupport = (dataProvider: DataProvider) => ({
// partial pagination
return dataProvider
.getList(resource, params)
.then(({ data, total }) => ({
.then(({ data, total, meta }) => ({
data,
pageInfo: {
hasNextPage:
params.pagination.perPage * params.pagination.page <
(total || 0),
hasPreviousPage: params.pagination.page > 1,
},
meta,
}));
}
if (resource === 'tags') {
Expand Down
47 changes: 47 additions & 0 deletions packages/ra-core/src/dataProvider/populateQueryCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { QueryClient } from '@tanstack/react-query';

export type PopulateQueryCacheOptions = {
data: Record<string, any[]>;
queryClient: QueryClient;
staleTime?: number;
};

/**
* Populate react-query's query cache with a data dictionnary
* @example
* const data = {
* posts: [{ id: 1, title: 'Hello, world' }, { id: 2, title: 'FooBar' }],
* comments: [{ id: 1, post_id: 1, body: 'Nice post!' }],
* };
* populateQueryCache({ data, queryClient });
* // setQueryData(['posts', 'getOne', { id: '1' }], { id: 1, title: 'Hello, world' });
* // setQueryData(['posts', 'getOne', { id: '2' }], { id: 2, title: 'FooBar' });
* // setQueryData(['posts', 'getMany', { ids: ['1', '2'] }], [{ id: 1, title: 'Hello, world' }, { id: 2, title: 'FooBar' }]);
* // setQueryData(['comments', 'getOne', { id: '1' }], { id: 1, post_id: 1, body: 'Nice post!' });
* // setQueryData(['comments', 'getMany', { ids: ['1'] }], [{ id: 1, post_id: 1, body: 'Nice post!' });
*/
export const populateQueryCache = ({
data,
queryClient,
staleTime = 500, // ms
}: PopulateQueryCacheOptions) => {
slax57 marked this conversation as resolved.
Show resolved Hide resolved
// setQueryData doesn't accept a stale time option
// So we set an updatedAt in the future to make sure the data is considered stale
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
const updatedAt = Date.now() + staleTime;
Object.keys(data).forEach(resource => {
data[resource].forEach(record => {
if (!record || record.id == null) return;
queryClient.setQueryData(
[resource, 'getOne', { id: String(record.id) }],
record,
{ updatedAt }
);
});
const recordIds = data[resource].map(record => String(record.id));
queryClient.setQueryData(
[resource, 'getMany', { ids: recordIds }],
data[resource],
{ updatedAt }
);
});
};
11 changes: 10 additions & 1 deletion packages/ra-core/src/dataProvider/useDataProvider.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useContext, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';

import DataProviderContext from './DataProviderContext';
import { defaultDataProvider } from './defaultDataProvider';
import validateResponseFormat from './validateResponseFormat';
import { DataProvider } from '../types';
import useLogoutIfAccessDenied from '../auth/useLogoutIfAccessDenied';
import { reactAdminFetchActions } from './dataFetchActions';
import { populateQueryCache } from './populateQueryCache';

/**
* Hook for getting a dataProvider
Expand Down Expand Up @@ -80,6 +82,7 @@ export const useDataProvider = <
>(): TDataProvider => {
const dataProvider = (useContext(DataProviderContext) ||
defaultDataProvider) as unknown as TDataProvider;
const queryClient = useQueryClient();

const logoutIfAccessDenied = useLogoutIfAccessDenied();

Expand Down Expand Up @@ -111,6 +114,12 @@ export const useDataProvider = <
) {
validateResponseFormat(response, type);
}
if (response?.meta?._embed) {
populateQueryCache({
data: response?.meta._embed,
queryClient,
});
}
return response;
})
.catch(error => {
Expand Down Expand Up @@ -146,7 +155,7 @@ export const useDataProvider = <
};
},
});
}, [dataProvider, logoutIfAccessDenied]);
}, [dataProvider, logoutIfAccessDenied, queryClient]);

return dataProviderProxy;
};
Expand Down
7 changes: 7 additions & 0 deletions packages/ra-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export interface GetOneParams<RecordType extends RaRecord = any> {
}
export interface GetOneResult<RecordType extends RaRecord = any> {
data: RecordType;
meta?: any;
}

export interface GetManyParams<RecordType extends RaRecord = any> {
Expand All @@ -188,6 +189,7 @@ export interface GetManyParams<RecordType extends RaRecord = any> {
}
export interface GetManyResult<RecordType extends RaRecord = any> {
data: RecordType[];
meta?: any;
}

export interface GetManyReferenceParams {
Expand Down Expand Up @@ -217,6 +219,7 @@ export interface UpdateParams<RecordType extends RaRecord = any> {
}
export interface UpdateResult<RecordType extends RaRecord = any> {
data: RecordType;
meta?: any;
}

export interface UpdateManyParams<T = any> {
Expand All @@ -226,6 +229,7 @@ export interface UpdateManyParams<T = any> {
}
export interface UpdateManyResult<RecordType extends RaRecord = any> {
data?: RecordType['id'][];
meta?: any;
}

export interface CreateParams<T = any> {
Expand All @@ -234,6 +238,7 @@ export interface CreateParams<T = any> {
}
export interface CreateResult<RecordType extends RaRecord = any> {
data: RecordType;
meta?: any;
}

export interface DeleteParams<RecordType extends RaRecord = any> {
Expand All @@ -243,6 +248,7 @@ export interface DeleteParams<RecordType extends RaRecord = any> {
}
export interface DeleteResult<RecordType extends RaRecord = any> {
data: RecordType;
meta?: any;
}

export interface DeleteManyParams<RecordType extends RaRecord = any> {
Expand All @@ -251,6 +257,7 @@ export interface DeleteManyParams<RecordType extends RaRecord = any> {
}
export interface DeleteManyResult<RecordType extends RaRecord = any> {
data?: RecordType['id'][];
meta?: any;
}

export type DataProviderResult<RecordType extends RaRecord = any> =
Expand Down
Loading
Loading