From 0f1a38c18f37a6aa91550b1b2973308a62f1ee8c Mon Sep 17 00:00:00 2001 From: mayrang Date: Thu, 28 May 2026 05:21:14 +0900 Subject: [PATCH] test(e2e): full Playwright coverage for v0.2 release + RHF discard fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Playwright coverage for every public surface, both v0.1 features that previously had only unit-test coverage and the v0.2 additions. Example app: - Hash-based router with a landing index and one page per demo - WizardPage (existing SignupWizard, unchanged behavior) - ConflictPage exercises both and - AutoStoragePage exercises autoAdapter migration logic with a low 5,000-char threshold so the e2e can bloat past it in one click - ExternalControlPage drives a draft via getFormDraft() from one sibling and reads status via useFormDraftStatus from another - HeartbeatPage runs a 1s interval probe against a route-intercepted /__heartbeat__ endpoint - RhfPage / FormikPage / TanstackPage one-input demos per adapter - SessionStoragePage / IndexedDBPage exercise non-default storage E2E specs (28 scenarios × 3 engines = 84 tests): - tests/e2e/conflict.spec.ts: cross-tab conflict, dialog merge, Esc dismiss, headless resolver pick-all-remote, dialog aria-modal + focus contract - tests/e2e/auto-storage.spec.ts: small → localStorage, bloat → IndexedDB migration, discard clears both - tests/e2e/external-control.spec.ts: status sibling reactivity, external getValues / save / discard - tests/e2e/heartbeat.spec.ts: online → offline → online transitions via page.route() interception (context.setOffline doesn't always intercept same-origin loopback) - tests/e2e/adapters.spec.ts: persist+restore+discard for RHF / Formik / TanStack - tests/e2e/storage-adapters.spec.ts: sessionStorage and IndexedDB persist + don't-leak Bug fix: - useFormDraftRHF.discard() now wraps draft.discard() to also call form.reset(defaultValues, { keepDefaultValues: true }), mirroring the Formik / TanStack adapters. Previously discard cleared storage but the RHF input still showed the stale text. Worse, RHF's watch subscription then fired on the reset and re-patched the empty defaults back into the just-cleared draft, silently un-discarding. - Added ignoreNextWatchRef to swallow the library-initiated reset events on both restore and discard paths. - New unit test locks the regression in. Also bumps package.json to 0.2.0, updates README badges + status section (202 unit tests + 84 Playwright e2e, 5.42 KB / 8 KB), and removes Formik/TanStack adapter from the open contribution list. --- .gitignore | 2 + README.md | 17 +- example/package-lock.json | 197 +++++++++++- example/package.json | 2 + example/src/App.tsx | 330 ++++++--------------- example/src/pages/AutoStoragePage.tsx | 95 ++++++ example/src/pages/ConflictPage.tsx | 159 ++++++++++ example/src/pages/ExternalControlPage.tsx | 139 +++++++++ example/src/pages/FormikPage.tsx | 54 ++++ example/src/pages/HeartbeatPage.tsx | 62 ++++ example/src/pages/IndexedDBPage.tsx | 48 +++ example/src/pages/RhfPage.tsx | 51 ++++ example/src/pages/SessionStoragePage.tsx | 48 +++ example/src/pages/TanstackPage.tsx | 79 +++++ example/src/pages/WizardPage.tsx | 256 ++++++++++++++++ package.json | 2 +- src/rhf/__tests__/useFormDraftRHF.test.tsx | 25 +- src/rhf/useFormDraftRHF.ts | 38 ++- tests/e2e/adapters.spec.ts | 82 +++++ tests/e2e/auto-storage.spec.ts | 65 ++++ tests/e2e/conflict.spec.ts | 139 +++++++++ tests/e2e/external-control.spec.ts | 60 ++++ tests/e2e/heartbeat.spec.ts | 51 ++++ tests/e2e/storage-adapters.spec.ts | 58 ++++ 24 files changed, 1804 insertions(+), 255 deletions(-) create mode 100644 example/src/pages/AutoStoragePage.tsx create mode 100644 example/src/pages/ConflictPage.tsx create mode 100644 example/src/pages/ExternalControlPage.tsx create mode 100644 example/src/pages/FormikPage.tsx create mode 100644 example/src/pages/HeartbeatPage.tsx create mode 100644 example/src/pages/IndexedDBPage.tsx create mode 100644 example/src/pages/RhfPage.tsx create mode 100644 example/src/pages/SessionStoragePage.tsx create mode 100644 example/src/pages/TanstackPage.tsx create mode 100644 example/src/pages/WizardPage.tsx create mode 100644 tests/e2e/adapters.spec.ts create mode 100644 tests/e2e/auto-storage.spec.ts create mode 100644 tests/e2e/conflict.spec.ts create mode 100644 tests/e2e/external-control.spec.ts create mode 100644 tests/e2e/heartbeat.spec.ts create mode 100644 tests/e2e/storage-adapters.spec.ts diff --git a/.gitignore b/.gitignore index c272db7..e86dba4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ coverage/ .vite/ example/node_modules/ example/dist/ +example/src/**/*.js +example/tsconfig.tsbuildinfo .size-limit-cache/ playwright-report/ test-results/ diff --git a/README.md b/README.md index d040a99..10f242f 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ > Production-grade form auto-save + offline survival for React. Zero runtime dependencies. -[![npm version](https://img.shields.io/npm/v/formdraft/rc?label=npm%20rc&color=cb3837)](https://www.npmjs.com/package/formdraft) +[![npm version](https://img.shields.io/npm/v/formdraft?label=npm&color=cb3837)](https://www.npmjs.com/package/formdraft) [![bundle size](https://img.shields.io/bundlephobia/minzip/formdraft?label=gzipped)](https://bundlephobia.com/package/formdraft) [![license](https://img.shields.io/npm/l/formdraft)](LICENSE) [![zero deps](https://img.shields.io/badge/runtime%20deps-0-success)](#zero-runtime-dependencies) -> ⚠️ **v0.1.0-rc.2 (release candidate).** Code-complete with 94 unit tests + 21 Playwright e2e tests (Chromium / Firefox / WebKit). Looking for production feedback before v0.1.0 stable. Try it, report bugs at https://github.com/mayrang/formdraft/issues. +> **v0.2.0** — 202 unit tests + 84 Playwright e2e tests (28 scenarios × Chromium / Firefox / WebKit). New in v0.2: Formik / TanStack Form adapters, `autoAdapter` (localStorage → IndexedDB), `getFormDraft` programmatic handle, `useFormDraftStatus` sibling reader, heartbeat detector, and field-level merge UI (`ConflictResolver` / `ConflictDialog` under `formdraft/ui`). ![demo](docs/assets/demo.gif) @@ -396,8 +396,7 @@ If your app must defend against malicious same-origin code (e.g., third-party sc PRs welcome. Especially: - Vue / Svelte / Solid adapters (architecture is framework-agnostic at the core; bindings live in `src//`) -- Formik / TanStack Form / Felte adapters -- Network heartbeat plugin (`navigator.onLine` is unreliable) +- Felte / React Final Form adapters - Real-world bug reports with reproduction Local development: @@ -415,7 +414,7 @@ npm run build A: Restore is asynchronous. The hook reads storage in a `useEffect`. The default values render first, then values restore on the next paint. To force-show a loading state while restoring, check `pendingChanges === false && values === defaultValues` for the first ~50ms. **Q: Can I use this with TanStack Form / Formik?** -A: Use the headless `useFormDraft` and wire your form lib's values into it via the form lib's watch API. The RHF adapter is a thin convenience wrapper. PRs for Formik / TanStack Form adapters welcome (target `formdraft/` subpath). +A: Yes — both ship as dedicated adapters in v0.2 alongside the RHF adapter. Import from `formdraft/formik` or `formdraft/tanstack-form` and wrap your form instance. See the "Form library integrations" section above for the wiring. **Q: My form has 100+ fields and IndexedDB feels slow.** A: localStorage handles up to ~5 MB synchronously; IndexedDB is recommended for forms with large binary content (base64 images, long markdown). Pure text forms should stay on localStorage. @@ -446,11 +445,11 @@ A: No. formdraft uses BroadcastChannel, navigator.onLine, IndexedDB — all brow ## Status -- **v0.1.0-rc.1** on [npm](https://www.npmjs.com/package/formdraft) (RC — looking for production feedback) -- 94 unit tests + 21 Playwright e2e (7 headline scenarios × Chromium / Firefox / WebKit) -- **~3.85 KB brotli** (8 KB CI gate) +- **v0.2.0** on [npm](https://www.npmjs.com/package/formdraft) +- 202 unit tests + 84 Playwright e2e (28 scenarios × Chromium / Firefox / WebKit) +- **~5.42 KB brotli** (8 KB CI gate) — UI helpers ship as a separate `formdraft/ui` chunk - React 18+; Browser support Chrome/Edge 88+, Firefox 78+, Safari 15.4+ -- WebKit (iOS Safari engine): persist, restore, discard race, offline queue, submit broadcast all verified e2e +- Every adapter (RHF / Formik / TanStack Form) and every storage backend (localStorage / sessionStorage / IndexedDB / autoAdapter) is exercised end-to-end on all three engines - 0 runtime dependencies ## License diff --git a/example/package-lock.json b/example/package-lock.json index 1e0b89e..63d5f72 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -6,7 +6,9 @@ "": { "name": "formdraft-example", "dependencies": { + "@tanstack/react-form": "^1.32.1", "formdraft": "file:..", + "formik": "^2.4.9", "react": "^18.3.0", "react-dom": "^18.3.0", "react-hook-form": "^7.50.0", @@ -24,7 +26,9 @@ "version": "0.1.0-rc.1", "license": "MIT", "devDependencies": { + "@playwright/test": "^1.60.0", "@size-limit/preset-small-lib": "^11.0.0", + "@tanstack/react-form": "^1.32.1", "@testing-library/react": "^16.0.0", "@types/node": "^20.19.41", "@types/react": "^18.3.0", @@ -33,6 +37,7 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^7.1.1", "fake-indexeddb": "^6.0.0", + "formik": "^2.4.9", "jsdom": "^25.0.0", "playwright": "^1.60.0", "react": "^18.3.0", @@ -45,11 +50,19 @@ "zod": "^3.23.0" }, "peerDependencies": { + "@tanstack/react-form": ">=1.0.0", + "formik": ">=2.4.0", "react": ">=18", "react-hook-form": ">=7.0.0", "zod": ">=3.0.0" }, "peerDependenciesMeta": { + "@tanstack/react-form": { + "optional": true + }, + "formik": { + "optional": true + }, "react-hook-form": { "optional": true }, @@ -1065,6 +1078,94 @@ "win32" ] }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", + "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.32.1.tgz", + "integrity": "sha512-5yTCJ1/0bBjdVDsZsqPpLMVZLLN/G39b+ONnwv4vjz2jDes4YAd63cVwti5RtWuGuS1yLc5tVrGl1rWyVYsNGw==", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-form": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.32.1.tgz", + "integrity": "sha512-GQ6IdIFnAJvhVaBZJIyVzi14c8b02W4SopJFzFZCjYFIAb5CfTZCbvHZ4Cnd5byd0OzTwLLW/R95noRKD+2+ZA==", + "dependencies": { + "@tanstack/form-core": "1.32.1", + "@tanstack/react-store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-form/node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1112,17 +1213,26 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" }, "node_modules/@types/react": { "version": "18.3.29", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", - "dev": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1231,8 +1341,7 @@ "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, "node_modules/debug": { "version": "4.4.3", @@ -1251,6 +1360,14 @@ } } }, + "node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.362", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.362.tgz", @@ -1308,6 +1425,30 @@ "resolved": "..", "link": true }, + "node_modules/formik": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", + "integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==", + "funding": [ + { + "type": "individual", + "url": "https://opencollective.com/formik" + } + ], + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.1", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1331,6 +1472,14 @@ "node": ">=6.9.0" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1360,6 +1509,16 @@ "node": ">=6" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1470,6 +1629,11 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" + }, "node_modules/react-hook-form": { "version": "7.76.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.1.tgz", @@ -1485,6 +1649,11 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1564,6 +1733,16 @@ "node": ">=0.10.0" } }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1607,6 +1786,14 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/example/package.json b/example/package.json index 6664e02..bd0d4cb 100644 --- a/example/package.json +++ b/example/package.json @@ -8,7 +8,9 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-form": "^1.32.1", "formdraft": "file:..", + "formik": "^2.4.9", "react": "^18.3.0", "react-dom": "^18.3.0", "react-hook-form": "^7.50.0", diff --git a/example/src/App.tsx b/example/src/App.tsx index 142811e..a025ddd 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,257 +1,113 @@ -import { z } from 'zod'; -import { useFormDraft, zodAdapter, localStorageAdapter } from 'formdraft'; +import { useEffect, useState, lazy, Suspense } from 'react'; import './App.css'; +import WizardPage from './pages/WizardPage'; + +const ConflictPage = lazy(() => import('./pages/ConflictPage')); +const AutoStoragePage = lazy(() => import('./pages/AutoStoragePage')); +const ExternalControlPage = lazy(() => import('./pages/ExternalControlPage')); +const HeartbeatPage = lazy(() => import('./pages/HeartbeatPage')); +const RhfPage = lazy(() => import('./pages/RhfPage')); +const FormikPage = lazy(() => import('./pages/FormikPage')); +const TanstackPage = lazy(() => import('./pages/TanstackPage')); +const SessionStoragePage = lazy(() => import('./pages/SessionStoragePage')); +const IndexedDBPage = lazy(() => import('./pages/IndexedDBPage')); + +type Route = + | 'wizard' + | 'conflict' + | 'auto-storage' + | 'external-control' + | 'heartbeat' + | 'rhf' + | 'formik' + | 'tanstack' + | 'session-storage' + | 'indexeddb' + | 'index'; + +function parseRoute(): Route { + const h = window.location.hash.replace(/^#\/?/, ''); + if (!h || h === 'wizard') return 'wizard'; + if (h === 'index') return 'index'; + if ( + h === 'conflict' || + h === 'auto-storage' || + h === 'external-control' || + h === 'heartbeat' || + h === 'rhf' || + h === 'formik' || + h === 'tanstack' || + h === 'session-storage' || + h === 'indexeddb' + ) return h; + return 'wizard'; +} -const Schema = z.object({ - email: z.string(), - password: z.string(), - name: z.string(), - bio: z.string(), - newsletter: z.boolean(), - theme: z.enum(['light', 'dark']), - step: z.number().min(1).max(5), -}); - -type V = z.infer; - -const DEFAULTS: V = { - email: '', password: '', name: '', bio: '', - newsletter: true, theme: 'light', step: 1, -}; - -const STEP_LABELS = ['Account', 'Profile', 'Preferences', 'Notifications', 'Confirm']; - -export default function App() { - const draft = useFormDraft({ - key: 'signup-wizard', - schema: zodAdapter(Schema), - defaultValues: DEFAULTS, - storage: localStorageAdapter(), - sync: async (v) => { - await new Promise((r) => setTimeout(r, 800)); - console.log('[sync]', v); - }, - syncDebounceMs: 1500, - excludeFields: ['password'], - }); - - const next = () => draft.set('step', Math.min(5, draft.values.step + 1) as V['step']); - const prev = () => draft.set('step', Math.max(1, draft.values.step - 1) as V['step']); +function useHashRoute(): Route { + const [route, setRoute] = useState(parseRoute); + useEffect(() => { + const handler = () => setRoute(parseRoute()); + window.addEventListener('hashchange', handler); + return () => window.removeEventListener('hashchange', handler); + }, []); + return route; +} +const DEMOS: Array<{ hash: Route; title: string; desc: string }> = [ + { hash: 'wizard', title: 'Signup wizard (default)', desc: '5-step localStorage wizard with excludeFields + offline sync.' }, + { hash: 'conflict', title: 'Conflict UI', desc: 'multiTab=warn + / field-level merge.' }, + { hash: 'auto-storage', title: 'autoAdapter', desc: 'localStorage → IndexedDB fallback on quota or large payloads.' }, + { hash: 'external-control', title: 'getFormDraft + useFormDraftStatus', desc: 'Drive a draft programmatically from outside the React tree.' }, + { hash: 'heartbeat', title: 'Heartbeat detector', desc: 'Background HEAD probe for captive portals; cheap cached read.' }, + { hash: 'rhf', title: 'React Hook Form adapter', desc: 'register + watch wiring against useFormDraft.' }, + { hash: 'formik', title: 'Formik adapter', desc: 'getFieldProps + setValues wiring.' }, + { hash: 'tanstack', title: 'TanStack Form adapter', desc: 'FormApi wiring.' }, + { hash: 'session-storage', title: 'sessionStorageAdapter', desc: 'Per-tab persistence; survives reload, not close.' }, + { hash: 'indexeddb', title: 'indexedDBAdapter', desc: 'Async storage with quota headroom.' }, +]; + +function Index() { return (
-

formdraft

-

- Fill out the form, then refresh the page. Your typing survives. -

+

formdraft demos

+

Each page exercises one feature. Open in a browser and follow the steps.

- - -
-
-

- Step {draft.values.step}: {STEP_LABELS[draft.values.step - 1]} -

- -
-
- {draft.values.step === 1 && ( - <> - - draft.set('email', e.target.value)} - /> - - - draft.set('password', e.target.value)} - /> - - - )} - - {draft.values.step === 2 && ( - <> - - draft.set('name', e.target.value)} - /> - - -