@teleporthq/teleport-project-generator-next 0.43.45 → 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.
- package/__tests__/_helpers/rules-of-hooks.ts +206 -0
- package/__tests__/emitted-components-rules-of-hooks.test.ts +133 -0
- package/dist/cjs/countdown/component-generator.d.ts.map +1 -1
- package/dist/cjs/countdown/component-generator.js +1 -1
- package/dist/cjs/countdown/component-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/widgets/motion-component.d.ts.map +1 -1
- package/dist/cjs/widgets/motion-component.js +1 -1
- package/dist/cjs/widgets/motion-component.js.map +1 -1
- package/dist/esm/countdown/component-generator.d.ts.map +1 -1
- package/dist/esm/countdown/component-generator.js +1 -1
- package/dist/esm/countdown/component-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/widgets/motion-component.d.ts.map +1 -1
- package/dist/esm/widgets/motion-component.js +1 -1
- package/dist/esm/widgets/motion-component.js.map +1 -1
- package/package.json +19 -19
- package/src/countdown/component-generator.ts +7 -5
- package/src/widgets/motion-component.ts +7 -1
|
@@ -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
|
+
})
|
|
@@ -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,
|
|
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
|
|
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,
|
|
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"}
|