Web Totals

Học từ Facebook #90: Phân trang connection, khớp cursor, bỏ edge trùng

ConnectionHandler của Relay ghép trang theo cursor: trang lệch cursor bị bỏ, edge trùng node bị lọc, ID edge tăng đơn điệu. Kèm biến thể News Feed.

hunghg255hunghg25527 min read
#facebook#relay#graphql#pagination#infinite-scroll
Học từ Facebook #90: Phân trang connection, khớp cursor, bỏ edge trùng

Infinite scroll trông đơn giản: cuộn tới cuối thì gọi trang tiếp và setItems([...items, ...page]). Rồi bug bắt đầu tới. Người dùng cuộn nhanh, hai request "trang tiếp" cùng bay với cùng một cursor, và bốn bài xuất hiện hai lần. Feed xếp hạng lại giữa hai lần tải, trang sau lẫn hai bài đã xem. React báo Encountered two children with the same key. Một bài vừa đăng hiện hai lần vì realtime báo lại đúng bài đó.

Relay giải quyết các ca này ở một chỗ: ConnectionHandler, đoạn code chạy mỗi khi một field có @connection nhận dữ liệu mới. Facebook còn có handler riêng cho News Feed với luật khác. Bài này đọc cả hai.

Tip

Tóm tắt

  • Trang after: X chỉ được ghép khi X đúng bằng endCursor hiện tại. Lệch thì bỏ cả trang: request đến muộn của một lần bấm đúp không làm hỏng danh sách.
  • Ghép bằng một Set node id theo thứ tự [cũ…, mới…] (hoặc [mới…, cũ…] khi tải ngược). Edge đến trước thắng, trang chồng lấn không sinh bản trùng.
  • Mỗi edge nhận một ID phía client client:<connection>:edges:<n> với n lấy từ bộ đếm __connection_next_edge_index chỉ tăng. ID không bao giờ dùng lại, kể cả khi edge bị xoá rồi thêm lại.
  • Handler của News Feed bỏ kiểm tra cursor, cấm phân trang ngược và coi "còn trang tiếp" là "trang vừa nhận không rỗng", vì feed xếp hạng không có điểm kết thúc.

#Facebook làm thế nào?

#ConnectionHandler.update

relay-runtime/handlers/connection/ConnectionHandler (trong 99 ChatComposer/all.js và 31.js, hai bản giống hệt). Relay là mã nguồn mở (MIT). Tên trường lấy từ ConnectionInterface (edges, node, cursor, pageInfo, endCursor…), và có thể đổi qua ConnectionInterface.inject.

Một field @connection có hai bản trong store: bản server (friends(first:10,after:"abc"), mỗi trang một bản) và bản client (__friends_connection, một bản duy nhất, gộp mọi trang). Handler chép từ bản server sang bản client. Lần đầu:

if (!clientConnection) {
  const conn = store.create(generateClientID(record.getDataID(), handleKey), serverConnection.getType());
  conn.setValue(0, '__connection_next_edge_index');
  conn.copyFieldsFrom(serverConnection);
  conn.setLinkedRecords(serverEdges.map((e) => buildConnectionEdge(store, conn, e)), 'edges');
  // pageInfo mới, mặc định false/null, rồi chép từ server
}
js

Những lần sau, phần quan trọng (đổi tên biến):

const prevEdges = conn.getLinkedRecords('edges');
const nextEdges = serverEdges.map((e) => buildConnectionEdge(store, conn, e));
let edges = [];
if (prevEdges && nextEdges) {
  if (args.after != null) {
    if (clientPageInfo && args.after === clientPageInfo.getValue('endCursor')) {
      const seen = new Set();
      mergeEdges(prevEdges, edges, seen);
      mergeEdges(nextEdges, edges, seen);
    } else {
      warning(false, 'Relay: Unexpected after cursor `%s`, edges must be fetched from the end of the list (`%s`).', args.after, clientPageInfo && clientPageInfo.getValue('endCursor'));
      return;
    }
  } else if (args.before != null) {
    /* tương tự với startCursor, thứ tự [mới…, cũ…] */
  } else {
    edges = nextEdges; // không có after/before: tải lại từ đầu, thay toàn bộ
  }
}
js

Và mergeEdges (tên gốc n):

function mergeEdges(source, target, seen) {
  for (const edge of source) {
    if (!edge) continue;
    const nodeID = edge.getLinkedRecord('node')?.getDataID();
    if (nodeID) {
      if (seen.has(nodeID)) continue; // node đã có: bỏ edge này
      seen.add(nodeID);
    }
    target.push(edge);
  }
}
js

Sau khi ghép, pageInfo chỉ cập nhật đầu tương ứng. Tải xuôi (after, hoặc first không có before) thì chỉ chép hasNextPage và endCursor. Tải ngược thì chỉ hasPreviousPage và startCursor. Nếu chép cả pageInfo từ trang mới, startCursor sẽ nhảy xuống giữa danh sách và nút "tải bài mới hơn" hỏng.

#ID của edge: bộ đếm chỉ tăng

function buildConnectionEdge(store, connection, edge) {
  if (edge == null) return edge;
  const index = connection.getValue('__connection_next_edge_index');
  invariant(typeof index === 'number', /* … */);
  const edgeID = generateClientID(connection.getDataID(), 'edges', index); // client:<conn>:edges:<n>
  const clone = store.create(edgeID, edge.getType());
  clone.copyFieldsFrom(edge);
  connection.setValue(index + 1, '__connection_next_edge_index');
  return clone;
}
js

Edge của server thường không có ID ổn định (ID của edge server phụ thuộc vào trang chứa nó). Relay clone mỗi edge sang một ID mới theo bộ đếm của connection. Bộ đếm không bao giờ giảm, nên một edge bị xoá rồi được tải lại sẽ có ID khác, và một record edge cũ không bao giờ bị ghi đè nhầm. Edge bị bỏ do trùng vẫn đã tiêu một số, nên dãy ID có lỗ. Trong demo, cột phải hiện edges:<n> cho từng dòng: bấm "Trang sau lẫn bài cũ" rồi xem số nhảy cóc.

#Chèn và xoá thủ công

Các hàm dành cho updater của mutation:

  • createEdge(store, record, node, edgeType): ID là client:<connection>:<nodeID>, theo node chứ không theo bộ đếm. Nếu record đó đã có thì dùng lại.
  • insertEdgeAfter(record, newEdge, cursor?): không có cursor thì nối vào cuối. Có cursor thì chèn sau edge có cursor đó, không tìm thấy thì cũng nối vào cuối. insertEdgeBefore đối xứng, nối vào đầu.
  • deleteNode(record, nodeID): duyệt edges, chỉ slice mảng ở lần đầu gặp node cần xoá, sau đó push phần còn lại. Không có node cần xoá thì không tạo mảng mới.

Hai hàm insert không kiểm tra trùng. Gọi createEdge cho cùng một node hai lần (mutation trả về, rồi subscription realtime báo lại đúng bài đó) thì cùng một record edge xuất hiện hai lần trong danh sách, hai dòng giống nhau và cùng ID. Đây là cạm bẫy quen thuộc khi dùng Relay. Bản viết lại thêm tuỳ chọn dedupe (bật mặc định).

#Hai điểm cần biết khi trang bị bỏ

Đọc code thấy:

  1. Cảnh báo chỉ có ở dev. Trang bị bỏ chỉ đi kèm một lời gọi warning(...). Trong bundle, warning trỏ tới warningComet (hoặc warningBlue ở giao diện cũ), và cả hai ở bản production là hàm rỗng function (a, b) {}. Trên production, trang bị bỏ trong im lặng. Đó là hành vi đúng cho request đến muộn, nhưng nếu cursor lệch vì bug thật (server đổi định dạng cursor chẳng hạn), bạn sẽ chỉ thấy "cuộn mãi không tải thêm".
  2. Field khác của connection vẫn bị ghi. Trước khi so cursor, handler đã gọi conn.copyFieldsFrom(serverConnection) rồi đặt lại edges và pageInfo cũ. Các field khác trên connection (ví dụ count) lấy giá trị từ trang bị bỏ.

#Handler riêng của News Feed

CometNewsFeedConnectionHandler (cũng trong 99 ChatComposer/all.js) viết lại hàm update cho feed, dùng lại buildConnectionEdge của Relay:

if (args.before != null)
  throw unrecoverableViolation('The newsfeed connection does not support backward pagination by design', 'comet_feed');
// …
const edges = [], seen = new Set();
if (args.after != null || (nextEdges?.length ?? 0) === 0) mergeEdges(prevEdges ?? [], edges, seen);
mergeEdges(nextEdges ?? [], edges, seen);
conn.setLinkedRecords(edges, 'edges');
conn.setValue(nextEdges.length, 'received_edges_count');
js

Khác ConnectionHandler ở bốn chỗ:

  • Không so cursor. Trang nào tới cũng được ghép, và Set node id lo phần trùng. Feed xếp hạng có thể trả lại bài đã thấy, nên lọc trùng quan trọng hơn kiểm cursor.
  • Làm mới mà server trả rỗng thì giữ danh sách cũ (nextEdges.length === 0 cũng giữ prevEdges). Người dùng kéo để làm mới mà mạng trả về rỗng thì feed không bị xoá trắng.
  • hasNextPage = trang vừa nhận không rỗng. Sau gatekeeper 1631636 thì thêm điều kiện "hoặc server nói còn". hasPreviousPage luôn false.
  • endCursor = cursor của server, thiếu thì cursor của edge cuối. Không có cả hai thì ghi log "Unable to set end_cursor…" và giữ nguyên.

Bài #50 (seen/read bằng 2 bit) có một handler khác của Facebook cho danh sách thông báo: thay node rút gọn từ cache server bằng record đầy đủ đã có.

#Bên trong: hai request cùng cursor

sequenceDiagram
  participant U as Người dùng
  participant H as ConnectionHandler
  participant S as Server
  U->>S: request 1: first 4, after p4
  U->>S: request 2: first 4, after p4
  S-->>H: trang 1: p5..p8
  H->>H: after p4 = endCursor p4, ghép, endCursor = p8
  S-->>H: trang 2: p5..p8
  H->>H: after p4 khác endCursor p8, bỏ cả trang
  Note over H: danh sách: p1..p8, không trùng

Cách nối thẳng mảng nhận cả hai trang: p5..p8 xuất hiện hai lần. Đó là bước đầu tiên trong demo ("Bấm Tải tiếp 2 lần liền"). Kiểm cursor là một dạng optimistic concurrency (đồng thời lạc quan): request mang theo "phiên bản" nó dựa vào, và kết quả chỉ được ghi nếu phiên bản đó vẫn còn đúng.

#Viết lại

File src/lib/connection.ts, bất biến: mỗi lần ghép trả về object Connection mới, không cần store. Ghép qua Set:

src/lib/connection.ts
/** Nối các danh sách theo thứ tự, bỏ edge có node id đã gặp (edge đến trước thắng). */
function mergeEdges<N extends NodeLike>(lists: readonly (readonly Edge<N>[])[]) {
  const seen = new Set<string>();
  const out: Edge<N>[] = [];
  let dropped = 0;
  for (const list of lists) {
    for (const edge of list) {
      const id = edge.node?.id;
      if (id != null) {
        if (seen.has(id)) {
          dropped++;
          continue;
        }
        seen.add(id);
      }
      out.push(edge); // edge không có node (null) vẫn được giữ, giống Relay
    }
  }
  return { edges: out, dropped };
}
ts

Phần thân của mergePage sau nhánh khởi tạo: kiểm cursor, ghép theo chiều, cập nhật đúng đầu của pageInfo:

src/lib/connection.ts
  const { pageInfo } = current;
  if (args.after != null && args.after !== pageInfo.endCursor) {
    return {
      connection: current,
      result: 'rejected',
      dropped: 0,
      warning: `after "${args.after}" khác endCursor "${pageInfo.endCursor}": bỏ cả trang`,
    };
  }
  if (args.before != null && args.before !== pageInfo.startCursor) {
    return {
      connection: current,
      result: 'rejected',
      dropped: 0,
      warning: `before "${args.before}" khác startCursor "${pageInfo.startCursor}": bỏ cả trang`,
    };
  }

  const built = buildEdges(connId, current.nextEdgeIndex, page);
  const server = page.pageInfo;
  let result: MergeResult;
  let merged: { edges: Edge<N>[]; dropped: number };
  let nextInfo: PageInfo;

  if (args.after != null) {
    result = 'append';
    merged = mergeEdges([current.edges, built.edges]);
    nextInfo = {
      ...pageInfo,
      hasNextPage: !!server.hasNextPage,
      endCursor: typeof server.endCursor === 'string' ? server.endCursor : pageInfo.endCursor,
    };
  } else if (args.before != null) {
    result = 'prepend';
    merged = mergeEdges([built.edges, current.edges]);
    nextInfo = {
      ...pageInfo,
      hasPreviousPage: !!server.hasPreviousPage,
      startCursor:
        typeof server.startCursor === 'string' ? server.startCursor : pageInfo.startCursor,
    };
  } else {
    // không có after/before: tải lại từ đầu, thay toàn bộ
    result = 'refetch';
    merged = { edges: built.edges, dropped: 0 };
    nextInfo = { ...EMPTY_PAGE_INFO, ...server };
  }
  return {
    connection: { id: connId, edges: merged.edges, pageInfo: nextInfo, nextEdgeIndex: built.next },
    result,
    dropped: merged.dropped,
  };
}
ts

Biến thể News Feed:

src/lib/connection.ts
export function mergeFeedPage<N extends NodeLike>(
  current: Connection<N> | null,
  connId: string,
  page: ServerPage<N>,
  args: PageArgs,
): MergeOutcome<N> {
  if (args.before != null) throw new Error('Feed không hỗ trợ phân trang ngược');
  const start = current?.nextEdgeIndex ?? 0;
  const built = buildEdges(connId, start, page);
  const keepOld = current && (args.after != null || built.edges.length === 0);
  const merged = mergeEdges(keepOld ? [current!.edges, built.edges] : [built.edges]);
  const last = merged.edges[merged.edges.length - 1];
  const pageInfo: PageInfo = {
    hasPreviousPage: false,
    hasNextPage: built.edges.length > 0 || page.pageInfo.hasNextPage === true,
    startCursor: null,
    endCursor: page.pageInfo.endCursor ?? last?.cursor ?? null,
  };
  return {
    connection: { id: connId, edges: merged.edges, pageInfo, nextEdgeIndex: built.next },
    result: current ? (args.after != null ? 'append' : 'refetch') : 'init',
    dropped: merged.dropped,
  };
}
ts
Xem toàn bộ src/lib/connection.ts
src/lib/connection.ts
/**
 * Ghép trang cho danh sách phân trang kiểu GraphQL connection, ý tưởng từ ConnectionHandler của Relay
 * (viết lại, bất biến, không cần store):
 * - mỗi edge nhận được có ID riêng `client:<conn>:edges:<n>` với n tăng đơn điệu, không bao giờ dùng lại,
 * - trang `after` chỉ được ghép khi cursor khớp endCursor hiện tại; trang `before` khớp startCursor,
 * - ghép qua một Set node id: edge đến trước thắng, nên trang chồng lấn không sinh bản trùng.
 */

export interface NodeLike {
  id: string;
}
export interface PageInfo {
  hasNextPage: boolean;
  hasPreviousPage: boolean;
  startCursor: string | null;
  endCursor: string | null;
}
export interface Edge<N extends NodeLike> {
  /** ID phía client, ổn định suốt đời edge, dùng làm React key */
  id: string;
  cursor: string | null;
  node: N | null;
}
export interface Connection<N extends NodeLike> {
  id: string;
  edges: readonly Edge<N>[];
  pageInfo: PageInfo;
  nextEdgeIndex: number;
}
export interface ServerPage<N extends NodeLike> {
  edges: { cursor: string | null; node: N | null }[];
  pageInfo: Partial<PageInfo>;
}
export interface PageArgs {
  first?: number;
  after?: string | null;
  last?: number;
  before?: string | null;
}
export type MergeResult = 'init' | 'refetch' | 'append' | 'prepend' | 'rejected';

const EMPTY_PAGE_INFO: PageInfo = {
  hasNextPage: false,
  hasPreviousPage: false,
  startCursor: null,
  endCursor: null,
};

/** Gán ID client cho các edge server, trả về edge mới và chỉ số kế tiếp. */
function buildEdges<N extends NodeLike>(connId: string, start: number, page: ServerPage<N>) {
  let n = start;
  const edges = page.edges.map((e) => ({
    id: `client:${connId}:edges:${n++}`,
    cursor: e.cursor,
    node: e.node,
  }));
  return { edges, next: n };
}

/** Nối các danh sách theo thứ tự, bỏ edge có node id đã gặp (edge đến trước thắng). */
function mergeEdges<N extends NodeLike>(lists: readonly (readonly Edge<N>[])[]) {
  const seen = new Set<string>();
  const out: Edge<N>[] = [];
  let dropped = 0;
  for (const list of lists) {
    for (const edge of list) {
      const id = edge.node?.id;
      if (id != null) {
        if (seen.has(id)) {
          dropped++;
          continue;
        }
        seen.add(id);
      }
      out.push(edge); // edge không có node (null) vẫn được giữ, giống Relay
    }
  }
  return { edges: out, dropped };
}

export interface MergeOutcome<N extends NodeLike> {
  connection: Connection<N>;
  result: MergeResult;
  /** số edge bị bỏ vì trùng node */
  dropped: number;
  warning?: string;
}

export function mergePage<N extends NodeLike>(
  current: Connection<N> | null,
  connId: string,
  page: ServerPage<N>,
  args: PageArgs,
): MergeOutcome<N> {
  if (!current) {
    const { edges, next } = buildEdges(connId, 0, page);
    return {
      connection: {
        id: connId,
        edges,
        nextEdgeIndex: next,
        pageInfo: { ...EMPTY_PAGE_INFO, ...page.pageInfo },
      },
      result: 'init',
      dropped: 0,
    };
  }
  const { pageInfo } = current;
  if (args.after != null && args.after !== pageInfo.endCursor) {
    return {
      connection: current,
      result: 'rejected',
      dropped: 0,
      warning: `after "${args.after}" khác endCursor "${pageInfo.endCursor}": bỏ cả trang`,
    };
  }
  if (args.before != null && args.before !== pageInfo.startCursor) {
    return {
      connection: current,
      result: 'rejected',
      dropped: 0,
      warning: `before "${args.before}" khác startCursor "${pageInfo.startCursor}": bỏ cả trang`,
    };
  }

  const built = buildEdges(connId, current.nextEdgeIndex, page);
  const server = page.pageInfo;
  let result: MergeResult;
  let merged: { edges: Edge<N>[]; dropped: number };
  let nextInfo: PageInfo;

  if (args.after != null) {
    result = 'append';
    merged = mergeEdges([current.edges, built.edges]);
    nextInfo = {
      ...pageInfo,
      hasNextPage: !!server.hasNextPage,
      endCursor: typeof server.endCursor === 'string' ? server.endCursor : pageInfo.endCursor,
    };
  } else if (args.before != null) {
    result = 'prepend';
    merged = mergeEdges([built.edges, current.edges]);
    nextInfo = {
      ...pageInfo,
      hasPreviousPage: !!server.hasPreviousPage,
      startCursor:
        typeof server.startCursor === 'string' ? server.startCursor : pageInfo.startCursor,
    };
  } else {
    // không có after/before: tải lại từ đầu, thay toàn bộ
    result = 'refetch';
    merged = { edges: built.edges, dropped: 0 };
    nextInfo = { ...EMPTY_PAGE_INFO, ...server };
  }
  return {
    connection: { id: connId, edges: merged.edges, pageInfo: nextInfo, nextEdgeIndex: built.next },
    result,
    dropped: merged.dropped,
  };
}

/**
 * Tạo edge cho node mới (vd vừa đăng bài). ID theo node như Relay createEdge,
 * nên chèn cùng node hai lần sẽ ra hai edge cùng ID: dùng insertEdge với `dedupe`.
 */
export function createEdge<N extends NodeLike>(connId: string, node: N): Edge<N> {
  return { id: `client:${connId}:${node.id}`, cursor: null, node };
}

export interface InsertOptions {
  /** chèn sau/trước edge có cursor này; không tìm thấy thì chèn ở cuối/đầu */
  cursor?: string | null;
  /** bỏ qua nếu node đã có trong danh sách (Relay không làm bước này) */
  dedupe?: boolean;
}

function insertEdge<N extends NodeLike>(
  conn: Connection<N>,
  edge: Edge<N>,
  where: 'after' | 'before',
  { cursor = null, dedupe = true }: InsertOptions = {},
): Connection<N> {
  if (dedupe && edge.node && conn.edges.some((e) => e.node?.id === edge.node!.id)) return conn;
  const edges = [...conn.edges];
  const at = cursor == null ? -1 : edges.findIndex((e) => e.cursor === cursor);
  if (at === -1) {
    if (where === 'after') edges.push(edge);
    else edges.unshift(edge);
  } else {
    edges.splice(where === 'after' ? at + 1 : at, 0, edge);
  }
  return { ...conn, edges };
}

export const insertEdgeAfter = <N extends NodeLike>(
  c: Connection<N>,
  e: Edge<N>,
  o?: InsertOptions,
) => insertEdge(c, e, 'after', o);
export const insertEdgeBefore = <N extends NodeLike>(
  c: Connection<N>,
  e: Edge<N>,
  o?: InsertOptions,
) => insertEdge(c, e, 'before', o);

/** Xoá mọi edge trỏ tới node; chỉ sao chép mảng khi thật sự có edge cần xoá. */
export function deleteNode<N extends NodeLike>(conn: Connection<N>, nodeId: string): Connection<N> {
  let out: Edge<N>[] | undefined;
  conn.edges.forEach((edge, i) => {
    if (edge.node?.id === nodeId) {
      if (out === undefined) out = conn.edges.slice(0, i);
    } else if (out !== undefined) {
      out.push(edge);
    }
  });
  return out === undefined ? conn : { ...conn, edges: out };
}

/**
 * Biến thể cho feed vô tận (theo CometNewsFeedConnectionHandler): không kiểm cursor,
 * không có chiều ngược, còn trang tiếp khi trang vừa nhận không rỗng,
 * endCursor lấy của server, thiếu thì lấy cursor của edge cuối.
 */
export function mergeFeedPage<N extends NodeLike>(
  current: Connection<N> | null,
  connId: string,
  page: ServerPage<N>,
  args: PageArgs,
): MergeOutcome<N> {
  if (args.before != null) throw new Error('Feed không hỗ trợ phân trang ngược');
  const start = current?.nextEdgeIndex ?? 0;
  const built = buildEdges(connId, start, page);
  const keepOld = current && (args.after != null || built.edges.length === 0);
  const merged = mergeEdges(keepOld ? [current!.edges, built.edges] : [built.edges]);
  const last = merged.edges[merged.edges.length - 1];
  const pageInfo: PageInfo = {
    hasPreviousPage: false,
    hasNextPage: built.edges.length > 0 || page.pageInfo.hasNextPage === true,
    startCursor: null,
    endCursor: page.pageInfo.endCursor ?? last?.cursor ?? null,
  };
  return {
    connection: { id: connId, edges: merged.edges, pageInfo, nextEdgeIndex: built.next },
    result: current ? (args.after != null ? 'append' : 'refetch') : 'init',
    dropped: merged.dropped,
  };
}
ts

Khác bản gốc:

  • Không có store chuẩn hoá: edge là object {id, cursor, node}, connection là một object bất biến. Kết hợp với recycleNodesInto (bài #87) nếu cần giữ tham chiếu.
  • Trang bị bỏ trả về result: 'rejected' kèm warning, để UI tự quyết định ghi log hay thử lại. Không ghi field nào từ trang bị bỏ.
  • insertEdgeAfter/insertEdgeBefore có dedupe mặc định bật.
  • mergeFeedPage gộp luật của News Feed vào một hàm, ném lỗi thường thay vì unrecoverableViolation.

#Đi qua code

  • buildEdges gán ID theo bộ đếm bắt đầu từ nextEdgeIndex và trả về giá trị kế tiếp. Mọi edge của trang đều tiêu một số, kể cả edge sau đó bị bỏ vì trùng.
  • mergeEdges nhận danh sách các mảng theo thứ tự ưu tiên. Edge có node rỗng (server trả null cho bài đã bị xoá) vẫn được giữ như Relay, để vị trí trong danh sách không lệch.
  • mergePage kiểm after trước, rồi before. Không có cả hai thì là làm mới: thay toàn bộ edges và pageInfo.
  • insertEdge tìm vị trí theo cursor. Không thấy thì đưa về cuối (after) hoặc đầu (before), như Relay.
  • deleteNode chỉ sao chép khi gặp node cần xoá lần đầu. Không có gì để xoá thì trả lại chính conn, nên === vẫn đúng và React không render lại.

#Tại sao không làm cách khác?

  • Dedupe khi render (uniqBy(items, 'id')): che được bản trùng nhưng không sửa được cursor. Request đến muộn vẫn ghi đè endCursor cũ, và lần tải tiếp theo lại lấy trùng một trang.
  • Chặn request đang bay (if (loading) return): cần thiết và nên có, nhưng không đủ. Refetch, subscription và tải ngược vẫn có thể chạy song song với "tải tiếp".
  • Huỷ request cũ bằng AbortController: tốt cho tìm kiếm (chỉ kết quả mới nhất có nghĩa). Với phân trang, request cũ thường vẫn đúng, chỉ là không còn khớp vị trí.
  • Offset (page=3) thay cho cursor: có bài mới ở đầu thì mọi offset trượt, trang sau lẫn bài của trang trước. Cursor theo vị trí bài (ID, thời gian) ổn định hơn, và kiểm "cursor khớp đầu" chỉ có nghĩa với cursor.
  • Dùng cursor của edge làm React key: hai lần tải cùng một bài thì cursor có thể khác, và edge tạo ở client thì không có cursor. ID client riêng ổn định hơn.

#Edge case và cạm bẫy

  • Trang lệch cursor bị bỏ hẳn. Nếu server đổi cursor của cùng vị trí (cursor có chữ ký thời gian chẳng hạn), mọi trang sau đều bị bỏ. Kiểm tra bằng cách log result === 'rejected' ở production.
  • Lọc trùng theo node id ẩn đi bài bị "đẩy lên". Feed đưa một bài cũ lên lại đầu (vì có bình luận mới), edge cũ thắng nên bài vẫn nằm ở vị trí cũ. Muốn đưa lên đầu thì phải deleteNode rồi insertEdgeBefore.
  • hasNextPage của feed không bao giờ false khi server còn trả bài. Cần một điều kiện dừng khác (giới hạn số trang, hoặc server trả trang rỗng).
  • Edge null: không bị lọc trùng, nên hai edge null liên tiếp đều được giữ. Khi render, bỏ qua edge không có node.
  • Bộ đếm edge chỉ tăng: một connection sống rất lâu (chat mở cả ngày) vẫn an toàn vì Number.MAX_SAFE_INTEGER lớn hơn nhiều so với số edge thực tế.

#Dùng trong dự án

import { mergePage, insertEdgeBefore, createEdge, type Connection } from './lib/connection';

function useFeed() {
  const [conn, setConn] = useState<Connection<Post> | null>(null);
  const connRef = useRef(conn);
  connRef.current = conn;

  const loadMore = async () => {
    const args = { first: 10, after: connRef.current?.pageInfo.endCursor ?? null }; // chụp cursor lúc gửi
    const page = await api.feed(args);
    setConn((cur) => {
      const out = mergePage(cur, 'feed', page, args);
      if (out.result === 'rejected') console.warn(out.warning);
      return out.connection;
    });
  };
  const onNewPost = (post: Post) => setConn((cur) => cur && insertEdgeBefore(cur, createEdge('feed', post)));
  return { conn, loadMore, onNewPost };
}

// render: key là edge.id, không phải index hay cursor
conn?.edges.map((e) => e.node && <PostCard key={e.id} post={e.node} />);
tsx

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { createEdge, deleteNode, insertEdgeBefore, mergeFeedPage, mergePage } from '../src/lib/connection';

const page = (ids: string[], info = {}) => ({
  edges: ids.map((id) => ({ cursor: id, node: { id } })),
  pageInfo: { startCursor: ids[0] ?? null, endCursor: ids.at(-1) ?? null, hasNextPage: true, ...info },
});

describe('mergePage', () => {
  const first = mergePage(null, 'c', page(['a', 'b']), { first: 2 }).connection;

  it('ghép trang after khớp endCursor, bỏ node trùng', () => {
    const out = mergePage(first, 'c', page(['b', 'c']), { first: 2, after: 'b' });
    expect(out.result).toBe('append');
    expect(out.connection.edges.map((e) => e.node!.id)).toEqual(['a', 'b', 'c']);
    expect(out.dropped).toBe(1);
    expect(out.connection.pageInfo.endCursor).toBe('c');
    expect(out.connection.pageInfo.startCursor).toBe('a'); // đầu kia giữ nguyên
  });

  it('bỏ cả trang khi cursor lệch', () => {
    const second = mergePage(first, 'c', page(['c', 'd']), { first: 2, after: 'b' }).connection;
    const late = mergePage(second, 'c', page(['c', 'd']), { first: 2, after: 'b' });
    expect(late.result).toBe('rejected');
    expect(late.connection).toBe(second);
  });

  it('tải ngược đặt trang mới lên đầu', () => {
    const out = mergePage(first, 'c', page(['y', 'z'], { hasPreviousPage: false }), { last: 2, before: 'a' });
    expect(out.connection.edges.map((e) => e.node!.id)).toEqual(['y', 'z', 'a', 'b']);
    expect(out.connection.pageInfo.startCursor).toBe('y');
  });

  it('ID edge tăng đơn điệu', () => {
    const out = mergePage(first, 'c', page(['b', 'c']), { first: 2, after: 'b' });
    expect(out.connection.edges.map((e) => e.id)).toEqual(['client:c:edges:0', 'client:c:edges:1', 'client:c:edges:3']);
    expect(out.connection.nextEdgeIndex).toBe(4);
  });
});

describe('insert/delete', () => {
  const conn = mergePage(null, 'c', page(['a', 'b']), { first: 2 }).connection;

  it('insertEdgeBefore bỏ qua node đã có', () => {
    const once = insertEdgeBefore(conn, createEdge('c', { id: 'n' }));
    expect(insertEdgeBefore(once, createEdge('c', { id: 'n' }))).toBe(once);
    expect(insertEdgeBefore(once, createEdge('c', { id: 'n' }), { dedupe: false }).edges).toHaveLength(4);
  });

  it('deleteNode không tạo object mới khi không có gì để xoá', () => {
    expect(deleteNode(conn, 'zzz')).toBe(conn);
    expect(deleteNode(conn, 'a').edges.map((e) => e.node!.id)).toEqual(['b']);
  });
});

describe('mergeFeedPage', () => {
  it('không kiểm cursor, refetch rỗng giữ danh sách cũ', () => {
    const a = mergeFeedPage(null, 'f', page(['a', 'b']), { first: 2 }).connection;
    const b = mergeFeedPage(a, 'f', page(['b', 'c']), { first: 2, after: 'zzz' }).connection;
    expect(b.edges.map((e) => e.node!.id)).toEqual(['a', 'b', 'c']);
    const c = mergeFeedPage(b, 'f', page([]), { first: 2 }).connection;
    expect(c.edges).toHaveLength(3);
    expect(c.pageInfo.hasNextPage).toBe(true); // server vẫn nói còn
    expect(() => mergeFeedPage(c, 'f', page([]), { last: 2, before: 'a' })).toThrow();
  });
});
ts

#So với thư viện và chuẩn web

  • GraphQL Cursor Connections Specification (đặc tả gốc của Relay) định nghĩa edges, node, cursor, pageInfo và các đối số first/after/last/before. Nó không nói gì về cách client ghép trang: phần đó là việc của từng thư viện.
  • Relay hooks (usePaginationFragment) chạy trên chính ConnectionHandler này, và còn chặn gọi loadNext khi đang có request cùng chiều.
  • Apollo Client: relayStylePagination() trong @apollo/client/utilities là field policy cho connection kiểu Relay, ghép theo cursor. Với danh sách thường, bạn tự viết merge trong typePolicies, và lọc trùng là việc của bạn.
  • TanStack Query useInfiniteQuery giữ mảng các trang riêng biệt (pages) thay vì gộp edges. Không có bản trùng trong một trang, nhưng trùng giữa các trang thì bạn tự lọc khi render.

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz