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

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 (127) 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/count-effect.d.ts +42 -0
  13. package/dist/cjs/count-effect.d.ts.map +1 -0
  14. package/dist/cjs/count-effect.js +81 -0
  15. package/dist/cjs/count-effect.js.map +1 -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/teleport.d.ts.map +1 -1
  20. package/dist/cjs/fetchers/teleport.js +3 -2
  21. package/dist/cjs/fetchers/teleport.js.map +1 -1
  22. package/dist/cjs/infinite-scroll.d.ts +65 -0
  23. package/dist/cjs/infinite-scroll.d.ts.map +1 -0
  24. package/dist/cjs/infinite-scroll.js +190 -0
  25. package/dist/cjs/infinite-scroll.js.map +1 -0
  26. package/dist/cjs/numbered-pagination.d.ts +42 -0
  27. package/dist/cjs/numbered-pagination.d.ts.map +1 -0
  28. package/dist/cjs/numbered-pagination.js +167 -0
  29. package/dist/cjs/numbered-pagination.js.map +1 -0
  30. package/dist/cjs/page-reset.d.ts +42 -0
  31. package/dist/cjs/page-reset.d.ts.map +1 -0
  32. package/dist/cjs/page-reset.js +116 -0
  33. package/dist/cjs/page-reset.js.map +1 -0
  34. package/dist/cjs/page-url-sync.d.ts +42 -0
  35. package/dist/cjs/page-url-sync.d.ts.map +1 -0
  36. package/dist/cjs/page-url-sync.js +180 -0
  37. package/dist/cjs/page-url-sync.js.map +1 -0
  38. package/dist/cjs/pagination-controls.d.ts +55 -0
  39. package/dist/cjs/pagination-controls.d.ts.map +1 -0
  40. package/dist/cjs/pagination-controls.js +159 -0
  41. package/dist/cjs/pagination-controls.js.map +1 -0
  42. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  43. package/dist/cjs/pagination-plugin.js +506 -160
  44. package/dist/cjs/pagination-plugin.js.map +1 -1
  45. package/dist/cjs/product-price-sort.d.ts +116 -0
  46. package/dist/cjs/product-price-sort.d.ts.map +1 -0
  47. package/dist/cjs/product-price-sort.js +169 -0
  48. package/dist/cjs/product-price-sort.js.map +1 -0
  49. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  50. package/dist/cjs/transformations/ecommerce-product.js +1 -1
  51. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  52. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  53. package/dist/esm/array-mapper-pagination.d.ts +9 -0
  54. package/dist/esm/array-mapper-pagination.d.ts.map +1 -1
  55. package/dist/esm/array-mapper-pagination.js.map +1 -1
  56. package/dist/esm/count-effect.d.ts +42 -0
  57. package/dist/esm/count-effect.d.ts.map +1 -0
  58. package/dist/esm/count-effect.js +53 -0
  59. package/dist/esm/count-effect.js.map +1 -0
  60. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  61. package/dist/esm/fetchers/postgresql.js +2 -1
  62. package/dist/esm/fetchers/postgresql.js.map +1 -1
  63. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  64. package/dist/esm/fetchers/teleport.js +3 -2
  65. package/dist/esm/fetchers/teleport.js.map +1 -1
  66. package/dist/esm/infinite-scroll.d.ts +65 -0
  67. package/dist/esm/infinite-scroll.d.ts.map +1 -0
  68. package/dist/esm/infinite-scroll.js +159 -0
  69. package/dist/esm/infinite-scroll.js.map +1 -0
  70. package/dist/esm/numbered-pagination.d.ts +42 -0
  71. package/dist/esm/numbered-pagination.d.ts.map +1 -0
  72. package/dist/esm/numbered-pagination.js +138 -0
  73. package/dist/esm/numbered-pagination.js.map +1 -0
  74. package/dist/esm/page-reset.d.ts +42 -0
  75. package/dist/esm/page-reset.d.ts.map +1 -0
  76. package/dist/esm/page-reset.js +87 -0
  77. package/dist/esm/page-reset.js.map +1 -0
  78. package/dist/esm/page-url-sync.d.ts +42 -0
  79. package/dist/esm/page-url-sync.d.ts.map +1 -0
  80. package/dist/esm/page-url-sync.js +152 -0
  81. package/dist/esm/page-url-sync.js.map +1 -0
  82. package/dist/esm/pagination-controls.d.ts +55 -0
  83. package/dist/esm/pagination-controls.d.ts.map +1 -0
  84. package/dist/esm/pagination-controls.js +126 -0
  85. package/dist/esm/pagination-controls.js.map +1 -0
  86. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  87. package/dist/esm/pagination-plugin.js +507 -161
  88. package/dist/esm/pagination-plugin.js.map +1 -1
  89. package/dist/esm/product-price-sort.d.ts +116 -0
  90. package/dist/esm/product-price-sort.d.ts.map +1 -0
  91. package/dist/esm/product-price-sort.js +160 -0
  92. package/dist/esm/product-price-sort.js.map +1 -0
  93. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  94. package/dist/esm/transformations/ecommerce-product.js +2 -2
  95. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  96. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +5 -5
  98. package/src/array-mapper-pagination.ts +10 -0
  99. package/src/count-effect.ts +137 -0
  100. package/src/fetchers/postgresql.ts +52 -13
  101. package/src/fetchers/teleport.ts +53 -13
  102. package/src/infinite-scroll.ts +401 -0
  103. package/src/numbered-pagination.ts +292 -0
  104. package/src/page-reset.ts +162 -0
  105. package/src/page-url-sync.ts +241 -0
  106. package/src/pagination-controls.ts +176 -0
  107. package/src/pagination-plugin.ts +755 -346
  108. package/src/product-price-sort.ts +204 -0
  109. package/src/transformations/ecommerce-product.ts +33 -7
  110. package/dist/cjs/array-mapper-registry.d.ts +0 -85
  111. package/dist/cjs/array-mapper-registry.d.ts.map +0 -1
  112. package/dist/cjs/array-mapper-registry.js +0 -296
  113. package/dist/cjs/array-mapper-registry.js.map +0 -1
  114. package/dist/cjs/pagination-with-count.d.ts +0 -6
  115. package/dist/cjs/pagination-with-count.d.ts.map +0 -1
  116. package/dist/cjs/pagination-with-count.js +0 -63
  117. package/dist/cjs/pagination-with-count.js.map +0 -1
  118. package/dist/esm/array-mapper-registry.d.ts +0 -85
  119. package/dist/esm/array-mapper-registry.d.ts.map +0 -1
  120. package/dist/esm/array-mapper-registry.js +0 -292
  121. package/dist/esm/array-mapper-registry.js.map +0 -1
  122. package/dist/esm/pagination-with-count.d.ts +0 -6
  123. package/dist/esm/pagination-with-count.d.ts.map +0 -1
  124. package/dist/esm/pagination-with-count.js +0 -34
  125. package/dist/esm/pagination-with-count.js.map +0 -1
  126. package/src/array-mapper-registry.ts +0 -416
  127. package/src/pagination-with-count.ts +0 -89
@@ -0,0 +1,182 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import {
4
+ ChunkType,
5
+ FileType,
6
+ ComponentStructure,
7
+ ChunkDefinition,
8
+ } from '@teleporthq/teleport-types'
9
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
10
+
11
+ // Same minimal `const TestComponent = (props) => { return <div/> }` shell the
12
+ // search-url-sync suite uses: the reset effect is derived from the UIDL
13
+ // registry, not from the JSX, so an empty body is enough to observe it.
14
+ const makeComponentChunk = (): ChunkDefinition => {
15
+ const body = types.blockStatement([
16
+ types.returnStatement(
17
+ types.jsxElement(
18
+ types.jsxOpeningElement(types.jsxIdentifier('div'), [], true),
19
+ null,
20
+ [],
21
+ true
22
+ )
23
+ ),
24
+ ])
25
+ const arrow = types.arrowFunctionExpression([types.identifier('props')], body)
26
+ const declaration = types.variableDeclaration('const', [
27
+ types.variableDeclarator(types.identifier('TestComponent'), arrow),
28
+ ])
29
+ return {
30
+ name: 'jsx-component',
31
+ type: ChunkType.AST,
32
+ fileType: FileType.JS,
33
+ linkAfter: [],
34
+ content: declaration,
35
+ meta: {},
36
+ }
37
+ }
38
+
39
+ interface ListOptions {
40
+ searchEnabled?: boolean
41
+ // A filter destination bound to component state (the category dropdown).
42
+ stateFilter?: string
43
+ // A filter destination read straight from the URL (`?categoryFilter=`).
44
+ urlFilter?: string
45
+ // A dynamic sort expression reading component state (the sort dropdown).
46
+ sortExpression?: string
47
+ }
48
+
49
+ // tslint:disable-next-line:no-any
50
+ const makeUidlNode = (options: ListOptions): any => {
51
+ // tslint:disable-next-line:no-any
52
+ const filters: any[] = []
53
+ if (options.stateFilter) {
54
+ filters.push({
55
+ source: 'category_filter_ids',
56
+ operand: 'array_overlap',
57
+ destination: {
58
+ type: 'dynamic',
59
+ content: { referenceType: 'state', id: options.stateFilter },
60
+ },
61
+ })
62
+ }
63
+ if (options.urlFilter) {
64
+ filters.push({
65
+ source: 'category_filter_ids',
66
+ operand: 'array_overlap',
67
+ destination: {
68
+ type: 'dynamic',
69
+ content: { referenceType: 'urlSearchParams', id: options.urlFilter },
70
+ },
71
+ })
72
+ }
73
+
74
+ return {
75
+ type: 'data-source-list',
76
+ content: {
77
+ renderPropIdentifier: 'items',
78
+ resourceDefinition: {
79
+ dataSourceId: 'ds1',
80
+ tableName: 'products',
81
+ dataSourceType: 'postgresql',
82
+ },
83
+ resource: {
84
+ params: {
85
+ queryColumns: { content: ['name'] },
86
+ ...(filters.length > 0 ? { filters: { content: filters } } : {}),
87
+ },
88
+ },
89
+ nodes: {
90
+ success: {
91
+ type: 'cms-list-repeater',
92
+ content: {
93
+ renderPropIdentifier: 'product',
94
+ paginated: true,
95
+ perPage: 20,
96
+ searchEnabled: options.searchEnabled !== false,
97
+ searchDebounce: 300,
98
+ ...(options.sortExpression
99
+ ? {
100
+ sort: { type: 'expr', content: options.sortExpression },
101
+ sortDirection: { type: 'static', content: 'asc' },
102
+ }
103
+ : {}),
104
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
105
+ },
106
+ },
107
+ },
108
+ },
109
+ }
110
+ }
111
+
112
+ // Babel prints object literals across several lines; assertions about a whole
113
+ // statement read better against a whitespace-collapsed copy.
114
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
115
+
116
+ const runPlugin = async (options: ListOptions): Promise<string> => {
117
+ const chunk = makeComponentChunk()
118
+ const structure: ComponentStructure = {
119
+ uidl: { name: 'TestComponent', node: makeUidlNode(options) },
120
+ chunks: [chunk],
121
+ dependencies: {},
122
+ options: { dataSources: {}, extractedResources: {} },
123
+ } as never
124
+ await createNextArrayMapperPaginationPlugin()(structure)
125
+ return generator(chunk.content as types.Node).code
126
+ }
127
+
128
+ describe('pagination plugin — page resets to 1 when a filter or sort changes', () => {
129
+ it('resets the combined state page for a state-bound filter', async () => {
130
+ const code = await runPlugin({ stateFilter: 'selectedCategory' })
131
+
132
+ expect(code).toContain('const ds_0_filterResetRef = useRef(null)')
133
+ expect(code).toContain('const __sig = JSON.stringify([selectedCategory])')
134
+ expect(code).toContain('if (ds_0_filterResetRef.current === __sig) return')
135
+ // First OBSERVED value is recorded, never acted on.
136
+ expect(code).toContain('const __first = ds_0_filterResetRef.current === null')
137
+ expect(code).toContain('if (__first) return')
138
+ // Functional update that bails when already on page 1, so a visitor who
139
+ // never paginated does not get an extra render (and refetch).
140
+ expect(collapse(code)).toContain(
141
+ 'setDs_0_state(state => state.page === 1 ? state : { ...state, page: 1 })'
142
+ )
143
+ expect(code).toContain('}, [selectedCategory])')
144
+ })
145
+
146
+ it('tracks the sort state alongside the filter', async () => {
147
+ const code = await runPlugin({
148
+ stateFilter: 'selectedCategory',
149
+ sortExpression: 'sortBy.split("-")[0]',
150
+ })
151
+
152
+ expect(code).toContain('const __sig = JSON.stringify([selectedCategory, sortBy])')
153
+ expect(code).toContain('}, [selectedCategory, sortBy])')
154
+ })
155
+
156
+ it('guards on router.isReady and null-coalesces URL params so deep links survive', async () => {
157
+ const code = await runPlugin({ urlFilter: 'categoryFilter' })
158
+
159
+ // ⛔ The reason this effect hashes a signature instead of skipping the first
160
+ // run: router.query is empty until the router hydrates, and treating that
161
+ // hydration as a change would reset a `?page=3&categoryFilter=X` deep link.
162
+ expect(code).toContain('if (!router.isReady) return')
163
+ expect(code).toContain('const __sig = JSON.stringify([router.query.categoryFilter ?? null])')
164
+ expect(code).toContain('}, [router.query.categoryFilter, router.isReady])')
165
+ })
166
+
167
+ it('uses the standalone page setter for a paginated-only list', async () => {
168
+ const code = await runPlugin({ searchEnabled: false, stateFilter: 'selectedCategory' })
169
+
170
+ expect(code).toContain('setDs_0_page(page => page === 1 ? page : 1)')
171
+ expect(code).not.toContain('setDs_0_state(')
172
+ })
173
+
174
+ it('emits nothing for a list that has no filter and no dynamic sort', async () => {
175
+ const code = await runPlugin({})
176
+
177
+ // Regression guard: an unfiltered mapper must generate exactly what it did
178
+ // before this feature existed.
179
+ expect(code).not.toContain('filterResetRef')
180
+ expect(code).not.toContain('__sig')
181
+ })
182
+ })
@@ -0,0 +1,208 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import {
4
+ ChunkType,
5
+ FileType,
6
+ ComponentStructure,
7
+ ChunkDefinition,
8
+ } from '@teleporthq/teleport-types'
9
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
10
+
11
+ const makeComponentChunk = (): ChunkDefinition => {
12
+ const body = types.blockStatement([
13
+ types.returnStatement(
14
+ types.jsxElement(
15
+ types.jsxOpeningElement(types.jsxIdentifier('div'), [], true),
16
+ null,
17
+ [],
18
+ true
19
+ )
20
+ ),
21
+ ])
22
+ const arrow = types.arrowFunctionExpression([types.identifier('props')], body)
23
+ return {
24
+ name: 'jsx-component',
25
+ type: ChunkType.AST,
26
+ fileType: FileType.JS,
27
+ linkAfter: [],
28
+ content: types.variableDeclaration('const', [
29
+ types.variableDeclarator(types.identifier('TestComponent'), arrow),
30
+ ]),
31
+ meta: {},
32
+ }
33
+ }
34
+
35
+ interface ListOptions {
36
+ pageUrlParamKey?: string
37
+ searchEnabled?: boolean
38
+ stateFilter?: string
39
+ infiniteScroll?: boolean
40
+ }
41
+
42
+ // tslint:disable-next-line:no-any
43
+ const makeUidlNode = (options: ListOptions): any => ({
44
+ type: 'data-source-list',
45
+ content: {
46
+ renderPropIdentifier: 'items',
47
+ resourceDefinition: {
48
+ dataSourceId: 'ds1',
49
+ tableName: 'products',
50
+ dataSourceType: 'postgresql',
51
+ },
52
+ resource: {
53
+ params: {
54
+ queryColumns: { content: ['name'] },
55
+ ...(options.stateFilter
56
+ ? {
57
+ filters: {
58
+ content: [
59
+ {
60
+ source: 'category_filter_ids',
61
+ operand: 'array_overlap',
62
+ destination: {
63
+ type: 'dynamic',
64
+ content: { referenceType: 'state', id: options.stateFilter },
65
+ },
66
+ },
67
+ ],
68
+ },
69
+ }
70
+ : {}),
71
+ },
72
+ },
73
+ nodes: {
74
+ success: {
75
+ type: 'cms-list-repeater',
76
+ content: {
77
+ renderPropIdentifier: 'product',
78
+ paginated: true,
79
+ perPage: 20,
80
+ searchEnabled: options.searchEnabled !== false,
81
+ searchDebounce: 300,
82
+ ...(options.pageUrlParamKey ? { pageUrlParamKey: options.pageUrlParamKey } : {}),
83
+ ...(options.infiniteScroll ? { infiniteScroll: true } : {}),
84
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
85
+ },
86
+ },
87
+ },
88
+ },
89
+ })
90
+
91
+ const runPlugin = async (options: ListOptions): Promise<string> => {
92
+ const chunk = makeComponentChunk()
93
+ const structure: ComponentStructure = {
94
+ uidl: { name: 'TestComponent', node: makeUidlNode(options) },
95
+ chunks: [chunk],
96
+ dependencies: {},
97
+ options: { dataSources: {}, extractedResources: {} },
98
+ } as never
99
+ await createNextArrayMapperPaginationPlugin()(structure)
100
+ return generator(chunk.content as types.Node).code
101
+ }
102
+
103
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
104
+
105
+ describe('pagination plugin — current page ⇄ URL (pageUrlParamKey)', () => {
106
+ it('adopts the URL page in an effect rather than seeding the state', async () => {
107
+ const code = await runPlugin({ pageUrlParamKey: 'page' })
108
+ const flat = collapse(code)
109
+
110
+ // ⛔ NOT seeded in the useState initializer, unlike the search query.
111
+ // A statically generated page renders page 1 on the server, and React does
112
+ // not repair mismatched ATTRIBUTES when it hydrates — a client that started
113
+ // on page 3 would keep the server's disabled "Previous" for good. Moving
114
+ // the page in an effect makes it a real state transition React will paint.
115
+ expect(code).not.toContain('window.location.search')
116
+ expect(flat).toContain('const [ds_0_state, setDs_0_state] = useState({ page: 1,')
117
+
118
+ expect(code).toContain('const router = useRouter()')
119
+ expect(code).toContain('const ds_0_pageUrlRef = useRef(null)')
120
+ expect(code).toContain('const __urlPage = Math.max(1, parseInt(__raw, 10) || 1)')
121
+ expect(code).toContain('const __statePage = ds_0_state.page')
122
+ expect(code).toContain('const __seenUrlPage = ds_0_pageUrlRef.current')
123
+ })
124
+
125
+ it('decides which side moved, so the two directions cannot race', async () => {
126
+ const code = await runPlugin({ pageUrlParamKey: 'page' })
127
+ const flat = collapse(code)
128
+
129
+ // The URL moved (deep link, <Link>, back/forward) ⇒ adopt it — but ONLY
130
+ // once every replace this component issued has landed. Mid-flight,
131
+ // `router.query` still holds the page the visitor is on their way out of.
132
+ expect(flat).toContain(
133
+ 'if (__tqQuerySyncRef.current.inFlight === 0 && __seenUrlPage !== __urlPage && __urlPage !== __statePage)'
134
+ )
135
+ expect(flat).toContain(
136
+ 'setDs_0_state(state => state.page === __urlPage ? state : { ...state, page: __urlPage })'
137
+ )
138
+ // The page moved (a click, or the filter reset) ⇒ write it. Page 1 is the
139
+ // ABSENCE of the key, so an unpaginated visit keeps a clean URL.
140
+ expect(flat).toContain('if (__urlPage === __statePage) return')
141
+ expect(flat).toContain(
142
+ '__tqWriteQueryParam("page", __statePage <= 1 ? undefined : __statePage)'
143
+ )
144
+ expect(code).toContain('}, [router.query.page, router.isReady, ds_0_state.page])')
145
+ })
146
+
147
+ it('applies to the standalone page state for a paginated-only list', async () => {
148
+ const code = await runPlugin({ pageUrlParamKey: 'page', searchEnabled: false })
149
+ const flat = collapse(code)
150
+
151
+ expect(code).toContain('const __statePage = ds_0_page')
152
+ expect(flat).toContain('setDs_0_page(page => page === __urlPage ? page : __urlPage)')
153
+ })
154
+
155
+ it('runs the URL sync AFTER the filter reset so a deep link wins', async () => {
156
+ const code = await runPlugin({ pageUrlParamKey: 'page', stateFilter: 'selectedCategory' })
157
+
158
+ // Both effects can fire in one flush on a navigation that changes filter and
159
+ // page together. Effects run in emission order, so the URL sync must be last
160
+ // or `?page=3&categoryFilter=X` would land on page 1.
161
+ const resetAt = code.indexOf('ds_0_filterResetRef.current = __sig')
162
+ const syncAt = code.indexOf('const __seenUrlPage = ds_0_pageUrlRef.current')
163
+ expect(resetAt).toBeGreaterThan(-1)
164
+ expect(syncAt).toBeGreaterThan(resetAt)
165
+ })
166
+
167
+ it('writes through the shared writer, so two controls cannot erase each other', async () => {
168
+ const code = await runPlugin({ pageUrlParamKey: 'page', stateFilter: 'selectedCategory' })
169
+ const flat = collapse(code)
170
+
171
+ // ⛔ THE DEFECT THIS GUARDS, seen live: picking a category while on page 3
172
+ // resets the page, so the category write-back and the page write-back fire
173
+ // in the SAME flush. Each used to build its next query from `router.query`,
174
+ // which Next updates asynchronously and does NOT refresh on a component's
175
+ // own setState — so the second `replace` wrote a query that predated the
176
+ // first, and `?categoryFilter=…` vanished even though the list was filtered.
177
+ // The read-back then cleared the category state, the write-back re-asserted
178
+ // it, and the pair swapped values forever: an endless refetch loop.
179
+ //
180
+ // The fix is that neither effect composes a query any more. Both hand one
181
+ // key to `__tqWriteQueryParam`, which merges into the last query the
182
+ // component ASKED for, so the second write builds on the first's payload.
183
+ expect(code).not.toContain('__nextQuery')
184
+ expect(flat).toContain('const base = __tqQuerySyncRef.current.pending || router.query')
185
+ expect(flat).toContain('__tqQuerySyncRef.current.pending = next')
186
+
187
+ // Exactly ONE writer is declared however many controls are bound to the URL.
188
+ expect((code.match(/const __tqWriteQueryParam = /g) || []).length).toBe(1)
189
+ // ...and exactly one `router.replace`, inside it.
190
+ expect((code.match(/router\.replace\(/g) || []).length).toBe(1)
191
+ })
192
+
193
+ it('emits nothing page-URL related without the key (unchanged behaviour)', async () => {
194
+ const code = await runPlugin({})
195
+
196
+ expect(code).toContain('page: 1')
197
+ expect(code).not.toContain('pageUrlRef')
198
+ expect(code).not.toContain('router.query.page')
199
+ expect(code).not.toContain('useRouter')
200
+ })
201
+
202
+ it('ignores the key on an infinite-scroll list, which has no pages to name', async () => {
203
+ const code = await runPlugin({ pageUrlParamKey: 'page', infiniteScroll: true })
204
+
205
+ expect(code).not.toContain('router.query.page')
206
+ expect(code).not.toContain('pageUrlRef')
207
+ })
208
+ })