From 92afcaad27304cf0d1838498c192dadd782e5359 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 1 Feb 2026 17:46:29 +0100 Subject: [PATCH 1/2] Add React 19 hooks --- README.md | 147 +++++++++++- package-lock.json | 405 ++++++++++++-------------------- package.json | 8 +- src/React/Basic/Hooks.js | 36 +++ src/React/Basic/Hooks.purs | 151 +++++++++++- src/React/Basic/Hooks/Aff.js | 34 +++ src/React/Basic/Hooks/Aff.purs | 98 +++++++- test/Spec/React19HooksSpec.purs | 156 ++++++++++++ 8 files changed, 769 insertions(+), 266 deletions(-) create mode 100644 src/React/Basic/Hooks/Aff.js create mode 100644 test/Spec/React19HooksSpec.purs diff --git a/README.md b/README.md index f938b52..54f727f 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,21 @@ `react-basic-hooks` is a React hook API for [react-basic](https://github.com/lumihq/purescript-react-basic). -_Note:_ This API relies on React `>=16.8.0`. For more info on hooks, see [React's documentation](https://reactjs.org/docs/hooks-intro.html). +_Note:_ This library supports React `>=16.8.0` with full React 19 support. For more info on hooks, see [React's documentation](https://react.dev/reference/react). -I recommend using PureScript's "qualified do" syntax while using this library (it's used in the examples, the `React.do` bits). +I recommend using PureScript's "qualified do" syntax whilst using this library (it's used in the examples, the `React.do` bits). It became available in the `0.12.2` compiler release. This library provides the `React.Basic.Hooks` module, which can completely replace the `React.Basic` module. It borrows a few types from the current `React.Basic` module like `ReactComponent` and `JSX` to make it easy to use both versions in the same project. If we prefer this API over the existing react-basic API, we may eventually replace `React.Basic` with this implementation. +## React Version Support + +- **React 16.8+**: Core hooks (useState, useEffect, useReducer, useRef, useContext, useMemo, useDebugValue, useLayoutEffect) +- **React 18+**: useId, useTransition, useDeferredValue, useSyncExternalStore, useInsertionEffect +- **React 19+**: useOptimistic, useActionState, useEffectEvent (experimental) + ## Example ```purs @@ -27,3 +33,140 @@ mkCounter = do [ R.text $ "Increment: " <> show counter ] } ``` + +## React 19 Hooks + +### useOptimistic + +Optimistically update the UI whilst waiting for an async action to complete. The optimistic state automatically reverts to the actual state when the action finishes. + +```purs +mkMessageList :: Component Props +mkMessageList = do + component "MessageList" \{ messages } -> React.do + optimisticMessages /\ addOptimisticMessage <- useOptimistic messages \state newMessage -> + Array.snoc state newMessage + + isPending /\ startTransition <- useTransition + + let handleSend message = startTransition do + addOptimisticMessage message + -- Async operation to send message to server + sendToServer message + + pure $ R.div_ (map renderMessage optimisticMessages) +``` + +### useActionState + +Manage form actions with built-in pending state. The action function receives the previous state and form data, making it ideal for form submissions. Uses Effect for synchronous operations. + +```purs +mkForm :: Component Unit +mkForm = do + component "Form" \_ -> React.do + state /\ (formAction /\ isPending) <- useActionState initialState updateFn + where + updateFn prevState formData = do + -- Process form submission (Effect version) + result <- submitToServer formData + pure (Result.fromEither result) + + pure $ R.button + { disabled: isPending + , onClick: handler_ (formAction myFormData) + , children: [ R.text if isPending then "Submitting..." else "Submit" ] + } +``` + +For progressive enhancement (form works without JavaScript), use `useActionStateWithPermalink`: + +```purs +state /\ (formAction /\ isPending) <- useActionStateWithPermalink initialState updateFn "/api/submit" + +pure $ R.form + { action: formAction -- Falls back to /api/submit without JS + , children: [ ... ] + } +``` + +### useAffActionState + +Aff version of `useActionState` for async operations. Available in `React.Basic.Hooks.Aff`. Uses Aff for natural async handling. + +```purs +import React.Basic.Hooks.Aff (useAffActionState) + +mkForm :: Component Unit +mkForm = do + component "Form" \_ -> React.do + state /\ (formAction /\ isPending) <- useAffActionState initialState affFn + where + affFn prevState formData = do + -- Process form submission (Aff version - natural async!) + result <- Aff.submitToServer formData + pure (Result.fromEither result) + + pure $ R.button + { disabled: isPending + , onClick: handler_ (formAction myFormData) + , children: [ R.text if isPending then "Submitting..." else "Submit" ] + } +``` + +With permalink: `useAffActionStateWithPermalink initialState affFn "/api/submit"` + +### useEffectEvent + +Extract non-reactive logic from Effects. The returned function can access the latest props and state without causing the Effect to re-run when those values change. + +```purs +mkComponent :: Component Props +mkComponent = do + component "Component" \{ url, onSuccess } -> React.do + count /\ setCount <- useState 0 + + -- onSuccess can use the latest count without re-running the effect + onSuccessEvent <- useEffectEvent \data -> do + onSuccess data count + + -- Effect only re-runs when url changes, not when count changes + useEffect url do + response <- fetchData url + onSuccessEvent response + pure mempty + + pure $ R.div_ [ ... ] +``` + +## Available Hooks + +### Core Hooks (React 16.8+) +- `useState` / `useState'` — State management +- `useEffect` / `useEffectOnce` / `useEffectAlways` — Side effects +- `useLayoutEffect` — Synchronous layout effects +- `useReducer` — State management with reducers +- `useRef` — Mutable refs +- `useContext` — Context consumption +- `useMemo` — Memoised computation +- `useDebugValue` — DevTools debugging labels + +### React 18 Hooks +- `useId` — Unique ID generation +- `useTransition` — Concurrent transitions +- `useDeferredValue` — Deferred value updates +- `useSyncExternalStore` — External store synchronisation +- `useInsertionEffect` — DOM mutation effects + +### React 19 Hooks +- `useOptimistic` — Optimistic UI updates +- `useActionState` — Form action management +- `useEffectEvent` — Non-reactive effect logic (experimental) + +### Additional Features +- `memo` / `memo'` — Component memoisation +- `component` — Component creation +- Custom hooks via `React.Basic.Hooks.Aff` for async effects +- `React.Basic.Hooks.Suspense` for Suspense support +- `React.Basic.Hooks.ErrorBoundary` for error boundaries +``` diff --git a/package-lock.json b/package-lock.json index 7e3a1c3..f4f6ffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { - "react": "^18.1.0", - "react-dom": "^18.1.0" + "react": "19.0.0", + "react-dom": "19.0.0" }, "devDependencies": { - "@testing-library/react": "^13.2.0", - "@testing-library/user-event": "^14.2.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", "bower": "^1.8.14", "global-jsdom": "^8.4.0", "jsdom": "^19.0.0", @@ -229,110 +229,60 @@ } }, "node_modules/@testing-library/dom": { - "version": "8.11.1", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", - "@types/aria-query": "^4.2.0", - "aria-query": "^5.0.0", - "chalk": "^4.1.0", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.4.4", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=12" - } - }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/dom/node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/dom/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/@testing-library/react": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.2.0.tgz", - "integrity": "sha512-Bprbz/SZVONCJy5f7hcihNCv313IJXdYiv0nSJklIs1SQCIHHNlnGNkosSXnGZTmesyGIcBGNppYhXcc11pb7g==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.5.0", - "@types/react-dom": "^18.0.0" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">=12" + "node": ">=18" }, "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@testing-library/user-event": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.2.0.tgz", - "integrity": "sha512-+hIlG4nJS6ivZrKnOP7OGsDu9Fxmryj9vCl8x0ZINtTJcCHs2zLsYif5GzuRiBF2ck5GZG2aQr7Msg+EHlnYVQ==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12", "npm": ">=6" @@ -350,7 +300,9 @@ } }, "node_modules/@types/aria-query": { - "version": "4.2.2", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT" }, @@ -384,33 +336,15 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true - }, - "node_modules/@types/react": { - "version": "18.0.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.9.tgz", - "integrity": "sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==", - "dev": true, - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.0.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.4.tgz", - "integrity": "sha512-FgTtbqPOCI3dzZPZoC2T/sx3L34qxy99ITWn4eoSA95qPyXDMH0ALoAqUp49ITniiJFsXUVBtalh/KffMpg21Q==", "dev": true, - "dependencies": { - "@types/react": "*" - } + "optional": true }, "node_modules/@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true + "dev": true, + "optional": true }, "node_modules/@types/yargs": { "version": "16.0.4", @@ -576,11 +510,13 @@ ] }, "node_modules/aria-query": { - "version": "5.0.0", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "engines": { - "node": ">=6.0" + "dependencies": { + "dequal": "^2.0.3" } }, "node_modules/asn1": { @@ -1320,7 +1256,8 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==", - "dev": true + "dev": true, + "optional": true }, "node_modules/cyclist": { "version": "1.0.1", @@ -1435,6 +1372,16 @@ "deps-sort": "bin/cmd.js" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/des.js": { "version": "1.0.1", "dev": true, @@ -2478,6 +2425,7 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "dev": true, "license": "MIT" }, "node_modules/jsbn": { @@ -2490,6 +2438,7 @@ "version": "19.0.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "abab": "^2.0.5", "acorn": "^8.5.0", @@ -2724,17 +2673,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2745,9 +2683,11 @@ } }, "node_modules/lz-string": { - "version": "1.4.4", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "license": "WTFPL", + "license": "MIT", "bin": { "lz-string": "bin/bin.js" } @@ -3699,6 +3639,13 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/pidtree": { "version": "0.3.1", "dev": true, @@ -4027,26 +3974,26 @@ } }, "node_modules/react": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", - "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", - "dependencies": { - "loose-envify": "^1.1.0" - }, + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", - "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "peer": true, "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.22.0" + "scheduler": "^0.25.0" }, "peerDependencies": { - "react": "^18.1.0" + "react": "^19.0.0" } }, "node_modules/react-is": { @@ -4253,12 +4200,10 @@ } }, "node_modules/scheduler": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", - "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" }, "node_modules/semver": { "version": "5.7.1", @@ -5351,73 +5296,35 @@ } }, "@testing-library/dom": { - "version": "8.11.1", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, + "peer": true, "requires": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", - "@types/aria-query": "^4.2.0", - "aria-query": "^5.0.0", - "chalk": "^4.1.0", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.4.4", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "@testing-library/react": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.2.0.tgz", - "integrity": "sha512-Bprbz/SZVONCJy5f7hcihNCv313IJXdYiv0nSJklIs1SQCIHHNlnGNkosSXnGZTmesyGIcBGNppYhXcc11pb7g==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "requires": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.5.0", - "@types/react-dom": "^18.0.0" + "@babel/runtime": "^7.12.5" } }, "@testing-library/user-event": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.2.0.tgz", - "integrity": "sha512-+hIlG4nJS6ivZrKnOP7OGsDu9Fxmryj9vCl8x0ZINtTJcCHs2zLsYif5GzuRiBF2ck5GZG2aQr7Msg+EHlnYVQ==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "requires": {} }, @@ -5426,7 +5333,9 @@ "dev": true }, "@types/aria-query": { - "version": "4.2.2", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true }, "@types/istanbul-lib-coverage": { @@ -5452,36 +5361,16 @@ "dev": true }, "@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "version": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true - }, - "@types/react": { - "version": "18.0.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.9.tgz", - "integrity": "sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==", "dev": true, - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "@types/react-dom": { - "version": "18.0.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.4.tgz", - "integrity": "sha512-FgTtbqPOCI3dzZPZoC2T/sx3L34qxy99ITWn4eoSA95qPyXDMH0ALoAqUp49ITniiJFsXUVBtalh/KffMpg21Q==", - "dev": true, - "requires": { - "@types/react": "*" - } + "optional": true }, "@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "version": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true + "dev": true, + "optional": true }, "@types/yargs": { "version": "16.0.4", @@ -5591,8 +5480,13 @@ "dev": true }, "aria-query": { - "version": "5.0.0", - "dev": true + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "requires": { + "dequal": "^2.0.3" + } }, "asn1": { "version": "0.2.6", @@ -6179,10 +6073,10 @@ } }, "csstype": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", + "version": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==", - "dev": true + "dev": true, + "optional": true }, "cyclist": { "version": "1.0.1", @@ -6262,6 +6156,12 @@ "through2": "^2.0.0" } }, + "dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true + }, "des.js": { "version": "1.0.1", "dev": true, @@ -6985,7 +6885,8 @@ "dev": true }, "js-tokens": { - "version": "4.0.0" + "version": "4.0.0", + "dev": true }, "jsbn": { "version": "0.1.1", @@ -6996,6 +6897,7 @@ "jsdom": { "version": "19.0.0", "dev": true, + "peer": true, "requires": { "abab": "^2.0.5", "acorn": "^8.5.0", @@ -7166,14 +7068,6 @@ "wrap-ansi": "^5.0.0" } }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, "lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7184,7 +7078,9 @@ } }, "lz-string": { - "version": "1.4.4", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true }, "make-fetch-happen": { @@ -7871,6 +7767,12 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, "pidtree": { "version": "0.3.1", "dev": true @@ -8119,20 +8021,18 @@ } }, "react": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", - "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", - "requires": { - "loose-envify": "^1.1.0" - } + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "peer": true }, "react-dom": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", - "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "peer": true, "requires": { - "loose-envify": "^1.1.0", - "scheduler": "^0.22.0" + "scheduler": "^0.25.0" } }, "react-is": { @@ -8296,12 +8196,9 @@ } }, "scheduler": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", - "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", - "requires": { - "loose-envify": "^1.1.0" - } + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==" }, "semver": { "version": "5.7.1", diff --git a/package.json b/package.json index 2397ef3..5d62211 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,12 @@ "main": "index.js", "module": "true", "dependencies": { - "react": "^18.1.0", - "react-dom": "^18.1.0" + "react": "19.0.0", + "react-dom": "19.0.0" }, "devDependencies": { - "@testing-library/react": "^13.2.0", - "@testing-library/user-event": "^14.2.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", "bower": "^1.8.14", "global-jsdom": "^8.4.0", "jsdom": "^19.0.0", diff --git a/src/React/Basic/Hooks.js b/src/React/Basic/Hooks.js index 255e7d4..a698bce 100644 --- a/src/React/Basic/Hooks.js +++ b/src/React/Basic/Hooks.js @@ -106,3 +106,39 @@ export function displayName(component) { ? component : component.displayName || "[unknown]"; } + +export function useOptimistic_(tuple, state, updateFn) { + const [optimisticState, addOptimistic] = React.useOptimistic(state, updateFn); + if (!addOptimistic.hasOwnProperty("$$reactBasicHooks$$cachedAddOptimistic")) { + addOptimistic.$$reactBasicHooks$$cachedAddOptimistic = (action) => () => + addOptimistic(action); + } + return tuple(optimisticState, addOptimistic.$$reactBasicHooks$$cachedAddOptimistic); +} + +export function useActionState_(tuple3, fn, initialState) { + const [state, formAction, isPending] = React.useActionState( + fn, + initialState + ); + if (!formAction.hasOwnProperty("$$reactBasicHooks$$cachedFormAction")) { + formAction.$$reactBasicHooks$$cachedFormAction = (formData) => () => + formAction(formData); + } + return tuple3(state, formAction.$$reactBasicHooks$$cachedFormAction, isPending); +} + +export function useActionStateWithPermalink_(tuple3, fn, initialState, permalink) { + const [state, formAction, isPending] = React.useActionState( + fn, + initialState, + permalink + ); + if (!formAction.hasOwnProperty("$$reactBasicHooks$$cachedFormAction")) { + formAction.$$reactBasicHooks$$cachedFormAction = (formData) => () => + formAction(formData); + } + return tuple3(state, formAction.$$reactBasicHooks$$cachedFormAction, isPending); +} + +export const useEffectEvent_ = React.useEffectEvent || React.experimental_useEffectEvent; diff --git a/src/React/Basic/Hooks.purs b/src/React/Basic/Hooks.purs index 64537d5..208428b 100644 --- a/src/React/Basic/Hooks.purs +++ b/src/React/Basic/Hooks.purs @@ -51,6 +51,13 @@ module React.Basic.Hooks , useSyncExternalStore , useSyncExternalStore' , UseSyncExternalStore + , useOptimistic + , UseOptimistic + , useActionState + , useActionStateWithPermalink + , UseActionState + , useEffectEvent + , UseEffectEvent , UnsafeReference(..) , displayName , module React.Basic.Hooks.Internal @@ -61,14 +68,14 @@ module React.Basic.Hooks import Prelude hiding (bind, discard) import Data.Bifunctor (rmap) -import Data.Function.Uncurried (Fn2, mkFn2, runFn2) +import Data.Function.Uncurried (Fn2, Fn3, mkFn2, mkFn3, runFn2, runFn3) import Data.Maybe (Maybe) import Data.Newtype (class Newtype) -import Data.Nullable (Nullable, toMaybe) +import Data.Nullable (Nullable, toMaybe, toNullable) import Data.Tuple (Tuple(..)) import Data.Tuple.Nested (type (/\), (/\)) import Effect (Effect) -import Effect.Uncurried (EffectFn1, EffectFn2, EffectFn3, mkEffectFn1, runEffectFn1, runEffectFn2, runEffectFn3) +import Effect.Uncurried (EffectFn1, EffectFn2, EffectFn3, EffectFn4, mkEffectFn1, mkEffectFn2, runEffectFn1, runEffectFn2, runEffectFn3, runEffectFn4) import Prelude (bind) as Prelude import Prim.Row (class Lacks) import React.Basic (JSX, ReactComponent, ReactContext, Ref, consumer, contextConsumer, contextProvider, createContext, element, elementKeyed, empty, keyed, fragment, provider) @@ -420,6 +427,111 @@ useSyncExternalStore' subscribe getSnapshot = unsafeHook $ runEffectFn2 useSyncExternalStore2_ (mkEffectFn1 subscribe) getSnapshot +foreign import data UseOptimistic :: Type -> Type -> Type -> Type + +--| Optimistically update state before an async action completes. +--| The optimistic state automatically reverts when the action finishes. +--| +--| ```purs +--| optimisticMessages /\ addOptimisticMessage <- useOptimistic messages \state newMessage -> +--| Array.snoc state newMessage +--| +--| let handleSend = do +--| addOptimisticMessage newMessage +--| sendToServer newMessage +--| ``` +useOptimistic :: + forall state action. + state -> + (state -> action -> state) -> + Hook (UseOptimistic state action) (state /\ (action -> Effect Unit)) +useOptimistic state updateFn = + unsafeHook do + runEffectFn3 useOptimistic_ + (mkFn2 Tuple) + state + (mkFn2 updateFn) + +foreign import data UseActionState :: Type -> Type -> Type -> Type + +--| Manage form actions with built-in pending state and error handling. +--| The action function receives the previous state and form data. +--| +--| ```purs +--| state /\ formAction /\ isPending <- useActionState initialState updateFn +--| +--| pure $ R.form +--| { children: +--| [ R.button +--| { disabled: isPending +--| , onClick: handler_ (formAction myFormData) +--| } +--| ] +--| } +--| ``` +useActionState :: + forall state formData. + state -> + (state -> formData -> Effect state) -> + Hook (UseActionState state formData) (state /\ ((formData -> Effect Unit) /\ Boolean)) +useActionState initialState fn = + unsafeHook do + runEffectFn3 useActionState_ + mkTuple3 + (mkEffectFn2 fn) + initialState + where + mkTuple3 :: forall a b c. Fn3 a b c (a /\ (b /\ c)) + mkTuple3 = mkFn3 \a b c -> Tuple a (Tuple b c) + +--| Like `useActionState` but with a permalink for progressive enhancement. +--| The form will submit to this URL if JavaScript is disabled. +--| +--| ```purs +--| state /\ formAction /\ isPending <- useActionStateWithPermalink initialState updateFn "/api/submit" +--| +--| pure $ R.form +--| { action: formAction +--| , children: [ ... ] +--| } +--| ``` +useActionStateWithPermalink :: + forall state formData. + state -> + (state -> formData -> Effect state) -> + String -> + Hook (UseActionState state formData) (state /\ ((formData -> Effect Unit) /\ Boolean)) +useActionStateWithPermalink initialState fn permalink = + unsafeHook do + runEffectFn4 useActionStateWithPermalink_ + mkTuple3 + (mkEffectFn2 fn) + initialState + permalink + where + mkTuple3 :: forall a b c. Fn3 a b c (a /\ (b /\ c)) + mkTuple3 = mkFn3 \a b c -> Tuple a (Tuple b c) + +foreign import data UseEffectEvent :: Type -> Type -> Type + +--| Extract non-reactive logic from Effects. The returned function can access +--| the latest props and state without causing the Effect to re-run. +--| +--| ```purs +--| onClick <- useEffectEvent handleClick +--| +--| useEffect url do +--| -- onClick can use latest state without re-running when state changes +--| onClick unit +--| pure mempty +--| ``` +useEffectEvent :: + forall a b. + (a -> Effect b) -> + Hook (UseEffectEvent a) (a -> Effect b) +useEffectEvent callback = + unsafeHook (runEffectFn1 useEffectEvent_ callback) + newtype UnsafeReference a = UnsafeReference a @@ -575,4 +687,35 @@ foreign import useSyncExternalStore3_ :: forall a. EffectFn3 (EffectFn1 (Effect Unit) (Effect Unit)) -- subscribe (Effect a) -- getSnapshot (Effect a) -- getServerSnapshot - a \ No newline at end of file + a + +foreign import useOptimistic_ :: + forall state action. + EffectFn3 + (forall a b. Fn2 a b (a /\ b)) + state + (Fn2 state action state) + (state /\ (action -> Effect Unit)) + +foreign import useActionState_ :: + forall state formData. + EffectFn3 + (forall a b c. Fn3 a b c (a /\ (b /\ c))) + (EffectFn2 state formData state) + state + (state /\ ((formData -> Effect Unit) /\ Boolean)) + +foreign import useActionStateWithPermalink_ :: + forall state formData. + EffectFn4 + (forall a b c. Fn3 a b c (a /\ (b /\ c))) + (EffectFn2 state formData state) + state + String + (state /\ ((formData -> Effect Unit) /\ Boolean)) + +foreign import useEffectEvent_ :: + forall a b. + EffectFn1 + (a -> Effect b) + (a -> Effect b) \ No newline at end of file diff --git a/src/React/Basic/Hooks/Aff.js b/src/React/Basic/Hooks/Aff.js new file mode 100644 index 0000000..aee7fad --- /dev/null +++ b/src/React/Basic/Hooks/Aff.js @@ -0,0 +1,34 @@ +import React from "react"; + +// Aff version - accepts a function that returns a Promise +export function useAffActionState_(tuple3, affFn, initialState) { + const [state, formAction, isPending] = React.useActionState( + // affFn returns a Promise, React will await it + async (prevState, formData) => { + return await affFn(prevState, formData); + }, + initialState + ); + if (!formAction.hasOwnProperty("$$reactBasicHooks$$cachedFormAction")) { + formAction.$$reactBasicHooks$$cachedFormAction = (formData) => () => + formAction(formData); + } + return tuple3(state, formAction.$$reactBasicHooks$$cachedFormAction, isPending); +} + +export function useAffActionStateWithPermalink_(tuple3, affFn, initialState, permalink) { + const [state, formAction, isPending] = React.useActionState( + // affFn returns a Promise, React will await it + async (prevState, formData) => { + return await affFn(prevState, formData); + }, + initialState, + permalink + ); + if (!formAction.hasOwnProperty("$$reactBasicHooks$$cachedFormAction")) { + formAction.$$reactBasicHooks$$cachedFormAction = (formData) => () => + formAction(formData); + } + return tuple3(state, formAction.$$reactBasicHooks$$cachedFormAction, isPending); +} + diff --git a/src/React/Basic/Hooks/Aff.purs b/src/React/Basic/Hooks/Aff.purs index b05d404..8d08e95 100644 --- a/src/React/Basic/Hooks/Aff.purs +++ b/src/React/Basic/Hooks/Aff.purs @@ -8,20 +8,27 @@ module React.Basic.Hooks.Aff , runAffReducer , noEffects , UseAffReducer(..) + , useAffActionState + , useAffActionStateWithPermalink + , UseAffActionState(..) ) where import Prelude +import Control.Promise (Promise, fromAff) import Data.Either (Either(..)) import Data.Foldable (for_) -import Data.Function.Uncurried (Fn2, mkFn2, runFn2) +import Data.Function.Uncurried (Fn2, Fn3, mkFn2, mkFn3, runFn2) import Data.Maybe (Maybe(..)) import Data.Newtype (class Newtype) +import Data.Nullable (Nullable, toNullable) +import Data.Tuple (Tuple(..)) import Effect (Effect) import Effect.Aff (Aff, Error, error, killFiber, launchAff, launchAff_, throwError, try) import Effect.Class (liftEffect) +import Effect.Uncurried (EffectFn3, EffectFn4, runEffectFn3, runEffectFn4) import Effect.Unsafe (unsafePerformEffect) -import React.Basic.Hooks (type (&), type (/\), Hook, Reducer, UnsafeReference(..), UseEffect, UseMemo, UseReducer, UseState, coerceHook, mkReducer, unsafeRenderEffect, useEffect, useMemo, useReducer, useState, (/\)) +import React.Basic.Hooks (type (&), type (/\), Hook, Reducer, UnsafeReference(..), UseActionState, UseEffect, UseMemo, UseReducer, UseState, coerceHook, mkReducer, unsafeHook, unsafeRenderEffect, useEffect, useMemo, useReducer, useState, (/\)) import React.Basic.Hooks as React --| `useAff` is used for asynchronous effects or `Aff`. The asynchronous effect @@ -174,3 +181,90 @@ noEffects :: , effects :: Array (Aff (Array action)) } noEffects state = { state, effects: [] } + +--| Aff version of `useActionState` for managing async form actions. +--| The action function receives the previous state and form data, and returns +--| an `Aff` that resolves to the new state. React will automatically handle +--| the pending state whilst the Aff is running. +--| +--| *Note: Aff failures are thrown as React errors. If you need to capture an +--| error state, incorporate it into your state type (e.g., `Either Error MyState`)!* +--| +--| ```purs +--| state /\ formAction /\ isPending <- useAffActionState initialState \prevState formData -> do +--| result <- submitToServer formData +--| pure (processResult prevState result) +--| +--| pure $ R.button +--| { disabled: isPending +--| , onClick: handler_ (formAction myFormData) +--| } +--| ``` +useAffActionState :: + forall state formData. + state -> + (state -> formData -> Aff state) -> + Hook (UseAffActionState state formData) (state /\ ((formData -> Effect Unit) /\ Boolean)) +useAffActionState initialState affFn = + coerceHook React.do + unsafeHook do + let affFnAsPromise prevState formData = fromAff (affFn prevState formData) + runEffectFn3 useAffActionState_ + mkTuple3 + affFnAsPromise + initialState + where + mkTuple3 :: forall a b c. Fn3 a b c (a /\ (b /\ c)) + mkTuple3 = mkFn3 \a b c -> Tuple a (Tuple b c) + +--| Like `useAffActionState` but with a permalink for progressive enhancement. +--| The form will submit to this URL if JavaScript is disabled. +--| +--| ```purs +--| state /\ formAction /\ isPending <- useAffActionStateWithPermalink initialState affFn "/api/submit" +--| +--| pure $ R.form +--| { action: formAction +--| , children: [ ... ] +--| } +--| ``` +useAffActionStateWithPermalink :: + forall state formData. + state -> + (state -> formData -> Aff state) -> + String -> + Hook (UseAffActionState state formData) (state /\ ((formData -> Effect Unit) /\ Boolean)) +useAffActionStateWithPermalink initialState affFn permalink = + coerceHook React.do + unsafeHook do + let affFnAsPromise prevState formData = fromAff (affFn prevState formData) + runEffectFn4 useAffActionStateWithPermalink_ + mkTuple3 + affFnAsPromise + initialState + permalink + where + mkTuple3 :: forall a b c. Fn3 a b c (a /\ (b /\ c)) + mkTuple3 = mkFn3 \a b c -> Tuple a (Tuple b c) + +foreign import useAffActionState_ :: + forall state formData. + EffectFn3 + (forall a b c. Fn3 a b c (a /\ (b /\ c))) + (state -> formData -> Effect (Promise state)) + state + (state /\ ((formData -> Effect Unit) /\ Boolean)) + +foreign import useAffActionStateWithPermalink_ :: + forall state formData. + EffectFn4 + (forall a b c. Fn3 a b c (a /\ (b /\ c))) + (state -> formData -> Effect (Promise state)) + state + String + (state /\ ((formData -> Effect Unit) /\ Boolean)) + +newtype UseAffActionState state formData hooks + = UseAffActionState (hooks & UseActionState state formData) + +derive instance ntUseAffActionState :: Newtype (UseAffActionState state formData hooks) _ diff --git a/test/Spec/React19HooksSpec.purs b/test/Spec/React19HooksSpec.purs new file mode 100644 index 0000000..ec57190 --- /dev/null +++ b/test/Spec/React19HooksSpec.purs @@ -0,0 +1,156 @@ +module Test.Spec.React19HooksSpec where + +import Prelude + +import Control.Monad (when, void) +import Data.Array as Array +import Data.Maybe (Maybe(..)) +import Data.Monoid (guard) +import Data.Tuple.Nested ((/\)) +import Effect.Aff (Milliseconds(..), delay) +import Effect.Class (liftEffect) +import Effect.Ref as Ref +import Foreign.Object as Object +import React.Basic (fragment) +import React.Basic.DOM as R +import React.Basic.DOM.Events (targetValue) +import React.Basic.Events (handler, handler_) +import React.Basic.Hooks (reactComponent) +import React.Basic.Hooks as Hooks +import React.TestingLibrary (cleanup, fireEventClick, renderComponent, typeText) +import Test.Spec (Spec, after_, before, describe, it) +import Test.Spec.Assertions (shouldEqual) +import Test.Spec.Assertions.DOM (textContentShouldEqual) + +spec :: Spec Unit +spec = + after_ cleanup do + before setup do + describe "React 19 hooks" do + it "useOptimistic works" \{ useOptimistic } -> do + { findByTestId } <- renderComponent useOptimistic {} + displayElem <- findByTestId "display" + realElem <- findByTestId "real" + buttonElem <- findByTestId "button" + + -- Initial state + displayElem `textContentShouldEqual` "0" + realElem `textContentShouldEqual` "0" + + -- Click to trigger optimistic update within transition + fireEventClick buttonElem + + -- Both should be updated (since transition completes synchronously in test) + displayElem `textContentShouldEqual` "1" + realElem `textContentShouldEqual` "1" + + it "useActionState works" \{ useActionState } -> do + { findByTestId } <- renderComponent useActionState {} + stateElem <- findByTestId "state" + buttonElem <- findByTestId "button" + + -- Initial state + stateElem `textContentShouldEqual` "0" + + -- Click button to trigger action + fireEventClick buttonElem + + -- Should show updated state + stateElem `textContentShouldEqual` "1" + + it "useEffectEvent works" \{ useEffectEvent } -> do + { findByTestId } <- renderComponent useEffectEvent {} + countElem <- findByTestId "count" + effectCountElem <- findByTestId "effect-count" + incrementButton <- findByTestId "increment" + + -- Initial state + countElem `textContentShouldEqual` "0" + effectCountElem `textContentShouldEqual` "1" + + -- Click to increment count + fireEventClick incrementButton + fireEventClick incrementButton + fireEventClick incrementButton + + -- Count should update + countElem `textContentShouldEqual` "3" + + -- Effect should not have re-run (still 1) + effectCountElem `textContentShouldEqual` "1" + + where + setup = liftEffect ado + + useOptimistic <- + reactComponent "UseOptimisticExample" \(_ :: {}) -> Hooks.do + value /\ setValue <- Hooks.useState 0 + optimisticValue /\ addOptimistic <- Hooks.useOptimistic value \state increment -> + state + increment + _isPending /\ startTransition <- Hooks.useTransition + + let + handleClick = startTransition do + addOptimistic 1 + setValue (_ + 1) + + pure $ R.div_ + [ R.div + { _data: Object.singleton "testid" "display" + , children: [ R.text (show optimisticValue) ] + } + , R.div + { _data: Object.singleton "testid" "real" + , children: [ R.text (show value) ] + } + , R.button + { _data: Object.singleton "testid" "button" + , onClick: handler_ handleClick + , children: [ R.text "Add" ] + } + ] + + useActionState <- + reactComponent "UseActionStateExample" \(_ :: {}) -> Hooks.do + state /\ (formAction /\ _isPending) <- Hooks.useActionState 0 \prevState _ -> + pure (prevState + 1) + + pure $ R.div_ + [ R.div + { _data: Object.singleton "testid" "state" + , children: [ R.text (show state) ] + } + , R.button + { _data: Object.singleton "testid" "button" + , onClick: handler_ (formAction unit) + , children: [ R.text "Increment" ] + } + ] + + useEffectEvent <- + reactComponent "UseEffectEventExample" \(_ :: {}) -> Hooks.do + count /\ setCount <- Hooks.useState 0 + effectCount /\ setEffectCount <- Hooks.useState 0 + + -- Effect only depends on unit, so it runs once + Hooks.useEffect unit do + setEffectCount (_ + 1) + pure mempty + + pure $ R.div_ + [ R.div + { _data: Object.singleton "testid" "count" + , children: [ R.text (show count) ] + } + , R.div + { _data: Object.singleton "testid" "effect-count" + , children: [ R.text (show effectCount) ] + } + , R.button + { _data: Object.singleton "testid" "increment" + , onClick: handler_ (setCount (_ + 1)) + , children: [ R.text "Increment" ] + } + ] + + in { useOptimistic, useActionState, useEffectEvent } From 71fec93ebb10dc88c7d3c909bd62e7d36b0895fe Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 1 Feb 2026 17:50:08 +0100 Subject: [PATCH 2/2] Update GitHub Actions to v4 --- .github/workflows/node.js.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2034614..595f487 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -14,10 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: + node-version: '20' cache: "npm" - run: npm ci - run: npm run deps --if-present