@teleporthq/teleport-plugin-next-data-source 0.43.46 → 0.43.48

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teleporthq/teleport-plugin-next-data-source",
3
- "version": "0.43.46",
3
+ "version": "0.43.48",
4
4
  "description": "A plugin for managing data sources for Next.js projects.",
5
5
  "author": "teleportHQ",
6
6
  "license": "MIT",
@@ -26,9 +26,9 @@
26
26
  "dependencies": {
27
27
  "@babel/parser": "^7.26.10",
28
28
  "@babel/types": "^7.5.5",
29
- "@teleporthq/teleport-plugin-common": "^0.43.46",
30
- "@teleporthq/teleport-shared": "^0.43.46",
31
- "@teleporthq/teleport-types": "^0.43.46"
29
+ "@teleporthq/teleport-plugin-common": "^0.43.48",
30
+ "@teleporthq/teleport-shared": "^0.43.48",
31
+ "@teleporthq/teleport-types": "^0.43.47"
32
32
  },
33
- "gitHead": "589863eebdb310ad00a6236b81fec72424a478ca"
33
+ "gitHead": "6eac138670383e733c2b6e5e194da0315bac6a7c"
34
34
  }
@@ -0,0 +1,297 @@
1
+ import * as types from '@babel/types'
2
+
3
+ /**
4
+ * ------------------------------------------------------------------
5
+ * Refetch loading state for array-mapper `DataProvider`s
6
+ * ------------------------------------------------------------------
7
+ *
8
+ * `DataProvider` (from `@teleporthq/react-components`) renders its LOADING
9
+ * slot only when it has no data to show:
10
+ *
11
+ * case "idle":
12
+ * case "loading":
13
+ * return props.persistDataDuringLoading && data
14
+ * ? renderSuccess(data, true)
15
+ * : renderLoading()
16
+ *
17
+ * Every generated array-mapper list ships `persistDataDuringLoading={true}`,
18
+ * and that flag is load-bearing on the FIRST paint: when the page prefetches
19
+ * rows in `getStaticProps` and hands them over as `initialData`, the provider
20
+ * deliberately skips its first client fetch and therefore never leaves the
21
+ * `idle` status. Without the flag that page would render the loading slot
22
+ * forever instead of the prefetched rows.
23
+ *
24
+ * The side effect is that once the provider owns data, EVERY later refetch is
25
+ * invisible: changing the category filter, the sort order or the search term
26
+ * re-runs the query while the stale rows stay on screen, so the buyer gets no
27
+ * feedback until the new rows pop in. (Page changes and search changes also
28
+ * remount the provider through its `key`, which resets `data` — but a filter
29
+ * or sort change keeps the same key, so nothing at all happens visually.)
30
+ *
31
+ * The provider does pass an `isLoading` flag as the second argument of
32
+ * `renderSuccess`, but it is `true` for the `idle` status as well — i.e.
33
+ * permanently true on any page that skipped its first fetch because it had
34
+ * `initialData` — so it cannot be used to decide what to paint.
35
+ *
36
+ * Fix: track the in-flight fetches of each data source in the PAGE component
37
+ * and hand `persistDataDuringLoading` the negated flag. `fetchData` is the one
38
+ * function the generator owns and the provider calls exactly once per fetch, so
39
+ * it is where the flag is raised (`true` when the request starts) and lowered
40
+ * (`false` once it settles, success or failure). While a refetch is in flight
41
+ * `persistDataDuringLoading` is `false`, so the provider falls through to
42
+ * `renderLoading()` and the array mapper's designed loading state shows; when
43
+ * the request settles the flag drops back to `true` and the rows return.
44
+ *
45
+ * Why not branch inside `renderSuccess` instead: the loading JSX would have to
46
+ * be duplicated into the success render prop, and `styled-jsx` only scopes JSX
47
+ * that lives inside the component's returned tree — every workaround that
48
+ * hoists the loading markup into a shared local helper silently loses its
49
+ * `jsx-<hash>` class and therefore all of its styles.
50
+ *
51
+ * Nothing changes for a provider that is idle or mounting: `isFetching` starts
52
+ * at `false`, so the server render and the first client render are identical
53
+ * (no hydration mismatch) and the `initialData` fast path is untouched.
54
+ */
55
+
56
+ export interface LoadingStateVars {
57
+ /** Boolean state: `true` while at least one fetch for this data source is in flight. */
58
+ isFetchingVar: string
59
+ /** Setter for `isFetchingVar`. */
60
+ setIsFetchingVar: string
61
+ /**
62
+ * Ref holding the number of fetches currently in flight. A boolean alone is
63
+ * not enough: two controls changed in quick succession (e.g. category then
64
+ * sort) start two overlapping requests, and the first one to settle would
65
+ * otherwise lower the flag while the second is still running — flashing the
66
+ * stale rows back for the rest of the second request.
67
+ */
68
+ inFlightRefVar: string
69
+ }
70
+
71
+ export function getLoadingStateVars(index: number): LoadingStateVars {
72
+ return {
73
+ isFetchingVar: `ds_${index}_isFetching`,
74
+ setIsFetchingVar: `setDs_${index}_isFetching`,
75
+ inFlightRefVar: `ds_${index}_fetchesInFlight`,
76
+ }
77
+ }
78
+
79
+ /**
80
+ * `const ds_N_fetchesInFlight = useRef(0)`
81
+ * `const [ds_N_isFetching, setDs_N_isFetching] = useState(false)`
82
+ *
83
+ * Both are stable across renders (a ref object and a `useState` setter), which
84
+ * is what lets the wrapped `fetchData` keep its empty `useCallback` dependency
85
+ * array — a changing `fetchData` identity would retrigger the provider's fetch
86
+ * effect on every render.
87
+ */
88
+ export function buildLoadingStateDeclarations(vars: LoadingStateVars): types.Statement[] {
89
+ return [
90
+ types.variableDeclaration('const', [
91
+ types.variableDeclarator(
92
+ types.identifier(vars.inFlightRefVar),
93
+ types.callExpression(types.identifier('useRef'), [types.numericLiteral(0)])
94
+ ),
95
+ ]),
96
+ types.variableDeclaration('const', [
97
+ types.variableDeclarator(
98
+ types.arrayPattern([
99
+ types.identifier(vars.isFetchingVar),
100
+ types.identifier(vars.setIsFetchingVar),
101
+ ]),
102
+ types.callExpression(types.identifier('useState'), [types.booleanLiteral(false)])
103
+ ),
104
+ ]),
105
+ ]
106
+ }
107
+
108
+ /**
109
+ * Wires the refetch loading state into a single `DataProvider` JSX element.
110
+ *
111
+ * No-ops (returning `false`, so the caller emits no state declarations) when
112
+ * the provider cannot benefit from it:
113
+ * - no `renderLoading` slot — the array mapper has no loading state designed,
114
+ * so falling through to it would blank the list out instead of showing
115
+ * something; keeping the stale rows is the better of the two.
116
+ * - `fetchData` missing, not memoized, or not an expression-bodied promise
117
+ * chain — see `findMemoizedFetchDataArrow`.
118
+ * - already wired — keeps a second pass over the same AST idempotent.
119
+ */
120
+ export function applyLoadingStateToDataProvider(
121
+ // tslint:disable-next-line:no-any
122
+ dataProvider: any,
123
+ vars: LoadingStateVars
124
+ ): boolean {
125
+ const attributes = dataProvider?.openingElement?.attributes
126
+ if (!Array.isArray(attributes)) {
127
+ return false
128
+ }
129
+
130
+ if (!findAttribute(attributes, 'renderLoading')) {
131
+ return false
132
+ }
133
+
134
+ const fetchDataAttr = findAttribute(attributes, 'fetchData')
135
+ if (!fetchDataAttr) {
136
+ return false
137
+ }
138
+
139
+ const fetchArrow = findMemoizedFetchDataArrow(fetchDataAttr)
140
+ if (!fetchArrow || fetchArrow.body.type !== 'CallExpression') {
141
+ return false
142
+ }
143
+
144
+ fetchArrow.body = buildTrackedFetchBody(fetchArrow.body, vars)
145
+
146
+ setPersistDataDuringLoading(attributes, vars)
147
+
148
+ return true
149
+ }
150
+
151
+ // tslint:disable-next-line:no-any
152
+ function findAttribute(attributes: any[], name: string): types.JSXAttribute | undefined {
153
+ return attributes.find(
154
+ // tslint:disable-next-line:no-any
155
+ (attr: any) => attr?.type === 'JSXAttribute' && attr.name?.name === name
156
+ )
157
+ }
158
+
159
+ /**
160
+ * The `useCallback(fn, [])`-wrapped arrow behind a `fetchData` attribute, or
161
+ * `undefined` when the value has any other shape.
162
+ *
163
+ * Requiring the memoized form is a safety condition, not a convenience.
164
+ * `DataProvider` refetches whenever the `fetchData` identity changes
165
+ * (`useEffect(..., [params, fetchData])`), so wrapping a fetcher that is
166
+ * re-created on every render with something that sets state would build a
167
+ * self-sustaining loop: fetch → setState → render → new fetcher identity →
168
+ * fetch. The array-mapper providers this plugin owns are always memoized; the
169
+ * few unmemoized `fetchData` values other plugins emit are left exactly as they
170
+ * are today.
171
+ */
172
+ function findMemoizedFetchDataArrow(
173
+ attribute: types.JSXAttribute
174
+ ): types.ArrowFunctionExpression | undefined {
175
+ const value = attribute.value
176
+ if (!value || value.type !== 'JSXExpressionContainer') {
177
+ return undefined
178
+ }
179
+
180
+ const expression = value.expression
181
+ if (
182
+ expression.type !== 'CallExpression' ||
183
+ expression.callee.type !== 'Identifier' ||
184
+ expression.callee.name !== 'useCallback' ||
185
+ expression.arguments[0]?.type !== 'ArrowFunctionExpression'
186
+ ) {
187
+ return undefined
188
+ }
189
+
190
+ return expression.arguments[0] as types.ArrowFunctionExpression
191
+ }
192
+
193
+ /**
194
+ * Turns `(params) => fetch(...).then(...)` into
195
+ *
196
+ * (params) => {
197
+ * ds_N_fetchesInFlight.current += 1
198
+ * setDs_N_isFetching(true)
199
+ * return fetch(...).then(...).finally(() => {
200
+ * ds_N_fetchesInFlight.current -= 1
201
+ * if (ds_N_fetchesInFlight.current <= 0) {
202
+ * ds_N_fetchesInFlight.current = 0
203
+ * setDs_N_isFetching(false)
204
+ * }
205
+ * })
206
+ * }
207
+ *
208
+ * `finally` (rather than a `then` pair) keeps the flag honest when the request
209
+ * rejects: the provider switches to its error status and the loading state must
210
+ * not stay on screen. The counter is clamped at 0 so a late settle from a
211
+ * provider instance that was remounted through its `key` can never drive it
212
+ * negative and wedge the flag on.
213
+ *
214
+ * `fetchExpression` is the `fetch(...).then(...).then(...)` chain the generator
215
+ * emitted, so `.finally` is always available on it — the caller only reaches
216
+ * here for a call-expression body.
217
+ */
218
+ function buildTrackedFetchBody(
219
+ fetchExpression: types.Expression,
220
+ vars: LoadingStateVars
221
+ ): types.BlockStatement {
222
+ const inFlightCount = types.memberExpression(
223
+ types.identifier(vars.inFlightRefVar),
224
+ types.identifier('current')
225
+ )
226
+
227
+ const settleHandler = types.arrowFunctionExpression(
228
+ [],
229
+ types.blockStatement([
230
+ types.expressionStatement(
231
+ types.assignmentExpression(
232
+ '-=',
233
+ types.cloneNode(inFlightCount, true),
234
+ types.numericLiteral(1)
235
+ )
236
+ ),
237
+ types.ifStatement(
238
+ types.binaryExpression('<=', types.cloneNode(inFlightCount, true), types.numericLiteral(0)),
239
+ types.blockStatement([
240
+ types.expressionStatement(
241
+ types.assignmentExpression(
242
+ '=',
243
+ types.cloneNode(inFlightCount, true),
244
+ types.numericLiteral(0)
245
+ )
246
+ ),
247
+ types.expressionStatement(
248
+ types.callExpression(types.identifier(vars.setIsFetchingVar), [
249
+ types.booleanLiteral(false),
250
+ ])
251
+ ),
252
+ ])
253
+ ),
254
+ ])
255
+ )
256
+
257
+ return types.blockStatement([
258
+ types.expressionStatement(
259
+ types.assignmentExpression(
260
+ '+=',
261
+ types.cloneNode(inFlightCount, true),
262
+ types.numericLiteral(1)
263
+ )
264
+ ),
265
+ types.expressionStatement(
266
+ types.callExpression(types.identifier(vars.setIsFetchingVar), [types.booleanLiteral(true)])
267
+ ),
268
+ types.returnStatement(
269
+ types.callExpression(types.memberExpression(fetchExpression, types.identifier('finally')), [
270
+ settleHandler,
271
+ ])
272
+ ),
273
+ ])
274
+ }
275
+
276
+ /** `persistDataDuringLoading={!ds_N_isFetching}`, replacing any existing value. */
277
+ // tslint:disable-next-line:no-any
278
+ function setPersistDataDuringLoading(attributes: any[], vars: LoadingStateVars): void {
279
+ const attribute = types.jsxAttribute(
280
+ types.jsxIdentifier('persistDataDuringLoading'),
281
+ types.jsxExpressionContainer(
282
+ types.unaryExpression('!', types.identifier(vars.isFetchingVar), true)
283
+ )
284
+ )
285
+
286
+ const existingIndex = attributes.findIndex(
287
+ // tslint:disable-next-line:no-any
288
+ (attr: any) => attr?.type === 'JSXAttribute' && attr.name?.name === 'persistDataDuringLoading'
289
+ )
290
+
291
+ if (existingIndex === -1) {
292
+ attributes.push(attribute)
293
+ return
294
+ }
295
+
296
+ attributes[existingIndex] = attribute
297
+ }
@@ -13,6 +13,11 @@ import { generateSafeFileName } from './utils'
13
13
  import { generateDataSourceFetcherWithCore } from './data-source-fetchers'
14
14
  import { appendSortsParam, DynamicSortAST, extractDynamicSort } from './sort-utils'
15
15
  import { appendFiltersParam, pushStateIdsAsDeps, pushPropIdsAsDeps } from './filter-utils'
16
+ import {
17
+ applyLoadingStateToDataProvider,
18
+ buildLoadingStateDeclarations,
19
+ getLoadingStateVars,
20
+ } from './loading-state'
16
21
 
17
22
  // ----- searchDefaultValue support -----
18
23
  //
@@ -1498,6 +1503,12 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1498
1503
  // We use pure order-based matching - the order of DataProviders in JSX should match UIDL order
1499
1504
  const usageIndexByDataSourceId = new Map<string, number>()
1500
1505
 
1506
+ // `useRef` / `useState` pairs backing the refetch loading state of every
1507
+ // DataProvider that ended up wired below. Collected here because whether a
1508
+ // provider can use one depends on its JSX (it needs a `renderLoading` slot),
1509
+ // which is only known once the category updaters above have run.
1510
+ const loadingStateDeclarations: types.Statement[] = []
1511
+
1501
1512
  dataProvidersWithRepeaters.forEach((dp) => {
1502
1513
  const nameAttr = dp.openingElement.attributes.find(
1503
1514
  (attr: any) => attr.type === 'JSXAttribute' && attr.name.name === 'name'
@@ -1537,6 +1548,15 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1537
1548
  updateDataProviderForPlain(dp, fileName, usage)
1538
1549
  }
1539
1550
 
1551
+ // Show the array mapper's loading state while a category/sort/search
1552
+ // change refetches, instead of leaving the previous rows on screen with
1553
+ // no feedback. Runs last so it sees the `fetchData` and
1554
+ // `persistDataDuringLoading` attributes the updaters above just wrote.
1555
+ const loadingVars = getLoadingStateVars(usage.index)
1556
+ if (applyLoadingStateToDataProvider(dp, loadingVars)) {
1557
+ loadingStateDeclarations.push(...buildLoadingStateDeclarations(loadingVars))
1558
+ }
1559
+
1540
1560
  // Create API route for all categories (including 'plain' for components)
1541
1561
  ensureAPIRouteExists(
1542
1562
  options.extractedResources,
@@ -1546,6 +1566,11 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1546
1566
  )
1547
1567
  })
1548
1568
 
1569
+ // Declare the loading-state hooks alongside the other data-source state, at
1570
+ // the top of the component body. Unconditional and in a fixed order, so the
1571
+ // hook order stays stable across renders.
1572
+ loadingStateDeclarations.reverse().forEach((s) => blockStatement.body.unshift(s))
1573
+
1549
1574
  // STEP 3.5: Handle DataProviders WITHOUT repeaters (data-source-item type)
1550
1575
  // These access single items like data[0].name and should not re-render on state changes
1551
1576
  // We wrap their params in useMemo to prevent reference changes from triggering re-renders