@teleporthq/teleport-project-generator-next 0.43.44 → 0.43.46

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,206 @@
1
+ /**
2
+ * A minimal Rules-of-Hooks analyser for the React source the Next project
3
+ * generator emits as plain strings.
4
+ *
5
+ * Those component files are template literals, so TypeScript, ESLint and the
6
+ * react-hooks plugin never see them — a hook placed after an early `return`
7
+ * type-checks, lints and builds cleanly and then throws
8
+ * "Rendered more hooks than during the previous render" in the browser on the
9
+ * second render. `tq-countdown` shipped exactly that defect: its `targetDate`
10
+ * useMemo sat below the mounted-guard return, so the component rendered 7 hooks
11
+ * on the first pass and 8 on the next, crashing every page that embedded it.
12
+ *
13
+ * The two conditions checked here are the two that React enforces at runtime:
14
+ * 1. a hook may not run after a statement that can return early
15
+ * 2. a hook may not run inside a condition, loop or try block
16
+ *
17
+ * Hook calls inside NESTED functions (a `useEffect` callback, an event handler,
18
+ * a `.map()` body) belong to that inner function, not the component, so the walk
19
+ * deliberately stops at every function boundary.
20
+ */
21
+
22
+ import { parse } from '@babel/parser'
23
+
24
+ type Node = Record<string, unknown> & { type: string; loc?: { start: { line: number } } }
25
+
26
+ const FUNCTION_TYPES = new Set([
27
+ 'FunctionDeclaration',
28
+ 'FunctionExpression',
29
+ 'ArrowFunctionExpression',
30
+ 'ObjectMethod',
31
+ 'ClassMethod',
32
+ ])
33
+
34
+ const CONTROL_FLOW_TYPES = new Set([
35
+ 'IfStatement',
36
+ 'ForStatement',
37
+ 'ForInStatement',
38
+ 'ForOfStatement',
39
+ 'WhileStatement',
40
+ 'DoWhileStatement',
41
+ 'SwitchStatement',
42
+ 'TryStatement',
43
+ 'ConditionalExpression',
44
+ 'LogicalExpression',
45
+ ])
46
+
47
+ const HOOK_NAME = /^use[A-Z]/
48
+
49
+ export type HookViolationKind = 'after-early-return' | 'conditional'
50
+
51
+ export interface HookViolation {
52
+ kind: HookViolationKind
53
+ hook: string
54
+ /** Enclosing function name, or `<anonymous>`. */
55
+ fn: string
56
+ line: number
57
+ }
58
+
59
+ function isNode(value: unknown): value is Node {
60
+ return !!value && typeof value === 'object' && typeof (value as Node).type === 'string'
61
+ }
62
+
63
+ /** Every child node of `node`, in source order. */
64
+ function childNodes(node: Node): Node[] {
65
+ const out: Node[] = []
66
+ for (const key of Object.keys(node)) {
67
+ if (key === 'loc' || key === 'leadingComments' || key === 'trailingComments') {
68
+ continue
69
+ }
70
+ const value = (node as Record<string, unknown>)[key]
71
+ if (Array.isArray(value)) {
72
+ for (const entry of value) {
73
+ if (isNode(entry)) {
74
+ out.push(entry)
75
+ }
76
+ }
77
+ continue
78
+ }
79
+ if (isNode(value)) {
80
+ out.push(value)
81
+ }
82
+ }
83
+ return out
84
+ }
85
+
86
+ function callName(node: Node): string | null {
87
+ if (node.type !== 'CallExpression') {
88
+ return null
89
+ }
90
+ const callee = node.callee as Node | undefined
91
+ if (!callee) {
92
+ return null
93
+ }
94
+ if (callee.type === 'Identifier') {
95
+ return String((callee as unknown as { name: string }).name)
96
+ }
97
+ // React.useMemo(...) — the hook is the property, not the object.
98
+ if (callee.type === 'MemberExpression') {
99
+ const property = (callee as unknown as { property?: Node }).property
100
+ if (property && property.type === 'Identifier') {
101
+ return String((property as unknown as { name: string }).name)
102
+ }
103
+ }
104
+ return null
105
+ }
106
+
107
+ /** Walks `node` and its descendants, stopping at nested function boundaries. */
108
+ function walkOwnScope(
109
+ node: Node,
110
+ visit: (current: Node, insideControlFlow: boolean) => void,
111
+ insideControlFlow = false
112
+ ): void {
113
+ for (const child of childNodes(node)) {
114
+ if (FUNCTION_TYPES.has(child.type)) {
115
+ continue
116
+ }
117
+ const nested = insideControlFlow || CONTROL_FLOW_TYPES.has(child.type)
118
+ visit(child, insideControlFlow)
119
+ walkOwnScope(child, visit, nested)
120
+ }
121
+ }
122
+
123
+ /** True when `statement` can hand control back to the caller before the next statement runs. */
124
+ function canReturnEarly(statement: Node): boolean {
125
+ if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement') {
126
+ return true
127
+ }
128
+ let found = false
129
+ walkOwnScope(statement, (current) => {
130
+ if (current.type === 'ReturnStatement' || current.type === 'ThrowStatement') {
131
+ found = true
132
+ }
133
+ })
134
+ return found
135
+ }
136
+
137
+ function functionName(node: Node, parentName: string | null): string {
138
+ const id = (node as unknown as { id?: { name?: string } }).id
139
+ if (id && id.name) {
140
+ return id.name
141
+ }
142
+ return parentName ?? '<anonymous>'
143
+ }
144
+
145
+ function analyseFunction(node: Node, name: string, violations: HookViolation[]): void {
146
+ const body = (node as unknown as { body?: Node }).body
147
+ if (!body || body.type !== 'BlockStatement') {
148
+ return
149
+ }
150
+ const statements = ((body as unknown as { body?: Node[] }).body ?? []).filter(isNode)
151
+
152
+ let earlyReturnIndex = -1
153
+ for (let index = 0; index < statements.length; index++) {
154
+ if (canReturnEarly(statements[index])) {
155
+ earlyReturnIndex = index
156
+ break
157
+ }
158
+ }
159
+
160
+ statements.forEach((statement, index) => {
161
+ const inspect = (current: Node, insideControlFlow: boolean): void => {
162
+ const called = callName(current)
163
+ if (!called || !HOOK_NAME.test(called)) {
164
+ return
165
+ }
166
+ const line = current.loc ? current.loc.start.line : 0
167
+ if (insideControlFlow || CONTROL_FLOW_TYPES.has(statement.type)) {
168
+ violations.push({ kind: 'conditional', hook: called, fn: name, line })
169
+ return
170
+ }
171
+ if (earlyReturnIndex !== -1 && index > earlyReturnIndex) {
172
+ violations.push({ kind: 'after-early-return', hook: called, fn: name, line })
173
+ }
174
+ }
175
+ inspect(statement, false)
176
+ walkOwnScope(statement, inspect)
177
+ })
178
+ }
179
+
180
+ /**
181
+ * Returns every Rules-of-Hooks violation in `source`.
182
+ *
183
+ * `source` must be a complete ES module — the generators emit exactly that.
184
+ */
185
+ export function findRulesOfHooksViolations(source: string): HookViolation[] {
186
+ const ast = parse(source, { sourceType: 'module', plugins: ['jsx'] }) as unknown as Node
187
+ const violations: HookViolation[] = []
188
+
189
+ const visit = (node: Node, inferredName: string | null): void => {
190
+ if (FUNCTION_TYPES.has(node.type)) {
191
+ analyseFunction(node, functionName(node, inferredName), violations)
192
+ }
193
+ for (const child of childNodes(node)) {
194
+ // `const TqCountdown = (props) => {...}` — carry the binding name onto the arrow.
195
+ let nextName: string | null = null
196
+ if (node.type === 'VariableDeclarator') {
197
+ const id = (node as unknown as { id?: { name?: string } }).id
198
+ nextName = id && id.name ? id.name : null
199
+ }
200
+ visit(child, nextName)
201
+ }
202
+ }
203
+
204
+ visit(ast, null)
205
+ return violations
206
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Every React component the Next project generator emits as a source STRING is
3
+ * invisible to TypeScript, ESLint and eslint-plugin-react-hooks. This suite is
4
+ * the only thing standing between a Rules-of-Hooks mistake in one of those
5
+ * templates and a runtime crash on every published page that uses it.
6
+ *
7
+ * Regression origin: `tq-countdown` computed its `targetDate` with `useMemo`
8
+ * BELOW the `if (!mounted) return …` guard. React counted 7 hooks on the first
9
+ * render and 8 on the second and threw "Rendered more hooks than during the
10
+ * previous render". The countdown lives in the Navigation component, so the
11
+ * whole site fell through to the error boundary — on localhost and on Vercel.
12
+ */
13
+
14
+ import { findRulesOfHooksViolations } from './_helpers/rules-of-hooks'
15
+
16
+ import { generateCountdownComponentCode } from '../src/countdown/component-generator'
17
+ import { generateKanbanComponentCode } from '../src/kanban/component-generator'
18
+ import { generateDragDropComponentCode } from '../src/drag-drop/component-generator'
19
+ import { generateRichTextEditorComponentCode } from '../src/rich-text-editor/component-generator'
20
+ import { generateMotionComponentCode } from '../src/widgets/motion-component'
21
+ import { generateSignatureComponentCode } from '../src/widgets/signature-component'
22
+ import { generateCategoriesFilterComponentCode } from '../src/widgets/categories-filter-component'
23
+ import { generateCategoriesMegamenuComponentCode } from '../src/widgets/categories-megamenu-component'
24
+ import { generateBarcodeComponentCode } from '../src/widgets/barcode-component'
25
+ import { generateQrCodeComponentCode } from '../src/widgets/qrcode-component'
26
+ import { generateEmojiPickerComponentCode } from '../src/widgets/emoji-picker-component'
27
+ import { generateColorPickerComponentCode } from '../src/widgets/color-picker-component'
28
+ import { generateFormFileInputComponentCode } from '../src/widgets/form-file-input-component'
29
+ import { COLLAPSIBLE_TEXT_OVERFLOW_COMPONENT_SOURCE } from '../src/collapsible-text/collapsible-text-overflow-component'
30
+ import { NAV_ACTIVE_LINK_COMPONENT_SOURCE } from '../src/nav-active-link/nav-active-link-component'
31
+ import { TRACKER_COMPONENT_SOURCE } from '../src/analytics/tracker-component'
32
+
33
+ const EMITTED_COMPONENTS: Array<[string, string]> = [
34
+ ['tq-countdown', generateCountdownComponentCode()],
35
+ ['tq-kanban', generateKanbanComponentCode()],
36
+ ['tq-drag-drop', generateDragDropComponentCode()],
37
+ ['rich-text-editor', generateRichTextEditorComponentCode()],
38
+ ['tq-motion', generateMotionComponentCode()],
39
+ ['tq-signature', generateSignatureComponentCode()],
40
+ ['tq-categories-filter', generateCategoriesFilterComponentCode()],
41
+ ['tq-categories-megamenu', generateCategoriesMegamenuComponentCode()],
42
+ ['tq-barcode', generateBarcodeComponentCode()],
43
+ ['tq-qrcode', generateQrCodeComponentCode()],
44
+ ['tq-emoji-picker', generateEmojiPickerComponentCode()],
45
+ ['tq-color-picker', generateColorPickerComponentCode()],
46
+ ['tq-form-file-input', generateFormFileInputComponentCode()],
47
+ ['tq-collapsible-text-overflow', COLLAPSIBLE_TEXT_OVERFLOW_COMPONENT_SOURCE],
48
+ ['nav-active-links', NAV_ACTIVE_LINK_COMPONENT_SOURCE],
49
+ ['analytics-tracker', TRACKER_COMPONENT_SOURCE],
50
+ ]
51
+
52
+ describe('emitted React components obey the Rules of Hooks', () => {
53
+ it.each(EMITTED_COMPONENTS)(
54
+ '%s calls no hook conditionally or after an early return',
55
+ (_name, source) => {
56
+ const violations = findRulesOfHooksViolations(source)
57
+ expect(violations).toEqual([])
58
+ }
59
+ )
60
+
61
+ it('covers every component source the generator can emit', () => {
62
+ // A new widget added without a row here would ship unguarded.
63
+ expect(EMITTED_COMPONENTS).toHaveLength(16)
64
+ })
65
+ })
66
+
67
+ describe('the Rules-of-Hooks analyser itself', () => {
68
+ it('flags a hook placed after a conditional early return', () => {
69
+ const violations = findRulesOfHooksViolations(`
70
+ import React, { useState, useMemo } from 'react'
71
+ const Broken = ({ value }) => {
72
+ const [mounted, setMounted] = useState(false)
73
+ if (!mounted) {
74
+ return null
75
+ }
76
+ const doubled = useMemo(() => value * 2, [value])
77
+ return <span>{doubled}</span>
78
+ }
79
+ export default Broken
80
+ `)
81
+ expect(violations).toEqual([
82
+ { kind: 'after-early-return', hook: 'useMemo', fn: 'Broken', line: 8 },
83
+ ])
84
+ })
85
+
86
+ it('flags a hook called inside a condition', () => {
87
+ const violations = findRulesOfHooksViolations(`
88
+ import React, { useEffect } from 'react'
89
+ const Broken = ({ enabled }) => {
90
+ if (enabled) {
91
+ useEffect(() => {}, [])
92
+ }
93
+ return null
94
+ }
95
+ export default Broken
96
+ `)
97
+ expect(violations).toEqual([{ kind: 'conditional', hook: 'useEffect', fn: 'Broken', line: 5 }])
98
+ })
99
+
100
+ it('does not flag an early return INSIDE a hook callback', () => {
101
+ const violations = findRulesOfHooksViolations(`
102
+ import React, { useEffect, useMemo } from 'react'
103
+ const Fine = ({ mode, value }) => {
104
+ useEffect(() => {
105
+ if (mode !== 'numeric') {
106
+ return undefined
107
+ }
108
+ const id = setInterval(() => {}, 1000)
109
+ return () => clearInterval(id)
110
+ }, [mode])
111
+ const doubled = useMemo(() => value * 2, [value])
112
+ return <span>{doubled}</span>
113
+ }
114
+ export default Fine
115
+ `)
116
+ expect(violations).toEqual([])
117
+ })
118
+
119
+ it('does not flag hooks in a sibling component declared after one that returns early', () => {
120
+ const violations = findRulesOfHooksViolations(`
121
+ import React, { useState } from 'react'
122
+ const First = () => {
123
+ return null
124
+ }
125
+ const Second = () => {
126
+ const [value] = useState(0)
127
+ return <span>{value}</span>
128
+ }
129
+ export default Second
130
+ `)
131
+ expect(violations).toEqual([])
132
+ })
133
+ })
@@ -0,0 +1,268 @@
1
+ import generator from '@babel/generator'
2
+ import * as types from '@babel/types'
3
+ import { createStateDataSourcePlugin } from '../src/state-data-source-plugin'
4
+
5
+ // Regression guard for the "Characters page renders empty lists" defect
6
+ // (GuildForge run, examples/uidl-samples/project.json): page states bound to a
7
+ // data source with a `{{Current User.*}}` query were silently dropped — no
8
+ // getStaticProps fetch is possible (the placeholder only resolves at runtime)
9
+ // and nothing else populated them, so every list bound to such a state stayed
10
+ // empty forever. The plugin must instead emit an API route + a client-side
11
+ // useEffect fetch keyed on the signed-in user.
12
+
13
+ const DS_ID = '8e4bd2e9-1ceb-41ee-91ea-6f2aca94076e'
14
+
15
+ const USER_QUERY =
16
+ "SELECT id, user_id, name FROM characters WHERE (user_id = '{{Current User.id}}' OR user_id IS NULL) ORDER BY name ASC LIMIT 20"
17
+
18
+ const makeState = (query?: string) => ({
19
+ type: 'array',
20
+ defaultValue: [] as unknown[],
21
+ dataSourceBinding: {
22
+ dataSourceId: DS_ID,
23
+ refPath: ['characters'],
24
+ },
25
+ ...(query ? { query } : {}),
26
+ })
27
+
28
+ const makeJsxComponentChunk = () => ({
29
+ type: 'AST',
30
+ fileType: 'js',
31
+ name: 'jsx-component',
32
+ linkAfter: [] as string[],
33
+ content: types.variableDeclaration('const', [
34
+ types.variableDeclarator(
35
+ types.identifier('Characters'),
36
+ types.arrowFunctionExpression(
37
+ [],
38
+ types.blockStatement([types.returnStatement(types.nullLiteral())])
39
+ )
40
+ ),
41
+ ]),
42
+ })
43
+
44
+ const makeStructure = (
45
+ stateDefinitions: Record<string, unknown>,
46
+ { auth = true, dynamicRouteAttribute }: { auth?: boolean; dynamicRouteAttribute?: string } = {}
47
+ ) => ({
48
+ uidl: {
49
+ name: 'Characters',
50
+ stateDefinitions,
51
+ node: { type: 'element', content: { elementType: 'container' } },
52
+ outputOptions: {
53
+ folderPath: [] as string[],
54
+ ...(dynamicRouteAttribute && { dynamicRouteAttribute }),
55
+ },
56
+ },
57
+ chunks: [makeJsxComponentChunk()] as any[],
58
+ dependencies: {} as Record<string, unknown>,
59
+ options: {
60
+ dataSources: {
61
+ [DS_ID]: {
62
+ id: DS_ID,
63
+ type: 'teleport',
64
+ config: {},
65
+ },
66
+ },
67
+ extractedResources: {} as Record<string, unknown>,
68
+ ...(auth ? { auth: { provider: 'teleport' } } : {}),
69
+ },
70
+ })
71
+
72
+ const componentCode = (structure: { chunks: any[] }): string => {
73
+ const chunk = structure.chunks.find((c) => c.name === 'jsx-component')
74
+ return generator(chunk.content as types.Node).code
75
+ }
76
+
77
+ describe('state-data-source-plugin runtime {{Current User.*}} fetch', () => {
78
+ const plugin = createStateDataSourcePlugin()
79
+
80
+ it('emits an API route + useEffect fetch for a Current-User-bound state', async () => {
81
+ const structure = makeStructure({ charactersResults: makeState(USER_QUERY) })
82
+
83
+ const result = await plugin(structure as any)
84
+
85
+ // API route extracted under pages/api/page-state with the parameterized query
86
+ const resources = result.options.extractedResources as Record<
87
+ string,
88
+ { path: string[]; content: string }
89
+ >
90
+ const routeKeys = Object.keys(resources).filter((k) => k.startsWith('pages/api/page-state/'))
91
+ expect(routeKeys).toHaveLength(1)
92
+ const route = resources[routeKeys[0]]
93
+ expect(route.path).toEqual(['pages', 'api', 'page-state'])
94
+ expect(route.content).toContain('user_id = $1')
95
+ expect(route.content).toContain('const { currentUserId } = req.query')
96
+ expect(route.content).not.toContain('{{Current User.id}}')
97
+
98
+ // Client-side effect wired to the signed-in user
99
+ const code = componentCode(result as any)
100
+ expect(code).toContain('const __pageStateCtx = useGlobalContext()')
101
+ expect(code).toContain('useEffect(')
102
+ expect(code).toContain('__pageStateCtx?.currentUser')
103
+ expect(code).toContain('encodeURIComponent(__user?.id ?? "")')
104
+ expect(code).toContain('setCharactersResults(Array.isArray(__data) ? __data : [])')
105
+ expect(code).toContain('/api/page-state/')
106
+
107
+ // Hook + context imports registered
108
+ expect(result.dependencies.useEffect).toBeDefined()
109
+ expect(result.dependencies.useGlobalContext).toBeDefined()
110
+
111
+ // No getStaticProps fetch was emitted for the runtime state
112
+ const gsp = result.chunks.find((c: any) => c.name === 'getStaticProps')
113
+ if (gsp) {
114
+ expect(generator(gsp.content as types.Node).code).not.toContain('charactersResults')
115
+ }
116
+ })
117
+
118
+ it('shares one fetch effect between states with identical queries and keeps distinct queries apart', async () => {
119
+ const structure = makeStructure({
120
+ charactersResults: makeState(USER_QUERY),
121
+ mirror: makeState(USER_QUERY),
122
+ mainSpotlight: makeState(
123
+ "SELECT id FROM characters WHERE user_id = '{{Current User.id}}' AND is_main = TRUE LIMIT 1"
124
+ ),
125
+ })
126
+
127
+ const result = await plugin(structure as any)
128
+
129
+ const routeKeys = Object.keys(result.options.extractedResources as object).filter((k) =>
130
+ k.startsWith('pages/api/page-state/')
131
+ )
132
+ expect(routeKeys).toHaveLength(2)
133
+
134
+ const code = componentCode(result as any)
135
+ // Shared group: both setters inside the same effect
136
+ expect(code).toContain('setCharactersResults(')
137
+ expect(code).toContain('setMirror(')
138
+ expect(code).toContain('setMainSpotlight(')
139
+ expect(code.match(/useEffect\(/g)).toHaveLength(2)
140
+ })
141
+
142
+ it('does nothing for such states when the project has no authentication', async () => {
143
+ const structure = makeStructure({ charactersResults: makeState(USER_QUERY) }, { auth: false })
144
+
145
+ const result = await plugin(structure as any)
146
+
147
+ expect(Object.keys(result.options.extractedResources as object)).toHaveLength(0)
148
+ const code = componentCode(result as any)
149
+ expect(code).not.toContain('useEffect(')
150
+ expect(result.dependencies.useEffect).toBeUndefined()
151
+ })
152
+
153
+ it('still skips queries with placeholders other than {{Current User.*}}', async () => {
154
+ const structure = makeStructure({
155
+ detailRows: makeState("SELECT * FROM events WHERE id = '{{Current Page Entity.id}}'"),
156
+ })
157
+
158
+ const result = await plugin(structure as any)
159
+
160
+ expect(Object.keys(result.options.extractedResources as object)).toHaveLength(0)
161
+ expect(componentCode(result as any)).not.toContain('useEffect(')
162
+ })
163
+
164
+ it('emits a route-param-driven fetch for a {{Current Page Entity.id}} query on a details page', async () => {
165
+ // GuildForge guild-details regression: guildMemberships was bound to an
166
+ // entity-scoped query; the binding was silently dropped so the members
167
+ // list stayed empty in the generated app while the GUI editor (which
168
+ // resolves the binding itself) showed it populated.
169
+ const entityQuery =
170
+ 'SELECT m.id, u.name FROM memberships m LEFT JOIN users u ON m.user_id = u.id ' +
171
+ "WHERE m.guild_id = '{{Current Page Entity.id}}' ORDER BY u.name ASC"
172
+ const structure = makeStructure(
173
+ {
174
+ guildMemberships: {
175
+ ...makeState(entityQuery),
176
+ dataSourceBinding: { dataSourceId: DS_ID, refPath: [] },
177
+ },
178
+ },
179
+ { dynamicRouteAttribute: 'id' }
180
+ )
181
+
182
+ const result = await plugin(structure as any)
183
+
184
+ const resources = result.options.extractedResources as Record<string, { content: string }>
185
+ const routeKeys = Object.keys(resources).filter((k) => k.startsWith('pages/api/page-state/'))
186
+ expect(routeKeys).toHaveLength(1)
187
+ expect(resources[routeKeys[0]].content).toContain('m.guild_id = $1')
188
+ expect(resources[routeKeys[0]].content).toContain('const { currentPageEntityId } = req.query')
189
+
190
+ const code = componentCode(result as any)
191
+ expect(code).toContain('const __pageStateRouter = useRouter()')
192
+ expect(code).toContain('__pageStateRouter.query?.["id"]')
193
+ expect(code).toContain('encodeURIComponent(__entityId)')
194
+ expect(code).toContain('setGuildMemberships(Array.isArray(__data) ? __data : [])')
195
+ // Entity-only query must not require auth context
196
+ expect(code).not.toContain('__pageStateCtx')
197
+ expect(result.dependencies.useRouter).toBeDefined()
198
+ expect(result.dependencies.useGlobalContext).toBeUndefined()
199
+ })
200
+
201
+ it('works without authentication when the query only uses {{Current Page Entity.id}}', async () => {
202
+ const entityQuery = "SELECT * FROM memberships WHERE guild_id = '{{Current Page Entity.id}}'"
203
+ const structure = makeStructure(
204
+ { members: makeState(entityQuery) },
205
+ { auth: false, dynamicRouteAttribute: 'id' }
206
+ )
207
+
208
+ const result = await plugin(structure as any)
209
+ expect(
210
+ Object.keys(result.options.extractedResources as object).filter((k) =>
211
+ k.startsWith('pages/api/page-state/')
212
+ )
213
+ ).toHaveLength(1)
214
+ expect(componentCode(result as any)).toContain('useEffect(')
215
+ })
216
+
217
+ it('skips a {{Current Page Entity.id}} query on a non-details page (no route param)', async () => {
218
+ const entityQuery = "SELECT * FROM memberships WHERE guild_id = '{{Current Page Entity.id}}'"
219
+ const structure = makeStructure({ members: makeState(entityQuery) })
220
+
221
+ const result = await plugin(structure as any)
222
+ expect(Object.keys(result.options.extractedResources as object)).toHaveLength(0)
223
+ expect(componentCode(result as any)).not.toContain('useEffect(')
224
+ })
225
+
226
+ it('combines user and entity tokens in one fetch with both guards', async () => {
227
+ const mixedQuery =
228
+ "SELECT * FROM memberships WHERE guild_id = '{{Current Page Entity.id}}' " +
229
+ "AND user_id = '{{Current User.id}}'"
230
+ const structure = makeStructure(
231
+ { myMembership: makeState(mixedQuery) },
232
+ { dynamicRouteAttribute: 'id' }
233
+ )
234
+
235
+ const result = await plugin(structure as any)
236
+
237
+ const resources = result.options.extractedResources as Record<string, { content: string }>
238
+ const routeKey = Object.keys(resources).find((k) => k.startsWith('pages/api/page-state/'))!
239
+ expect(resources[routeKey].content).toContain('guild_id = $1')
240
+ expect(resources[routeKey].content).toContain('user_id = $2')
241
+ expect(resources[routeKey].content).toContain('currentPageEntityId, currentUserId')
242
+
243
+ const code = componentCode(result as any)
244
+ expect(code).toContain('const __pageStateCtx = useGlobalContext()')
245
+ expect(code).toContain('const __pageStateRouter = useRouter()')
246
+ expect(code).toContain('if (!__user) return')
247
+ expect(code).toContain('if (!__entityId) return')
248
+ })
249
+
250
+ it('keeps build-time states in getStaticProps while runtime states get effects', async () => {
251
+ const structure = makeStructure({
252
+ allGuilds: makeState('SELECT * FROM guilds ORDER BY name ASC'),
253
+ myCharacters: makeState(USER_QUERY),
254
+ })
255
+
256
+ const result = await plugin(structure as any)
257
+
258
+ const gsp = result.chunks.find((c: any) => c.name === 'getStaticProps')
259
+ expect(gsp).toBeDefined()
260
+ const gspCode = generator(gsp.content as types.Node).code
261
+ expect(gspCode).toContain('allGuilds')
262
+ expect(gspCode).not.toContain('myCharacters')
263
+
264
+ const code = componentCode(result as any)
265
+ expect(code).toContain('setMyCharacters(')
266
+ expect(code).not.toContain('setAllGuilds(')
267
+ })
268
+ })
@@ -1 +1 @@
1
- {"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../../../src/countdown/component-generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,8BAA8B,QAAO,MA0JjD,CAAA"}
1
+ {"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../../../src/countdown/component-generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,8BAA8B,QAAO,MA4JjD,CAAA"}
@@ -15,7 +15,7 @@ exports.generateCountdownComponentCode = void 0;
15
15
  * to the live countdown — so hydration matches and ticking starts client-side.
16
16
  */
17
17
  var generateCountdownComponentCode = function () {
18
- return "import React, { useState, useEffect, useMemo } from 'react'\nimport Countdown, { zeroPad } from 'react-countdown'\n\nconst ALL_UNITS = ['days', 'hours', 'minutes', 'seconds']\nconst DEFAULT_LABELS = { days: 'Days', hours: 'Hours', minutes: 'Minutes', seconds: 'Seconds' }\n\nconst parseCsv = (value) =>\n typeof value === 'string' ? value.split(',').map((s) => s.trim()).filter(Boolean) : []\n\nconst resolveUnits = (units) => {\n const parsed = parseCsv(units).filter((u) => ALL_UNITS.indexOf(u) !== -1)\n return parsed.length > 0 ? parsed : ALL_UNITS\n}\n\nconst resolveLabels = (units, labels) => {\n const raw = parseCsv(labels)\n return units.map((u, i) => raw[i] || DEFAULT_LABELS[u] || u)\n}\n\nconst wrapperStyle = (format) => ({\n display: 'flex',\n alignItems: 'center',\n gap: format === 'inline' ? 0 : 16,\n fontVariantNumeric: 'tabular-nums',\n})\n\nconst renderSegments = (segments, format) => {\n if (format === 'inline') {\n return (\n <div className=\"tq-countdown tq-countdown-inline\" style={wrapperStyle('inline')}>\n <span className=\"tq-countdown-value\">{segments.map((s) => s.value).join(':')}</span>\n </div>\n )\n }\n return (\n <div className=\"tq-countdown tq-countdown-blocks\" style={wrapperStyle('blocks')}>\n {segments.map((s, i) => (\n <div\n key={i}\n className=\"tq-countdown-unit\"\n style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}\n >\n <span className=\"tq-countdown-value\" style={{ fontSize: '2em', fontWeight: 700, lineHeight: 1 }}>\n {s.value}\n </span>\n {s.label ? (\n <span className=\"tq-countdown-label\" style={{ fontSize: '0.75em', opacity: 0.7 }}>\n {s.label}\n </span>\n ) : null}\n </div>\n ))}\n </div>\n )\n}\n\nconst dateSegments = (timeDelta, units, labels) => {\n const showDays = units.indexOf('days') !== -1\n const hours = showDays ? timeDelta.hours : timeDelta.days * 24 + timeDelta.hours\n const byUnit = {\n days: timeDelta.days,\n hours: hours,\n minutes: timeDelta.minutes,\n seconds: timeDelta.seconds,\n }\n return units.map((unit, i) => ({ value: zeroPad(byUnit[unit] != null ? byUnit[unit] : 0), label: labels[i] || '' }))\n}\n\nconst TqCountdown = ({\n mode = 'date',\n target,\n from = '100',\n to = '0',\n format = 'blocks',\n units,\n labels,\n onComplete = 'keep-zero',\n completeMessage = '',\n ...rest\n}) => {\n const [mounted, setMounted] = useState(false)\n const [numericStart] = useState(() => Date.now())\n const [now, setNow] = useState(() => Date.now())\n\n useEffect(() => {\n setMounted(true)\n }, [])\n\n // Numeric mode ticks via its own interval.\n useEffect(() => {\n if (mode !== 'numeric') {\n return undefined\n }\n const id = setInterval(() => setNow(Date.now()), 1000)\n return () => clearInterval(id)\n }, [mode])\n\n const resolvedUnits = useMemo(() => resolveUnits(units), [units])\n const resolvedLabels = useMemo(() => resolveLabels(resolvedUnits, labels), [resolvedUnits, labels])\n\n // Deterministic placeholder (zeros) for SSR + first paint, so hydration matches.\n if (!mounted) {\n const zeros =\n mode === 'numeric'\n ? [{ value: String(from), label: resolvedLabels[0] || '' }]\n : resolvedUnits.map((u, i) => ({ value: '00', label: resolvedLabels[i] || '' }))\n return <div {...rest}>{renderSegments(zeros, format)}</div>\n }\n\n if (mode === 'numeric') {\n const fromN = parseFloat(from) || 0\n const toN = parseFloat(to) || 0\n const elapsed = Math.max(0, Math.floor((now - numericStart) / 1000))\n const current = fromN > toN ? Math.max(toN, fromN - elapsed) : Math.min(toN, fromN + elapsed)\n const completed = current === toN\n if (completed && onComplete === 'hide') {\n return <div {...rest} />\n }\n if (completed && onComplete === 'show-message') {\n return (\n <div {...rest}>\n <span className=\"tq-countdown-complete\">{completeMessage}</span>\n </div>\n )\n }\n return <div {...rest}>{renderSegments([{ value: String(current), label: resolvedLabels[0] || '' }], format)}</div>\n }\n\n const renderer = ({ days, hours, minutes, seconds, completed }) => {\n if (completed && onComplete === 'hide') {\n return <span className=\"tq-countdown-complete\" />\n }\n if (completed && onComplete === 'show-message') {\n return <span className=\"tq-countdown-complete\">{completeMessage}</span>\n }\n const segments = dateSegments({ days, hours, minutes, seconds }, resolvedUnits, resolvedLabels)\n return renderSegments(segments, format)\n }\n\n const targetDate = useMemo(() => {\n const d = new Date(target)\n return isNaN(d.getTime()) ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) : d\n }, [target])\n\n return (\n <div {...rest}>\n <Countdown date={targetDate} renderer={renderer} overtime={false} />\n </div>\n )\n}\n\nexport default TqCountdown\n";
18
+ return "import React, { useState, useEffect, useMemo } from 'react'\nimport Countdown, { zeroPad } from 'react-countdown'\n\nconst ALL_UNITS = ['days', 'hours', 'minutes', 'seconds']\nconst DEFAULT_LABELS = { days: 'Days', hours: 'Hours', minutes: 'Minutes', seconds: 'Seconds' }\n\nconst parseCsv = (value) =>\n typeof value === 'string' ? value.split(',').map((s) => s.trim()).filter(Boolean) : []\n\nconst resolveUnits = (units) => {\n const parsed = parseCsv(units).filter((u) => ALL_UNITS.indexOf(u) !== -1)\n return parsed.length > 0 ? parsed : ALL_UNITS\n}\n\nconst resolveLabels = (units, labels) => {\n const raw = parseCsv(labels)\n return units.map((u, i) => raw[i] || DEFAULT_LABELS[u] || u)\n}\n\nconst wrapperStyle = (format) => ({\n display: 'flex',\n alignItems: 'center',\n gap: format === 'inline' ? 0 : 16,\n fontVariantNumeric: 'tabular-nums',\n})\n\nconst renderSegments = (segments, format) => {\n if (format === 'inline') {\n return (\n <div className=\"tq-countdown tq-countdown-inline\" style={wrapperStyle('inline')}>\n <span className=\"tq-countdown-value\">{segments.map((s) => s.value).join(':')}</span>\n </div>\n )\n }\n return (\n <div className=\"tq-countdown tq-countdown-blocks\" style={wrapperStyle('blocks')}>\n {segments.map((s, i) => (\n <div\n key={i}\n className=\"tq-countdown-unit\"\n style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}\n >\n <span className=\"tq-countdown-value\" style={{ fontSize: '2em', fontWeight: 700, lineHeight: 1 }}>\n {s.value}\n </span>\n {s.label ? (\n <span className=\"tq-countdown-label\" style={{ fontSize: '0.75em', opacity: 0.7 }}>\n {s.label}\n </span>\n ) : null}\n </div>\n ))}\n </div>\n )\n}\n\nconst dateSegments = (timeDelta, units, labels) => {\n const showDays = units.indexOf('days') !== -1\n const hours = showDays ? timeDelta.hours : timeDelta.days * 24 + timeDelta.hours\n const byUnit = {\n days: timeDelta.days,\n hours: hours,\n minutes: timeDelta.minutes,\n seconds: timeDelta.seconds,\n }\n return units.map((unit, i) => ({ value: zeroPad(byUnit[unit] != null ? byUnit[unit] : 0), label: labels[i] || '' }))\n}\n\nconst TqCountdown = ({\n mode = 'date',\n target,\n from = '100',\n to = '0',\n format = 'blocks',\n units,\n labels,\n onComplete = 'keep-zero',\n completeMessage = '',\n ...rest\n}) => {\n const [mounted, setMounted] = useState(false)\n const [numericStart] = useState(() => Date.now())\n const [now, setNow] = useState(() => Date.now())\n\n useEffect(() => {\n setMounted(true)\n }, [])\n\n // Numeric mode ticks via its own interval.\n useEffect(() => {\n if (mode !== 'numeric') {\n return undefined\n }\n const id = setInterval(() => setNow(Date.now()), 1000)\n return () => clearInterval(id)\n }, [mode])\n\n const resolvedUnits = useMemo(() => resolveUnits(units), [units])\n const resolvedLabels = useMemo(() => resolveLabels(resolvedUnits, labels), [resolvedUnits, labels])\n // Every hook must run on EVERY render: the mounted-guard and the numeric-mode\n // branch below both return early, so a hook placed after them would only run\n // on some renders (\"Rendered more hooks than during the previous render\").\n const targetDate = useMemo(() => {\n const d = new Date(target)\n return isNaN(d.getTime()) ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) : d\n }, [target])\n\n // Deterministic placeholder (zeros) for SSR + first paint, so hydration matches.\n if (!mounted) {\n const zeros =\n mode === 'numeric'\n ? [{ value: String(from), label: resolvedLabels[0] || '' }]\n : resolvedUnits.map((u, i) => ({ value: '00', label: resolvedLabels[i] || '' }))\n return <div {...rest}>{renderSegments(zeros, format)}</div>\n }\n\n if (mode === 'numeric') {\n const fromN = parseFloat(from) || 0\n const toN = parseFloat(to) || 0\n const elapsed = Math.max(0, Math.floor((now - numericStart) / 1000))\n const current = fromN > toN ? Math.max(toN, fromN - elapsed) : Math.min(toN, fromN + elapsed)\n const completed = current === toN\n if (completed && onComplete === 'hide') {\n return <div {...rest} />\n }\n if (completed && onComplete === 'show-message') {\n return (\n <div {...rest}>\n <span className=\"tq-countdown-complete\">{completeMessage}</span>\n </div>\n )\n }\n return <div {...rest}>{renderSegments([{ value: String(current), label: resolvedLabels[0] || '' }], format)}</div>\n }\n\n const renderer = ({ days, hours, minutes, seconds, completed }) => {\n if (completed && onComplete === 'hide') {\n return <span className=\"tq-countdown-complete\" />\n }\n if (completed && onComplete === 'show-message') {\n return <span className=\"tq-countdown-complete\">{completeMessage}</span>\n }\n const segments = dateSegments({ days, hours, minutes, seconds }, resolvedUnits, resolvedLabels)\n return renderSegments(segments, format)\n }\n\n return (\n <div {...rest}>\n <Countdown date={targetDate} renderer={renderer} overtime={false} />\n </div>\n )\n}\n\nexport default TqCountdown\n";
19
19
  };
20
20
  exports.generateCountdownComponentCode = generateCountdownComponentCode;
21
21
  //# sourceMappingURL=component-generator.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"component-generator.js","sourceRoot":"","sources":["../../../src/countdown/component-generator.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;GAYG;AACI,IAAM,8BAA8B,GAAG;IAC5C,OAAO,29JAwJR,CAAA;AACD,CAAC,CAAA;AA1JY,QAAA,8BAA8B,kCA0J1C"}
1
+ {"version":3,"file":"component-generator.js","sourceRoot":"","sources":["../../../src/countdown/component-generator.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;GAYG;AACI,IAAM,8BAA8B,GAAG;IAC5C,OAAO,6sKA0JR,CAAA;AACD,CAAC,CAAA;AA5JY,QAAA,8BAA8B,kCA4J1C"}
@@ -1 +1 @@
1
- {"version":3,"file":"state-data-source-plugin.d.ts","sourceRoot":"","sources":["../../src/state-data-source-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,sBAAsB,EAQvB,MAAM,4BAA4B,CAAA;AAohBnC,eAAO,MAAM,2BAA2B,EAAE,sBAAsB,CAAC,EAAE,CAyJlE,CAAA"}
1
+ {"version":3,"file":"state-data-source-plugin.d.ts","sourceRoot":"","sources":["../../src/state-data-source-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,sBAAsB,EAQvB,MAAM,4BAA4B,CAAA;AA0/BnC,eAAO,MAAM,2BAA2B,EAAE,sBAAsB,CAAC,EAAE,CAkKlE,CAAA"}