hasura / ra-data-hasura

react-admin data provider for Hasura GraphQL Engine

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Output order non-deterministic... breaking react-admin UI

michaelcm opened this issue · comments

Summary
I am running into an issue where the graphql data coming out of Hasura (Postgres in my case) breaks React-Admin list pages when using the <InfiniteList> tag. Infinite list is a infinite scroll version of the standard list. It fills the screen with data, so if the window is 15 rows high, it will load at least 20 rows initially.

Error:

Warning: Encountered two children with the same key, 13. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.

Environment

  • "react-admin": "^4.16.5",
  • "ra-data-hasura": "^0.6.0",
  • Tag:

Background
React requires a unique key for each row rendered on the screen. This is typically just the primary key, or in our case an incrementing number column. Collisions to this ID cause rendering issues and an error emitted on the screen.

Repro

  1. Use react-admin with ra-data-hasura.
  2. Create a resource in react-admin to target a table in hasura.
  3. Customize that resource with an InfiniteList element. Display all the columns, such as id, name and count (or some number).
  4. Ensure the hasura table for the resource has:
    • at least 20-40 rows
    • at least one number column
    • 15 rows that have the same numerical value 0 (just so they show up at the top)
  5. Run this in the browser, and sort on the numerical column.

Expected
A screen worth of rows load (make sure your screen is taller than 10 rows so the second page of data is loaded).

Actual
Some rows load, errors are displayed in the console. When changing sort order further, some rows are stuck on the screen.

Error:

Warning: Encountered two children with the same key, 13. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.

Explanation
InfiniteList loads records 10 rows at a time. So the first 10, sorted by count, with offset 0. And the second 10 , sorted by count, with offset 10. Which look something like this when executed:
SELECT * FROM users ORDER BY count LIMIT 10 OFFSET 0;
SELECT * FROM users ORDER BY count LIMIT 10 OFFSET 10;

The sort on a column with multiple identical values is non-deterministic in SQL. That is to say, if there are 15 entries with count = 0, the order of the 15 entries may be different each time its sorted by count. For the first SELECT the table is sorted by count, and 10 rows returned. Then the table is sorted again for the second SELECT, and the rows 10-19 returned.

Example. Note the overlapping IDs. That breaks react admin.

Response for SELECT * FROM users ORDER BY count LIMIT 10 OFFSET 0;
ID Name Count
1 One 0
12 One 0
13 One 0
22 One 0
3 One 0
6 One 0
9 One 0
11 One 0
15 One 0
31 One 0

Response for SELECT * FROM users ORDER BY count LIMIT 10 OFFSET 10;
ID Name Count
4 One 0
32 One 0
33 One 0
34 One 0
1 One 0
8 One 0
13 One 0
6 One 0
4 One 3
30 One 8

Solution
Always append the table's primary key column to ORDER BY (see , id below):
SELECT * FROM users ORDER BY count, id LIMIT 10 OFFSET 0;

This change I think makes the most sense in the data provider so it can be forgotten about by everyone else using this data provider in the future.

Something like this was needed to force the search results to always return in the same order, even with duplicate values in the sort column.

import {
  DataProvider,
  GetListParams,
  SortPayload,
  withLifecycleCallbacks,
} from 'react-admin';

interface Sort {
  field: string;
  order?: 'ASC' | 'DESC';
}

interface SortCorrection {
  fields: Sort[];
}

interface SortCorrections {
  [resource: string]: SortCorrection;
}

const sortCorrections: SortCorrections = {
  users: {
    fields: [
      { field: 'user_name' },
      { field: 'display_name' },
      { field: 'id' },
    ],
  },
};

interface ApplySortCorrectionsParams {
  originalSort: SortPayload;
  corrections: SortCorrection;
}

/**
 * Append the sort corrections onto the existing sort parameters.
 */
const applySortCorrections = ({
  originalSort,
  corrections,
}: ApplySortCorrectionsParams): SortPayload => {
  const correctedSort: SortPayload = { ...originalSort };

  const allFields = [originalSort, ...corrections.fields];

  const fields = allFields.map((correction) => correction.field).join(',');
  let orders = allFields
    .map((correction) => correction.order || originalSort.order || 'ASC')
    .join(',');

  correctedSort.field = fields;
  correctedSort.order = orders as any;

  return correctedSort;
};

export type AddDeterministicListSortProps = {
  dataProvider: DataProvider;
};

/**
 * Introduced to address an issue where IDs in subsequent pages of data were
 * colliding due to SQL being non-deterministic in how it sorts data. This
 * function adds additional sort parameters to force the results to be
 * deterministic such that we can page through the entire dataset in a
 * predictable manner.
 */
export const addDeterministicListSort = ({
  dataProvider,
}: AddDeterministicListSortProps) => {
  const lifecycleCallbacks = Object.entries(sortCorrections).map(
    ([resource, corrections]) => ({
      resource,
      beforeGetList: async (params: GetListParams) => {
        params.sort = applySortCorrections({
          originalSort: params.sort,
          corrections,
        });
        return params;
      },
    }),
  );

  return withLifecycleCallbacks(dataProvider, lifecycleCallbacks);
};