@btst/stack 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/api/index.d.cts +2 -2
  2. package/dist/api/index.d.mts +2 -2
  3. package/dist/api/index.d.ts +2 -2
  4. package/dist/client/index.d.cts +2 -2
  5. package/dist/client/index.d.mts +2 -2
  6. package/dist/client/index.d.ts +2 -2
  7. package/dist/context/index.d.cts +1 -1
  8. package/dist/context/index.d.mts +1 -1
  9. package/dist/context/index.d.ts +1 -1
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/packages/stack/src/plugins/cms/api/getters.cjs +142 -6
  14. package/dist/packages/stack/src/plugins/cms/api/getters.mjs +139 -8
  15. package/dist/packages/stack/src/plugins/cms/api/operations.cjs +67 -61
  16. package/dist/packages/stack/src/plugins/cms/api/operations.mjs +69 -63
  17. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +26 -8
  18. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +27 -9
  19. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.cjs +15 -1
  20. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.mjs +14 -2
  21. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.cjs +45 -24
  22. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.mjs +45 -24
  23. package/dist/packages/stack/src/plugins/cms/schemas.cjs +22 -0
  24. package/dist/packages/stack/src/plugins/cms/schemas.mjs +20 -1
  25. package/dist/plugins/api/index.d.cts +2 -2
  26. package/dist/plugins/api/index.d.mts +2 -2
  27. package/dist/plugins/api/index.d.ts +2 -2
  28. package/dist/plugins/blog/api/index.d.cts +2 -2
  29. package/dist/plugins/blog/api/index.d.mts +2 -2
  30. package/dist/plugins/blog/api/index.d.ts +2 -2
  31. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  32. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  33. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  34. package/dist/plugins/blog/client/index.d.cts +2 -2
  35. package/dist/plugins/blog/client/index.d.mts +2 -2
  36. package/dist/plugins/blog/client/index.d.ts +2 -2
  37. package/dist/plugins/blog/query-keys.d.cts +2 -2
  38. package/dist/plugins/blog/query-keys.d.mts +2 -2
  39. package/dist/plugins/blog/query-keys.d.ts +2 -2
  40. package/dist/plugins/client/index.d.cts +2 -2
  41. package/dist/plugins/client/index.d.mts +2 -2
  42. package/dist/plugins/client/index.d.ts +2 -2
  43. package/dist/plugins/cms/api/index.cjs +1 -0
  44. package/dist/plugins/cms/api/index.d.cts +43 -11
  45. package/dist/plugins/cms/api/index.d.mts +43 -11
  46. package/dist/plugins/cms/api/index.d.ts +43 -11
  47. package/dist/plugins/cms/api/index.mjs +1 -1
  48. package/dist/plugins/cms/client/hooks/index.d.cts +90 -45
  49. package/dist/plugins/cms/client/hooks/index.d.mts +90 -45
  50. package/dist/plugins/cms/client/hooks/index.d.ts +90 -45
  51. package/dist/plugins/cms/client/index.d.cts +2 -1
  52. package/dist/plugins/cms/client/index.d.mts +2 -1
  53. package/dist/plugins/cms/client/index.d.ts +2 -1
  54. package/dist/plugins/cms/query-keys.cjs +69 -13
  55. package/dist/plugins/cms/query-keys.d.cts +89 -212
  56. package/dist/plugins/cms/query-keys.d.mts +89 -212
  57. package/dist/plugins/cms/query-keys.d.ts +89 -212
  58. package/dist/plugins/cms/query-keys.mjs +70 -14
  59. package/dist/plugins/kanban/client/index.d.cts +1 -1
  60. package/dist/plugins/kanban/client/index.d.mts +1 -1
  61. package/dist/plugins/kanban/client/index.d.ts +1 -1
  62. package/dist/plugins/route-docs/client/index.d.cts +1 -1
  63. package/dist/plugins/route-docs/client/index.d.mts +1 -1
  64. package/dist/plugins/route-docs/client/index.d.ts +1 -1
  65. package/dist/plugins/ui-builder/client/hooks/index.d.cts +3 -3
  66. package/dist/plugins/ui-builder/client/hooks/index.d.mts +3 -3
  67. package/dist/plugins/ui-builder/client/hooks/index.d.ts +3 -3
  68. package/dist/plugins/ui-builder/client/index.d.cts +3 -3
  69. package/dist/plugins/ui-builder/client/index.d.mts +3 -3
  70. package/dist/plugins/ui-builder/client/index.d.ts +3 -3
  71. package/dist/plugins/ui-builder/query-keys.d.cts +4 -3
  72. package/dist/plugins/ui-builder/query-keys.d.mts +4 -3
  73. package/dist/plugins/ui-builder/query-keys.d.ts +4 -3
  74. package/dist/shared/{stack.DMj68qY0.d.mts → stack.B2jlVVhD.d.mts} +5 -5
  75. package/dist/shared/{stack.Cvnj99cs.d.ts → stack.BB9tubJJ.d.ts} +9 -3
  76. package/dist/shared/{stack.Bt3WG2yb.d.cts → stack.BWduIMKG.d.cts} +35 -35
  77. package/dist/shared/stack.Bw09Lg2z.d.ts +1420 -0
  78. package/dist/shared/{stack.CkEz7vSt.d.mts → stack.C2Pvl-RS.d.mts} +9 -3
  79. package/dist/shared/{stack.C2l2wd_X.d.ts → stack.Ch9-ppmQ.d.ts} +35 -35
  80. package/dist/shared/{stack.Ht8mhDj5.d.cts → stack.D17yFglU.d.cts} +5 -5
  81. package/dist/shared/{stack.CiD115Ov.d.mts → stack.D6fPBfYe.d.mts} +35 -35
  82. package/dist/shared/stack.DFGj0gb7.d.mts +1420 -0
  83. package/dist/shared/{stack.pi0FxoEa.d.ts → stack.Dcfg4GNN.d.ts} +5 -5
  84. package/dist/shared/{stack.3rxARhgS.d.cts → stack.DrB3L7Ih.d.cts} +52 -3
  85. package/dist/shared/{stack.3rxARhgS.d.mts → stack.DrB3L7Ih.d.mts} +52 -3
  86. package/dist/shared/{stack.3rxARhgS.d.ts → stack.DrB3L7Ih.d.ts} +52 -3
  87. package/dist/shared/stack.NNrBKBkM.d.cts +1420 -0
  88. package/dist/shared/{stack.mRRyZBUg.d.cts → stack.Rb87lHqK.d.cts} +9 -3
  89. package/package.json +1 -1
  90. package/src/__tests__/cms-query-keys.test.ts +57 -0
  91. package/src/plugins/api/index.ts +1 -0
  92. package/src/plugins/api/operation.ts +16 -3
  93. package/src/plugins/cms/__tests__/authorization.test.ts +652 -187
  94. package/src/plugins/cms/__tests__/client-runtime.browser.test.tsx +655 -0
  95. package/src/plugins/cms/__tests__/content-read.typecheck.ts +351 -0
  96. package/src/plugins/cms/__tests__/getters.test.ts +286 -0
  97. package/src/plugins/cms/api/getters.ts +270 -12
  98. package/src/plugins/cms/api/index.ts +10 -0
  99. package/src/plugins/cms/api/operations.ts +163 -75
  100. package/src/plugins/cms/api/plugin.ts +50 -12
  101. package/src/plugins/cms/api/query-key-defs.ts +29 -2
  102. package/src/plugins/cms/client/hooks/cms-hooks.tsx +400 -91
  103. package/src/plugins/cms/client/index.ts +9 -0
  104. package/src/plugins/cms/query-keys.ts +171 -16
  105. package/src/plugins/cms/schemas.ts +30 -0
  106. package/src/plugins/cms/types.ts +49 -2
  107. package/src/plugins/media/__tests__/identity-partition.test.tsx +26 -16
  108. package/dist/shared/stack.CAmVUwjV.d.cts +0 -764
  109. package/dist/shared/stack.CQK02ujt.d.ts +0 -764
  110. package/dist/shared/stack.D9x1fR5a.d.mts +0 -764
  111. package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
  112. package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
  113. package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
@@ -10,8 +10,19 @@ import type { CMSApiRouter } from "../api";
10
10
  import { cmsClientPlugin } from "../client";
11
11
  import {
12
12
  useContentTypes,
13
+ useContentOptions,
13
14
  useCreateContent,
14
15
  useSuspenseContentTypes,
16
+ useContent,
17
+ useContentItem,
18
+ useContentItemBySlug,
19
+ useUpdateContent,
20
+ useSuspenseContent,
21
+ useSuspenseContentItem,
22
+ useContentByRelation,
23
+ useSuspenseContentByRelation,
24
+ useInverseRelationItems,
25
+ useDeleteContent,
15
26
  } from "../client/hooks";
16
27
  import { createCMSQueryKeys } from "../query-keys";
17
28
  import { uiBuilderClientPlugin } from "../../ui-builder/client";
@@ -88,6 +99,650 @@ describe("CMS and UI Builder browser runtime", () => {
88
99
  vi.restoreAllMocks();
89
100
  });
90
101
 
102
+ it("keeps full and content reads separate and refreshes both after updates", async () => {
103
+ let title = "Before";
104
+ const requests: string[] = [];
105
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
106
+ const request =
107
+ input instanceof Request ? input : new Request(input, init);
108
+ const url = new URL(request.url);
109
+ requests.push(`${request.method} ${url.pathname}${url.search}`);
110
+ if (request.method === "PUT") title = "After";
111
+ const full = { ...contentItem, parsedData: { title, body: "Long body" } };
112
+ const item =
113
+ url.searchParams.get("view") === "content"
114
+ ? {
115
+ id: full.id,
116
+ slug: full.slug,
117
+ parsedData: url.searchParams.has("fields")
118
+ ? Object.fromEntries(
119
+ JSON.parse(url.searchParams.get("fields")!).map(
120
+ (field: string) => [
121
+ field,
122
+ full.parsedData[field as keyof typeof full.parsedData],
123
+ ],
124
+ ),
125
+ )
126
+ : full.parsedData,
127
+ createdAt: full.createdAt,
128
+ updatedAt: full.updatedAt,
129
+ }
130
+ : full;
131
+ return jsonResponse(
132
+ url.pathname.endsWith("/article")
133
+ ? { items: [item], total: 1, limit: 10, offset: 0 }
134
+ : item,
135
+ );
136
+ });
137
+ const queryClient = new QueryClient();
138
+ const stack = createClientStack({
139
+ api: { baseURL: "https://app.example.com", basePath: "/api/data" },
140
+ site: { baseURL: "https://app.example.com", basePath: "/pages" },
141
+ queryClient,
142
+ plugins: { cms: cmsClientPlugin() },
143
+ });
144
+ let projected: ReturnType<typeof useContentItem>;
145
+ let equivalent: ReturnType<typeof useContentItem>;
146
+ let bodyOnly: ReturnType<typeof useContentItem>;
147
+ let full: ReturnType<typeof useContentItem>;
148
+ let content: ReturnType<typeof useContentItem>;
149
+ let slug: ReturnType<typeof useContentItemBySlug>;
150
+ let list: ReturnType<typeof useContent>;
151
+ let update: ReturnType<typeof useUpdateContent>;
152
+ function Probe() {
153
+ full = useContentItem("article", "item-1");
154
+ projected = useContentItem("article", "item-1", {
155
+ view: "content",
156
+ fields: ["title"],
157
+ });
158
+ equivalent = useContentItem("article", "item-1", {
159
+ view: "content",
160
+ fields: ["title", "title"],
161
+ });
162
+ bodyOnly = useContentItem("article", "item-1", {
163
+ view: "content",
164
+ fields: ["body"],
165
+ });
166
+ content = useContentItem("article", "item-1", { view: "content" });
167
+ slug = useContentItemBySlug("article", "runtime-article", {
168
+ view: "content",
169
+ fields: ["title"],
170
+ });
171
+ list = useContent("article", { view: "content", fields: ["title"] });
172
+ update = useUpdateContent("article");
173
+ return null;
174
+ }
175
+ await act(async () =>
176
+ root.render(
177
+ <StackProvider stack={stack}>
178
+ <Probe />
179
+ </StackProvider>,
180
+ ),
181
+ );
182
+ await waitFor(
183
+ () =>
184
+ !!full?.item &&
185
+ !!content?.item &&
186
+ !!slug?.item &&
187
+ !!list?.items.length &&
188
+ !!projected?.item &&
189
+ !!equivalent?.item &&
190
+ !!bodyOnly?.item,
191
+ );
192
+ expect(projected!.item?.parsedData).toEqual({ title: "Before" });
193
+ expect(equivalent!.item?.parsedData).toEqual({ title: "Before" });
194
+ expect(bodyOnly!.item?.parsedData).toEqual({ body: "Long body" });
195
+ expect(
196
+ requests.filter((value) => {
197
+ const url = new URL(
198
+ value.slice(value.indexOf(" ") + 1),
199
+ "https://app.example.com",
200
+ );
201
+ return (
202
+ url.pathname.endsWith("/item-1") &&
203
+ url.searchParams.get("fields") === '["title"]'
204
+ );
205
+ }),
206
+ ).toHaveLength(1);
207
+ expect(full!.item).toHaveProperty("data");
208
+ expect(content!.item).not.toHaveProperty("data");
209
+ expect(slug!.item).not.toHaveProperty("data");
210
+ expect(slug!.item?.parsedData).toEqual({ title: "Before" });
211
+ expect(list!.items[0]?.parsedData).toEqual({ title: "Before" });
212
+ expect(list!.items[0]).not.toHaveProperty("data");
213
+ await act(async () => {
214
+ await update!.mutateAsync({
215
+ id: "item-1",
216
+ data: { data: { title: "After" } },
217
+ });
218
+ });
219
+ await waitFor(() =>
220
+ [
221
+ full?.item,
222
+ content?.item,
223
+ slug?.item,
224
+ list?.items[0],
225
+ projected?.item,
226
+ equivalent?.item,
227
+ ].every((item) => item?.parsedData.title === "After"),
228
+ );
229
+ expect(projected!.item?.parsedData).toEqual({ title: "After" });
230
+ expect(bodyOnly!.item?.parsedData).toEqual({ body: "Long body" });
231
+ expect(projected!.item).not.toHaveProperty("data");
232
+ expect(content!.item).not.toHaveProperty("data");
233
+ expect(requests.some((value) => value.includes("view=content"))).toBe(true);
234
+ });
235
+
236
+ it("projects picker search results and selected-ID preloads with separate normalized caches", async () => {
237
+ let revision = 0;
238
+ const requests: URL[] = [];
239
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
240
+ const request =
241
+ input instanceof Request ? input : new Request(input, init);
242
+ const url = new URL(request.url);
243
+ requests.push(url);
244
+ if (request.method === "PUT") revision += 1;
245
+ const detail = url.pathname.endsWith("/selected-2");
246
+ const full = {
247
+ ...contentItem,
248
+ id: detail ? "selected-2" : "option-1",
249
+ parsedData: {
250
+ title: `${detail ? "Selected" : "Option"} ${revision}`,
251
+ body: "Unrequested body",
252
+ },
253
+ };
254
+ const value =
255
+ url.searchParams.get("view") === "content"
256
+ ? {
257
+ id: full.id,
258
+ slug: full.slug,
259
+ createdAt: full.createdAt,
260
+ updatedAt: full.updatedAt,
261
+ parsedData: url.searchParams.has("fields")
262
+ ? Object.fromEntries(
263
+ JSON.parse(url.searchParams.get("fields")!).map(
264
+ (field: string) => [
265
+ field,
266
+ full.parsedData[field as keyof typeof full.parsedData],
267
+ ],
268
+ ),
269
+ )
270
+ : full.parsedData,
271
+ }
272
+ : full;
273
+ return jsonResponse(
274
+ detail || request.method === "PUT"
275
+ ? value
276
+ : { items: [value], total: 1, limit: 50, offset: 0 },
277
+ );
278
+ });
279
+ const stack = createClientStack({
280
+ api: { baseURL: "https://app.example.com", basePath: "/api/data" },
281
+ site: { baseURL: "https://app.example.com", basePath: "/pages" },
282
+ queryClient: new QueryClient(),
283
+ plugins: { cms: cmsClientPlugin() },
284
+ });
285
+ let full: ReturnType<typeof useContentOptions>;
286
+ let projected: ReturnType<typeof useContentOptions>;
287
+ let equivalent: ReturnType<typeof useContentOptions>;
288
+ let update: ReturnType<typeof useUpdateContent>;
289
+ function Probe() {
290
+ full = useContentOptions({
291
+ targetType: "article",
292
+ value: ["selected-2"],
293
+ getOptionLabel: (item) => String(item.parsedData.title),
294
+ });
295
+ projected = useContentOptions({
296
+ targetType: "article",
297
+ value: ["selected-2"],
298
+ view: "content",
299
+ fields: ["title"],
300
+ getOptionLabel: (item) => String(item.parsedData.title),
301
+ });
302
+ equivalent = useContentOptions({
303
+ targetType: "article",
304
+ value: ["selected-2"],
305
+ view: "content",
306
+ fields: ["title", "title"],
307
+ getOptionLabel: (item) => String(item.parsedData.title),
308
+ });
309
+ update = useUpdateContent("article");
310
+ return null;
311
+ }
312
+ await act(async () =>
313
+ root.render(
314
+ <StackProvider stack={stack}>
315
+ <Probe />
316
+ </StackProvider>,
317
+ ),
318
+ );
319
+ await waitFor(
320
+ () =>
321
+ !!full &&
322
+ !!projected &&
323
+ !!equivalent &&
324
+ !full.isLoading &&
325
+ !projected.isLoading &&
326
+ !equivalent.isLoading,
327
+ );
328
+ expect(full!.selectedOptions[0]?.item).toHaveProperty("data");
329
+ for (const result of [projected!, equivalent!]) {
330
+ expect(result.options[0]?.item?.parsedData).toEqual({
331
+ title: "Option 0",
332
+ });
333
+ expect(result.selectedOptions[0]?.item?.parsedData).toEqual({
334
+ title: "Selected 0",
335
+ });
336
+ expect(result.selectedOptions[0]?.item).not.toHaveProperty("data");
337
+ }
338
+ const projectedRequests = requests.filter(
339
+ (url) => url.searchParams.get("fields") === '["title"]',
340
+ );
341
+ expect(projectedRequests).toHaveLength(2);
342
+ expect(
343
+ projectedRequests.every(
344
+ (url) => url.searchParams.get("view") === "content",
345
+ ),
346
+ ).toBe(true);
347
+ await act(async () => projected!.setSearch("target"));
348
+ await waitFor(() =>
349
+ requests.some(
350
+ (url) =>
351
+ url.searchParams.get("search") === "target" &&
352
+ url.searchParams.get("fields") === '["title"]',
353
+ ),
354
+ );
355
+
356
+ await act(async () => {
357
+ await update!.mutateAsync({
358
+ id: "selected-2",
359
+ data: { data: { title: "Updated" } },
360
+ });
361
+ });
362
+ await waitFor(() => projected!.selectedOptions[0]?.label === "Selected 1");
363
+ expect(projected!.selectedOptions[0]?.item?.parsedData).toEqual({
364
+ title: "Selected 1",
365
+ });
366
+ expect(projected!.selectedOptions[0]?.item).not.toHaveProperty("data");
367
+ });
368
+
369
+ it.each([undefined, ["title", "title"]] as const)(
370
+ "hydrates content list and detail hooks without fetching a different response shape (%s fields)",
371
+ async (fields) => {
372
+ const apiClient = createApiClient<CMSApiRouter>({
373
+ baseURL: "https://app.example.com",
374
+ basePath: "/api/data",
375
+ });
376
+ const queries = createCMSQueryKeys(apiClient);
377
+ const serverQueryClient = new QueryClient();
378
+ const item = {
379
+ id: contentItem.id,
380
+ slug: contentItem.slug,
381
+ parsedData: contentItem.parsedData,
382
+ createdAt: contentItem.createdAt,
383
+ updatedAt: contentItem.updatedAt,
384
+ };
385
+ serverQueryClient.setQueryData(
386
+ queries.cmsContent.list({
387
+ typeSlug: "article",
388
+ limit: 10,
389
+ view: "content",
390
+ fields,
391
+ }).queryKey,
392
+ {
393
+ pages: [{ items: [item], total: 1, limit: 10, offset: 0 }],
394
+ pageParams: [0],
395
+ },
396
+ );
397
+ serverQueryClient.setQueryData(
398
+ queries.cmsContent.detail("article", item.id, {
399
+ view: "content",
400
+ fields,
401
+ }).queryKey,
402
+ item,
403
+ );
404
+ const browserQueryClient = new QueryClient();
405
+ hydrate(browserQueryClient, dehydrate(serverQueryClient));
406
+ const stack = createClientStack({
407
+ api: { baseURL: "https://app.example.com", basePath: "/api/data" },
408
+ site: { baseURL: "https://app.example.com", basePath: "/pages" },
409
+ queryClient: browserQueryClient,
410
+ plugins: { cms: cmsClientPlugin() },
411
+ });
412
+ const fetchMock = vi.spyOn(globalThis, "fetch");
413
+ function Probe() {
414
+ const list = useSuspenseContent("article", { view: "content", fields });
415
+ const detail = useSuspenseContentItem("article", item.id, {
416
+ view: "content",
417
+ fields,
418
+ });
419
+ return (
420
+ <span>{`${list.items[0]?.parsedData.title}:${detail.item?.slug}`}</span>
421
+ );
422
+ }
423
+ await act(async () =>
424
+ root.render(
425
+ <StackProvider stack={stack}>
426
+ <Suspense fallback={<span>loading</span>}>
427
+ <Probe />
428
+ </Suspense>
429
+ </StackProvider>,
430
+ ),
431
+ );
432
+ expect(container.textContent).toBe("Runtime article:runtime-article");
433
+ expect(fetchMock).not.toHaveBeenCalled();
434
+ },
435
+ );
436
+
437
+ it("keeps relation page sizes and inverse offsets separate in the client cache", async () => {
438
+ const records = [
439
+ contentItem,
440
+ { ...contentItem, id: "item-2" },
441
+ { ...contentItem, id: "item-3" },
442
+ ];
443
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
444
+ const url = new URL(input instanceof Request ? input.url : String(input));
445
+ const limit = Number(url.searchParams.get("limit") ?? 20);
446
+ const offset = Number(url.searchParams.get("offset") ?? 0);
447
+ const items = records.slice(offset, offset + limit);
448
+ return jsonResponse({
449
+ items:
450
+ url.searchParams.get("view") === "content"
451
+ ? items.map(({ id, slug, parsedData, createdAt, updatedAt }) => ({
452
+ id,
453
+ slug,
454
+ parsedData,
455
+ createdAt,
456
+ updatedAt,
457
+ }))
458
+ : items,
459
+ total: 3,
460
+ limit,
461
+ offset,
462
+ });
463
+ });
464
+ const stack = createClientStack({
465
+ api: { baseURL: "https://app.example.com", basePath: "/api/data" },
466
+ site: { baseURL: "https://app.example.com", basePath: "/pages" },
467
+ queryClient: new QueryClient(),
468
+ plugins: { cms: cmsClientPlugin() },
469
+ });
470
+ let small: ReturnType<typeof useContentByRelation>;
471
+ let large: ReturnType<typeof useContentByRelation>;
472
+ let first: ReturnType<typeof useInverseRelationItems>;
473
+ let second: ReturnType<typeof useInverseRelationItems>;
474
+ let content: ReturnType<typeof useContentByRelation>;
475
+ let inverseContent: ReturnType<typeof useInverseRelationItems>;
476
+ function Probe() {
477
+ small = useContentByRelation("article", "categoryId", "category-1", {
478
+ limit: 1,
479
+ });
480
+ large = useContentByRelation("article", "categoryId", "category-1", {
481
+ limit: 2,
482
+ });
483
+ content = useContentByRelation("article", "categoryId", "category-1", {
484
+ limit: 1,
485
+ view: "content",
486
+ });
487
+ const params = {
488
+ contentTypeSlug: "category",
489
+ sourceType: "article",
490
+ itemId: "category-1",
491
+ fieldName: "categoryId",
492
+ };
493
+ first = useInverseRelationItems(params, { limit: 1, offset: 0 });
494
+ second = useInverseRelationItems(params, { limit: 1, offset: 1 });
495
+ inverseContent = useInverseRelationItems(params, {
496
+ limit: 1,
497
+ offset: 1,
498
+ view: "content",
499
+ });
500
+ return null;
501
+ }
502
+ await act(async () =>
503
+ root.render(
504
+ <StackProvider stack={stack}>
505
+ <Probe />
506
+ </StackProvider>,
507
+ ),
508
+ );
509
+ await waitFor(
510
+ () =>
511
+ !small?.isLoading &&
512
+ !large?.isLoading &&
513
+ !first?.isLoading &&
514
+ !second?.isLoading &&
515
+ !content?.isLoading &&
516
+ !inverseContent?.isLoading,
517
+ );
518
+ expect(small!.items.map((item) => item.id)).toEqual(["item-1"]);
519
+ expect(large!.items.map((item) => item.id)).toEqual(["item-1", "item-2"]);
520
+ expect(first!.items.map((item) => item.id)).toEqual(["item-1"]);
521
+ expect(second!.items.map((item) => item.id)).toEqual(["item-2"]);
522
+ expect(content!.items.map((item) => item.id)).toEqual(["item-1"]);
523
+ expect(inverseContent!.items.map((item) => item.id)).toEqual(["item-2"]);
524
+ expect(small!.items[0]).toHaveProperty("data");
525
+ expect(second!.items[0]).toHaveProperty("data");
526
+ expect(content!.items[0]).not.toHaveProperty("data");
527
+ expect(inverseContent!.items[0]).not.toHaveProperty("contentType");
528
+ await act(async () => small!.loadMore());
529
+ await waitFor(() => small!.items.length === 2);
530
+ expect(small!.items.map((item) => item.id)).toEqual(["item-1", "item-2"]);
531
+ expect(large!.items).toHaveLength(2);
532
+ });
533
+
534
+ it("refreshes direct and inverse relation pages after content mutations", async () => {
535
+ let revision = 0;
536
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
537
+ const request =
538
+ input instanceof Request ? input : new Request(input, init);
539
+ const item = {
540
+ ...contentItem,
541
+ parsedData: { title: `Revision ${revision}` },
542
+ };
543
+ if (request.method !== "GET") {
544
+ revision += 1;
545
+ if (request.method === "DELETE") return jsonResponse({ success: true });
546
+ return jsonResponse({
547
+ ...item,
548
+ parsedData: { title: `Revision ${revision}` },
549
+ });
550
+ }
551
+ const responseItem =
552
+ new URL(request.url).searchParams.get("view") === "content"
553
+ ? {
554
+ id: item.id,
555
+ slug: item.slug,
556
+ parsedData: item.parsedData,
557
+ createdAt: item.createdAt,
558
+ updatedAt: item.updatedAt,
559
+ }
560
+ : item;
561
+ return jsonResponse({
562
+ items: [responseItem],
563
+ total: 1,
564
+ limit: 20,
565
+ offset: 0,
566
+ });
567
+ });
568
+ const stack = createClientStack({
569
+ api: { baseURL: "https://app.example.com", basePath: "/api/data" },
570
+ site: { baseURL: "https://app.example.com", basePath: "/pages" },
571
+ queryClient: new QueryClient(),
572
+ plugins: { cms: cmsClientPlugin() },
573
+ });
574
+ let direct: ReturnType<typeof useContentByRelation>;
575
+ let inverse: ReturnType<typeof useInverseRelationItems>;
576
+ let content: ReturnType<typeof useContentByRelation>;
577
+ let inverseContent: ReturnType<typeof useInverseRelationItems>;
578
+ let update: ReturnType<typeof useUpdateContent>;
579
+ let create: ReturnType<typeof useCreateContent>;
580
+ let remove: ReturnType<typeof useDeleteContent>;
581
+ function Probe() {
582
+ direct = useContentByRelation("article", "categoryId", "category-1");
583
+ content = useContentByRelation("article", "categoryId", "category-1", {
584
+ view: "content",
585
+ fields: ["title"],
586
+ });
587
+ inverse = useInverseRelationItems({
588
+ contentTypeSlug: "category",
589
+ sourceType: "article",
590
+ itemId: "category-1",
591
+ fieldName: "categoryId",
592
+ });
593
+ inverseContent = useInverseRelationItems(
594
+ {
595
+ contentTypeSlug: "category",
596
+ sourceType: "article",
597
+ itemId: "category-1",
598
+ fieldName: "categoryId",
599
+ },
600
+ { view: "content", fields: ["title"] },
601
+ );
602
+ update = useUpdateContent("article");
603
+ create = useCreateContent("article");
604
+ remove = useDeleteContent("article");
605
+ return null;
606
+ }
607
+ await act(async () =>
608
+ root.render(
609
+ <StackProvider stack={stack}>
610
+ <Probe />
611
+ </StackProvider>,
612
+ ),
613
+ );
614
+ await waitFor(
615
+ () =>
616
+ !direct?.isLoading &&
617
+ !inverse?.isLoading &&
618
+ !content?.isLoading &&
619
+ !inverseContent?.isLoading,
620
+ );
621
+ expect(direct!.items[0]?.parsedData.title).toBe("Revision 0");
622
+ expect(inverse!.items[0]?.parsedData.title).toBe("Revision 0");
623
+ for (const [index, mutate] of [
624
+ () =>
625
+ update!.mutateAsync({
626
+ id: "item-1",
627
+ data: { data: { title: "Updated" } },
628
+ }),
629
+ () =>
630
+ create!.mutateAsync({
631
+ slug: "new-article",
632
+ data: { title: "Created" },
633
+ }),
634
+ () => remove!.mutateAsync("item-2"),
635
+ ].entries()) {
636
+ await act(async () => {
637
+ await mutate();
638
+ });
639
+ await waitFor(() =>
640
+ [direct, inverse, content, inverseContent].every(
641
+ (result) =>
642
+ result?.items[0]?.parsedData.title === `Revision ${index + 1}`,
643
+ ),
644
+ );
645
+ expect(direct!.items[0]).toHaveProperty("data");
646
+ expect(inverse!.items[0]).toHaveProperty("data");
647
+ expect(content!.items[0]).not.toHaveProperty("data");
648
+ expect(inverseContent!.items[0]).not.toHaveProperty("contentType");
649
+ }
650
+ });
651
+
652
+ it.each([undefined, ["title", "title"]] as const)(
653
+ "hydrates direct and inverse content relation pages with matching client identities (%s fields)",
654
+ async (fields) => {
655
+ const queries = createCMSQueryKeys(
656
+ createApiClient<CMSApiRouter>({
657
+ baseURL: "https://app.example.com",
658
+ basePath: "/api/data",
659
+ }),
660
+ );
661
+ const item = {
662
+ id: contentItem.id,
663
+ slug: contentItem.slug,
664
+ parsedData: contentItem.parsedData,
665
+ createdAt: contentItem.createdAt,
666
+ updatedAt: contentItem.updatedAt,
667
+ };
668
+ const serverQueryClient = new QueryClient();
669
+ serverQueryClient.setQueryData(
670
+ queries.cmsContent.byRelation({
671
+ typeSlug: "article",
672
+ field: "categoryId",
673
+ targetId: "category-1",
674
+ limit: 2,
675
+ view: "content",
676
+ fields,
677
+ }).queryKey,
678
+ {
679
+ pages: [{ items: [item], total: 1, limit: 2, offset: 0 }],
680
+ pageParams: [0],
681
+ },
682
+ );
683
+ serverQueryClient.setQueryData(
684
+ queries.cmsTypes.inverseRelationItems({
685
+ slug: "category",
686
+ sourceType: "article",
687
+ itemId: "category-1",
688
+ fieldName: "categoryId",
689
+ limit: 1,
690
+ offset: 5,
691
+ view: "content",
692
+ fields,
693
+ }).queryKey,
694
+ { items: [item], total: 6, limit: 1, offset: 5 },
695
+ );
696
+ const queryClient = new QueryClient();
697
+ hydrate(queryClient, dehydrate(serverQueryClient));
698
+ const stack = createClientStack({
699
+ api: { baseURL: "https://app.example.com", basePath: "/api/data" },
700
+ site: { baseURL: "https://app.example.com", basePath: "/pages" },
701
+ queryClient,
702
+ plugins: { cms: cmsClientPlugin() },
703
+ });
704
+ const fetchMock = vi.spyOn(globalThis, "fetch");
705
+ function Probe() {
706
+ const direct = useSuspenseContentByRelation(
707
+ "article",
708
+ "categoryId",
709
+ "category-1",
710
+ { limit: 2, view: "content", fields: fields ? ["title"] : undefined },
711
+ );
712
+ const inverse = useInverseRelationItems(
713
+ {
714
+ contentTypeSlug: "category",
715
+ sourceType: "article",
716
+ itemId: "category-1",
717
+ fieldName: "categoryId",
718
+ },
719
+ {
720
+ limit: 1,
721
+ offset: 5,
722
+ view: "content",
723
+ fields: fields ? ["title"] : undefined,
724
+ },
725
+ );
726
+ expect(direct.items[0]).not.toHaveProperty("data");
727
+ expect(inverse.items[0]).not.toHaveProperty("contentType");
728
+ return (
729
+ <span>{`${direct.items[0]?.slug}:${inverse.items[0]?.parsedData.title}`}</span>
730
+ );
731
+ }
732
+ await act(async () =>
733
+ root.render(
734
+ <StackProvider stack={stack}>
735
+ <Suspense fallback={<span>loading</span>}>
736
+ <Probe />
737
+ </Suspense>
738
+ </StackProvider>,
739
+ ),
740
+ );
741
+ expect(container.textContent).toBe("runtime-article:Runtime article");
742
+ expect(fetchMock).not.toHaveBeenCalled();
743
+ },
744
+ );
745
+
91
746
  it("hydrates CMS and UI Builder hooks through the one resolved stack cache", async () => {
92
747
  const serverQueryClient = new QueryClient();
93
748
  const apiClient = createApiClient<CMSApiRouter>({