@teleporthq/teleport-plugin-next-data-source 0.43.59 → 0.43.61

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 (141) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +230 -0
  2. package/__tests__/ecommerce-product-discounts.test.ts +196 -0
  3. package/__tests__/infinite-scroll.test.ts +143 -0
  4. package/__tests__/numbered-pagination.test.ts +113 -0
  5. package/__tests__/page-reset-on-filter-sort.test.ts +182 -0
  6. package/__tests__/page-url-sync.test.ts +208 -0
  7. package/__tests__/product-price-sort.ts +313 -0
  8. package/__tests__/search-url-sync.test.ts +7 -3
  9. package/dist/cjs/array-mapper-pagination.d.ts +9 -0
  10. package/dist/cjs/array-mapper-pagination.d.ts.map +1 -1
  11. package/dist/cjs/array-mapper-pagination.js.map +1 -1
  12. package/dist/cjs/cache/ast.d.ts.map +1 -1
  13. package/dist/cjs/cache/ast.js +2 -2
  14. package/dist/cjs/cache/ast.js.map +1 -1
  15. package/dist/cjs/count-effect.d.ts +42 -0
  16. package/dist/cjs/count-effect.d.ts.map +1 -0
  17. package/dist/cjs/count-effect.js +81 -0
  18. package/dist/cjs/count-effect.js.map +1 -0
  19. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  20. package/dist/cjs/fetchers/postgresql.js +2 -1
  21. package/dist/cjs/fetchers/postgresql.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/infinite-scroll.d.ts +65 -0
  26. package/dist/cjs/infinite-scroll.d.ts.map +1 -0
  27. package/dist/cjs/infinite-scroll.js +190 -0
  28. package/dist/cjs/infinite-scroll.js.map +1 -0
  29. package/dist/cjs/numbered-pagination.d.ts +42 -0
  30. package/dist/cjs/numbered-pagination.d.ts.map +1 -0
  31. package/dist/cjs/numbered-pagination.js +167 -0
  32. package/dist/cjs/numbered-pagination.js.map +1 -0
  33. package/dist/cjs/page-reset.d.ts +42 -0
  34. package/dist/cjs/page-reset.d.ts.map +1 -0
  35. package/dist/cjs/page-reset.js +116 -0
  36. package/dist/cjs/page-reset.js.map +1 -0
  37. package/dist/cjs/page-url-sync.d.ts +42 -0
  38. package/dist/cjs/page-url-sync.d.ts.map +1 -0
  39. package/dist/cjs/page-url-sync.js +180 -0
  40. package/dist/cjs/page-url-sync.js.map +1 -0
  41. package/dist/cjs/pagination-controls.d.ts +55 -0
  42. package/dist/cjs/pagination-controls.d.ts.map +1 -0
  43. package/dist/cjs/pagination-controls.js +159 -0
  44. package/dist/cjs/pagination-controls.js.map +1 -0
  45. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  46. package/dist/cjs/pagination-plugin.js +508 -164
  47. package/dist/cjs/pagination-plugin.js.map +1 -1
  48. package/dist/cjs/product-price-sort.d.ts +116 -0
  49. package/dist/cjs/product-price-sort.d.ts.map +1 -0
  50. package/dist/cjs/product-price-sort.js +169 -0
  51. package/dist/cjs/product-price-sort.js.map +1 -0
  52. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  53. package/dist/cjs/transformations/ecommerce-product.js +1 -1
  54. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  55. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  56. package/dist/cjs/utils.d.ts.map +1 -1
  57. package/dist/cjs/utils.js +5 -7
  58. package/dist/cjs/utils.js.map +1 -1
  59. package/dist/esm/array-mapper-pagination.d.ts +9 -0
  60. package/dist/esm/array-mapper-pagination.d.ts.map +1 -1
  61. package/dist/esm/array-mapper-pagination.js.map +1 -1
  62. package/dist/esm/cache/ast.d.ts.map +1 -1
  63. package/dist/esm/cache/ast.js +2 -2
  64. package/dist/esm/cache/ast.js.map +1 -1
  65. package/dist/esm/count-effect.d.ts +42 -0
  66. package/dist/esm/count-effect.d.ts.map +1 -0
  67. package/dist/esm/count-effect.js +53 -0
  68. package/dist/esm/count-effect.js.map +1 -0
  69. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  70. package/dist/esm/fetchers/postgresql.js +2 -1
  71. package/dist/esm/fetchers/postgresql.js.map +1 -1
  72. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  73. package/dist/esm/fetchers/teleport.js +3 -2
  74. package/dist/esm/fetchers/teleport.js.map +1 -1
  75. package/dist/esm/infinite-scroll.d.ts +65 -0
  76. package/dist/esm/infinite-scroll.d.ts.map +1 -0
  77. package/dist/esm/infinite-scroll.js +159 -0
  78. package/dist/esm/infinite-scroll.js.map +1 -0
  79. package/dist/esm/numbered-pagination.d.ts +42 -0
  80. package/dist/esm/numbered-pagination.d.ts.map +1 -0
  81. package/dist/esm/numbered-pagination.js +138 -0
  82. package/dist/esm/numbered-pagination.js.map +1 -0
  83. package/dist/esm/page-reset.d.ts +42 -0
  84. package/dist/esm/page-reset.d.ts.map +1 -0
  85. package/dist/esm/page-reset.js +87 -0
  86. package/dist/esm/page-reset.js.map +1 -0
  87. package/dist/esm/page-url-sync.d.ts +42 -0
  88. package/dist/esm/page-url-sync.d.ts.map +1 -0
  89. package/dist/esm/page-url-sync.js +152 -0
  90. package/dist/esm/page-url-sync.js.map +1 -0
  91. package/dist/esm/pagination-controls.d.ts +55 -0
  92. package/dist/esm/pagination-controls.d.ts.map +1 -0
  93. package/dist/esm/pagination-controls.js +126 -0
  94. package/dist/esm/pagination-controls.js.map +1 -0
  95. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  96. package/dist/esm/pagination-plugin.js +510 -166
  97. package/dist/esm/pagination-plugin.js.map +1 -1
  98. package/dist/esm/product-price-sort.d.ts +116 -0
  99. package/dist/esm/product-price-sort.d.ts.map +1 -0
  100. package/dist/esm/product-price-sort.js +160 -0
  101. package/dist/esm/product-price-sort.js.map +1 -0
  102. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  103. package/dist/esm/transformations/ecommerce-product.js +2 -2
  104. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  105. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  106. package/dist/esm/utils.d.ts.map +1 -1
  107. package/dist/esm/utils.js +6 -8
  108. package/dist/esm/utils.js.map +1 -1
  109. package/package.json +5 -5
  110. package/src/array-mapper-pagination.ts +10 -0
  111. package/src/cache/ast.ts +2 -2
  112. package/src/count-effect.ts +137 -0
  113. package/src/fetchers/postgresql.ts +52 -13
  114. package/src/fetchers/teleport.ts +53 -13
  115. package/src/infinite-scroll.ts +401 -0
  116. package/src/numbered-pagination.ts +292 -0
  117. package/src/page-reset.ts +162 -0
  118. package/src/page-url-sync.ts +241 -0
  119. package/src/pagination-controls.ts +176 -0
  120. package/src/pagination-plugin.ts +758 -351
  121. package/src/product-price-sort.ts +204 -0
  122. package/src/transformations/ecommerce-product.ts +33 -7
  123. package/src/utils.ts +6 -8
  124. package/dist/cjs/array-mapper-registry.d.ts +0 -85
  125. package/dist/cjs/array-mapper-registry.d.ts.map +0 -1
  126. package/dist/cjs/array-mapper-registry.js +0 -296
  127. package/dist/cjs/array-mapper-registry.js.map +0 -1
  128. package/dist/cjs/pagination-with-count.d.ts +0 -6
  129. package/dist/cjs/pagination-with-count.d.ts.map +0 -1
  130. package/dist/cjs/pagination-with-count.js +0 -63
  131. package/dist/cjs/pagination-with-count.js.map +0 -1
  132. package/dist/esm/array-mapper-registry.d.ts +0 -85
  133. package/dist/esm/array-mapper-registry.d.ts.map +0 -1
  134. package/dist/esm/array-mapper-registry.js +0 -292
  135. package/dist/esm/array-mapper-registry.js.map +0 -1
  136. package/dist/esm/pagination-with-count.d.ts +0 -6
  137. package/dist/esm/pagination-with-count.d.ts.map +0 -1
  138. package/dist/esm/pagination-with-count.js +0 -34
  139. package/dist/esm/pagination-with-count.js.map +0 -1
  140. package/src/array-mapper-registry.ts +0 -416
  141. package/src/pagination-with-count.ts +0 -89
@@ -6,8 +6,8 @@ import {
6
6
  } from '@teleporthq/teleport-types'
7
7
  import * as types from '@babel/types'
8
8
  import { parseExpression } from '@babel/parser'
9
- import { ASTStatementOrder, StringUtils } from '@teleporthq/teleport-shared'
10
- import { ASTUtils, URLSearchParamSync } from '@teleporthq/teleport-plugin-common'
9
+ import { ASTStatementOrder, GenericUtils, StringUtils } from '@teleporthq/teleport-shared'
10
+ import { ASTUtils, URLQueryWriter, URLSearchParamSync } from '@teleporthq/teleport-plugin-common'
11
11
  import { generateSafeFileName } from './utils'
12
12
  import { generateDataSourceFetcherWithCore } from './data-source-fetchers'
13
13
  import {
@@ -30,6 +30,33 @@ import {
30
30
  buildCachedParamsDeclarations,
31
31
  registerCacheClientImports,
32
32
  } from './cache/ast'
33
+ import {
34
+ convertControlToButton,
35
+ dropInactivePaginationControls,
36
+ findPaginationControl,
37
+ setJSXExpressionAttribute,
38
+ type PaginationControlKind,
39
+ } from './pagination-controls'
40
+ import { buildCountFetchEffect } from './count-effect'
41
+ import {
42
+ buildPageTokensDeclaration,
43
+ wireNumberedPagination,
44
+ type NumberedPaginationVars,
45
+ } from './numbered-pagination'
46
+ import {
47
+ buildAccumulatingResponseHandler,
48
+ buildInfiniteScrollDeclarations,
49
+ buildSentinelElement,
50
+ buildSentinelObserverEffect,
51
+ getInfiniteScrollVars,
52
+ } from './infinite-scroll'
53
+ import {
54
+ buildFilterSortPageResetEffect,
55
+ buildPageResetRefDeclaration,
56
+ hasNoPageResetDeps,
57
+ type PageResetDeps,
58
+ } from './page-reset'
59
+ import { buildPageUrlRefDeclaration, buildPageUrlSyncEffect } from './page-url-sync'
33
60
 
34
61
  // ----- searchDefaultValue support -----
35
62
  //
@@ -202,6 +229,22 @@ interface DataSourceUsage {
202
229
  // cache — in which case every emit site below must produce exactly what it
203
230
  // produced before caching existed.
204
231
  cache?: ResolvedDataSourceCache
232
+ // Which pagination controls this mapper wires. `'numbered'` additionally wires
233
+ // First/Last and repeats the authored page-number template button; `'buttons'`
234
+ // is today's Previous/Next pair. Normalised to `'buttons'` whenever
235
+ // `infiniteScroll` is set — an accumulating list has no pages to jump between.
236
+ paginationMode: 'buttons' | 'numbered'
237
+ // Append rows instead of replacing them page by page. Suppresses every
238
+ // pagination control except an authored Load More button.
239
+ infiniteScroll: boolean
240
+ // With `infiniteScroll`: append on a Load More click rather than automatically
241
+ // when the visitor scrolls to the end of the list.
242
+ infiniteScrollLoadMore: boolean
243
+ // URL query-param key the current page is two-way bound to (e.g. `'page'`) —
244
+ // the pagination counterpart of `searchUrlParamKey`, sharing its
245
+ // `URLSearchParamSync` builders. Page 1 is written as the ABSENCE of the key.
246
+ // Cleared when `infiniteScroll` is set.
247
+ pageUrlParamKey?: string
205
248
  // Computed category
206
249
  category: 'paginated+search' | 'paginated-only' | 'search-only' | 'plain'
207
250
  }
@@ -562,6 +605,20 @@ function buildStateRegistry(uidlNode: any): StateRegistry {
562
605
  ? content.searchUrlParamKey.trim()
563
606
  : undefined
564
607
 
608
+ // An accumulating list has no discrete pages, so it can neither jump
609
+ // between them nor put one in the URL. Normalising here — rather than at
610
+ // each of the ~6 emit sites — keeps every downstream branch able to trust
611
+ // `paginationMode` / `pageUrlParamKey` on their own.
612
+ const infiniteScroll = !!content.infiniteScroll
613
+ const pageUrlParamKey =
614
+ !infiniteScroll &&
615
+ typeof content.pageUrlParamKey === 'string' &&
616
+ content.pageUrlParamKey.trim().length > 0
617
+ ? content.pageUrlParamKey.trim()
618
+ : undefined
619
+ const paginationMode: 'buttons' | 'numbered' =
620
+ !infiniteScroll && content.paginationMode === 'numbered' ? 'numbered' : 'buttons'
621
+
565
622
  const usage: DataSourceUsage = {
566
623
  index: index++,
567
624
  dataSourceIdentifier: parentDataSource.identifier,
@@ -585,6 +642,10 @@ function buildStateRegistry(uidlNode: any): StateRegistry {
585
642
  filterPropIds,
586
643
  filterUrlSearchParamKeys,
587
644
  cache: resolveCacheFromRepeaterContent(content.cache, parentDataSource.resourceDef),
645
+ paginationMode,
646
+ infiniteScroll,
647
+ infiniteScrollLoadMore: infiniteScroll && !!content.infiniteScrollLoadMore,
648
+ pageUrlParamKey,
588
649
  category: 'plain',
589
650
  }
590
651
 
@@ -698,6 +759,19 @@ function usageHasSearchUrlSync(usage: DataSourceUsage): boolean {
698
759
  )
699
760
  }
700
761
 
762
+ // The page ⇄ URL counterpart of `usageHasSearchUrlSync`: the usage must carry a
763
+ // `pageUrlParamKey` AND own a page state (only the paginated categories declare
764
+ // `ds_N_page` / `ds_N_state.page`). `infiniteScroll` usages are already excluded
765
+ // by the registry, which clears their key.
766
+ function usageHasPageUrlSync(usage: DataSourceUsage): boolean {
767
+ return (
768
+ !!usage.pageUrlParamKey &&
769
+ usage.paginated &&
770
+ !usage.infiniteScroll &&
771
+ (usage.category === 'paginated+search' || usage.category === 'paginated-only')
772
+ )
773
+ }
774
+
701
775
  // Emits the two URL-sync effects for a search input bound to a URL param
702
776
  // (`searchUrlParamKey`), reusing the shared `URLSearchParamSync` builders so the
703
777
  // search input behaves exactly like the `selectedCategory` / `sortBy` dropdowns:
@@ -754,6 +828,250 @@ function pushSearchUrlSyncEffects(
754
828
  )
755
829
  }
756
830
 
831
+ // How one usage reads and writes its current page.
832
+ //
833
+ // A paginated+search list keeps the page inside a combined
834
+ // `{ page, debouncedQuery }` object so a search reset can replace both at once,
835
+ // while a paginated-only list has a plain `ds_N_page`. Every control that moves
836
+ // the page goes through this pair rather than re-deriving the distinction.
837
+ function getPageAccessors(
838
+ usage: DataSourceUsage,
839
+ vars: ReturnType<typeof getStateVarsForUsage>
840
+ ): {
841
+ currentPageExpr: () => types.Expression
842
+ buildSetPageStatement: (pageExpr: types.Expression) => types.Expression
843
+ } {
844
+ const isCombinedState = usage.category === 'paginated+search'
845
+
846
+ return {
847
+ currentPageExpr: () =>
848
+ isCombinedState
849
+ ? types.memberExpression(types.identifier(vars.combinedStateVar), types.identifier('page'))
850
+ : types.identifier(vars.pageStateVar),
851
+ // Functional updates that bail when the page is unchanged: clicking the
852
+ // page you are already on must not schedule a render, and with it a refetch.
853
+ buildSetPageStatement: (pageExpr: types.Expression) =>
854
+ isCombinedState
855
+ ? types.callExpression(types.identifier(vars.setCombinedStateVar), [
856
+ types.arrowFunctionExpression(
857
+ [types.identifier('state')],
858
+ types.conditionalExpression(
859
+ types.binaryExpression(
860
+ '===',
861
+ types.memberExpression(types.identifier('state'), types.identifier('page')),
862
+ types.cloneNode(pageExpr, true) as types.Expression
863
+ ),
864
+ types.identifier('state'),
865
+ types.objectExpression([
866
+ types.spreadElement(types.identifier('state')),
867
+ types.objectProperty(
868
+ types.identifier('page'),
869
+ types.cloneNode(pageExpr, true) as types.Expression
870
+ ),
871
+ ])
872
+ )
873
+ ),
874
+ ])
875
+ : types.callExpression(types.identifier(vars.setPageStateVar), [
876
+ types.arrowFunctionExpression(
877
+ [types.identifier('page')],
878
+ types.conditionalExpression(
879
+ types.binaryExpression(
880
+ '===',
881
+ types.identifier('page'),
882
+ types.cloneNode(pageExpr, true) as types.Expression
883
+ ),
884
+ types.identifier('page'),
885
+ types.cloneNode(pageExpr, true) as types.Expression
886
+ )
887
+ ),
888
+ ]),
889
+ }
890
+ }
891
+
892
+ /**
893
+ * Advance one page, unless the count already says this is the last one. Shared
894
+ * by the scroll sentinel and the Load More button.
895
+ *
896
+ * ⛔ The next page is derived from the updater's OWN argument, never from the
897
+ * page in scope. The sentinel's effect is re-armed on `hasMore` / `maxPages`,
898
+ * and neither changes on an append while the total is unknown — so a captured
899
+ * `ds_N_page` would still read 1 on the second intersection and the list would
900
+ * stop growing after one page, on exactly the sources that cannot be counted.
901
+ */
902
+ function buildAdvancePageStatement(
903
+ usage: DataSourceUsage,
904
+ vars: ReturnType<typeof getStateVarsForUsage>
905
+ ): types.Statement {
906
+ const isCombinedState = usage.category === 'paginated+search'
907
+ const paramName = isCombinedState ? 'state' : 'page'
908
+ const pageOfParam = (): types.Expression =>
909
+ isCombinedState
910
+ ? types.memberExpression(types.identifier(paramName), types.identifier('page'))
911
+ : types.identifier(paramName)
912
+
913
+ // maxPages > 0 && <param page> >= maxPages
914
+ const atLastPage = types.logicalExpression(
915
+ '&&',
916
+ types.binaryExpression('>', types.identifier(vars.maxPagesStateVar), types.numericLiteral(0)),
917
+ types.binaryExpression('>=', pageOfParam(), types.identifier(vars.maxPagesStateVar))
918
+ )
919
+ const nextPage = types.binaryExpression('+', pageOfParam(), types.numericLiteral(1))
920
+
921
+ return types.expressionStatement(
922
+ types.callExpression(
923
+ types.identifier(isCombinedState ? vars.setCombinedStateVar : vars.setPageStateVar),
924
+ [
925
+ types.arrowFunctionExpression(
926
+ [types.identifier(paramName)],
927
+ types.conditionalExpression(
928
+ atLastPage,
929
+ types.identifier(paramName),
930
+ isCombinedState
931
+ ? types.objectExpression([
932
+ types.spreadElement(types.identifier(paramName)),
933
+ types.objectProperty(types.identifier('page'), nextPage),
934
+ ])
935
+ : nextPage
936
+ )
937
+ ),
938
+ ]
939
+ )
940
+ )
941
+ }
942
+
943
+ // The accumulator, the end-of-data flag and (in auto mode) the sentinel ref,
944
+ // declared only for a mapper that appends.
945
+ function pushInfiniteScrollDeclarations(
946
+ stateDeclarations: types.Statement[],
947
+ effectStatements: types.Statement[],
948
+ usage: DataSourceUsage,
949
+ vars: ReturnType<typeof getStateVarsForUsage>
950
+ ): void {
951
+ if (!usage.infiniteScroll || !usage.paginated) {
952
+ return
953
+ }
954
+ const infiniteVars = getInfiniteScrollVars(usage.index)
955
+ const { currentPageExpr } = getPageAccessors(usage, vars)
956
+ const usesSentinel = !usage.infiniteScrollLoadMore
957
+
958
+ stateDeclarations.push(
959
+ ...buildInfiniteScrollDeclarations(
960
+ infiniteVars,
961
+ vars.maxPagesStateVar,
962
+ currentPageExpr(),
963
+ usesSentinel
964
+ )
965
+ )
966
+
967
+ if (usesSentinel) {
968
+ effectStatements.push(
969
+ buildSentinelObserverEffect(infiniteVars, vars.maxPagesStateVar, () =>
970
+ buildAdvancePageStatement(usage, vars)
971
+ )
972
+ )
973
+ }
974
+ }
975
+
976
+ // The `useMemo` holding the visible page numbers, declared only for a mapper in
977
+ // numbered mode.
978
+ function pushPageTokensDeclaration(
979
+ stateDeclarations: types.Statement[],
980
+ usage: DataSourceUsage,
981
+ vars: ReturnType<typeof getStateVarsForUsage>
982
+ ): void {
983
+ if (usage.paginationMode !== 'numbered' || !usage.paginated) {
984
+ return
985
+ }
986
+ const { currentPageExpr } = getPageAccessors(usage, vars)
987
+ stateDeclarations.push(
988
+ buildPageTokensDeclaration(usage.index, vars.maxPagesStateVar, currentPageExpr())
989
+ )
990
+ }
991
+
992
+ // Emits the page ⇄ `?<key>=` sync for a mapper bound to `pageUrlParamKey` — the
993
+ // pagination counterpart of `pushSearchUrlSyncEffects`, but a SINGLE effect
994
+ // that owns both directions. `page-url-sync.ts` explains at length why the
995
+ // usual read-back / write-back pair cannot be used here.
996
+ //
997
+ // ⛔ MUST be pushed AFTER the filter/sort reset effect. Both can fire in one
998
+ // flush on a navigation that changes filter and page together; effects run in
999
+ // emission order, so this one running LAST is what makes the URL win when it
1000
+ // carries a page, while a plain user filter change (whose URL has not moved
1001
+ // yet) is left with the reset's page 1 and writes that back.
1002
+ function pushPageUrlSyncEffects(
1003
+ stateDeclarations: types.Statement[],
1004
+ effectStatements: types.Statement[],
1005
+ usage: DataSourceUsage,
1006
+ vars: ReturnType<typeof getStateVarsForUsage>
1007
+ ): void {
1008
+ if (!usageHasPageUrlSync(usage)) {
1009
+ return
1010
+ }
1011
+ const refVar = `ds_${usage.index}_pageUrlRef`
1012
+ const { currentPageExpr, buildSetPageStatement } = getPageAccessors(usage, vars)
1013
+
1014
+ stateDeclarations.push(buildPageUrlRefDeclaration(refVar))
1015
+ effectStatements.push(
1016
+ buildPageUrlSyncEffect({
1017
+ paramKey: usage.pageUrlParamKey as string,
1018
+ refVar,
1019
+ currentPageExpr,
1020
+ buildSetPageStatement,
1021
+ })
1022
+ )
1023
+ }
1024
+
1025
+ // The name of the ref holding this usage's last-seen filter/sort signature.
1026
+ function getPageResetRefVar(usage: DataSourceUsage): string {
1027
+ return `ds_${usage.index}_filterResetRef`
1028
+ }
1029
+
1030
+ // Everything that can narrow this list: filter destinations (state-, prop- and
1031
+ // URL-bound) plus the state a dynamic sort reads. Sort does not change WHICH
1032
+ // rows match, but it does change which row is on which page, so holding the
1033
+ // visitor on page 5 across a sort change shows them an arbitrary window of a
1034
+ // list they just reordered.
1035
+ function collectPageResetDeps(usage: DataSourceUsage): PageResetDeps {
1036
+ return {
1037
+ stateIds: [...usage.filterStateIds, ...(usage.dynamicSort?.depStateIds ?? [])],
1038
+ propIds: usage.filterPropIds,
1039
+ urlSearchParamKeys: usage.filterUrlSearchParamKeys,
1040
+ }
1041
+ }
1042
+
1043
+ // Emits the "a filter or sort changed ⇒ go back to page 1" effect for one
1044
+ // paginated usage. No-op for a list nothing can filter or reorder, so a mapper
1045
+ // without filters generates byte-for-byte what it always did.
1046
+ function pushFilterSortPageResetEffect(
1047
+ stateDeclarations: types.Statement[],
1048
+ effectStatements: types.Statement[],
1049
+ usage: DataSourceUsage,
1050
+ vars: ReturnType<typeof getStateVarsForUsage>
1051
+ ): void {
1052
+ if (!usage.paginated) {
1053
+ return
1054
+ }
1055
+ const deps = collectPageResetDeps(usage)
1056
+ if (hasNoPageResetDeps(deps)) {
1057
+ return
1058
+ }
1059
+
1060
+ const refVar = getPageResetRefVar(usage)
1061
+ stateDeclarations.push(buildPageResetRefDeclaration(refVar))
1062
+
1063
+ // The setter bails when the page is already 1 (see `getPageAccessors`), so a
1064
+ // visitor who was on page 1 all along and merely ticked a category does not
1065
+ // get an extra render — and with it an extra fetch.
1066
+ const { buildSetPageStatement } = getPageAccessors(usage, vars)
1067
+
1068
+ effectStatements.push(
1069
+ buildFilterSortPageResetEffect(refVar, deps, () =>
1070
+ types.expressionStatement(buildSetPageStatement(types.numericLiteral(1)))
1071
+ )
1072
+ )
1073
+ }
1074
+
757
1075
  // ==================== MAIN PLUGIN ====================
758
1076
 
759
1077
  export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> = () => {
@@ -963,18 +1281,40 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
963
1281
  types.arrowFunctionExpression(
964
1282
  [],
965
1283
  types.blockStatement([
1284
+ // ⛔ Functional, and a no-op when the query has not
1285
+ // actually moved. This effect fires on ANY change to
1286
+ // the input state — including the URL read-back
1287
+ // echoing back a value the visitor never typed (a
1288
+ // browser Forward to `?searchKeyword=x&page=3`) — and
1289
+ // replacing the whole object would silently drop the
1290
+ // page it had just adopted, as well as remount the
1291
+ // provider on a `key` that contains the query.
966
1292
  types.expressionStatement(
967
1293
  types.callExpression(types.identifier(vars.setCombinedStateVar), [
968
- types.objectExpression([
969
- types.objectProperty(
970
- types.identifier('page'),
971
- types.numericLiteral(1)
972
- ),
973
- types.objectProperty(
974
- types.identifier('debouncedQuery'),
975
- types.identifier(vars.searchQueryVar)
976
- ),
977
- ]),
1294
+ types.arrowFunctionExpression(
1295
+ [types.identifier('state')],
1296
+ types.conditionalExpression(
1297
+ types.binaryExpression(
1298
+ '===',
1299
+ types.memberExpression(
1300
+ types.identifier('state'),
1301
+ types.identifier('debouncedQuery')
1302
+ ),
1303
+ types.identifier(vars.searchQueryVar)
1304
+ ),
1305
+ types.identifier('state'),
1306
+ types.objectExpression([
1307
+ types.objectProperty(
1308
+ types.identifier('page'),
1309
+ types.numericLiteral(1)
1310
+ ),
1311
+ types.objectProperty(
1312
+ types.identifier('debouncedQuery'),
1313
+ types.identifier(vars.searchQueryVar)
1314
+ ),
1315
+ ])
1316
+ )
1317
+ ),
978
1318
  ])
979
1319
  ),
980
1320
  ])
@@ -1054,110 +1394,6 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1054
1394
  // Add filters to count fetch params if present
1055
1395
  appendFiltersParam(urlParams, usage.filters, buildFilterDestinationExpression)
1056
1396
 
1057
- // Build the count fetch effect body
1058
- const countFetchEffectBody: types.Statement[] = []
1059
-
1060
- // Refresh the count on mount (no skip-on-mount guard). Pages seed maxPages
1061
- // from the build-time `getStaticProps` count, but that snapshot goes stale:
1062
- // rows added AFTER the build (e.g. the normalized product_variants table,
1063
- // which is seeded/grows past one page during store generation) are not
1064
- // reflected, so a frozen maxPages of 0/1 leaves "Next" permanently disabled
1065
- // on data-heavy tables. Re-fetching the live count on mount corrects
1066
- // maxPages so pagination tracks the real row count. The effect still runs
1067
- // when the search query or a filter changes (see deps below).
1068
-
1069
- // Add the fetch call
1070
- countFetchEffectBody.push(
1071
- types.expressionStatement(
1072
- types.callExpression(
1073
- types.memberExpression(
1074
- types.callExpression(
1075
- types.memberExpression(
1076
- types.callExpression(types.identifier('fetch'), [
1077
- types.templateLiteral(
1078
- [
1079
- types.templateElement({
1080
- raw: `/api/${fileName}-count?`,
1081
- cooked: `/api/${fileName}-count?`,
1082
- }),
1083
- types.templateElement({ raw: '', cooked: '' }),
1084
- ],
1085
- [
1086
- types.newExpression(types.identifier('URLSearchParams'), [
1087
- types.objectExpression(urlParams),
1088
- ]),
1089
- ]
1090
- ),
1091
- ]),
1092
- types.identifier('then')
1093
- ),
1094
- [
1095
- types.arrowFunctionExpression(
1096
- [types.identifier('res')],
1097
- types.callExpression(
1098
- types.memberExpression(types.identifier('res'), types.identifier('json')),
1099
- []
1100
- )
1101
- ),
1102
- ]
1103
- ),
1104
- types.identifier('then')
1105
- ),
1106
- [
1107
- types.arrowFunctionExpression(
1108
- [types.identifier('data')],
1109
- types.blockStatement([
1110
- types.ifStatement(
1111
- types.logicalExpression(
1112
- '&&',
1113
- types.identifier('data'),
1114
- types.binaryExpression(
1115
- 'in',
1116
- types.stringLiteral('count'),
1117
- types.identifier('data')
1118
- )
1119
- ),
1120
- types.blockStatement([
1121
- types.expressionStatement(
1122
- types.callExpression(types.identifier(vars.setMaxPagesStateVar), [
1123
- types.conditionalExpression(
1124
- types.binaryExpression(
1125
- '===',
1126
- types.memberExpression(
1127
- types.identifier('data'),
1128
- types.identifier('count')
1129
- ),
1130
- types.numericLiteral(0)
1131
- ),
1132
- types.numericLiteral(0),
1133
- types.callExpression(
1134
- types.memberExpression(
1135
- types.identifier('Math'),
1136
- types.identifier('ceil')
1137
- ),
1138
- [
1139
- types.binaryExpression(
1140
- '/',
1141
- types.memberExpression(
1142
- types.identifier('data'),
1143
- types.identifier('count')
1144
- ),
1145
- types.numericLiteral(usage.perPage)
1146
- ),
1147
- ]
1148
- )
1149
- ),
1150
- ])
1151
- ),
1152
- ])
1153
- ),
1154
- ])
1155
- ),
1156
- ]
1157
- )
1158
- )
1159
- )
1160
-
1161
1397
  const countEffectDeps: types.Expression[] = [
1162
1398
  types.memberExpression(
1163
1399
  types.identifier(vars.combinedStateVar),
@@ -1176,16 +1412,28 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1176
1412
  // Same goes for URL-driven filters (already documented above).
1177
1413
  pushUrlSearchParamMemoDeps(countEffectDeps, usage)
1178
1414
  effectStatements.push(
1179
- types.expressionStatement(
1180
- types.callExpression(types.identifier('useEffect'), [
1181
- types.arrowFunctionExpression([], types.blockStatement(countFetchEffectBody)),
1182
- types.arrayExpression(countEffectDeps),
1183
- ])
1184
- )
1415
+ buildCountFetchEffect({
1416
+ fileName,
1417
+ perPage: usage.perPage,
1418
+ setMaxPagesVar: vars.setMaxPagesStateVar,
1419
+ urlParams,
1420
+ deps: countEffectDeps,
1421
+ })
1185
1422
  )
1186
1423
 
1187
1424
  // Two-way URL sync for the search input when bound to a URL param.
1188
1425
  pushSearchUrlSyncEffects(effectStatements, usage, vars)
1426
+
1427
+ // Visible page numbers, when this mapper draws a numbered strip.
1428
+ pushPageTokensDeclaration(stateDeclarations, usage, vars)
1429
+ // Accumulation state, when it appends instead of paging.
1430
+ pushInfiniteScrollDeclarations(stateDeclarations, effectStatements, usage, vars)
1431
+
1432
+ // A filter or sort change invalidates the page the visitor is on.
1433
+ pushFilterSortPageResetEffect(stateDeclarations, effectStatements, usage, vars)
1434
+ // Ordering matters — see `pushPageUrlSyncEffects`: the read-back runs
1435
+ // after the reset so a URL that names a page wins over it.
1436
+ pushPageUrlSyncEffects(stateDeclarations, effectStatements, usage, vars)
1189
1437
  } else if (usage.category === 'paginated-only') {
1190
1438
  // Simple page state
1191
1439
  const maxPagesInit = isPage
@@ -1225,122 +1473,72 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1225
1473
  ])
1226
1474
  )
1227
1475
 
1228
- // For components (not pages), add a useEffect to fetch count on mount
1229
- // Pages get count from getStaticProps, but components need to fetch it client-side
1230
- if (!isPage) {
1476
+ // The live row count behind `ds_N_maxPages`.
1477
+ //
1478
+ // Components have never had a build-time count to fall back on, so they
1479
+ // have always fetched one here. Pages seed theirs from `getStaticProps`,
1480
+ // which only knows the STATIC filters — good enough to enable/disable a
1481
+ // "Next" button, but not to label a numbered strip or to know when an
1482
+ // infinite list has reached its end, so those two modes fetch a live
1483
+ // count as well.
1484
+ const needsLiveCount =
1485
+ !isPage || usage.paginationMode === 'numbered' || usage.infiniteScroll
1486
+ if (needsLiveCount) {
1231
1487
  const fileName = generateSafeFileName(
1232
1488
  usage.resourceDefinition.dataSourceType,
1233
1489
  usage.resourceDefinition.tableName,
1234
1490
  usage.resourceDefinition.dataSourceId
1235
1491
  )
1236
1492
 
1237
- effectStatements.push(
1238
- types.expressionStatement(
1239
- types.callExpression(types.identifier('useEffect'), [
1240
- types.arrowFunctionExpression(
1241
- [],
1242
- types.blockStatement([
1243
- types.expressionStatement(
1244
- types.callExpression(
1245
- types.memberExpression(
1246
- types.callExpression(
1247
- types.memberExpression(
1248
- types.callExpression(types.identifier('fetch'), [
1249
- types.stringLiteral(`/api/${fileName}-count`),
1250
- ]),
1251
- types.identifier('then')
1252
- ),
1253
- [
1254
- types.arrowFunctionExpression(
1255
- [types.identifier('res')],
1256
- types.callExpression(
1257
- types.memberExpression(
1258
- types.identifier('res'),
1259
- types.identifier('json')
1260
- ),
1261
- []
1262
- )
1263
- ),
1264
- ]
1265
- ),
1266
- types.identifier('then')
1267
- ),
1268
- [
1269
- types.arrowFunctionExpression(
1270
- [types.identifier('data')],
1271
- types.blockStatement([
1272
- types.ifStatement(
1273
- types.logicalExpression(
1274
- '&&',
1275
- types.identifier('data'),
1276
- types.binaryExpression(
1277
- 'in',
1278
- types.stringLiteral('count'),
1279
- types.identifier('data')
1280
- )
1281
- ),
1282
- types.blockStatement([
1283
- types.expressionStatement(
1284
- types.callExpression(
1285
- types.identifier(vars.setMaxPagesStateVar),
1286
- [
1287
- types.conditionalExpression(
1288
- types.binaryExpression(
1289
- '===',
1290
- types.memberExpression(
1291
- types.identifier('data'),
1292
- types.identifier('count')
1293
- ),
1294
- types.numericLiteral(0)
1295
- ),
1296
- types.numericLiteral(0),
1297
- types.callExpression(
1298
- types.memberExpression(
1299
- types.identifier('Math'),
1300
- types.identifier('ceil')
1301
- ),
1302
- [
1303
- types.binaryExpression(
1304
- '/',
1305
- types.memberExpression(
1306
- types.identifier('data'),
1307
- types.identifier('count')
1308
- ),
1309
- types.numericLiteral(usage.perPage)
1310
- ),
1311
- ]
1312
- )
1313
- ),
1314
- ]
1315
- )
1316
- ),
1317
- ])
1318
- ),
1319
- ])
1320
- ),
1321
- ]
1322
- )
1323
- ),
1324
- ])
1325
- ),
1326
- // Default to mount-only; refresh when ANY filter destination
1327
- // changes — state-bound (e.g. `selectedCategory`) and
1328
- // URL-driven — so the pagination control reflects the current
1329
- // filtered count instead of the unfiltered mount-time total.
1330
- types.arrayExpression(
1331
- ((): types.Expression[] => {
1332
- const deps: types.Expression[] = []
1333
- const depsSeen = new Set<string>()
1334
- pushStateIdsAsDeps(deps, depsSeen, usage.filterStateIds)
1335
- pushPropIdsAsDeps(deps, depsSeen, usage.filterPropIds)
1336
- pushUrlSearchParamMemoDeps(deps, usage)
1337
- return deps
1338
- })()
1339
- ),
1340
- ])
1493
+ // ⛔ This list has no search input, so no `query` param — but it may
1494
+ // well have filters, and omitting them (as this call site did until
1495
+ // the shared builder replaced it) counts the WHOLE table: "Next"
1496
+ // stayed enabled far past the end of a filtered result set.
1497
+ const countUrlParams: types.ObjectProperty[] = []
1498
+ if (usage.queryColumns.length > 0) {
1499
+ countUrlParams.push(
1500
+ types.objectProperty(
1501
+ types.identifier('queryColumns'),
1502
+ types.callExpression(
1503
+ types.memberExpression(types.identifier('JSON'), types.identifier('stringify')),
1504
+ [types.arrayExpression(usage.queryColumns.map((c) => types.stringLiteral(c)))]
1505
+ )
1506
+ )
1341
1507
  )
1508
+ }
1509
+ appendFiltersParam(countUrlParams, usage.filters, buildFilterDestinationExpression)
1510
+
1511
+ // Default to mount-only; refresh when ANY filter destination
1512
+ // changes — state-bound (e.g. `selectedCategory`) and URL-driven —
1513
+ // so the pagination control reflects the current filtered count
1514
+ // instead of the unfiltered mount-time total.
1515
+ const countDeps: types.Expression[] = []
1516
+ const countDepsSeen = new Set<string>()
1517
+ pushStateIdsAsDeps(countDeps, countDepsSeen, usage.filterStateIds)
1518
+ pushPropIdsAsDeps(countDeps, countDepsSeen, usage.filterPropIds)
1519
+ pushUrlSearchParamMemoDeps(countDeps, usage)
1520
+
1521
+ effectStatements.push(
1522
+ buildCountFetchEffect({
1523
+ fileName,
1524
+ perPage: usage.perPage,
1525
+ setMaxPagesVar: vars.setMaxPagesStateVar,
1526
+ urlParams: countUrlParams,
1527
+ deps: countDeps,
1528
+ })
1342
1529
  )
1343
1530
  }
1531
+
1532
+ // Visible page numbers, when this mapper draws a numbered strip.
1533
+ pushPageTokensDeclaration(stateDeclarations, usage, vars)
1534
+ // Accumulation state, when it appends instead of paging.
1535
+ pushInfiniteScrollDeclarations(stateDeclarations, effectStatements, usage, vars)
1536
+
1537
+ // A filter or sort change invalidates the page the visitor is on.
1538
+ pushFilterSortPageResetEffect(stateDeclarations, effectStatements, usage, vars)
1539
+ // Ordering matters — see `pushPageUrlSyncEffects`: the read-back runs
1540
+ // after the reset so a URL that names a page wins over it.
1541
+ pushPageUrlSyncEffects(stateDeclarations, effectStatements, usage, vars)
1344
1542
  } else if (usage.category === 'search-only') {
1345
1543
  // Search-only state
1346
1544
  stateDeclarations.push(
@@ -1464,7 +1662,7 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1464
1662
  // same predicate as the effects themselves so router is only injected when
1465
1663
  // it is actually used.
1466
1664
  const needsUseRouter = registry.usages.some(
1467
- (u) => hasUrlSearchParamFilters(u) || usageHasSearchUrlSync(u)
1665
+ (u) => hasUrlSearchParamFilters(u) || usageHasSearchUrlSync(u) || usageHasPageUrlSync(u)
1468
1666
  )
1469
1667
  if (needsUseRouter) {
1470
1668
  if (!dependencies.useRouter) {
@@ -1502,6 +1700,14 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1502
1700
  }
1503
1701
  }
1504
1702
 
1703
+ // Both the search write-back and the page ⇄ URL effect write the URL through
1704
+ // the shared `URLQueryWriter`, so the ref + helper they close over must be in
1705
+ // the body. Idempotent with `createNextUrlSearchParamsPlugin`, which needs the
1706
+ // same pair for the category/sort dropdowns and may have run first.
1707
+ if (registry.usages.some((u) => usageHasSearchUrlSync(u) || usageHasPageUrlSync(u))) {
1708
+ URLQueryWriter.ensureQuerySyncDeclarations(blockStatement.body)
1709
+ }
1710
+
1505
1711
  // Insert effects before return statement
1506
1712
  const returnIndex = blockStatement.body.findIndex((s: any) => s.type === 'ReturnStatement')
1507
1713
  const insertIndex = returnIndex !== -1 ? returnIndex : blockStatement.body.length
@@ -1525,6 +1731,11 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1525
1731
  // provider can use one depends on its JSX (it needs a `renderLoading` slot),
1526
1732
  // which is only known once the category updaters above have run.
1527
1733
  const loadingStateDeclarations: types.Statement[] = []
1734
+ // Usage indexes whose provider actually got the refetch loading state.
1735
+ const usagesWithLoadingState = new Set<number>()
1736
+ // Which mapper each `DataProvider` JSX element belongs to, so the pagination
1737
+ // widgets can be matched to their owner rather than counted off in order.
1738
+ const usageByProviderNode = new Map<any, DataSourceUsage>()
1528
1739
  const cacheDeclarations: types.Statement[] = []
1529
1740
 
1530
1741
  dataProvidersWithRepeaters.forEach((dp) => {
@@ -1547,6 +1758,7 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1547
1758
 
1548
1759
  const usage = usages[currentIndex]
1549
1760
  usageIndexByDataSourceId.set(dataSourceIdentifier, currentIndex + 1)
1761
+ usageByProviderNode.set(dp, usage)
1550
1762
 
1551
1763
  const vars = getStateVarsForUsage(usage)
1552
1764
  const fileName = generateSafeFileName(
@@ -1573,6 +1785,20 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1573
1785
  const loadingVars = getLoadingStateVars(usage.index)
1574
1786
  if (applyLoadingStateToDataProvider(dp, loadingVars)) {
1575
1787
  loadingStateDeclarations.push(...buildLoadingStateDeclarations(loadingVars))
1788
+ // Recorded because the loading state is only declared for a provider
1789
+ // that HAS a loading slot; a Load More button wired later may only
1790
+ // reference `ds_N_isFetching` if it actually exists.
1791
+ usagesWithLoadingState.add(usage.index)
1792
+ }
1793
+
1794
+ // An auto-loading list needs something at the end of the rows for its
1795
+ // IntersectionObserver to watch.
1796
+ if (usage.infiniteScroll && usage.paginated && !usage.infiniteScrollLoadMore) {
1797
+ insertSentinelAfterProvider(
1798
+ blockStatement,
1799
+ dp,
1800
+ buildSentinelElement(getInfiniteScrollVars(usage.index))
1801
+ )
1576
1802
  }
1577
1803
 
1578
1804
  // Create API route for all categories (including 'plain' for components)
@@ -1658,23 +1884,30 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1658
1884
  wireSearchInput(input.node, vars)
1659
1885
  })
1660
1886
 
1661
- // STEP 5: Wire pagination buttons
1662
- // Match pagination nodes to usages by order (within paginated usages)
1663
- const paginationNodes = findAllPaginationNodesInJSX(blockStatement)
1664
-
1665
- // Get all paginated usages in order
1666
- const paginatedUsages = registry.usages.filter((u) => u.paginated)
1667
-
1668
- // Match by order - pagination node 0 -> paginatedUsages[0], etc.
1669
- paginationNodes.forEach((paginationNode, idx) => {
1670
- if (idx >= paginatedUsages.length) {
1671
- return
1887
+ // STEP 5: Wire pagination widgets
1888
+ //
1889
+ // Each widget belongs to the mapper whose `DataProvider` it follows in the
1890
+ // document — the builder emits it as a sibling of (or inside) that provider.
1891
+ //
1892
+ // ⛔ Not a positional zip of "widgets" against "paginated usages": the two
1893
+ // lists no longer have to be the same length, because an infinite-scroll
1894
+ // mapper renders no widget. Counting one for it would shift every later
1895
+ // mapper onto the wrong usage and wire one list's buttons to another list's
1896
+ // state. Anchoring on the provider cannot drift for the same reason the
1897
+ // provider loop above cannot.
1898
+ findPaginationNodesWithOwners(blockStatement, usageByProviderNode).forEach(
1899
+ ({ node, usage }) => {
1900
+ if (!usage) {
1901
+ return
1902
+ }
1903
+ wirePaginationWidget(
1904
+ node,
1905
+ usage,
1906
+ getStateVarsForUsage(usage),
1907
+ usagesWithLoadingState.has(usage.index)
1908
+ )
1672
1909
  }
1673
-
1674
- const usage = paginatedUsages[idx]
1675
- const vars = getStateVarsForUsage(usage)
1676
- wirePaginationButtons(paginationNode.node, usage, vars)
1677
- })
1910
+ )
1678
1911
 
1679
1912
  // STEP 6: Update getStaticProps if this is a page
1680
1913
  if (isPage) {
@@ -1689,6 +1922,63 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1689
1922
 
1690
1923
  // ==================== HELPER FUNCTIONS ====================
1691
1924
 
1925
+ /**
1926
+ * Inserts the scroll sentinel immediately after a `DataProvider`, as its next
1927
+ * sibling, so it sits below the rendered rows.
1928
+ *
1929
+ * It has to be a sibling: the provider's own child is a render-prop function,
1930
+ * and anything placed inside it would be re-created on every fetch — the
1931
+ * observer would then be watching an element that is no longer in the document.
1932
+ *
1933
+ * Returns false when the provider has no element parent to splice into (a
1934
+ * provider returned directly from the component), leaving the list on its
1935
+ * count-driven `hasMore` rather than injecting a stray node.
1936
+ */
1937
+ function insertSentinelAfterProvider(
1938
+ blockStatement: types.BlockStatement,
1939
+ provider: any,
1940
+ sentinel: types.JSXElement
1941
+ ): boolean {
1942
+ let inserted = false
1943
+
1944
+ const traverse = (node: any): void => {
1945
+ if (!node || inserted) {
1946
+ return
1947
+ }
1948
+ if (Array.isArray(node.children)) {
1949
+ const at = node.children.indexOf(provider)
1950
+ if (at !== -1) {
1951
+ node.children.splice(at + 1, 0, sentinel)
1952
+ inserted = true
1953
+ return
1954
+ }
1955
+ node.children.forEach(traverse)
1956
+ }
1957
+ if (node.body) {
1958
+ if (Array.isArray(node.body)) {
1959
+ node.body.forEach(traverse)
1960
+ } else {
1961
+ traverse(node.body)
1962
+ }
1963
+ }
1964
+ if (node.consequent) {
1965
+ traverse(node.consequent)
1966
+ }
1967
+ if (node.alternate) {
1968
+ traverse(node.alternate)
1969
+ }
1970
+ if (node.expression) {
1971
+ traverse(node.expression)
1972
+ }
1973
+ if (node.argument) {
1974
+ traverse(node.argument)
1975
+ }
1976
+ }
1977
+
1978
+ traverse(blockStatement)
1979
+ return inserted
1980
+ }
1981
+
1692
1982
  function findAllDataProvidersInJSX(blockStatement: types.BlockStatement): any[] {
1693
1983
  const results: any[] = []
1694
1984
 
@@ -1835,10 +2125,21 @@ function findAllSearchInputsInJSX(
1835
2125
  return results
1836
2126
  }
1837
2127
 
1838
- function findAllPaginationNodesInJSX(
1839
- blockStatement: types.BlockStatement
1840
- ): Array<{ node: any; className: string }> {
1841
- const results: Array<{ node: any; className: string }> = []
2128
+ /**
2129
+ * Every pagination widget in the JSX, paired with the mapper it belongs to.
2130
+ *
2131
+ * Ownership follows document order: a widget belongs to the most recent
2132
+ * `DataProvider` seen before it, which is exactly how the builder emits it —
2133
+ * as a sibling that follows its provider, or nested inside that provider's
2134
+ * subtree. A widget that precedes every provider (nothing sensible to wire it
2135
+ * to) comes back with no owner and is left alone.
2136
+ */
2137
+ function findPaginationNodesWithOwners(
2138
+ blockStatement: types.BlockStatement,
2139
+ usageByProviderNode: Map<any, DataSourceUsage>
2140
+ ): Array<{ node: any; usage?: DataSourceUsage }> {
2141
+ const results: Array<{ node: any; usage?: DataSourceUsage }> = []
2142
+ let currentUsage: DataSourceUsage | undefined
1842
2143
 
1843
2144
  const traverse = (node: any): void => {
1844
2145
  if (!node) {
@@ -1846,12 +2147,16 @@ function findAllPaginationNodesInJSX(
1846
2147
  }
1847
2148
 
1848
2149
  if (node.type === 'JSXElement') {
2150
+ const owner = usageByProviderNode.get(node)
2151
+ if (owner) {
2152
+ currentUsage = owner
2153
+ }
1849
2154
  const classAttr = node.openingElement?.attributes?.find(
1850
2155
  (attr: any) => attr.type === 'JSXAttribute' && attr.name.name === 'className'
1851
2156
  )
1852
2157
  const className = classAttr?.value?.value || classAttr?.value?.expression?.value || ''
1853
2158
  if (className.includes('cms-pagination-node')) {
1854
- results.push({ node, className })
2159
+ results.push({ node, usage: currentUsage })
1855
2160
  }
1856
2161
  }
1857
2162
 
@@ -2000,7 +2305,12 @@ function updateDataProviderForPaginatedSearch(
2000
2305
  // the first client fetch (its internal guard only skips when initialData is
2001
2306
  // defined on mount). Without that skip, toggling sort correctly triggers a
2002
2307
  // refetch via the useMemo params dependency chain.
2003
- if (!usage.dynamicSort) {
2308
+ //
2309
+ // An appending list skips it for a second reason: `initialData` makes
2310
+ // DataProvider skip its first fetch, so page 1 would render from the prefetch
2311
+ // and never enter the accumulator — appending page 2 would then REPLACE the
2312
+ // visible rows with page 2 alone.
2313
+ if (!usage.dynamicSort && !usage.infiniteScroll) {
2004
2314
  const initialDataCondition = wrapInitialDataWithUrlFilterGuard(
2005
2315
  types.logicalExpression(
2006
2316
  '&&',
@@ -2044,38 +2354,56 @@ function updateDataProviderForPaginatedSearch(
2044
2354
  // "skip-first-fetch-when-we-have-initialData" guard, which would prevent the
2045
2355
  // new sort params from reaching the fetcher. Leaving sort out of the key
2046
2356
  // lets the useMemo params identity change alone drive refetch.
2357
+ //
2358
+ // ⛔ The page is left out too when the list APPENDS: remounting on every
2359
+ // appended page would unmount the accumulated rows and refetch from scratch.
2360
+ // The query stays in the key, so a new search still starts a clean list.
2047
2361
  dp.openingElement.attributes.push(
2048
2362
  types.jsxAttribute(
2049
2363
  types.jsxIdentifier('key'),
2050
2364
  types.jsxExpressionContainer(
2051
- types.templateLiteral(
2052
- [
2053
- types.templateElement({
2054
- raw: `${usage.dataSourceIdentifier}-`,
2055
- cooked: `${usage.dataSourceIdentifier}-`,
2056
- }),
2057
- types.templateElement({ raw: '-', cooked: '-' }),
2058
- types.templateElement({ raw: '', cooked: '' }),
2059
- ],
2060
- [
2061
- types.memberExpression(
2062
- types.identifier(vars.combinedStateVar),
2063
- types.identifier('page')
2064
- ),
2065
- types.memberExpression(
2066
- types.identifier(vars.combinedStateVar),
2067
- types.identifier('debouncedQuery')
2068
- ),
2069
- ]
2070
- )
2365
+ usage.infiniteScroll
2366
+ ? types.templateLiteral(
2367
+ [
2368
+ types.templateElement({
2369
+ raw: `${usage.dataSourceIdentifier}-`,
2370
+ cooked: `${usage.dataSourceIdentifier}-`,
2371
+ }),
2372
+ types.templateElement({ raw: '', cooked: '' }),
2373
+ ],
2374
+ [
2375
+ types.memberExpression(
2376
+ types.identifier(vars.combinedStateVar),
2377
+ types.identifier('debouncedQuery')
2378
+ ),
2379
+ ]
2380
+ )
2381
+ : types.templateLiteral(
2382
+ [
2383
+ types.templateElement({
2384
+ raw: `${usage.dataSourceIdentifier}-`,
2385
+ cooked: `${usage.dataSourceIdentifier}-`,
2386
+ }),
2387
+ types.templateElement({ raw: '-', cooked: '-' }),
2388
+ types.templateElement({ raw: '', cooked: '' }),
2389
+ ],
2390
+ [
2391
+ types.memberExpression(
2392
+ types.identifier(vars.combinedStateVar),
2393
+ types.identifier('page')
2394
+ ),
2395
+ types.memberExpression(
2396
+ types.identifier(vars.combinedStateVar),
2397
+ types.identifier('debouncedQuery')
2398
+ ),
2399
+ ]
2400
+ )
2071
2401
  )
2072
2402
  )
2073
2403
  )
2074
2404
 
2075
2405
  // Add fetchData
2076
- dp.openingElement.attributes.push(
2077
- createFetchDataAttribute(fileName, usage.cache?.client ? usage.cache : undefined)
2078
- )
2406
+ dp.openingElement.attributes.push(buildFetchDataForUsage(fileName, usage))
2079
2407
 
2080
2408
  // Add persistDataDuringLoading
2081
2409
  dp.openingElement.attributes.push(
@@ -2128,8 +2456,9 @@ function updateDataProviderForPaginationOnly(
2128
2456
  const cachedInitialData = pushParamsAttribute(dp, usage, paramsProps, memoDeps, cacheDeclarations)
2129
2457
 
2130
2458
  // Add initialData. See paginated+search updater for why we skip prefetch
2131
- // reuse when a dynamic state-bound sort is active.
2132
- if (!usage.dynamicSort) {
2459
+ // reuse when a dynamic state-bound sort is active, and why an appending list
2460
+ // skips it too.
2461
+ if (!usage.dynamicSort && !usage.infiniteScroll) {
2133
2462
  dp.openingElement.attributes.push(
2134
2463
  types.jsxAttribute(
2135
2464
  types.jsxIdentifier('initialData'),
@@ -2156,29 +2485,32 @@ function updateDataProviderForPaginationOnly(
2156
2485
  )
2157
2486
  }
2158
2487
 
2159
- // Add key — sort is intentionally NOT included; see paginated+search updater.
2160
- dp.openingElement.attributes.push(
2161
- types.jsxAttribute(
2162
- types.jsxIdentifier('key'),
2163
- types.jsxExpressionContainer(
2164
- types.templateLiteral(
2165
- [
2166
- types.templateElement({
2167
- raw: `${usage.dataSourceIdentifier}-page-`,
2168
- cooked: `${usage.dataSourceIdentifier}-page-`,
2169
- }),
2170
- types.templateElement({ raw: '', cooked: '' }),
2171
- ],
2172
- [types.identifier(vars.pageStateVar)]
2488
+ // Add key — sort is intentionally NOT included; see paginated+search updater,
2489
+ // which also explains why an appending list keys on nothing at all (there is
2490
+ // no query here to vary it by; a filter change restarts the accumulator
2491
+ // through its signature instead).
2492
+ if (!usage.infiniteScroll) {
2493
+ dp.openingElement.attributes.push(
2494
+ types.jsxAttribute(
2495
+ types.jsxIdentifier('key'),
2496
+ types.jsxExpressionContainer(
2497
+ types.templateLiteral(
2498
+ [
2499
+ types.templateElement({
2500
+ raw: `${usage.dataSourceIdentifier}-page-`,
2501
+ cooked: `${usage.dataSourceIdentifier}-page-`,
2502
+ }),
2503
+ types.templateElement({ raw: '', cooked: '' }),
2504
+ ],
2505
+ [types.identifier(vars.pageStateVar)]
2506
+ )
2173
2507
  )
2174
2508
  )
2175
2509
  )
2176
- )
2510
+ }
2177
2511
 
2178
2512
  // Add fetchData
2179
- dp.openingElement.attributes.push(
2180
- createFetchDataAttribute(fileName, usage.cache?.client ? usage.cache : undefined)
2181
- )
2513
+ dp.openingElement.attributes.push(buildFetchDataForUsage(fileName, usage))
2182
2514
 
2183
2515
  dp.openingElement.attributes.push(
2184
2516
  types.jsxAttribute(
@@ -2430,7 +2762,10 @@ function stabilizeDataProviderWithoutRepeater(dp: any): void {
2430
2762
 
2431
2763
  function createFetchDataAttribute(
2432
2764
  fileName: string,
2433
- cache?: ResolvedDataSourceCache
2765
+ cache?: ResolvedDataSourceCache,
2766
+ // When set, the resolved value is every page fetched so far rather than just
2767
+ // the page that was requested — see `infinite-scroll.ts`.
2768
+ accumulate?: { usage: DataSourceUsage }
2434
2769
  ): types.JSXAttribute {
2435
2770
  const fetchCall = types.callExpression(types.identifier('fetch'), [
2436
2771
  types.templateLiteral(
@@ -2481,9 +2816,22 @@ function createFetchDataAttribute(
2481
2816
  )
2482
2817
  )
2483
2818
 
2819
+ // ⛔ Accumulation and the client cache are mutually exclusive: a cache hit
2820
+ // short-circuits the fetch and hands back one page's rows, which would drop
2821
+ // every page already accumulated. The caller suppresses the client cache for
2822
+ // an appending list, so this branch never sees both.
2823
+ const responseTail = accumulate
2824
+ ? buildAccumulatingResponseHandler(
2825
+ getInfiniteScrollVars(accumulate.usage.index),
2826
+ accumulate.usage.perPage
2827
+ )
2828
+ : cache
2829
+ ? buildCacheStoreThen(cache)
2830
+ : uncachedTail
2831
+
2484
2832
  const networkChain = types.callExpression(
2485
2833
  types.memberExpression(parsedJson, types.identifier('then')),
2486
- [cache ? buildCacheStoreThen(cache) : uncachedTail]
2834
+ [responseTail]
2487
2835
  )
2488
2836
 
2489
2837
  const arrow = cache
@@ -2534,57 +2882,128 @@ function wireSearchInput(inputNode: any, vars: ReturnType<typeof getStateVarsFor
2534
2882
  )
2535
2883
  }
2536
2884
 
2537
- function wirePaginationButtons(
2885
+ /**
2886
+ * The `fetchData` attribute for one paginated usage.
2887
+ *
2888
+ * ⛔ An appending list gets no CLIENT cache: a cache hit resolves with one
2889
+ * page's rows without going through the accumulator, which would silently
2890
+ * discard every page already loaded. The SERVER cache is untouched — it sits
2891
+ * behind the API route and returns the same page either way.
2892
+ */
2893
+ function buildFetchDataForUsage(fileName: string, usage: DataSourceUsage): types.JSXAttribute {
2894
+ if (usage.infiniteScroll) {
2895
+ return createFetchDataAttribute(fileName, undefined, { usage })
2896
+ }
2897
+ return createFetchDataAttribute(fileName, usage.cache?.client ? usage.cache : undefined)
2898
+ }
2899
+
2900
+ /**
2901
+ * Wires the authored Load More button of an appending list.
2902
+ *
2903
+ * Disabled — not hidden — when there is nothing left to load, so the author's
2904
+ * styling of the finished state is theirs to decide, and so the control does
2905
+ * not vanish from under the visitor's cursor mid-click.
2906
+ */
2907
+ function wireLoadMoreButton(
2538
2908
  paginationNode: any,
2539
2909
  usage: DataSourceUsage,
2540
- vars: ReturnType<typeof getStateVarsForUsage>
2910
+ vars: ReturnType<typeof getStateVarsForUsage>,
2911
+ hasLoadingState: boolean
2541
2912
  ): void {
2542
- // Find prev and next buttons
2543
- const findButton = (node: any, direction: 'previous' | 'next'): any => {
2544
- if (!node) {
2545
- return null
2546
- }
2913
+ if (!usage.infiniteScrollLoadMore) {
2914
+ return
2915
+ }
2916
+ const button = findPaginationControl(paginationNode, 'load-more')
2917
+ if (!button) {
2918
+ return
2919
+ }
2920
+ const infiniteVars = getInfiniteScrollVars(usage.index)
2921
+
2922
+ convertControlToButton(button)
2923
+ setJSXExpressionAttribute(
2924
+ button,
2925
+ 'onClick',
2926
+ types.arrowFunctionExpression(
2927
+ [],
2928
+ // The advance statement is an expression statement; the handler needs the
2929
+ // expression itself.
2930
+ (buildAdvancePageStatement(usage, vars) as types.ExpressionStatement).expression
2931
+ )
2932
+ )
2933
+ // The in-flight term is only referenced when the provider declared it —
2934
+ // a mapper with no loading branch never gets `ds_N_isFetching`.
2935
+ const notHasMore = types.unaryExpression('!', types.identifier(infiniteVars.hasMoreVar), true)
2936
+ setJSXExpressionAttribute(
2937
+ button,
2938
+ 'disabled',
2939
+ hasLoadingState
2940
+ ? types.logicalExpression(
2941
+ '||',
2942
+ notHasMore,
2943
+ types.identifier(getLoadingStateVars(usage.index).isFetchingVar)
2944
+ )
2945
+ : notHasMore
2946
+ )
2947
+ }
2547
2948
 
2548
- if (node.type === 'JSXElement') {
2549
- const classAttr = node.openingElement?.attributes?.find(
2550
- (attr: any) => attr.type === 'JSXAttribute' && attr.name.name === 'className'
2551
- )
2552
- const className = classAttr?.value?.value || classAttr?.value?.expression?.value || ''
2553
- if (className.includes(direction)) {
2554
- return node
2555
- }
2556
- }
2949
+ /**
2950
+ * Wires one mapper's pagination container according to the mode it is in, and
2951
+ * removes the controls belonging to the other modes.
2952
+ *
2953
+ * The builder never deletes a control when the author switches mode — doing so
2954
+ * would throw away their styling — so the container can hold a superset of what
2955
+ * any one mode needs. Pruning here is what keeps a Previous/Next list from
2956
+ * rendering a dead numbered strip, and an infinite list from rendering page
2957
+ * buttons beside its Load More.
2958
+ */
2959
+ function wirePaginationWidget(
2960
+ paginationNode: any,
2961
+ usage: DataSourceUsage,
2962
+ vars: ReturnType<typeof getStateVarsForUsage>,
2963
+ hasLoadingState: boolean
2964
+ ): void {
2965
+ if (usage.infiniteScroll) {
2966
+ // Only the Load More button survives; page controls make no sense for a
2967
+ // list the visitor never leaves.
2968
+ dropInactivePaginationControls(paginationNode, ['load-more'])
2969
+ wireLoadMoreButton(paginationNode, usage, vars, hasLoadingState)
2970
+ return
2971
+ }
2557
2972
 
2558
- if (node.children && Array.isArray(node.children)) {
2559
- for (const c of node.children) {
2560
- const found = findButton(c, direction)
2561
- if (found) {
2562
- return found
2563
- }
2564
- }
2973
+ const activeKinds: PaginationControlKind[] =
2974
+ usage.paginationMode === 'numbered'
2975
+ ? ['first', 'previous', 'pages', 'page', 'ellipsis', 'next', 'last']
2976
+ : ['previous', 'next']
2977
+ dropInactivePaginationControls(paginationNode, activeKinds)
2978
+
2979
+ wirePaginationButtons(paginationNode, usage, vars)
2980
+
2981
+ if (usage.paginationMode === 'numbered') {
2982
+ const { currentPageExpr, buildSetPageStatement } = getPageAccessors(usage, vars)
2983
+ const numberedVars: NumberedPaginationVars = {
2984
+ index: usage.index,
2985
+ maxPagesVar: vars.maxPagesStateVar,
2986
+ currentPageExpr,
2987
+ buildSetPageStatement,
2565
2988
  }
2566
- return null
2989
+ wireNumberedPagination(paginationNode, numberedVars)
2567
2990
  }
2991
+ }
2568
2992
 
2569
- const prevButton = findButton(paginationNode, 'previous')
2570
- const nextButton = findButton(paginationNode, 'next')
2993
+ function wirePaginationButtons(
2994
+ paginationNode: any,
2995
+ usage: DataSourceUsage,
2996
+ vars: ReturnType<typeof getStateVarsForUsage>
2997
+ ): void {
2998
+ // Marker attribute first, class-name substring second — projects generated
2999
+ // before the marker existed carry only the class name.
3000
+ const prevButton = findPaginationControl(paginationNode, 'previous', 'previous')
3001
+ const nextButton = findPaginationControl(paginationNode, 'next', 'next')
2571
3002
 
2572
3003
  const isCombinedState = usage.category === 'paginated+search'
2573
3004
 
2574
3005
  if (prevButton) {
2575
- // Change to button element
2576
- prevButton.openingElement.name.name = 'button'
2577
- if (prevButton.closingElement) {
2578
- prevButton.closingElement.name.name = 'button'
2579
- }
2580
-
2581
- // Add type="button"
2582
- const hasType = prevButton.openingElement.attributes.some((a: any) => a.name?.name === 'type')
2583
- if (!hasType) {
2584
- prevButton.openingElement.attributes.push(
2585
- types.jsxAttribute(types.jsxIdentifier('type'), types.stringLiteral('button'))
2586
- )
2587
- }
3006
+ convertControlToButton(prevButton)
2588
3007
 
2589
3008
  // Add onClick
2590
3009
  prevButton.openingElement.attributes = prevButton.openingElement.attributes.filter(
@@ -2694,17 +3113,7 @@ function wirePaginationButtons(
2694
3113
  }
2695
3114
 
2696
3115
  if (nextButton) {
2697
- nextButton.openingElement.name.name = 'button'
2698
- if (nextButton.closingElement) {
2699
- nextButton.closingElement.name.name = 'button'
2700
- }
2701
-
2702
- const hasType = nextButton.openingElement.attributes.some((a: any) => a.name?.name === 'type')
2703
- if (!hasType) {
2704
- nextButton.openingElement.attributes.push(
2705
- types.jsxAttribute(types.jsxIdentifier('type'), types.stringLiteral('button'))
2706
- )
2707
- }
3116
+ convertControlToButton(nextButton)
2708
3117
 
2709
3118
  nextButton.openingElement.attributes = nextButton.openingElement.attributes.filter(
2710
3119
  (a: any) => a.name?.name !== 'onClick'
@@ -3188,8 +3597,7 @@ function updateGetStaticProps(
3188
3597
 
3189
3598
  // Add import dependency for the fetcher
3190
3599
  if (!dependencies[fetcherImportName]) {
3191
- const depth = (folderPath ? folderPath.length : 0) + 1
3192
- const relativePrefix = '../'.repeat(depth)
3600
+ const relativePrefix = GenericUtils.generatePageToRootPrefix({ folderPath })
3193
3601
  dependencies[fetcherImportName] = {
3194
3602
  type: 'local',
3195
3603
  path: `${relativePrefix}utils/data-sources/${fileName}`,
@@ -3406,8 +3814,7 @@ function updateGetStaticProps(
3406
3814
 
3407
3815
  // Add import dependency for the fetcher
3408
3816
  if (!dependencies[fetcherImportName]) {
3409
- const depth = (folderPath ? folderPath.length : 0) + 1
3410
- const relativePrefix = '../'.repeat(depth)
3817
+ const relativePrefix = GenericUtils.generatePageToRootPrefix({ folderPath })
3411
3818
  dependencies[fetcherImportName] = {
3412
3819
  type: 'local',
3413
3820
  path: `${relativePrefix}utils/data-sources/${fileName}`,