Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1import { stringifyVariables } from '@urql/core';
2import type { Cache, Resolver, Variables, NullArray } from '../types';
3
4export type MergeMode = 'outwards' | 'inwards';
5
6/** Input parameters for the {@link relayPagination} factory. */
7export interface PaginationParams {
8 /** Flip between inwards and outwards pagination.
9 *
10 * @remarks
11 * This is only relevant if you’re querying pages using forwards and
12 * backwards pagination at the same time.
13 * When set to `'inwards'`, its default, pages that have been queried
14 * forward are placed in front of all pages that were queried backwards.
15 * When set to `'outwards'`, the two sets are merged in reverse.
16 */
17 mergeMode?: MergeMode;
18}
19
20interface PageInfo {
21 __typename: string;
22 endCursor: null | string;
23 startCursor: null | string;
24 hasNextPage: boolean;
25 hasPreviousPage: boolean;
26}
27
28interface Page {
29 __typename: string;
30 edges: NullArray<string>;
31 nodes: NullArray<string>;
32 pageInfo: PageInfo;
33}
34
35const defaultPageInfo: PageInfo = {
36 __typename: 'PageInfo',
37 endCursor: null,
38 startCursor: null,
39 hasNextPage: false,
40 hasPreviousPage: false,
41};
42
43const ensureKey = (x: any): string | null => (typeof x === 'string' ? x : null);
44
45const concatEdges = (
46 cache: Cache,
47 leftEdges: NullArray<string>,
48 rightEdges: NullArray<string>
49) => {
50 const ids = new Set<string>();
51 for (let i = 0, l = leftEdges.length; i < l; i++) {
52 const edge = leftEdges[i] as string | null;
53 const node = cache.resolve(edge, 'node');
54 if (typeof node === 'string') ids.add(node);
55 }
56
57 const newEdges = leftEdges.slice();
58 for (let i = 0, l = rightEdges.length; i < l; i++) {
59 const edge = rightEdges[i] as string | null;
60 const node = cache.resolve(edge, 'node');
61 if (typeof node === 'string' && !ids.has(node)) {
62 ids.add(node);
63 newEdges.push(edge);
64 }
65 }
66
67 return newEdges;
68};
69
70const concatNodes = (
71 leftNodes: NullArray<string>,
72 rightNodes: NullArray<string>
73) => {
74 const ids = new Set<string>();
75 for (let i = 0, l = leftNodes.length; i < l; i++) {
76 const node = leftNodes[i];
77 if (typeof node === 'string') ids.add(node);
78 }
79
80 const newNodes = leftNodes.slice();
81 for (let i = 0, l = rightNodes.length; i < l; i++) {
82 const node = rightNodes[i];
83 if (typeof node === 'string' && !ids.has(node)) {
84 ids.add(node);
85 newNodes.push(node);
86 }
87 }
88
89 return newNodes;
90};
91
92const compareArgs = (
93 fieldArgs: Variables,
94 connectionArgs: Variables
95): boolean => {
96 for (const key in connectionArgs) {
97 if (
98 key === 'first' ||
99 key === 'last' ||
100 key === 'after' ||
101 key === 'before'
102 ) {
103 continue;
104 } else if (!(key in fieldArgs)) {
105 return false;
106 }
107
108 const argA = fieldArgs[key];
109 const argB = connectionArgs[key];
110
111 if (
112 typeof argA !== typeof argB || typeof argA !== 'object'
113 ? argA !== argB
114 : stringifyVariables(argA) !== stringifyVariables(argB)
115 ) {
116 return false;
117 }
118 }
119
120 for (const key in fieldArgs) {
121 if (
122 key === 'first' ||
123 key === 'last' ||
124 key === 'after' ||
125 key === 'before'
126 ) {
127 continue;
128 }
129
130 if (!(key in connectionArgs)) return false;
131 }
132
133 return true;
134};
135
136const getPage = (
137 cache: Cache,
138 entityKey: string,
139 fieldKey: string
140): Page | null => {
141 const link = ensureKey(cache.resolve(entityKey, fieldKey));
142 if (!link) return null;
143
144 const typename = cache.resolve(link, '__typename') as string;
145 const edges = (cache.resolve(link, 'edges') || []) as NullArray<string>;
146 const nodes = (cache.resolve(link, 'nodes') || []) as NullArray<string>;
147 if (typeof typename !== 'string') {
148 return null;
149 }
150
151 const page: Page = {
152 __typename: typename,
153 edges,
154 nodes,
155 pageInfo: defaultPageInfo,
156 };
157
158 const pageInfoKey = cache.resolve(link, 'pageInfo');
159 if (typeof pageInfoKey === 'string') {
160 const pageInfoType = ensureKey(cache.resolve(pageInfoKey, '__typename'));
161 const endCursor = ensureKey(cache.resolve(pageInfoKey, 'endCursor'));
162 const startCursor = ensureKey(cache.resolve(pageInfoKey, 'startCursor'));
163 const hasNextPage = cache.resolve(pageInfoKey, 'hasNextPage');
164 const hasPreviousPage = cache.resolve(pageInfoKey, 'hasPreviousPage');
165
166 const pageInfo: PageInfo = (page.pageInfo = {
167 __typename: typeof pageInfoType === 'string' ? pageInfoType : 'PageInfo',
168 hasNextPage: typeof hasNextPage === 'boolean' ? hasNextPage : !!endCursor,
169 hasPreviousPage:
170 typeof hasPreviousPage === 'boolean' ? hasPreviousPage : !!startCursor,
171 endCursor,
172 startCursor,
173 });
174
175 if (pageInfo.endCursor === null) {
176 const edge = edges[edges.length - 1] as string | null;
177 if (edge) {
178 const endCursor = cache.resolve(edge, 'cursor');
179 pageInfo.endCursor = ensureKey(endCursor);
180 }
181 }
182
183 if (pageInfo.startCursor === null) {
184 const edge = edges[0] as string | null;
185 if (edge) {
186 const startCursor = cache.resolve(edge, 'cursor');
187 pageInfo.startCursor = ensureKey(startCursor);
188 }
189 }
190 }
191
192 return page;
193};
194
195/** Creates a {@link Resolver} that combines pages that comply to the Relay pagination spec.
196 *
197 * @param params - A {@link PaginationParams} configuration object.
198 * @returns the created Relay pagination {@link Resolver}.
199 *
200 * @remarks
201 * `relayPagination` is a factory that creates a {@link Resolver} that can combine
202 * multiple pages on a field that complies to the Relay pagination spec into a single,
203 * combined list for infinite scrolling.
204 *
205 * This resolver will only work on fields that return a `Connection` GraphQL object
206 * type, according to the Relay pagination spec.
207 *
208 * Hint: It's not recommended to use this when you can handle infinite scrolling
209 * in your UI code instead.
210 *
211 * @see {@link https://urql.dev/goto/docs/graphcache/local-resolvers#relay-pagination} for more information.
212 * @see {@link https://urql.dev/goto/docs/basics/ui-patterns/#infinite-scrolling} for an alternate approach.
213 */
214export const relayPagination = (
215 params: PaginationParams = {}
216): Resolver<any, any, any> => {
217 const mergeMode = params.mergeMode || 'inwards';
218
219 return (_parent, fieldArgs, cache, info) => {
220 const { parentKey: entityKey, fieldName } = info;
221
222 const allFields = cache.inspectFields(entityKey);
223 const fieldInfos = allFields.filter(info => info.fieldName === fieldName);
224 const size = fieldInfos.length;
225 if (size === 0) {
226 return undefined;
227 }
228
229 let typename: string | null = null;
230 let startEdges: NullArray<string> = [];
231 let endEdges: NullArray<string> = [];
232 let startNodes: NullArray<string> = [];
233 let endNodes: NullArray<string> = [];
234 let pageInfo: PageInfo = { ...defaultPageInfo };
235
236 for (let i = 0; i < size; i++) {
237 const { fieldKey, arguments: args } = fieldInfos[i];
238 if (args === null || !compareArgs(fieldArgs, args)) {
239 continue;
240 }
241
242 const page = getPage(cache, entityKey, fieldKey);
243 if (page === null) {
244 continue;
245 }
246 if (page.nodes.length === 0 && page.edges.length === 0 && typename) {
247 continue;
248 }
249
250 if (
251 mergeMode === 'inwards' &&
252 typeof args.last === 'number' &&
253 typeof args.first === 'number'
254 ) {
255 const firstEdges = page.edges.slice(0, args.first + 1);
256 const lastEdges = page.edges.slice(-args.last);
257 const firstNodes = page.nodes.slice(0, args.first + 1);
258 const lastNodes = page.nodes.slice(-args.last);
259
260 startEdges = concatEdges(cache, startEdges, firstEdges);
261 endEdges = concatEdges(cache, lastEdges, endEdges);
262 startNodes = concatNodes(startNodes, firstNodes);
263 endNodes = concatNodes(lastNodes, endNodes);
264
265 pageInfo = page.pageInfo;
266 } else if (args.after) {
267 startEdges = concatEdges(cache, startEdges, page.edges);
268 startNodes = concatNodes(startNodes, page.nodes);
269 pageInfo.endCursor = page.pageInfo.endCursor;
270 pageInfo.hasNextPage = page.pageInfo.hasNextPage;
271 } else if (args.before) {
272 endEdges = concatEdges(cache, page.edges, endEdges);
273 endNodes = concatNodes(page.nodes, endNodes);
274 pageInfo.startCursor = page.pageInfo.startCursor;
275 pageInfo.hasPreviousPage = page.pageInfo.hasPreviousPage;
276 } else if (typeof args.last === 'number') {
277 endEdges = concatEdges(cache, page.edges, endEdges);
278 endNodes = concatNodes(page.nodes, endNodes);
279 pageInfo = page.pageInfo;
280 } else {
281 startEdges = concatEdges(cache, startEdges, page.edges);
282 startNodes = concatNodes(startNodes, page.nodes);
283 pageInfo = page.pageInfo;
284 }
285
286 if (page.pageInfo.__typename !== pageInfo.__typename)
287 pageInfo.__typename = page.pageInfo.__typename;
288 if (typename !== page.__typename) typename = page.__typename;
289 }
290
291 if (typeof typename !== 'string') {
292 return undefined;
293 }
294
295 const hasCurrentPage = !!ensureKey(
296 cache.resolve(entityKey, fieldName, fieldArgs)
297 );
298 if (!hasCurrentPage) {
299 if (!(info as any).store.schema) {
300 return undefined;
301 } else {
302 info.partial = true;
303 }
304 }
305
306 return {
307 __typename: typename,
308 edges:
309 mergeMode === 'inwards'
310 ? concatEdges(cache, startEdges, endEdges)
311 : concatEdges(cache, endEdges, startEdges),
312 nodes:
313 mergeMode === 'inwards'
314 ? concatNodes(startNodes, endNodes)
315 : concatNodes(endNodes, startNodes),
316 pageInfo: {
317 __typename: pageInfo.__typename,
318 endCursor: pageInfo.endCursor,
319 startCursor: pageInfo.startCursor,
320 hasNextPage: pageInfo.hasNextPage,
321 hasPreviousPage: pageInfo.hasPreviousPage,
322 },
323 };
324 };
325};