@teleporthq/teleport-plugin-next-data-source 0.43.74 → 0.43.75

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 (143) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +8 -1
  2. package/__tests__/blog-adjacent-arranged.test.ts +176 -0
  3. package/__tests__/blog-post-contents.test.ts +212 -0
  4. package/__tests__/blog-post-page-enrichment.test.ts +28 -5
  5. package/__tests__/blog-post-picture-stand-in.test.ts +48 -0
  6. package/__tests__/blog-post-reading-time.test.ts +55 -0
  7. package/__tests__/browser-row-policy.test.ts +5 -1
  8. package/__tests__/content-preset-wiring.test.ts +108 -0
  9. package/__tests__/fixed-list-sort.test.ts +183 -0
  10. package/__tests__/pagination-filter-groups.test.ts +186 -0
  11. package/__tests__/plain-list-sort.test.ts +215 -0
  12. package/__tests__/product-price-sort.ts +47 -19
  13. package/__tests__/rest-api-key-header.test.ts +18 -0
  14. package/__tests__/search-url-sync.test.ts +34 -15
  15. package/__tests__/shared-resource-prefetch.test.ts +65 -0
  16. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  17. package/dist/cjs/fetchers/postgresql.js +2 -1
  18. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  19. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  20. package/dist/cjs/fetchers/rest-api.js +3 -1
  21. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  22. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  23. package/dist/cjs/fetchers/teleport.js +3 -2
  24. package/dist/cjs/fetchers/teleport.js.map +1 -1
  25. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +7 -6
  26. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  27. package/dist/cjs/fetchers/utils/browser-row-policy.js +7 -33
  28. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  29. package/dist/cjs/filter-utils.d.ts +19 -7
  30. package/dist/cjs/filter-utils.d.ts.map +1 -1
  31. package/dist/cjs/filter-utils.js +49 -13
  32. package/dist/cjs/filter-utils.js.map +1 -1
  33. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  34. package/dist/cjs/pagination-plugin.js +137 -118
  35. package/dist/cjs/pagination-plugin.js.map +1 -1
  36. package/dist/cjs/product-price-sort.d.ts +0 -9
  37. package/dist/cjs/product-price-sort.d.ts.map +1 -1
  38. package/dist/cjs/product-price-sort.js +1 -13
  39. package/dist/cjs/product-price-sort.js.map +1 -1
  40. package/dist/cjs/row-order.d.ts +19 -0
  41. package/dist/cjs/row-order.d.ts.map +1 -0
  42. package/dist/cjs/row-order.js +45 -0
  43. package/dist/cjs/row-order.js.map +1 -0
  44. package/dist/cjs/sort-utils.d.ts +10 -0
  45. package/dist/cjs/sort-utils.d.ts.map +1 -1
  46. package/dist/cjs/sort-utils.js +22 -1
  47. package/dist/cjs/sort-utils.js.map +1 -1
  48. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +11 -1
  49. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -1
  50. package/dist/cjs/transformations/blog-adjacent-posts.js +14 -1
  51. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -1
  52. package/dist/cjs/transformations/blog-comments.d.ts +1 -1
  53. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -1
  54. package/dist/cjs/transformations/blog-comments.js +1 -1
  55. package/dist/cjs/transformations/blog-comments.js.map +1 -1
  56. package/dist/cjs/transformations/blog-post.d.ts +14 -0
  57. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  58. package/dist/cjs/transformations/blog-post.js +4 -1
  59. package/dist/cjs/transformations/blog-post.js.map +1 -1
  60. package/dist/cjs/transformations/heading-anchors.d.ts +32 -8
  61. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -1
  62. package/dist/cjs/transformations/heading-anchors.js +34 -10
  63. package/dist/cjs/transformations/heading-anchors.js.map +1 -1
  64. package/dist/cjs/transformations/index.d.ts +24 -12
  65. package/dist/cjs/transformations/index.d.ts.map +1 -1
  66. package/dist/cjs/transformations/index.js +59 -11
  67. package/dist/cjs/transformations/index.js.map +1 -1
  68. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  69. package/dist/cjs/utils.d.ts.map +1 -1
  70. package/dist/cjs/utils.js +52 -3
  71. package/dist/cjs/utils.js.map +1 -1
  72. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  73. package/dist/esm/fetchers/postgresql.js +3 -2
  74. package/dist/esm/fetchers/postgresql.js.map +1 -1
  75. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  76. package/dist/esm/fetchers/rest-api.js +3 -1
  77. package/dist/esm/fetchers/rest-api.js.map +1 -1
  78. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  79. package/dist/esm/fetchers/teleport.js +4 -3
  80. package/dist/esm/fetchers/teleport.js.map +1 -1
  81. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +7 -6
  82. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  83. package/dist/esm/fetchers/utils/browser-row-policy.js +5 -31
  84. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  85. package/dist/esm/filter-utils.d.ts +19 -7
  86. package/dist/esm/filter-utils.d.ts.map +1 -1
  87. package/dist/esm/filter-utils.js +46 -12
  88. package/dist/esm/filter-utils.js.map +1 -1
  89. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  90. package/dist/esm/pagination-plugin.js +140 -121
  91. package/dist/esm/pagination-plugin.js.map +1 -1
  92. package/dist/esm/product-price-sort.d.ts +0 -9
  93. package/dist/esm/product-price-sort.d.ts.map +1 -1
  94. package/dist/esm/product-price-sort.js +0 -11
  95. package/dist/esm/product-price-sort.js.map +1 -1
  96. package/dist/esm/row-order.d.ts +19 -0
  97. package/dist/esm/row-order.d.ts.map +1 -0
  98. package/dist/esm/row-order.js +40 -0
  99. package/dist/esm/row-order.js.map +1 -0
  100. package/dist/esm/sort-utils.d.ts +10 -0
  101. package/dist/esm/sort-utils.d.ts.map +1 -1
  102. package/dist/esm/sort-utils.js +20 -0
  103. package/dist/esm/sort-utils.js.map +1 -1
  104. package/dist/esm/transformations/blog-adjacent-posts.d.ts +11 -1
  105. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -1
  106. package/dist/esm/transformations/blog-adjacent-posts.js +14 -1
  107. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -1
  108. package/dist/esm/transformations/blog-comments.d.ts +1 -1
  109. package/dist/esm/transformations/blog-comments.d.ts.map +1 -1
  110. package/dist/esm/transformations/blog-comments.js +1 -1
  111. package/dist/esm/transformations/blog-comments.js.map +1 -1
  112. package/dist/esm/transformations/blog-post.d.ts +14 -0
  113. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  114. package/dist/esm/transformations/blog-post.js +4 -1
  115. package/dist/esm/transformations/blog-post.js.map +1 -1
  116. package/dist/esm/transformations/heading-anchors.d.ts +32 -8
  117. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -1
  118. package/dist/esm/transformations/heading-anchors.js +33 -9
  119. package/dist/esm/transformations/heading-anchors.js.map +1 -1
  120. package/dist/esm/transformations/index.d.ts +24 -12
  121. package/dist/esm/transformations/index.d.ts.map +1 -1
  122. package/dist/esm/transformations/index.js +58 -11
  123. package/dist/esm/transformations/index.js.map +1 -1
  124. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  125. package/dist/esm/utils.d.ts.map +1 -1
  126. package/dist/esm/utils.js +52 -3
  127. package/dist/esm/utils.js.map +1 -1
  128. package/package.json +5 -5
  129. package/src/fetchers/postgresql.ts +11 -15
  130. package/src/fetchers/rest-api.ts +3 -1
  131. package/src/fetchers/teleport.ts +12 -16
  132. package/src/fetchers/utils/browser-row-policy.ts +10 -20
  133. package/src/filter-utils.ts +102 -28
  134. package/src/pagination-plugin.ts +211 -175
  135. package/src/product-price-sort.ts +0 -11
  136. package/src/row-order.ts +54 -0
  137. package/src/sort-utils.ts +25 -0
  138. package/src/transformations/blog-adjacent-posts.ts +89 -3
  139. package/src/transformations/blog-comments.ts +5 -5
  140. package/src/transformations/blog-post.ts +64 -13
  141. package/src/transformations/heading-anchors.ts +79 -16
  142. package/src/transformations/index.ts +80 -13
  143. package/src/utils.ts +74 -3
@@ -8,11 +8,16 @@ import * as types from '@babel/types'
8
8
  import { parseExpression } from '@babel/parser'
9
9
  import { ASTStatementOrder, GenericUtils, StringUtils } from '@teleporthq/teleport-shared'
10
10
  import { ASTUtils, URLQueryWriter, URLSearchParamSync } from '@teleporthq/teleport-plugin-common'
11
- import { generateSafeFileName } from './utils'
11
+ import { collectFilterLeafConditions, generateSafeFileName, pruneFilterTree } from './utils'
12
12
  import { generateDataSourceFetcherWithCore } from './data-source-fetchers'
13
13
  import { buildProductTransformOptions, type EntityTransformOptions } from './transformations'
14
- import { appendSortsParam, DynamicSortAST, extractDynamicSort } from './sort-utils'
15
- import { appendFiltersParam, pushStateIdsAsDeps, pushPropIdsAsDeps } from './filter-utils'
14
+ import { appendSortsParam, DynamicSortAST, extractDynamicSort, readFixedSort } from './sort-utils'
15
+ import {
16
+ appendFiltersParam,
17
+ buildStaticFiltersStringifyCall,
18
+ pushStateIdsAsDeps,
19
+ pushPropIdsAsDeps,
20
+ } from './filter-utils'
16
21
  import { appendItemsPathParam, extractItemsPath } from './items-path'
17
22
  import { resolveRepeaterPerPage } from './repeater-page-size'
18
23
  import {
@@ -121,51 +126,20 @@ function searchDefaultValueInitAST(value: SearchDefaultValue | undefined): types
121
126
  return types.cloneNode(value.ast, /* deep */ true) as types.Expression
122
127
  }
123
128
 
124
- // Builds the `useState(...)` initializer for the search query. When the
125
- // cms-list-repeater is bound to a URL param (`searchUrlParamKey`), the query is
126
- // seeded from `window.location.search` on the client so a deep link such as
127
- // `/products-list?searchKeyword=yoga` arrives pre-filtered on the very first
128
- // paint; the server render (where `window` is undefined) and any missing param
129
- // fall back to the `searchDefaultValue` seed (empty string by default). This
130
- // mirrors the `selectedCategory` / `sortBy` initializer emitted by
131
- // `createStateHookAST` for state-level `urlSearchParamBinding`, keeping the
132
- // three products-list controls consistent.
129
+ // Builds the `useState(...)` initializer for the search query: the
130
+ // `searchDefaultValue` seed (empty string by default), never the address. A
131
+ // statically generated page is rendered on the server without the visitor's
132
+ // `?searchKeyword=`, and the browser's first render must match it — a query
133
+ // seeded from `window.location.search` rendered a filled input and skipped the
134
+ // prefetched rows there, and React warned of a hydration mismatch. A URL-bound
135
+ // search adopts the address after mount instead (`pushSearchUrlSyncEffects`),
136
+ // as the page number does (`page-url-sync.ts`).
133
137
  //
134
138
  // Returns a fresh AST on every call (no shared node references) so the same
135
139
  // initializer can seed both the immediate `ds_N_searchQuery` state and the
136
140
  // debounced `ds_N_state.debouncedQuery` value.
137
141
  function buildSearchQueryInitAST(usage: DataSourceUsage): types.Expression {
138
- const fallback = searchDefaultValueInitAST(usage.searchDefaultValue)
139
- if (!usage.searchUrlParamKey) {
140
- return fallback
141
- }
142
- // (typeof window !== 'undefined'
143
- // ? new URLSearchParams(window.location.search).get('<key>')
144
- // : null) ?? <fallback>
145
- return types.logicalExpression(
146
- '??',
147
- types.conditionalExpression(
148
- types.binaryExpression(
149
- '!==',
150
- types.unaryExpression('typeof', types.identifier('window')),
151
- types.stringLiteral('undefined')
152
- ),
153
- types.callExpression(
154
- types.memberExpression(
155
- types.newExpression(types.identifier('URLSearchParams'), [
156
- types.memberExpression(
157
- types.memberExpression(types.identifier('window'), types.identifier('location')),
158
- types.identifier('search')
159
- ),
160
- ]),
161
- types.identifier('get')
162
- ),
163
- [types.stringLiteral(usage.searchUrlParamKey)]
164
- ),
165
- types.nullLiteral()
166
- ),
167
- fallback
168
- )
142
+ return searchDefaultValueInitAST(usage.searchDefaultValue)
169
143
  }
170
144
 
171
145
  // ==================== UIDL-FIRST STATE MANAGEMENT ====================
@@ -213,10 +187,11 @@ interface DataSourceUsage {
213
187
  // Dynamic single-column sort bound to component state (set when legacy sorts empty
214
188
  // and cms-list-repeater declares sort/sortDirection fields)
215
189
  dynamicSort?: DynamicSortAST
216
- // Filters from resource params (FLAT condition array — any `{ type: 'group',
217
- // children: [...] }` wrappers from the UIDL inspector have already been
218
- // unwrapped via `flattenFilterGroups`). Downstream emit sites rely on
219
- // entries having `.source` / `.destination` / `.operand` directly.
190
+ // Filters from resource params, as the TREE the inspector authored: condition
191
+ // leaves and `{ type: 'group', operator, children }` entries (see
192
+ // `readFilterTree`). Anything that needs the leaves walks the tree with
193
+ // `collectFilterLeafConditions`; the emit sites write the tree out whole, so
194
+ // an `or` group reaches the data source as an `or`.
220
195
  // tslint:disable-next-line:no-any
221
196
  filters: any[]
222
197
  // State IDs from dynamic filter destinations (for useMemo dependencies).
@@ -272,53 +247,26 @@ interface StateRegistry {
272
247
  byArrayMapperRenderProp: Map<string, DataSourceUsage>
273
248
  }
274
249
 
275
- // The UIDL's `filters.content` array can wrap one or more conditions inside
276
- // a `{ type: 'group', operator: 'and' | 'or', children: [...] }` entry — the
277
- // GUI builds this shape when the inspector adds a logical group. The data
278
- // source API endpoint (`processFilters` in the generated data-source module)
279
- // only knows how to consume a FLAT array of `{ source, destination, operand }`
280
- // conditions, so we walk groups recursively here and collect their leaf
281
- // conditions in order. Anything that isn't a group AND isn't a condition is
282
- // dropped — defensive against partially-built filter entries that would
283
- // otherwise emit `{ source: '', destination: '', operand: '' }` rows the
284
- // API ignores anyway.
285
- //
286
- // `or`-grouped conditions are flattened just like `and`-grouped ones — the
287
- // downstream SQL builder always joins flat filters with `AND`, so emitting
288
- // the conditions side-by-side approximates AND semantics. A future API
289
- // upgrade that respects group operators would key off the original tree
290
- // directly; until then, AND-flattening is the closest correct behaviour and
291
- // matches what the inspector preview shows for the common single-group case
292
- // the GUI emits today.
293
- function flattenFilterGroups(filters: any[]): any[] {
250
+ // The UIDL's `filters.content` array is the tree the inspector authored:
251
+ // `{ type: 'group', operator: 'and' | 'or', children: [...] }` entries around
252
+ // `{ source, destination, operand }` conditions, nested as deep as the user
253
+ // grouped them. The tree is kept as it is — the generated fetchers read it with
254
+ // `normalizeFilterTree`, so an `or` group stays an `or`. Only junk is dropped:
255
+ // an entry that is neither a group nor a condition (a half-built inspector
256
+ // row) would otherwise reach the API as `{ source: '', destination: '' }`.
257
+ // Entries without an explicit `type` are the legacy flat form and are
258
+ // conditions.
259
+ function readFilterTree(filters: any[]): any[] {
294
260
  if (!Array.isArray(filters)) {
295
261
  return []
296
262
  }
297
- const out: any[] = []
298
- const walk = (entry: any): void => {
299
- if (!entry || typeof entry !== 'object') {
300
- return
301
- }
302
- if (entry.type === 'group' && Array.isArray(entry.children)) {
303
- for (const child of entry.children) {
304
- walk(child)
305
- }
306
- return
307
- }
308
- // Backwards-compatible: entries without an explicit `type` (legacy flat
309
- // form) and entries explicitly tagged `condition` are both treated as
310
- // condition leaves.
311
- if (entry.type === undefined || entry.type === 'condition') {
312
- out.push(entry)
313
- }
314
- }
315
- for (const entry of filters) {
316
- walk(entry)
317
- }
318
- return out
263
+ return pruneFilterTree(
264
+ filters,
265
+ (entry: any) => entry.type === undefined || entry.type === 'condition'
266
+ )
319
267
  }
320
268
 
321
- // Walks a flat filter list to extract every `urlSearchParams` reference key
269
+ // Walks the filter tree to extract every `urlSearchParams` reference key
322
270
  // (e.g. `'categoryFilter'`). Used to (a) inject `const router = useRouter()`
323
271
  // once at the top of the component, (b) wire `router.query.<key>` into the
324
272
  // `useMemo` dependency array so the client-side fetch reruns whenever the
@@ -327,7 +275,7 @@ function flattenFilterGroups(filters: any[]): any[] {
327
275
  function collectFilterUrlSearchParamKeys(filters: any[]): string[] {
328
276
  const seen = new Set<string>()
329
277
  const out: string[] = []
330
- for (const f of filters) {
278
+ for (const f of collectFilterLeafConditions(filters) as any[]) {
331
279
  const dest = f?.destination
332
280
  if (!ASTUtils.isUIDLDynamicReference(dest)) {
333
281
  continue
@@ -345,6 +293,16 @@ function collectFilterUrlSearchParamKeys(filters: any[]): string[] {
345
293
  return out
346
294
  }
347
295
 
296
+ // The filter tree with every dynamic destination pruned — what `getStaticProps`
297
+ // can resolve at build time. Groups keep their operator; a group left empty by
298
+ // the pruning disappears with it (`pruneFilterTree`).
299
+ function staticFiltersOf(usage: DataSourceUsage): any[] {
300
+ return pruneFilterTree(
301
+ usage.filters || [],
302
+ (f: any) => !ASTUtils.isUIDLDynamicReference(f.destination)
303
+ )
304
+ }
305
+
348
306
  // Returns true when any filter destination is a `urlSearchParams` dynamic
349
307
  // reference — used by the plugin to know it needs to import `useRouter` and
350
308
  // emit `const router = useRouter()` at the top of the component. Cheaper to
@@ -550,23 +508,28 @@ function buildStateRegistry(uidlNode: any): StateRegistry {
550
508
  sorts = parentDataSource.resourceParams.sorts.content
551
509
  }
552
510
 
553
- // If legacy sorts aren't set, fall back to the new dynamic single-column
554
- // sort fields on the cms-list-repeater (used by admin-panel listing pages).
511
+ // If legacy sorts aren't set, fall back to the single-column sort fields
512
+ // on the cms-list-repeater. A FIXED one (a column chosen in the editor,
513
+ // e.g. a blog listing's newest first) is a static sort: read as dynamic,
514
+ // it made the list drop the server-side first page (`initialData` below
515
+ // is skipped for a dynamic sort) — the pre-rendered HTML carried no
516
+ // posts, and the browser fetched page 1 a second time. Only a sort
517
+ // bound to state (the admin panel's sortable columns) is dynamic.
555
518
  let dynamicSort: DynamicSortAST | undefined
556
519
  if ((!sorts || sorts.length === 0) && content.sort) {
557
- dynamicSort = extractDynamicSort(content.sort, content.sortDirection)
520
+ const fixedSort = readFixedSort(content.sort, content.sortDirection)
521
+ if (fixedSort) {
522
+ sorts = [fixedSort]
523
+ } else {
524
+ dynamicSort = extractDynamicSort(content.sort, content.sortDirection)
525
+ }
558
526
  }
559
527
 
560
- // Extract filters from parent's resource params. The inspector wraps
561
- // every condition in a `{ type: 'group' }` envelope (single-group or
562
- // nested), so flatten to leaf conditions before the downstream emit
563
- // sites consume `.source` / `.destination` / `.operand` directly —
564
- // they expect a flat condition array. See `flattenFilterGroups`'s
565
- // header comment for why AND-flattening is the correct fallback for
566
- // the API endpoint's flat-condition contract.
528
+ // Extract filters from parent's resource params, keeping the group tree
529
+ // the inspector authored (see `readFilterTree`).
567
530
  let filters: any[] = []
568
531
  if (parentDataSource.resourceParams?.filters?.content) {
569
- filters = flattenFilterGroups(parentDataSource.resourceParams.filters.content)
532
+ filters = readFilterTree(parentDataSource.resourceParams.filters.content)
570
533
  }
571
534
 
572
535
  // Split dynamic destination keys by reference type. `state`/`prop`
@@ -575,7 +538,7 @@ function buildStateRegistry(uidlNode: any): StateRegistry {
575
538
  // `useRouter()` declaration injected separately.
576
539
  const filterStateIds: string[] = []
577
540
  const filterPropIds: string[] = []
578
- for (const f of filters) {
541
+ for (const f of collectFilterLeafConditions(filters) as any[]) {
579
542
  if (!ASTUtils.isUIDLDynamicReference(f.destination)) {
580
543
  continue
581
544
  }
@@ -838,21 +801,118 @@ function pushSearchUrlSyncEffects(
838
801
  )
839
802
  : types.identifier(vars.debouncedSearchQueryVar)
840
803
 
804
+ // The query starts at its default (see `buildSearchQueryInitAST`), so the
805
+ // pair must agree on who goes first: the read-back adopts the address, THEN
806
+ // the write-back may write — before that, its first run would delete the very
807
+ // `?<key>=` the read-back is about to adopt.
808
+ const adoptedRefVar = `ds_${usage.index}_searchUrlAdopted`
809
+ const adopterVar = `ds_${usage.index}_adoptUrlSearch`
810
+ effectStatements.push(
811
+ types.variableDeclaration('const', [
812
+ types.variableDeclarator(
813
+ types.identifier(adoptedRefVar),
814
+ types.callExpression(types.identifier('useRef'), [types.booleanLiteral(false)])
815
+ ),
816
+ ]),
817
+ buildUrlSearchAdopter(adopterVar, usage, vars)
818
+ )
819
+
841
820
  // write-back: debounced query → `?<searchUrlParamKey>=`
842
821
  effectStatements.push(
843
822
  URLSearchParamSync.buildUrlWriteBackEffect(
844
823
  usage.searchUrlParamKey,
845
824
  buildDebouncedValueExpr(),
846
- buildDebouncedValueExpr()
825
+ buildDebouncedValueExpr(),
826
+ undefined,
827
+ adoptedRefVar
847
828
  )
848
829
  )
849
- // read-back: `?<searchUrlParamKey>=` → input (`setDs_N_searchQuery`). The
850
- // debounce effect propagates the change into the debounced value + fetch.
830
+ // read-back: `?<searchUrlParamKey>=` → the input AND the applied query at once
831
+ // (`ds_N_adoptUrlSearch`), so an address is not a typed change: it skips the
832
+ // debounce and keeps the page — `?searchKeyword=x&page=3` lands on page 3.
851
833
  effectStatements.push(
852
- URLSearchParamSync.buildUrlReadBackEffect(usage.searchUrlParamKey, vars.setSearchQueryVar)
834
+ URLSearchParamSync.buildUrlReadBackEffect(
835
+ usage.searchUrlParamKey,
836
+ adopterVar,
837
+ undefined,
838
+ adoptedRefVar
839
+ )
853
840
  )
854
841
  }
855
842
 
843
+ // `const ds_N_adoptUrlSearch = (update) => { … }`: the setter the search's URL
844
+ // read-back calls with its `(prev) => next` updater. It moves the input and the
845
+ // applied (debounced) query together; the combined paginated state keeps its
846
+ // page, which a typed change resets to 1 in the debounce effect.
847
+ function buildUrlSearchAdopter(
848
+ adopterVar: string,
849
+ usage: DataSourceUsage,
850
+ vars: ReturnType<typeof getStateVarsForUsage>
851
+ ): types.VariableDeclaration {
852
+ const update = types.identifier('update')
853
+ const applyToDebounced =
854
+ usage.category === 'paginated+search'
855
+ ? // setDs_N_state((state) => {
856
+ // const next = update(state.debouncedQuery)
857
+ // return next === state.debouncedQuery ? state : { ...state, debouncedQuery: next }
858
+ // })
859
+ types.callExpression(types.identifier(vars.setCombinedStateVar), [
860
+ types.arrowFunctionExpression(
861
+ [types.identifier('state')],
862
+ types.blockStatement([
863
+ types.variableDeclaration('const', [
864
+ types.variableDeclarator(
865
+ types.identifier('next'),
866
+ types.callExpression(types.identifier('update'), [
867
+ types.memberExpression(
868
+ types.identifier('state'),
869
+ types.identifier('debouncedQuery')
870
+ ),
871
+ ])
872
+ ),
873
+ ]),
874
+ types.returnStatement(
875
+ types.conditionalExpression(
876
+ types.binaryExpression(
877
+ '===',
878
+ types.identifier('next'),
879
+ types.memberExpression(
880
+ types.identifier('state'),
881
+ types.identifier('debouncedQuery')
882
+ )
883
+ ),
884
+ types.identifier('state'),
885
+ types.objectExpression([
886
+ types.spreadElement(types.identifier('state')),
887
+ types.objectProperty(
888
+ types.identifier('debouncedQuery'),
889
+ types.identifier('next')
890
+ ),
891
+ ])
892
+ )
893
+ ),
894
+ ])
895
+ ),
896
+ ])
897
+ : types.callExpression(types.identifier(vars.setDebouncedSearchQueryVar), [update])
898
+ return types.variableDeclaration('const', [
899
+ types.variableDeclarator(
900
+ types.identifier(adopterVar),
901
+ types.arrowFunctionExpression(
902
+ [types.identifier('update')],
903
+ types.blockStatement([
904
+ types.expressionStatement(
905
+ types.callExpression(types.identifier(vars.setSearchQueryVar), [
906
+ types.identifier('update'),
907
+ ])
908
+ ),
909
+ types.expressionStatement(applyToDebounced),
910
+ ])
911
+ )
912
+ ),
913
+ ])
914
+ }
915
+
856
916
  // How one usage reads and writes its current page.
857
917
  //
858
918
  // A paginated+search list keeps the page inside a combined
@@ -1258,13 +1318,12 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1258
1318
  ])
1259
1319
  )
1260
1320
 
1261
- // Immediate search query state — seeded with `searchDefaultValue` (or
1262
- // the `searchUrlParamKey` URL value when bound) so the input is
1263
- // pre-filled on mount. The debounced value above is seeded identically
1264
- // so a deep-linked `?searchKeyword=` fetches filtered results on the
1265
- // first paint: the paginated+search `initialData` is gated on
1266
- // `!ds_N_state.debouncedQuery`, so a non-empty seed forces the
1267
- // DataProvider to fetch instead of reusing the unfiltered SSG prefetch.
1321
+ // Immediate search query state — seeded with `searchDefaultValue`, like
1322
+ // the debounced value above. A deep-linked `?searchKeyword=` is adopted
1323
+ // after mount by the URL read-back, which sets both at once; the
1324
+ // paginated+search `initialData` is gated on `!ds_N_state.debouncedQuery`,
1325
+ // so the adopted query makes the DataProvider fetch filtered rows
1326
+ // instead of reusing the unfiltered SSG prefetch the first render shows.
1268
1327
  stateDeclarations.push(
1269
1328
  types.variableDeclaration('const', [
1270
1329
  types.variableDeclarator(
@@ -2736,6 +2795,15 @@ function updateDataProviderForPlain(
2736
2795
  usage: DataSourceUsage,
2737
2796
  localized: boolean
2738
2797
  ): void {
2798
+ // A sort bound to state is the visitor's, which the server cannot know: rows
2799
+ // pre-rendered in the table's order would stand in for it, since a seeded
2800
+ // provider skips its first fetch — the paginated lists drop them the same way.
2801
+ if (usage.dynamicSort) {
2802
+ dp.openingElement.attributes = dp.openingElement.attributes.filter(
2803
+ (attr: any) => !(attr.type === 'JSXAttribute' && attr.name?.name === 'initialData')
2804
+ )
2805
+ }
2806
+
2739
2807
  const attrs = dp.openingElement.attributes
2740
2808
 
2741
2809
  const clientCache = usage.cache?.client ? usage.cache : undefined
@@ -2774,13 +2842,15 @@ function updateDataProviderForPlain(
2774
2842
 
2775
2843
  // Find the params attribute. A plain list may have none at all — but the
2776
2844
  // fetch it was just given sends `params` regardless, and in a localized
2777
- // project those have to carry the locale, so the attribute is created.
2845
+ // project those have to carry the locale, and a sorted list its sort, so the
2846
+ // attribute is created.
2778
2847
  let paramsAttrIndex = attrs.findIndex(
2779
2848
  (attr: any) => attr.type === 'JSXAttribute' && attr.name.name === 'params'
2780
2849
  )
2850
+ const isSorted = usage.sorts.length > 0 || !!usage.dynamicSort
2781
2851
 
2782
2852
  if (paramsAttrIndex === -1) {
2783
- if (!localized) {
2853
+ if (!localized && !isSorted) {
2784
2854
  return
2785
2855
  }
2786
2856
  attrs.push(
@@ -2814,10 +2884,22 @@ function updateDataProviderForPlain(
2814
2884
  return
2815
2885
  }
2816
2886
 
2817
- // Build useMemo dependencies including filter state IDs
2887
+ // The repeater's sort, which the paginated and searchable lists add to the
2888
+ // params they rebuild: kept as they come, a plain list's params never
2889
+ // carried it, and the rows arrived in the table's own order.
2890
+ if (paramsExpression.type === 'ObjectExpression' && !hasObjectKey(paramsExpression, 'sorts')) {
2891
+ appendSortsParam(
2892
+ paramsExpression.properties as types.ObjectProperty[],
2893
+ usage.sorts,
2894
+ usage.dynamicSort
2895
+ )
2896
+ }
2897
+
2898
+ // Build useMemo dependencies including filter state IDs and a dynamic sort's state
2818
2899
  const memoDeps: types.Expression[] = []
2819
2900
  const memoSeen = new Set<string>()
2820
2901
  pushStateIdsAsDeps(memoDeps, memoSeen, usage.filterStateIds)
2902
+ pushStateIdsAsDeps(memoDeps, memoSeen, usage.dynamicSort?.depStateIds ?? [])
2821
2903
  pushPropIdsAsDeps(memoDeps, memoSeen, usage.filterPropIds)
2822
2904
  pushUrlSearchParamMemoDeps(memoDeps, usage)
2823
2905
  if (localized && paramsExpression.type === 'ObjectExpression') {
@@ -2837,6 +2919,14 @@ function updateDataProviderForPlain(
2837
2919
  )
2838
2920
  }
2839
2921
 
2922
+ const hasObjectKey = (object: types.ObjectExpression, key: string): boolean =>
2923
+ object.properties.some(
2924
+ (property) =>
2925
+ property.type === 'ObjectProperty' &&
2926
+ ((property.key.type === 'Identifier' && property.key.name === key) ||
2927
+ (property.key.type === 'StringLiteral' && property.key.value === key))
2928
+ )
2929
+
2840
2930
  function stabilizeDataProviderWithoutRepeater(dp: any): void {
2841
2931
  const attrs = dp.openingElement.attributes
2842
2932
 
@@ -3650,38 +3740,12 @@ function updateGetStaticProps(
3650
3740
 
3651
3741
  // Add filters if present
3652
3742
  // Filter out dynamic destinations for getStaticProps (they can't be resolved at build time)
3653
- const staticFilters = (usage.filters || []).filter(
3654
- (f: any) => !ASTUtils.isUIDLDynamicReference(f.destination)
3655
- )
3743
+ const staticFilters = staticFiltersOf(usage)
3656
3744
  if (staticFilters.length > 0) {
3657
3745
  fetchParams.push(
3658
3746
  types.objectProperty(
3659
3747
  types.identifier('filters'),
3660
- types.callExpression(
3661
- types.memberExpression(types.identifier('JSON'), types.identifier('stringify')),
3662
- [
3663
- types.arrayExpression(
3664
- staticFilters.map((filter: any) =>
3665
- types.objectExpression([
3666
- types.objectProperty(
3667
- types.identifier('source'),
3668
- types.stringLiteral(filter.source || '')
3669
- ),
3670
- types.objectProperty(
3671
- types.identifier('destination'),
3672
- types.stringLiteral(
3673
- typeof filter.destination === 'string' ? filter.destination : ''
3674
- )
3675
- ),
3676
- types.objectProperty(
3677
- types.identifier('operand'),
3678
- types.stringLiteral(filter.operand || '')
3679
- ),
3680
- ])
3681
- )
3682
- ),
3683
- ]
3684
- )
3748
+ buildStaticFiltersStringifyCall(staticFilters)
3685
3749
  )
3686
3750
  )
3687
3751
  }
@@ -3766,9 +3830,7 @@ function updateGetStaticProps(
3766
3830
  const maxPagesPropName = `${vars.propsPrefix}_maxPages`
3767
3831
  // Use filter-specific count variable if usage has static filters
3768
3832
  // (dynamic filters can't be resolved at build time)
3769
- const staticFiltersForCount = (usage.filters || []).filter(
3770
- (f: any) => !ASTUtils.isUIDLDynamicReference(f.destination)
3771
- )
3833
+ const staticFiltersForCount = staticFiltersOf(usage)
3772
3834
  // A count scoped to an inner array is a different number from the
3773
3835
  // whole-payload one, so it gets its own variable just like a filtered one.
3774
3836
  const hasScopedCount = staticFiltersForCount.length > 0 || !!usage.itemsPath
@@ -3854,9 +3916,7 @@ function updateGetStaticProps(
3854
3916
  usagesByFilters.forEach((usages, filtersKey) => {
3855
3917
  const firstUsage = usages[0]
3856
3918
  // Filter out dynamic destinations for getStaticProps (they can't be resolved at build time)
3857
- const staticFiltersForCount = (firstUsage.filters || []).filter(
3858
- (f: any) => !ASTUtils.isUIDLDynamicReference(f.destination)
3859
- )
3919
+ const staticFiltersForCount = staticFiltersOf(firstUsage)
3860
3920
  const hasStaticFilters = staticFiltersForCount.length > 0
3861
3921
 
3862
3922
  // Create unique count variable name based on filters (and inner-array
@@ -3887,31 +3947,7 @@ function updateGetStaticProps(
3887
3947
  countParams.push(
3888
3948
  types.objectProperty(
3889
3949
  types.identifier('filters'),
3890
- types.callExpression(
3891
- types.memberExpression(types.identifier('JSON'), types.identifier('stringify')),
3892
- [
3893
- types.arrayExpression(
3894
- staticFiltersForCount.map((f: any) =>
3895
- types.objectExpression([
3896
- types.objectProperty(
3897
- types.identifier('source'),
3898
- types.stringLiteral(f.source || '')
3899
- ),
3900
- types.objectProperty(
3901
- types.identifier('destination'),
3902
- types.stringLiteral(
3903
- typeof f.destination === 'string' ? f.destination : ''
3904
- )
3905
- ),
3906
- types.objectProperty(
3907
- types.identifier('operand'),
3908
- types.stringLiteral(f.operand || '')
3909
- ),
3910
- ])
3911
- )
3912
- ),
3913
- ]
3914
- )
3950
+ buildStaticFiltersStringifyCall(staticFiltersForCount)
3915
3951
  )
3916
3952
  )
3917
3953
  }
@@ -191,14 +191,3 @@ export const generateSortFallbackFieldHelper = (tableName: string): string => {
191
191
 
192
192
  return ['function sortFallbackField(field) {', branches + ' return field', '}'].join('\n')
193
193
  }
194
-
195
- /**
196
- * A deterministic tiebreaker appended to every products ORDER BY.
197
- *
198
- * Without one, a paginated sort with ties — and "sort by discount" ties every
199
- * undiscounted product at zero — has no defined order between equal rows, so
200
- * the same product can appear on two pages or on none. `id` is on the products
201
- * table from its first migration.
202
- */
203
- export const generateSortTiebreakSql = (tableName: string): string =>
204
- supportsProductPriceSort(tableName) ? ', "id" ASC' : ''
@@ -0,0 +1,54 @@
1
+ import { supportsProductPriceSort } from './product-price-sort'
2
+
3
+ interface ColumnLike {
4
+ name?: unknown
5
+ isPrimaryKey?: unknown
6
+ }
7
+
8
+ /**
9
+ * The column that tells a table's rows apart, read from the table the project
10
+ * saved: its primary key, else a column named `id`. The products table has `id`
11
+ * from its first migration. Null when neither is known — then nothing is added,
12
+ * rather than a column that may not exist.
13
+ */
14
+ export const rowIdentityColumn = (
15
+ config: Record<string, unknown>,
16
+ tableName: string
17
+ ): string | null => {
18
+ const selectedTables = (config as { selectedTables?: Record<string, { columns?: unknown }> })
19
+ .selectedTables
20
+ const saved = selectedTables?.[tableName]?.columns
21
+ const columns = (Array.isArray(saved) ? (saved as ColumnLike[]) : []).filter(
22
+ (column) => typeof column?.name === 'string'
23
+ )
24
+ const key =
25
+ columns.find((column) => column.isPrimaryKey === true)?.name ??
26
+ columns.find((column) => column.name === 'id')?.name
27
+ if (typeof key === 'string') {
28
+ return key
29
+ }
30
+ return supportsProductPriceSort(tableName) ? 'id' : null
31
+ }
32
+
33
+ /**
34
+ * The `rowOrderTiebreak(fields, direction)` helper a SQL fetcher embeds: what
35
+ * ends an ORDER BY so that it orders every row.
36
+ *
37
+ * Rows that share every requested key have no order of their own, and the
38
+ * database deals them out differently for each page — "sort by discount" ties
39
+ * every undiscounted product at zero, and an import dates hundreds of articles
40
+ * alike — so a paginated list repeated some rows and never showed others.
41
+ * The tie follows the last key's direction, as the editor's canvas does.
42
+ */
43
+ export const generateRowOrderTiebreakHelper = (identityColumn: string | null): string => {
44
+ if (!identityColumn) {
45
+ return "const rowOrderTiebreak = () => ''"
46
+ }
47
+ const quoted = `"${identityColumn.replace(/"/g, '""')}"`
48
+ return [
49
+ 'const rowOrderTiebreak = (fields, direction) =>',
50
+ ` fields.includes(${JSON.stringify(identityColumn)}) ? '' : ${JSON.stringify(
51
+ `, ${quoted} `
52
+ )} + direction`,
53
+ ].join('\n')
54
+ }
package/src/sort-utils.ts CHANGED
@@ -63,6 +63,31 @@ const toExpressionAndDeps = (
63
63
  return { expr: parsed, deps: Array.from(deps) }
64
64
  }
65
65
 
66
+ /**
67
+ * The `{ field, order }` of a sort the UIDL FIXES — a static column and a
68
+ * static direction (or none: ascending) — or undefined for one bound to state.
69
+ * A fixed sort is a static sort: it rides on the server-side first-page fetch
70
+ * and the client keeps that page, which a dynamic sort cannot let it do.
71
+ */
72
+ export const readFixedSort = (
73
+ sort: UIDLStaticValue | UIDLExpressionValue | undefined,
74
+ sortDirection: UIDLStaticValue | UIDLExpressionValue | undefined
75
+ ): { field: string; order: 'asc' | 'desc' } | undefined => {
76
+ if (sort?.type !== 'static' || typeof sort.content !== 'string' || !sort.content.trim()) {
77
+ return undefined
78
+ }
79
+ if (
80
+ sortDirection &&
81
+ (sortDirection.type !== 'static' || typeof sortDirection.content !== 'string')
82
+ ) {
83
+ return undefined
84
+ }
85
+ const direction = String(sortDirection?.content ?? '')
86
+ .trim()
87
+ .toLowerCase()
88
+ return { field: sort.content.trim(), order: direction.startsWith('desc') ? 'desc' : 'asc' }
89
+ }
90
+
66
91
  export const extractDynamicSort = (
67
92
  sort: UIDLStaticValue | UIDLExpressionValue | undefined,
68
93
  sortDirection: UIDLStaticValue | UIDLExpressionValue | undefined