Skip to content

Commit ce6b90b

Browse files
feat: add react package and test coverage
1 parent 21fdf8e commit ce6b90b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+12235
-6
lines changed

e2e/memoryDOM.spec.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { test, expect } from '@playwright/test'
22

3+
test.describe.configure({ mode: 'serial', timeout: 90000 })
4+
35
test('vue component dom nodes get garbage collected (control)', async ({
46
page,
57
}) => {
@@ -16,7 +18,7 @@ test('FormKit text nodes get garbage collected', async ({ page }) => {
1618
await expect(async () => {
1719
const value = await page.getByTestId('collectionData').textContent()
1820
expect(value).toBe('1/1')
19-
}).toPass({ intervals: [100], timeout: 25000 })
21+
}).toPass({ intervals: [100], timeout: 60000 })
2022
})
2123

2224
test('FormKit checkbox nodes get garbage collected', async ({ page }) => {
@@ -26,7 +28,7 @@ test('FormKit checkbox nodes get garbage collected', async ({ page }) => {
2628
await expect(async () => {
2729
const value = await page.getByTestId('collectionData').textContent()
2830
expect(value).toBe('1/1')
29-
}).toPass({ intervals: [100], timeout: 25000 })
31+
}).toPass({ intervals: [100], timeout: 60000 })
3032
})
3133

3234
test('FormKit radio nodes get garbage collected', async ({ page }) => {
@@ -36,7 +38,7 @@ test('FormKit radio nodes get garbage collected', async ({ page }) => {
3638
await expect(async () => {
3739
const value = await page.getByTestId('collectionData').textContent()
3840
expect(value).toBe('1/1')
39-
}).toPass({ intervals: [100], timeout: 25000 })
41+
}).toPass({ intervals: [100], timeout: 60000 })
4042
})
4143

4244
test('FormKit select nodes get garbage collected', async ({ page }) => {
@@ -46,7 +48,7 @@ test('FormKit select nodes get garbage collected', async ({ page }) => {
4648
await expect(async () => {
4749
const value = await page.getByTestId('collectionData').textContent()
4850
expect(value).toBe('1/1')
49-
}).toPass({ intervals: [100], timeout: 40000 })
51+
}).toPass({ intervals: [100], timeout: 60000 })
5052
})
5153

5254
test('FormKit form nodes get garbage collected', async ({ page }) => {

e2e/memoryReactSSR.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { test, expect, Page } from '@playwright/test'
2+
3+
async function cycle(page: Page, total = 30, cycleCount = 0) {
4+
return new Promise<void>(async (resolve) => {
5+
await page.reload()
6+
setTimeout(async () => {
7+
if (cycleCount < total) await cycle(page, total, cycleCount + 1)
8+
resolve()
9+
}, 500)
10+
})
11+
}
12+
13+
async function getMemory(page: Page) {
14+
await new Promise((resolve) => setTimeout(resolve, 1000))
15+
await page.reload()
16+
return Number(await page.locator('input').first().inputValue())
17+
}
18+
19+
test('formkit react app gets garbage collected', async ({ page }) => {
20+
await page.goto('http://localhost:8788/')
21+
await cycle(page, 2) // Warm up
22+
const initialMemory = await getMemory(page)
23+
await cycle(page, 20)
24+
const finalMemory = await getMemory(page)
25+
expect((finalMemory - initialMemory) / 20).toBeLessThan(0.1)
26+
expect(finalMemory).toBeLessThan(initialMemory + 5)
27+
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React from 'react'
2+
import { renderToString } from 'react-dom/server'
3+
import http from 'http'
4+
import {
5+
FormKit,
6+
FormKitProvider,
7+
defaultConfig,
8+
} from '../../packages/react/dist/index.mjs'
9+
10+
function App() {
11+
return React.createElement(
12+
FormKitProvider,
13+
{ config: defaultConfig() },
14+
React.createElement(
15+
FormKit,
16+
{ type: 'form' },
17+
React.createElement(FormKit, {
18+
type: 'text',
19+
name: 'name',
20+
id: 'name',
21+
validation: 'required|not:Admin',
22+
label: 'Name',
23+
help: "Enter your character's full name",
24+
placeholder: 'Scarlet Sword',
25+
}),
26+
React.createElement(FormKit, {
27+
type: 'select',
28+
label: 'Class',
29+
name: 'class',
30+
id: 'class',
31+
placeholder: 'Select a class',
32+
options: ['Warrior', 'Mage', 'Assassin'],
33+
}),
34+
React.createElement(FormKit, {
35+
type: 'range',
36+
name: 'strength',
37+
id: 'strength',
38+
label: 'Strength',
39+
value: '5',
40+
validation: 'min:2|max:9',
41+
validationVisibility: 'live',
42+
min: '1',
43+
max: '10',
44+
step: '1',
45+
help: 'How many strength points should this character have?',
46+
}),
47+
React.createElement(FormKit, {
48+
type: 'range',
49+
name: 'skill',
50+
id: 'skill',
51+
validation: 'required|max:10',
52+
label: 'Skill',
53+
value: '5',
54+
min: '1',
55+
max: '10',
56+
step: '1',
57+
help: 'How much skill points to start with',
58+
}),
59+
React.createElement(FormKit, {
60+
type: 'range',
61+
name: 'dexterity',
62+
id: 'dexterity',
63+
validation: 'required|max:10',
64+
label: 'Dexterity',
65+
value: '5',
66+
min: '1',
67+
max: '10',
68+
step: '1',
69+
help: 'How much dexterity points to start with',
70+
})
71+
)
72+
)
73+
}
74+
75+
const server = http.createServer((req, res) => {
76+
if (req.url === '/favicon.ico') {
77+
res.statusCode = 404
78+
res.end()
79+
return
80+
}
81+
82+
const html = renderToString(React.createElement(App))
83+
if (typeof globalThis.gc === 'function') globalThis.gc() // eslint-disable-line no-undef
84+
85+
res.statusCode = 200
86+
res.setHeader('Content-Type', 'text/html')
87+
res.end(`<!DOCTYPE html>
88+
<html>
89+
Memory used: <input value="${
90+
Math.round(process.memoryUsage().heapUsed / 1000 / 100) / 10
91+
}">Mb
92+
<br>
93+
<div id="app">${html}</div>
94+
</html>`)
95+
})
96+
97+
server.listen(8788, 'localhost', () => {
98+
console.log('Server started: http://localhost:8788')
99+
})

examples/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
"@formkit/core": "workspace:^",
1313
"@formkit/i18n": "workspace:^",
1414
"@formkit/inputs": "workspace:^",
15+
"@formkit/react": "workspace:^",
1516
"@formkit/themes": "workspace:^",
1617
"@formkit/vue": "workspace:^",
1718
"@formkit/zod": "workspace:^",
1819
"@vitejs/plugin-vue": "^5.2.0",
1920
"vite": "^6.0.0",
21+
"react": "^19.0.0",
22+
"react-dom": "^19.0.0",
2023
"vue-router": "^4.5.0"
2124
},
2225
"devDependencies": {

examples/react/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>FormKit React Playground</title>
7+
</head>
8+
<body>
9+
<div id="react-app"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

examples/react/src/App.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useMemo, useState } from 'react'
2+
import {
3+
FormKit,
4+
FormKitProvider,
5+
FormKitSchema,
6+
defaultConfig,
7+
} from '@formkit/react'
8+
import type { FormKitSchemaNode } from '@formkit/core'
9+
10+
export function App() {
11+
const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null)
12+
const config = useMemo(() => defaultConfig(), [])
13+
14+
const schema = useMemo<FormKitSchemaNode[]>(
15+
() => [
16+
{
17+
$el: 'div',
18+
attrs: { class: 'schema-box' },
19+
children: [
20+
{ $el: 'h2', children: 'Schema (React renderer)' },
21+
{
22+
$formkit: 'text',
23+
name: 'schema_message',
24+
label: 'Schema Input',
25+
help: 'Rendered by FormKitSchema in React',
26+
},
27+
],
28+
},
29+
],
30+
[]
31+
)
32+
33+
return (
34+
<main className="page">
35+
<h1>FormKit React Dev Server</h1>
36+
<p>Interactive playground for @formkit/react.</p>
37+
38+
<FormKitProvider config={config}>
39+
<FormKit
40+
type="form"
41+
id="react-playground-form"
42+
submitLabel="Submit"
43+
onSubmit={(data) => setSubmitted(data as Record<string, unknown>)}
44+
>
45+
<FormKit type="text" name="email" label="Email" validation="required|email" />
46+
<FormKit type="checkbox" name="updates" label="Email me updates" />
47+
<FormKit
48+
type="select"
49+
name="country"
50+
label="Country"
51+
options={[
52+
{ label: 'United States', value: 'US' },
53+
{ label: 'Canada', value: 'CA' },
54+
]}
55+
/>
56+
<FormKitSchema schema={schema} />
57+
</FormKit>
58+
</FormKitProvider>
59+
60+
<section className="output">
61+
<h2>Submitted value</h2>
62+
<pre>{submitted ? JSON.stringify(submitted, null, 2) : 'Submit the form.'}</pre>
63+
</section>
64+
</main>
65+
)
66+
}

examples/react/src/main.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { StrictMode } from 'react'
2+
import { createRoot } from 'react-dom/client'
3+
import { App } from './App'
4+
import '@formkit/themes/genesis'
5+
import './styles.css'
6+
7+
const root = document.getElementById('react-app')
8+
9+
if (!root) {
10+
throw new Error('Missing #react-app root element')
11+
}
12+
13+
createRoot(root).render(
14+
<StrictMode>
15+
<App />
16+
</StrictMode>
17+
)

examples/react/src/styles.css

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
* {
2+
box-sizing: border-box;
3+
}
4+
5+
body {
6+
font-family: "Avenir Next", "Segoe UI", sans-serif;
7+
margin: 0;
8+
background: #f7f8fc;
9+
color: #1e2230;
10+
}
11+
12+
.page {
13+
max-width: 760px;
14+
margin: 2rem auto;
15+
padding: 0 1rem;
16+
}
17+
18+
h1 {
19+
margin: 0 0 0.5rem;
20+
}
21+
22+
h2 {
23+
margin: 0 0 0.5rem;
24+
}
25+
26+
.schema-box {
27+
margin-top: 1rem;
28+
padding: 1rem;
29+
border: 1px solid #d9deeb;
30+
border-radius: 10px;
31+
background: #fff;
32+
}
33+
34+
.output {
35+
margin-top: 1.25rem;
36+
padding: 1rem;
37+
border: 1px solid #d9deeb;
38+
border-radius: 10px;
39+
background: #fff;
40+
}
41+
42+
pre {
43+
margin: 0;
44+
white-space: pre-wrap;
45+
}

examples/vite.react.config.mts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from 'vite'
2+
import react from '@vitejs/plugin-react'
3+
4+
export default defineConfig({
5+
root: './examples/react',
6+
plugins: [react()],
7+
build: {
8+
minify: false,
9+
},
10+
})

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@
1313
"@rollup/plugin-node-resolve": "^16.0.0",
1414
"@rollup/plugin-typescript": "^12.1.0",
1515
"@types/node": "^22.10.0",
16+
"@types/react": "^19.0.3",
17+
"@types/react-dom": "^19.0.2",
1618
"@types/postcss-import": "^14.0.3",
1719
"typescript-eslint": "^8.18.0",
20+
"@vitejs/plugin-react": "^4.3.4",
1821
"@vitejs/plugin-vue-jsx": "^4.1.0",
1922
"@vue/eslint-config-typescript": "^14.1.0",
2023
"@vue/server-renderer": "^3.5.0",
2124
"@vue/shared": "^3.5.0",
2225
"@vue/test-utils": "^2.4.6",
2326
"@tailwindcss/vite": "^4.0.0",
27+
"@testing-library/react": "^16.1.0",
2428
"autoprefixer": "^10.4.20",
2529
"axios": "^1.7.9",
2630
"cac": "^6.7.14",
@@ -55,6 +59,8 @@
5559
"stylelint-config-standard": "^36.0.1",
5660
"tailwindcss": "^4.0.0",
5761
"terser": "^5.37.0",
62+
"react": "^19.0.0",
63+
"react-dom": "^19.0.0",
5864
"tsup": "^8.3.5",
5965
"typescript": "^5.7.2",
6066
"unplugin": "^2.1.0",
@@ -74,7 +80,9 @@
7480
"translate": "node scripts/cli.mjs --script translate",
7581
"cli": "node scripts/cli.mjs",
7682
"dev": "vite --config ./examples/vite.config.mts --host",
83+
"dev-react": "pnpm -C packages/react dev",
7784
"dev-build": "vite build --config ./examples/vite.config.mts",
85+
"dev-react-build": "pnpm -C packages/react dev-build",
7886
"lint": "node scripts/lint.mjs",
7987
"playwright": "playwright test --config ./playwright.config.ts",
8088
"playwright-build": "NODE_ENV=production vite build --config ./examples/vite.config.mts",

0 commit comments

Comments
 (0)