@teleporthq/teleport-plugin-common 0.43.21 → 0.43.22

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.
@@ -0,0 +1,286 @@
1
+ import * as types from '@babel/types'
2
+
3
+ // Shared builders for two-way `<state> ⇄ ?key=` URL search-param syncing.
4
+ //
5
+ // Used by BOTH:
6
+ // • `createNextUrlSearchParamsPlugin` (teleport-project-generator-next) for
7
+ // page/component state defs that declare `urlSearchParamBinding` — e.g.
8
+ // the products-list `selectedCategory` / `sortBy` dropdowns.
9
+ // • `createNextArrayMapperPaginationPlugin` (teleport-plugin-next-data-source)
10
+ // for the products-list search input, whose query lives in a
11
+ // pagination-managed state (`ds_N_searchQuery` / `ds_N_state.debouncedQuery`)
12
+ // rather than a UIDL state def, so it cannot ride the plugin above.
13
+ //
14
+ // Both emit the SAME loop-free read-back / write-back effect shapes, so the
15
+ // builders live here to keep the two call sites byte-identical and the
16
+ // loop-prevention reasoning in one place.
17
+
18
+ // Strict JS identifier (Latin only is fine: UIDL URL keys are limited to the
19
+ // subset Next.js supports as a query param key, which the GUI guarantees to
20
+ // be at minimum URL-safe; but the *generated* code must be valid JS, and
21
+ // `__nextQuery.foo-bar` parses as a subtraction). Anything off-pattern goes
22
+ // through bracket notation: `__nextQuery['foo-bar']`.
23
+ const VALID_JS_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
24
+
25
+ // `object.<paramKey>` for identifier-safe keys, `object['param-key']` otherwise,
26
+ // so the emitted code is always valid JS regardless of the URL key shape.
27
+ const accessKeyOf = (object: types.Expression, paramKey: string): types.MemberExpression => {
28
+ if (VALID_JS_IDENTIFIER.test(paramKey)) {
29
+ return types.memberExpression(object, types.identifier(paramKey))
30
+ }
31
+ return types.memberExpression(object, types.stringLiteral(paramKey), true)
32
+ }
33
+
34
+ const routerQueryAccess = (paramKey: string): types.MemberExpression =>
35
+ accessKeyOf(
36
+ types.memberExpression(types.identifier('router'), types.identifier('query')),
37
+ paramKey
38
+ )
39
+
40
+ // Clones an expression so it can be slotted into multiple positions of the
41
+ // emitted AST — Babel does not support sharing the same node reference across
42
+ // the tree, and callers pass arbitrary value/dependency expressions.
43
+ const clone = (expr: types.Expression): types.Expression =>
44
+ types.cloneNode(expr, /* deep */ true) as types.Expression
45
+
46
+ // Builds the state→URL write-back useEffect. Emitted shape (for a value of
47
+ // `selectedCategory` bound to URL key `categoryFilter`):
48
+ //
49
+ // useEffect(() => {
50
+ // if (typeof window === 'undefined') return
51
+ // if (!router.isReady) return
52
+ // const __nextQuery = { ...router.query }
53
+ // if (selectedCategory === '' || selectedCategory == null) {
54
+ // delete __nextQuery.categoryFilter
55
+ // } else {
56
+ // __nextQuery.categoryFilter = String(selectedCategory)
57
+ // }
58
+ // if (__nextQuery.categoryFilter === router.query.categoryFilter) return
59
+ // router.replace(
60
+ // { pathname: router.pathname, query: __nextQuery },
61
+ // undefined,
62
+ // { shallow: true }
63
+ // )
64
+ // }, [selectedCategory, router.isReady])
65
+ //
66
+ // `valueExpr` is the expression read each render and pushed to the URL (a bare
67
+ // state identifier for dropdowns, `ds_N_state.debouncedQuery` for the search
68
+ // input). `depExpr` is the effect dependency that re-triggers the write-back
69
+ // when that value changes — usually the same expression as `valueExpr`.
70
+ //
71
+ // Notes:
72
+ // 1. `router.isReady` gate: on the very first SSG render `router.query` is
73
+ // empty, so without this gate any value initialized from
74
+ // `window.location.search` would immediately `router.replace` itself with
75
+ // the same value, racing with Next's own hydration. `router.isReady` is
76
+ // also in the deps array — once it flips to true, the effect re-runs with
77
+ // the now-hydrated `router.query` and decides correctly whether to replace.
78
+ // 2. The equality check after building `__nextQuery` short-circuits replaces
79
+ // that would not change the URL — important when the user re-enters the
80
+ // same value, when the value mounts equal to the URL, and when the
81
+ // read-back effect fires its setter with the value just pushed.
82
+ // 3. `shallow: true` keeps `getStaticProps` from re-running just because the
83
+ // URL bar changed; the data-source `useMemo` reacts to the state, not the
84
+ // URL.
85
+ // 4. Empty / null value deletes the key entirely so the URL never keeps a
86
+ // sticky `?key=` empty param.
87
+ export const buildUrlWriteBackEffect = (
88
+ paramKey: string,
89
+ valueExpr: types.Expression,
90
+ depExpr: types.Expression
91
+ ): types.ExpressionStatement => {
92
+ const effectBody = types.blockStatement([
93
+ // if (typeof window === 'undefined') return
94
+ types.ifStatement(
95
+ types.binaryExpression(
96
+ '===',
97
+ types.unaryExpression('typeof', types.identifier('window')),
98
+ types.stringLiteral('undefined')
99
+ ),
100
+ types.returnStatement(null)
101
+ ),
102
+ // if (!router.isReady) return
103
+ types.ifStatement(
104
+ types.unaryExpression(
105
+ '!',
106
+ types.memberExpression(types.identifier('router'), types.identifier('isReady'))
107
+ ),
108
+ types.returnStatement(null)
109
+ ),
110
+ // const __nextQuery = { ...router.query }
111
+ types.variableDeclaration('const', [
112
+ types.variableDeclarator(
113
+ types.identifier('__nextQuery'),
114
+ types.objectExpression([
115
+ types.spreadElement(
116
+ types.memberExpression(types.identifier('router'), types.identifier('query'))
117
+ ),
118
+ ])
119
+ ),
120
+ ]),
121
+ // if (value === '' || value == null) { delete __nextQuery.key }
122
+ // else { __nextQuery.key = String(value) }
123
+ types.ifStatement(
124
+ types.logicalExpression(
125
+ '||',
126
+ types.binaryExpression('===', clone(valueExpr), types.stringLiteral('')),
127
+ types.binaryExpression('==', clone(valueExpr), types.nullLiteral())
128
+ ),
129
+ types.blockStatement([
130
+ types.expressionStatement(
131
+ types.unaryExpression(
132
+ 'delete',
133
+ accessKeyOf(types.identifier('__nextQuery'), paramKey)
134
+ ) as unknown as types.Expression
135
+ ),
136
+ ]),
137
+ types.blockStatement([
138
+ types.expressionStatement(
139
+ types.assignmentExpression(
140
+ '=',
141
+ accessKeyOf(types.identifier('__nextQuery'), paramKey),
142
+ types.callExpression(types.identifier('String'), [clone(valueExpr)])
143
+ )
144
+ ),
145
+ ])
146
+ ),
147
+ // if (__nextQuery.key === router.query.key) return
148
+ types.ifStatement(
149
+ types.binaryExpression(
150
+ '===',
151
+ accessKeyOf(types.identifier('__nextQuery'), paramKey),
152
+ routerQueryAccess(paramKey)
153
+ ),
154
+ types.returnStatement(null)
155
+ ),
156
+ // router.replace({ pathname: router.pathname, query: __nextQuery }, undefined, { shallow: true })
157
+ types.expressionStatement(
158
+ types.callExpression(
159
+ types.memberExpression(types.identifier('router'), types.identifier('replace')),
160
+ [
161
+ types.objectExpression([
162
+ types.objectProperty(
163
+ types.identifier('pathname'),
164
+ types.memberExpression(types.identifier('router'), types.identifier('pathname'))
165
+ ),
166
+ types.objectProperty(types.identifier('query'), types.identifier('__nextQuery')),
167
+ ]),
168
+ types.identifier('undefined'),
169
+ types.objectExpression([
170
+ types.objectProperty(types.identifier('shallow'), types.booleanLiteral(true)),
171
+ ]),
172
+ ]
173
+ )
174
+ ),
175
+ ])
176
+
177
+ return types.expressionStatement(
178
+ types.callExpression(types.identifier('useEffect'), [
179
+ types.arrowFunctionExpression([], effectBody),
180
+ types.arrayExpression([
181
+ clone(depExpr),
182
+ types.memberExpression(types.identifier('router'), types.identifier('isReady')),
183
+ ]),
184
+ ])
185
+ )
186
+ }
187
+
188
+ // Builds the URL→state read-back useEffect. Emitted shape (for setter
189
+ // `setSelectedCategory` bound to URL key `categoryFilter`):
190
+ //
191
+ // useEffect(() => {
192
+ // if (!router.isReady) return
193
+ // const __urlValue = router.query.categoryFilter
194
+ // const __nextValue =
195
+ // typeof __urlValue === 'string' ? __urlValue : Array.isArray(__urlValue) ? (__urlValue[0] || '') : ''
196
+ // setSelectedCategory((prev) => (prev === __nextValue ? prev : __nextValue))
197
+ // }, [router.query.categoryFilter, router.isReady])
198
+ //
199
+ // Why pair this with the write-back effect:
200
+ // - On `<Link>` / `router.push` shallow navigations that land on the same path
201
+ // with a different query (or browser back/forward, which can change the query
202
+ // without remounting the page), `router.query` updates but the state hook does
203
+ // not. Without this effect, the control would be stuck on whatever the user
204
+ // last picked even though the URL has moved on.
205
+ // - The functional `setState((prev) => prev === next ? prev : next)` form
206
+ // guarantees React bails out when the URL value matches state, which prevents
207
+ // an infinite write-back ↔ read-back loop: when the user picks a new value,
208
+ // state→URL fires once, URL→state observes the matching value and returns
209
+ // `prev`, so React does not re-render or fire any deps.
210
+ // - `router.query[key]` can be either `string` (single value) or `string[]`
211
+ // (duplicate keys) per Next.js's typings; the helper normalizes both to a
212
+ // string before comparing, defaulting to `''` for the missing-key case so the
213
+ // comparison aligns with the write-back's `value === ''` empty-check.
214
+ export const buildUrlReadBackEffect = (
215
+ paramKey: string,
216
+ setterName: string
217
+ ): types.ExpressionStatement => {
218
+ const normalizeArg = types.conditionalExpression(
219
+ // typeof __urlValue === 'string' ? __urlValue
220
+ types.binaryExpression(
221
+ '===',
222
+ types.unaryExpression('typeof', types.identifier('__urlValue')),
223
+ types.stringLiteral('string')
224
+ ),
225
+ types.identifier('__urlValue'),
226
+ types.conditionalExpression(
227
+ // Array.isArray(__urlValue) ? (__urlValue[0] || '') : ''
228
+ types.callExpression(
229
+ types.memberExpression(types.identifier('Array'), types.identifier('isArray')),
230
+ [types.identifier('__urlValue')]
231
+ ),
232
+ types.logicalExpression(
233
+ '||',
234
+ types.memberExpression(types.identifier('__urlValue'), types.numericLiteral(0), true),
235
+ types.stringLiteral('')
236
+ ),
237
+ types.stringLiteral('')
238
+ )
239
+ )
240
+
241
+ const effectBody = types.blockStatement([
242
+ // if (!router.isReady) return
243
+ types.ifStatement(
244
+ types.unaryExpression(
245
+ '!',
246
+ types.memberExpression(types.identifier('router'), types.identifier('isReady'))
247
+ ),
248
+ types.returnStatement(null)
249
+ ),
250
+ // const __urlValue = router.query.<paramKey>
251
+ types.variableDeclaration('const', [
252
+ types.variableDeclarator(types.identifier('__urlValue'), routerQueryAccess(paramKey)),
253
+ ]),
254
+ // const __nextValue = (normalized)
255
+ types.variableDeclaration('const', [
256
+ types.variableDeclarator(types.identifier('__nextValue'), normalizeArg),
257
+ ]),
258
+ // setState((prev) => prev === __nextValue ? prev : __nextValue)
259
+ types.expressionStatement(
260
+ types.callExpression(types.identifier(setterName), [
261
+ types.arrowFunctionExpression(
262
+ [types.identifier('prev')],
263
+ types.conditionalExpression(
264
+ types.binaryExpression(
265
+ '===',
266
+ types.identifier('prev'),
267
+ types.identifier('__nextValue')
268
+ ),
269
+ types.identifier('prev'),
270
+ types.identifier('__nextValue')
271
+ )
272
+ ),
273
+ ])
274
+ ),
275
+ ])
276
+
277
+ return types.expressionStatement(
278
+ types.callExpression(types.identifier('useEffect'), [
279
+ types.arrowFunctionExpression([], effectBody),
280
+ types.arrayExpression([
281
+ routerQueryAccess(paramKey),
282
+ types.memberExpression(types.identifier('router'), types.identifier('isReady')),
283
+ ]),
284
+ ])
285
+ )
286
+ }