@btst/stack 2.11.8 → 2.12.1

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 (80) hide show
  1. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +14 -11
  2. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +14 -11
  3. package/dist/packages/stack/src/plugins/cms/client/components/forms/relation-field.cjs +87 -15
  4. package/dist/packages/stack/src/plugins/cms/client/components/forms/relation-field.mjs +87 -15
  5. package/dist/packages/stack/src/plugins/cms/schemas.cjs +11 -6
  6. package/dist/packages/stack/src/plugins/cms/schemas.mjs +10 -6
  7. package/dist/packages/stack/src/plugins/comments/api/query-key-defs.cjs +2 -1
  8. package/dist/packages/stack/src/plugins/comments/api/query-key-defs.mjs +2 -1
  9. package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.cjs +6 -2
  10. package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.mjs +6 -2
  11. package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.cjs +5 -3
  12. package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.mjs +5 -3
  13. package/dist/packages/ui/src/components/auto-form/fields/array.cjs +181 -4
  14. package/dist/packages/ui/src/components/auto-form/fields/array.mjs +184 -7
  15. package/dist/packages/ui/src/components/auto-form/index.cjs +16 -1
  16. package/dist/packages/ui/src/components/auto-form/index.mjs +17 -2
  17. package/dist/plugins/cms/api/index.cjs +2 -0
  18. package/dist/plugins/cms/api/index.d.cts +3 -2
  19. package/dist/plugins/cms/api/index.d.mts +3 -2
  20. package/dist/plugins/cms/api/index.d.ts +3 -2
  21. package/dist/plugins/cms/api/index.mjs +1 -0
  22. package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
  23. package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
  24. package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
  25. package/dist/plugins/cms/query-keys.d.cts +398 -7
  26. package/dist/plugins/cms/query-keys.d.mts +398 -7
  27. package/dist/plugins/cms/query-keys.d.ts +398 -7
  28. package/dist/plugins/comments/api/index.d.cts +4 -4
  29. package/dist/plugins/comments/api/index.d.mts +4 -4
  30. package/dist/plugins/comments/api/index.d.ts +4 -4
  31. package/dist/plugins/comments/client/components/index.d.cts +11 -0
  32. package/dist/plugins/comments/client/components/index.d.mts +11 -0
  33. package/dist/plugins/comments/client/components/index.d.ts +11 -0
  34. package/dist/plugins/comments/client/hooks/index.d.cts +17 -1
  35. package/dist/plugins/comments/client/hooks/index.d.mts +17 -1
  36. package/dist/plugins/comments/client/hooks/index.d.ts +17 -1
  37. package/dist/plugins/comments/client/index.d.cts +9 -0
  38. package/dist/plugins/comments/client/index.d.mts +9 -0
  39. package/dist/plugins/comments/client/index.d.ts +9 -0
  40. package/dist/plugins/comments/query-keys.cjs +1 -0
  41. package/dist/plugins/comments/query-keys.d.cts +3 -2
  42. package/dist/plugins/comments/query-keys.d.mts +3 -2
  43. package/dist/plugins/comments/query-keys.d.ts +3 -2
  44. package/dist/plugins/comments/query-keys.mjs +1 -0
  45. package/dist/plugins/kanban/api/index.d.cts +1 -1
  46. package/dist/plugins/kanban/api/index.d.mts +1 -1
  47. package/dist/plugins/kanban/api/index.d.ts +1 -1
  48. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  49. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  50. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  51. package/dist/plugins/ui-builder/index.d.cts +1 -1
  52. package/dist/plugins/ui-builder/index.d.mts +1 -1
  53. package/dist/plugins/ui-builder/index.d.ts +1 -1
  54. package/dist/shared/{stack.CJE9sAjV.d.ts → stack.Bq3kiUaM.d.ts} +1 -1
  55. package/dist/shared/{stack.C-b3Sn8j.d.cts → stack.C7KNNiJm.d.cts} +3 -1
  56. package/dist/shared/{stack.C-b3Sn8j.d.mts → stack.C7KNNiJm.d.mts} +3 -1
  57. package/dist/shared/{stack.C-b3Sn8j.d.ts → stack.C7KNNiJm.d.ts} +3 -1
  58. package/dist/shared/{stack.Dcz6636A.d.mts → stack.CCncwK6g.d.cts} +1 -1
  59. package/dist/shared/{stack.CmHRdhl8.d.cts → stack.CO1g47IS.d.mts} +1 -1
  60. package/dist/shared/{stack.B8QD11QU.d.cts → stack.DFtpFS00.d.cts} +10 -0
  61. package/dist/shared/{stack.B8QD11QU.d.mts → stack.DFtpFS00.d.mts} +10 -0
  62. package/dist/shared/{stack.B8QD11QU.d.ts → stack.DFtpFS00.d.ts} +10 -0
  63. package/package.json +1 -1
  64. package/src/plugins/cms/__tests__/schema-roundtrip.test.ts +203 -0
  65. package/src/plugins/cms/api/index.ts +1 -0
  66. package/src/plugins/cms/api/plugin.ts +33 -16
  67. package/src/plugins/cms/client/components/forms/relation-field.tsx +121 -36
  68. package/src/plugins/cms/schemas.ts +26 -6
  69. package/src/plugins/cms/types.ts +10 -0
  70. package/src/plugins/comments/api/query-key-defs.ts +5 -1
  71. package/src/plugins/comments/client/components/comment-thread.tsx +15 -0
  72. package/src/plugins/comments/client/hooks/use-comments.tsx +26 -3
  73. package/src/plugins/comments/client/overrides.ts +10 -0
  74. package/src/plugins/comments/query-keys.ts +2 -0
  75. package/dist/shared/stack.DbnCLdNF.d.mts +0 -399
  76. package/dist/shared/stack.fXlShnhv.d.ts +0 -399
  77. package/dist/shared/stack.r6HohbyX.d.cts +0 -399
  78. package/dist/shared/{stack.B6S3cgwN.d.cts → stack.CuI_-z-_.d.cts} +16 -16
  79. package/dist/shared/{stack.Bzfx-_lq.d.mts → stack.D2RVkQD8.d.mts} +16 -16
  80. package/dist/shared/{stack.j5SFLC1d.d.ts → stack.DXrOtuek.d.ts} +16 -16
@@ -1,7 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useCallback, useMemo } from "react";
4
+ import { useQueries } from "@tanstack/react-query";
5
+ import { createApiClient } from "@btst/stack/plugins/client";
6
+ import { usePluginOverrides } from "@btst/stack/context";
4
7
  import { useContent, useCreateContent } from "../../hooks";
8
+ import type { CMSApiRouter } from "../../../api";
9
+ import type { SerializedContentItemWithType } from "../../../types";
10
+ import type { CMSPluginOverrides } from "../../overrides";
11
+ import { createCMSQueryKeys } from "../../../query-keys";
5
12
  import MultipleSelector from "@workspace/ui/components/multi-select";
6
13
  import type { Option } from "@workspace/ui/components/multi-select";
7
14
  import { Button } from "@workspace/ui/components/button";
@@ -19,6 +26,16 @@ import { Textarea } from "@workspace/ui/components/textarea";
19
26
  import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
20
27
  import type { RelationConfig } from "../../../types";
21
28
 
29
+ /** Match cms-hooks SHARED_QUERY_CONFIG for detail fetches (deduped labels). */
30
+ const RELATION_DETAIL_QUERY_OPTS = {
31
+ retry: false,
32
+ refetchOnWindowFocus: false,
33
+ refetchOnMount: false,
34
+ refetchOnReconnect: false,
35
+ staleTime: 1000 * 60 * 5,
36
+ gcTime: 1000 * 60 * 10,
37
+ } as const;
38
+
22
39
  interface RelationFieldProps extends AutoFormInputComponentProps {
23
40
  relation: RelationConfig;
24
41
  }
@@ -43,16 +60,25 @@ export function RelationField({
43
60
  const [newItemDescription, setNewItemDescription] = useState("");
44
61
  const [createError, setCreateError] = useState<string | null>(null);
45
62
 
46
- // For belongsTo (single relation), we only allow one selection
47
- const isSingleSelect = relation.type === "belongsTo";
63
+ const { apiBaseURL, apiBasePath, headers } =
64
+ usePluginOverrides<CMSPluginOverrides>("cms");
48
65
 
49
- // Fetch available items from the target content type
50
- const { items: availableItems, isLoading } = useContent(relation.targetType, {
51
- limit: 100, // Load a good chunk for the dropdown
52
- });
66
+ const listClient = useMemo(
67
+ () =>
68
+ createApiClient<CMSApiRouter>({
69
+ baseURL: apiBaseURL,
70
+ basePath: apiBasePath,
71
+ }),
72
+ [apiBaseURL, apiBasePath],
73
+ );
53
74
 
54
- // Mutation for creating new items
55
- const createMutation = useCreateContent(relation.targetType);
75
+ const cmsQueries = useMemo(
76
+ () => createCMSQueryKeys(listClient, headers),
77
+ [listClient, headers],
78
+ );
79
+
80
+ // For belongsTo (single relation), we only allow one selection
81
+ const isSingleSelect = relation.type === "belongsTo";
56
82
 
57
83
  // Normalize the field value to an array for internal use
58
84
  // belongsTo stores as single object { id }, hasMany/manyToMany store as array
@@ -72,36 +98,95 @@ export function RelationField({
72
98
  return (field.value as Array<{ id: string }>) || [];
73
99
  }, [field.value, isSingleSelect]);
74
100
 
75
- // Convert normalized value to Option[] for MultipleSelector
76
- const selectedOptions: Option[] = normalizedValue
77
- .map((v) => {
78
- const item = availableItems.find((item) => item.id === v.id);
79
- if (item) {
80
- const displayValue =
81
- (item.parsedData as Record<string, unknown>)?.[
82
- relation.displayField
83
- ] || item.slug;
84
- return {
85
- value: item.id,
86
- label: String(displayValue),
87
- };
101
+ // Fetch available items from the target content type (first page only)
102
+ const { items: availableItems, isLoading } = useContent(relation.targetType, {
103
+ limit: 500,
104
+ });
105
+
106
+ const missingDetailIds = useMemo(() => {
107
+ const loadedIds = new Set(availableItems.map((i) => i.id));
108
+ return normalizedValue
109
+ .map((v) => v.id)
110
+ .filter((id) => id.length > 0 && !loadedIds.has(id));
111
+ }, [availableItems, normalizedValue]);
112
+
113
+ const hydrationResult = useQueries({
114
+ queries: missingDetailIds.map((id) => ({
115
+ ...cmsQueries.cmsContent.detail(relation.targetType, id),
116
+ ...RELATION_DETAIL_QUERY_OPTS,
117
+ enabled: Boolean(relation.targetType && id),
118
+ })),
119
+ combine: (results) => ({
120
+ data: results.map(
121
+ (r) => r.data as SerializedContentItemWithType | null | undefined,
122
+ ),
123
+ isHydrating: results.some((r) => r.isFetching),
124
+ }),
125
+ });
126
+
127
+ const isHydratingLabels = hydrationResult.isHydrating;
128
+
129
+ const itemById = useMemo(() => {
130
+ const m = new Map<string, SerializedContentItemWithType>();
131
+ for (const it of availableItems) {
132
+ m.set(it.id, it as SerializedContentItemWithType);
133
+ }
134
+ for (let i = 0; i < missingDetailIds.length; i++) {
135
+ const row = hydrationResult.data[i];
136
+ if (row?.id) {
137
+ m.set(row.id, row);
88
138
  }
89
- // Item not found in loaded items - show ID
90
- return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };
91
- })
92
- .filter(Boolean);
139
+ }
140
+ return m;
141
+ }, [availableItems, missingDetailIds, hydrationResult.data]);
93
142
 
94
- // Convert available items to options
95
- const options: Option[] = availableItems.map((item) => {
96
- const displayValue =
97
- (item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
98
- item.slug;
99
- return {
100
- value: item.id,
101
- label: String(displayValue),
102
- };
143
+ // Convert normalized value to Option[] for MultipleSelector
144
+ const selectedOptions: Option[] = normalizedValue.map((v) => {
145
+ const item = itemById.get(v.id);
146
+ if (item) {
147
+ const displayValue =
148
+ (item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
149
+ item.slug;
150
+ return {
151
+ value: item.id,
152
+ label: String(displayValue),
153
+ };
154
+ }
155
+ return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };
103
156
  });
104
157
 
158
+ // Listed options + any selected partners loaded by id (not on first list page)
159
+ const options: Option[] = useMemo(() => {
160
+ const merged: SerializedContentItemWithType[] = [
161
+ ...(availableItems as SerializedContentItemWithType[]),
162
+ ];
163
+ const seen = new Set(merged.map((x) => x.id));
164
+ for (let i = 0; i < missingDetailIds.length; i++) {
165
+ const row = hydrationResult.data[i];
166
+ if (row?.id && !seen.has(row.id)) {
167
+ merged.push(row);
168
+ seen.add(row.id);
169
+ }
170
+ }
171
+ return merged.map((item) => {
172
+ const displayValue =
173
+ (item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
174
+ item.slug;
175
+ return {
176
+ value: item.id,
177
+ label: String(displayValue),
178
+ };
179
+ });
180
+ }, [
181
+ availableItems,
182
+ hydrationResult.data,
183
+ missingDetailIds,
184
+ relation.displayField,
185
+ ]);
186
+
187
+ // Mutation for creating new items
188
+ const createMutation = useCreateContent(relation.targetType);
189
+
105
190
  // Handle selection change - convert back to appropriate format
106
191
  const handleChange = useCallback(
107
192
  (newOptions: Option[]) => {
@@ -187,11 +272,11 @@ export function RelationField({
187
272
  onChange={handleChange}
188
273
  options={options}
189
274
  placeholder={
190
- isLoading
275
+ isLoading || isHydratingLabels
191
276
  ? "Loading..."
192
277
  : `Select ${relation.targetType}${isSingleSelect ? "" : "(s)"}...`
193
278
  }
194
- disabled={isLoading}
279
+ disabled={isLoading || isHydratingLabels}
195
280
  hidePlaceholderWhenSelected
196
281
  emptyIndicator={
197
282
  <p className="text-center text-sm text-muted-foreground py-4">
@@ -1,13 +1,33 @@
1
1
  import { z } from "zod";
2
2
 
3
+ /** Default upper bound for a single page when no maxPageSize is configured. */
4
+ export const DEFAULT_MAX_PAGE_SIZE = 1000;
5
+
3
6
  /**
4
- * Schema for listing content items with pagination
7
+ * Factory that creates the list-content query schema with a configurable
8
+ * upper bound on the `limit` parameter.
9
+ *
10
+ * Use this inside the backend plugin factory (where `config.maxPageSize` is
11
+ * available) so the cap is set at registration time rather than hardcoded.
5
12
  */
6
- export const listContentQuerySchema = z.object({
7
- slug: z.string().optional(),
8
- limit: z.coerce.number().min(1).max(100).optional().default(20),
9
- offset: z.coerce.number().min(0).optional().default(0),
10
- });
13
+ export function createListContentQuerySchema(
14
+ maxPageSize = DEFAULT_MAX_PAGE_SIZE,
15
+ ) {
16
+ return z.object({
17
+ slug: z.string().optional(),
18
+ limit: z.coerce.number().min(1).max(maxPageSize).optional().default(20),
19
+ offset: z.coerce.number().min(0).optional().default(0),
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Schema for listing content items with pagination.
25
+ * Uses the default maxPageSize (1000).
26
+ *
27
+ * @deprecated Prefer {@link createListContentQuerySchema} inside plugin
28
+ * factories so consumers can configure the upper bound via `maxPageSize`.
29
+ */
30
+ export const listContentQuerySchema = createListContentQuerySchema();
11
31
 
12
32
  /**
13
33
  * Schema for creating a content item
@@ -303,4 +303,14 @@ export interface CMSBackendConfig {
303
303
  contentTypes: ContentTypeConfig[];
304
304
  /** Optional hooks for customizing behavior */
305
305
  hooks?: CMSBackendHooks;
306
+ /**
307
+ * Maximum number of items that can be requested in a single page.
308
+ * Applied to all list endpoints (`/content/:typeSlug`, by-relation, and inverse-relation).
309
+ *
310
+ * Raise this when your content types have many items and you need to fetch
311
+ * large pages (e.g. for SSG, CSV export, or programmatic access).
312
+ *
313
+ * @default 1000
314
+ */
315
+ maxPageSize?: number;
306
316
  }
@@ -71,6 +71,7 @@ export interface CommentsThreadDiscriminator {
71
71
  parentId: string | null | undefined;
72
72
  status: string | undefined;
73
73
  currentUserId: string | undefined;
74
+ sort: string | undefined;
74
75
  limit: number;
75
76
  }
76
77
 
@@ -80,6 +81,7 @@ export function commentsThreadDiscriminator(params?: {
80
81
  parentId?: string | null;
81
82
  status?: string;
82
83
  currentUserId?: string;
84
+ sort?: string;
83
85
  limit?: number;
84
86
  }): CommentsThreadDiscriminator {
85
87
  return {
@@ -88,6 +90,7 @@ export function commentsThreadDiscriminator(params?: {
88
90
  parentId: params?.parentId,
89
91
  status: params?.status,
90
92
  currentUserId: params?.currentUserId,
93
+ sort: params?.sort,
91
94
  limit: params?.limit ?? 20,
92
95
  };
93
96
  }
@@ -128,7 +131,7 @@ export const COMMENTS_QUERY_KEYS = {
128
131
 
129
132
  /**
130
133
  * Key for the infinite thread query (top-level comments, load-more).
131
- * Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, limit }]
134
+ * Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, sort, limit }]
132
135
  * Offset is excluded — it is driven by `pageParam`, not baked into the key.
133
136
  */
134
137
  commentsThread: (params?: {
@@ -137,6 +140,7 @@ export const COMMENTS_QUERY_KEYS = {
137
140
  parentId?: string | null;
138
141
  status?: string;
139
142
  currentUserId?: string;
143
+ sort?: string;
140
144
  limit?: number;
141
145
  }) =>
142
146
  ["commentsThread", "list", commentsThreadDiscriminator(params)] as const,
@@ -98,6 +98,17 @@ export interface CommentThreadProps {
98
98
  * Defaults to true.
99
99
  */
100
100
  allowEditing?: boolean;
101
+ /**
102
+ * Sort direction for top-level comments by `createdAt`.
103
+ * - `"desc"` (default): newest first.
104
+ * - `"asc"`: oldest first.
105
+ *
106
+ * Replies inside each thread always render chronologically (oldest → newest)
107
+ * and are unaffected by this prop.
108
+ *
109
+ * Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.
110
+ */
111
+ sort?: "asc" | "desc";
101
112
  }
102
113
 
103
114
  const DEFAULT_RENDERER: ComponentType<CommentRendererProps> = ({ body }) => (
@@ -325,6 +336,7 @@ function CommentThreadInner({
325
336
  pageSize: pageSizeProp,
326
337
  allowPosting: allowPostingProp,
327
338
  allowEditing: allowEditingProp,
339
+ sort: sortProp,
328
340
  }: CommentThreadProps) {
329
341
  const overrides = usePluginOverrides<
330
342
  CommentsPluginOverrides,
@@ -334,6 +346,7 @@ function CommentThreadInner({
334
346
  pageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;
335
347
  const allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;
336
348
  const allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;
349
+ const sort = sortProp ?? overrides.defaultCommentSort ?? "desc";
337
350
  const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };
338
351
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
339
352
  const [expandedReplies, setExpandedReplies] = useState<Set<string>>(
@@ -357,6 +370,7 @@ function CommentThreadInner({
357
370
  status: "approved",
358
371
  parentId: null,
359
372
  currentUserId,
373
+ sort,
360
374
  pageSize,
361
375
  });
362
376
 
@@ -366,6 +380,7 @@ function CommentThreadInner({
366
380
  currentUserId,
367
381
  infiniteKey: threadQueryKey,
368
382
  pageSize,
383
+ sort,
369
384
  });
370
385
 
371
386
  const handlePost = async (body: string) => {
@@ -164,6 +164,11 @@ export function useInfiniteComments(
164
164
  parentId?: string | null;
165
165
  status?: "pending" | "approved" | "spam";
166
166
  currentUserId?: string;
167
+ /**
168
+ * Sort direction by `createdAt`. Default: `"asc"` (oldest first) — matches
169
+ * the server-side default. Pass `"desc"` for newest-first threads.
170
+ */
171
+ sort?: "asc" | "desc";
167
172
  pageSize?: number;
168
173
  },
169
174
  options?: { enabled?: boolean },
@@ -178,6 +183,7 @@ export function useInfiniteComments(
178
183
  parentId: params.parentId ?? null,
179
184
  status: params.status,
180
185
  currentUserId: params.currentUserId,
186
+ sort: params.sort,
181
187
  limit: pageSize,
182
188
  });
183
189
 
@@ -266,6 +272,17 @@ export function usePostComment(
266
272
  * `nextOffset` from `lastPage.limit` instead of a hardcoded fallback.
267
273
  */
268
274
  pageSize?: number;
275
+ /**
276
+ * Sort direction of the surrounding infinite thread.
277
+ * - `"asc"` (default): newest comments belong on the LAST page → optimistic
278
+ * item is appended to `pages[last].items`.
279
+ * - `"desc"`: newest comments belong on the FIRST page → optimistic item
280
+ * is prepended to `pages[0].items`.
281
+ *
282
+ * Must match the `sort` passed to `useInfiniteComments` so the optimistic
283
+ * item appears in the same position the server will place it.
284
+ */
285
+ sort?: "asc" | "desc";
269
286
  },
270
287
  ) {
271
288
  const queryClient = useQueryClient();
@@ -350,6 +367,7 @@ export function usePostComment(
350
367
  const previous =
351
368
  queryClient.getQueryData<InfiniteData<CommentListResult>>(listKey);
352
369
 
370
+ const isDesc = params.sort === "desc";
353
371
  queryClient.setQueryData<InfiniteData<CommentListResult>>(
354
372
  listKey,
355
373
  (old) => {
@@ -366,16 +384,21 @@ export function usePostComment(
366
384
  pageParams: [0],
367
385
  };
368
386
  }
369
- const lastIdx = old.pages.length - 1;
387
+ // For asc (oldest-first) threads the new comment lives at the end →
388
+ // append to the last page. For desc (newest-first) threads it lives
389
+ // at the top → prepend to the first page.
390
+ const targetIdx = isDesc ? 0 : old.pages.length - 1;
370
391
  return {
371
392
  ...old,
372
393
  // Increment `total` on every page so the header count (which reads
373
394
  // pages[0].total) stays in sync even after multiple pages are loaded.
374
395
  pages: old.pages.map((page, idx) =>
375
- idx === lastIdx
396
+ idx === targetIdx
376
397
  ? {
377
398
  ...page,
378
- items: [...page.items, optimistic],
399
+ items: isDesc
400
+ ? [optimistic, ...page.items]
401
+ : [...page.items, optimistic],
379
402
  total: page.total + 1,
380
403
  }
381
404
  : { ...page, total: page.total + 1 },
@@ -75,6 +75,16 @@ export interface CommentsPluginOverrides {
75
75
  */
76
76
  defaultCommentPageSize?: number;
77
77
 
78
+ /**
79
+ * Default sort direction (by `createdAt`) for top-level comments in
80
+ * `CommentThread`.
81
+ * - `"desc"` (default): newest comments first.
82
+ * - `"asc"`: oldest comments first.
83
+ *
84
+ * Can be overridden per-instance via the `sort` prop on `CommentThread`.
85
+ */
86
+ defaultCommentSort?: "asc" | "desc";
87
+
78
88
  /**
79
89
  * When false, the comment form and reply buttons are hidden in all
80
90
  * `CommentThread` instances. Users can still read existing comments.
@@ -142,6 +142,7 @@ function createCommentsThreadQueries(
142
142
  parentId?: string | null;
143
143
  status?: "pending" | "approved" | "spam";
144
144
  currentUserId?: string;
145
+ sort?: "asc" | "desc";
145
146
  limit?: number;
146
147
  }) => ({
147
148
  // Offset is excluded from the key — it is driven by pageParam.
@@ -162,6 +163,7 @@ function createCommentsThreadQueries(
162
163
  // The server resolves the caller's identity server-side via the
163
164
  // resolveCurrentUserId hook. It is still included in the queryKey
164
165
  // above for client-side cache segregation.
166
+ sort: params?.sort,
165
167
  limit: params?.limit ?? 20,
166
168
  offset: pageParam ?? 0,
167
169
  },