git.iko.soy/rowtype-yoga/purescript-yoga-react-dom
on main help sign in
Add React 19 ref prop, IsReactRef typeclass, bump to 2.0.0

- Ref: change from Ref (Nullable Node) to opaque ReactRef with IsReactRef typeclass for Ref objects, callback refs, and React 19 cleanup callback refs - Re-export class IsReactRef and reactRef from Yoga.React.DOM - Bump version to 2.0.0 (breaking: ref type change) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit84d81fc328bbd06a857a6817a51f54851f8320ff authorMark <mark.eibes@gmail.com> dateSat 21 Feb 2026, 11:04 (7 months ago) parent34f0e17
10 files changed+93−21
.gitignore+1 bun.lock+23 index.html+1−1 spago.yaml+1−1 src/Yoga/React/DOM.purs+2−1 src/Yoga/React/DOM/Attributes.purs+18−3 src/Yoga/React/DOM/HTML/Button.purs+3−1 src/Yoga/React/DOM/HTML/Form.purs+29−11 src/Yoga/React/DOM/HTML/Input.purs+1 test/Example.purs+14−3
.gitignore+1
@@ -9,3 +9,4 @@
99/.psa*
1010/.spago
1111index.js
12+bun.lock
bun.locknew file+23
@@ -0,0 +1,23 @@
1+{
2+ "lockfileVersion": 1,
3+ "workspaces": {
4+ "": {
5+ "name": "purescript-react-basic-dom-beta",
6+ "dependencies": {
7+ "react": "^18.2.0",
8+ "react-dom": "^18.2.0",
9+ },
10+ },
11+ },
12+ "packages": {
13+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
14+
15+ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
16+
17+ "react": ["react@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="],
18+
19+ "react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="],
20+
21+ "scheduler": ["scheduler@0.23.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw=="],
22+ }
23+}
index.html+1−1
@@ -1 +1 @@
1−<html><head><title>My Web Page</title><meta charSet="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="stylesheet" href="styles.css"/><style>body { background-color: #f0f0f2; }</style><script src="https://cdn.tailwindcss.com"></script><body class="w-full"><div id="react-root"><div><nav><ul class="navList"><li class="navItem"><a href="/">Home</a></li><li class="navItem">About</li><li class="navItem">Contact</li></ul></nav><aside><h4 class="title">My Blog</h4><p class="text">This is my blog. I hope you like it!</p></aside><main class="w-[80ch] max-w-[80ch] bg-white mx-auto shadow"><article class="px-8"><nav class="navigation"><ul class="flex flex-row justify-between"><li class="navItem">Home</li><li class="navItem">About</li><li class="navItem">Contact</li></ul></nav><div class="content"><h1 class="text-4xl">My Web Page</h1><p class="text">This is <b>my </b><i>web page</i>. Isn&#x27;t it grand?</p><blockquote class="italic">I am hungry.</blockquote><cite class="text"> -- Alfred Einstein</cite><div class="container"><video src="movie.mp4" width="320" height="240"><track kind="subtitles" src="subtitles_en.vtt" srcLang="en"/></video></div><table class="table"><thead class="thead"><tr class="headerRow"><th class="headerCell">Header 1</th><th class="headerCell">Header 2</th></tr></thead><tbody class="tbody"><tr class="tableRow"><td class="tableData">Row 1, Col 1</td><td class="tableData">Row 1, Col 2</td></tr><tr class="tableRow"><td class="tableData">Row 2, Col 1</td><td class="tableData">Row 2, Col 2</td></tr></tbody><tfoot class="tfoot"><tr class="footerRow"><td class="footerCell">Footer 1</td><td class="footerCell">Footer 2</td></tr></tfoot></table><h3>&lt;p&gt;, &lt;sup&gt;, &lt;sub&gt;</h3><p class="text">The temperature of boiling water is 100</p><sup class="text">o</sup>C<sub class="text">1</sub></div><h3>&lt;address&gt;</h3><address class="footer">1234 Main St, Anytown, USA</address></article><div class="myForm"><form action="#"><input type="text" placeholder="Type something" class="inputField"/><input type="submit" class="submitBtn" value="Submit"/></form><input value="hi"/></div></main><button class="bg-black px-4 py-2 rounded shadow font-bold text-white">Count up!</button><p>You clicked me <b>0</b> times.</p><footer class="footer">1234 Main St, Anytown, USA</footer></div></div></body><script src="index.js"></script></head></html>
1+<html><head><title>My Web Page</title><meta charSet="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="stylesheet" href="styles.css"/><style>body { background-color: #f0f0f2; }</style><script src="https://cdn.tailwindcss.com"></script><body class="w-full"><div id="react-root"><div><nav><ul class="navList"><li class="navItem"><a href="/">Home</a></li><li class="navItem">About</li><li class="navItem">Contact</li></ul></nav><aside><h4 class="title">My Blog</h4><p class="text">This is my blog. I hope you like it!</p></aside><main class="w-[80ch] max-w-[80ch] bg-white mx-auto shadow"><article class="px-8"><nav class="navigation"><ul class="flex flex-row justify-between"><li class="navItem">Home</li><li class="navItem">About</li><li class="navItem">Contact</li></ul></nav><div class="content"><h1 class="text-4xl">My Web Page</h1><p class="text">This is <b>my </b><i>web page</i>. Isn&#x27;t it grand?</p><blockquote class="italic">I am hungry.</blockquote><cite class="text"> -- Alfred Einstein</cite><div class="container"><video src="movie.mp4" width="320" height="240"><track kind="subtitles" src="subtitles_en.vtt" srcLang="en"/></video></div><table class="table"><thead class="thead"><tr class="headerRow"><th class="headerCell">Header 1</th><th class="headerCell">Header 2</th></tr></thead><tbody class="tbody"><tr class="tableRow"><td class="tableData">Row 1, Col 1</td><td class="tableData">Row 1, Col 2</td></tr><tr class="tableRow"><td class="tableData">Row 2, Col 1</td><td class="tableData">Row 2, Col 2</td></tr></tbody><tfoot class="tfoot"><tr class="footerRow"><td class="footerCell">Footer 1</td><td class="footerCell">Footer 2</td></tr></tfoot></table><h3>&lt;p&gt;, &lt;sup&gt;, &lt;sub&gt;</h3><p class="text">The temperature of boiling water is 100</p><sup class="text">o</sup>C<sub class="text">1</sub></div><h3>&lt;address&gt;</h3><address class="footer">1234 Main St, Anytown, USA</address></article><div class="myForm"><form action="#"><input type="text" placeholder="Type something" class="inputField"/><input type="submit" class="submitBtn" value="Submit"/></form><input value="hi"/></div></main><button class="bg-black px-4 py-2 rounded shadow font-bold text-white">Count up!</button><div><p>You clicked me <b>0</b> times.</p></div><div></div><div></div><footer class="footer">1234 Main St, Anytown, USA</footer></div></div></body><script src="index.js"></script></head></html>
spago.yaml+1−1
@@ -30,7 +30,7 @@ package:
3030 # Optional section for configuring the `spago publish` command.
3131 # If you intend to publish your package, this section becomes mandatory.
3232 publish:
33− version: 1.0.3
33+ version: 2.0.0
3434 license: WTFPL
3535 location:
3636 githubOwner: rowtype-yoga
src/Yoga/React/DOM.purs+2−1
@@ -1,12 +1,13 @@
11module Yoga.React.DOM
22 ( module Yoga.React.DOM.HTML
33 , module Yoga.React.DOM.Internal
4+ , module Yoga.React.DOM.Attributes
45 ) where
56
67import Yoga.React.DOM.Internal
78import Prelude hiding (div, map, sub)
89
9−import Yoga.React.DOM.Attributes (BaseAttributes, BaseAttributesF, BaseAttributesR)
10+import Yoga.React.DOM.Attributes (BaseAttributes, BaseAttributesF, BaseAttributesR, ReactRef, class IsReactRef, reactRef)
1011import Yoga.React.DOM.Attributes.AutoCapitalize (AutoCapitalize)
1112import Yoga.React.DOM.Attributes.InputMode (InputMode)
1213import Yoga.React.DOM.Attributes.YesOrNo (YesOrNo)
src/Yoga/React/DOM/Attributes.purs+18−3
@@ -7,11 +7,12 @@ import Yoga.React.DOM.Attributes.InputMode (InputMode)
77import Yoga.React.DOM.Attributes.YesOrNo (YesOrNo)
88import Yoga.React.DOM.Event (AnimationEvent, BaseEvent, ClipboardEvent, CompositionEvent, DragEvent, FocusEvent, InputEvent, MouseEvent, PointerEvent, ReactEventObject, TouchEvent, TransitionEvent, WheelEvent)
99import Data.Nullable (Nullable)
10−import Effect.Uncurried (EffectFn1)
10+import Effect (Effect)
11+import Effect.Uncurried (EffectFn1, mkEffectFn1)
1112import ForgetMeNot (Id)
1213import React.Basic (Ref) as React
1314import Type.Row (type (+))
14−import Web.DOM.Internal.Types (Node)
15+import Unsafe.Coerce (unsafeCoerce)
1516import Yoga.React.DOM.Internal (CSS)
1617import React.Basic.Events (EventHandler)
1718
@@ -39,7 +40,7 @@ type ReactAttributesF :: forall k. (Type -> k) -> Row k -> Row k
3940type ReactAttributesF f r =
4041 ( key :: f String
4142 , dangerouslySetInnerHTML :: f { __html :: String }
42− , ref :: f (React.Ref (Nullable Node))
43+ , ref :: f ReactRef
4344 , suppressContentEditableWarning :: f Boolean
4445 , suppressHydrationWarning :: f Boolean
4546 , style :: f CSS
@@ -312,3 +313,17 @@ type ARIA_AttributesF f r =
312313 , "aria-valuetext" :: f String
313314 | r
314315 )
316+
317+foreign import data ReactRef :: Type
318+
319+class IsReactRef a where
320+ reactRef :: a -> ReactRef
321+
322+instance IsReactRef (React.Ref a) where
323+ reactRef = unsafeCoerce
324+
325+else instance IsReactRef (Nullable a -> Effect Unit) where
326+ reactRef f = unsafeCoerce (mkEffectFn1 f)
327+
328+else instance IsReactRef (a -> Effect (Effect Unit)) where
329+ reactRef f = unsafeCoerce (mkEffectFn1 f)
src/Yoga/React/DOM/HTML/Button.purs+3−1
@@ -3,6 +3,7 @@ module Yoga.React.DOM.HTML.Button (button, button_) where
33import Prelude
44
55import Yoga.React.DOM.Attributes (BaseAttributes, BaseAttributesR)
6+import Yoga.React.DOM.HTML.Form (FormAction)
67import Yoga.React.DOM.Internal (FFIComponent, FFIComponent_, createBuiltinElement, createBuiltinElement_)
78
89-- | The `<button>` tag in HTML, represents a clickable button.
@@ -44,4 +45,5 @@ type ButtonProps = BaseAttributesR
4445 , className :: String
4546 , disabled :: Boolean
4647 , form :: String
47− )
48+ , formAction :: FormAction
49+ )
src/Yoga/React/DOM/HTML/Form.purs+29−11
@@ -1,27 +1,45 @@
1−module Yoga.React.DOM.HTML.Form (form) where
1+module Yoga.React.DOM.HTML.Form (form, FormAction, urlAction, handlerAction) where
22
33import Prelude
44
55import Yoga.React.DOM.Attributes (BaseAttributesR)
66import Yoga.React.DOM.Internal (FFIComponent, createBuiltinElement)
77import React.Basic.Events (EventHandler)
8+import Unsafe.Coerce (unsafeCoerce)
89
9−-- | The `<form>` HTML element represents a form, which is a component of an application
10−-- | that is designed for the performing of an action by the user.
10+-- | The `<form>` HTML element represents a form.
1111-- |
12−-- | #### Example:
12+-- | In React 19 the `action` prop accepts either a URL string or a handler function.
13+-- |
14+-- | #### URL action
15+-- | ```purescript
16+-- | form { action: urlAction "/submit", method: "post" } children
17+-- | ```
18+-- |
19+-- | #### React 19 form action (useActionState dispatch)
1320-- | ```purescript
14−-- | form { action: "/path/to/submit", method: "post", onSubmit }
15−-- | [ input { type: "text", name: "username", value: userName, onChange: onChangeUserName }
16−-- | , input { type: "password", name: "password", value: password, onChange: onChangePassword }
17−-- | , input { type: "submit", value: "Login" }
18−-- | ]
21+-- | form { action: handlerAction dispatch } children
1922-- | ```
2023form :: FFIComponent FormAttributes
2124form = createBuiltinElement "form"
2225
2326type FormAttributes = BaseAttributesR
24− ( action :: String
27+ ( action :: FormAction
2528 , method :: String
2629 , onSubmit :: EventHandler
27− )
30+ )
31+
32+-- | A form action — either a URL string or a React 19 dispatch function.
33+-- | Use `urlAction` or `handlerAction` to construct.
34+foreign import data FormAction :: Type
35+
36+-- | Create a form action from a URL string.
37+urlAction :: String -> FormAction
38+urlAction = unsafeCoerce
39+
40+-- | Create a form action from a React 19 dispatch function.
41+-- | Accepts any value because React's form action prop is a JS union type
42+-- | (`string | function`) that can't be expressed as a single PureScript type.
43+-- | Intended for dispatch functions from hooks like `useActionState`.
44+handlerAction :: forall a. a -> FormAction
45+handlerAction = unsafeCoerce
src/Yoga/React/DOM/HTML/Input.purs+1
@@ -32,6 +32,7 @@ type InputAttributes = BaseAttributesR
3232 ( type :: String
3333 , name :: String
3434 , value :: String
35+ , defaultValue :: String
3536 , placeholder :: String
3637 , disabled :: Boolean
3738 , autoFocus :: Boolean
test/Example.purs+14−3
@@ -12,9 +12,11 @@ import Yoga.React.DOM.HTML.Blockquote (blockquote) as H
1212import Node.FS.Aff (writeTextFile)
1313import Effect.Aff (launchAff_)
1414import Node.Encoding (Encoding(UTF8))
15−import React.Basic.Hooks (Component, component, useState)
15+import React.Basic.Hooks (Component, component, useRef, useState)
1616import Data.Tuple.Nested ((/\))
1717import React.Basic.Hooks as React
18+import Data.Nullable (Nullable, null)
19+import Yoga.React.DOM.Attributes (reactRef)
1820import Yoga.React.DOM.Client (createRoot, renderRoot)
1921import Web.HTML (window)
2022import Web.HTML.Window (document)
@@ -50,9 +52,12 @@ mkExample = do
5052
5153mkCounter = component "Counter" \_ -> React.do
5254 count /\ setCount <- useState 0
55+ divRef <- useRef null
5356 pure $ fragment
5457 [ H.button { className: "bg-black px-4 py-2 rounded shadow font-bold text-white", onClick: handler_ $ setCount (_ + 1) } "Count up!"
55− , H.p {} ("You clicked me " ++ H.b {} (show count) ++ " times.")
58+ , H.div { ref: reactRef divRef } [ H.p {} ("You clicked me " ++ H.b {} (show count) ++ " times.") ]
59+ , H.div { ref: reactRef callbackRefExample } []
60+ , H.div { ref: reactRef cleanupRefExample } []
5661 ]
5762
5863mainExample = H.main { className: "w-[80ch] max-w-[80ch] bg-white mx-auto shadow" }
@@ -129,7 +134,7 @@ tableExample = H.table { className: "table" }
129134
130135formExample :: JSX
131136formExample = H.div { className: "myForm" }
132− [ H.form { action: "#" }
137+ [ H.form { action: H.urlAction "#" }
133138 [ H.input { type: "text", placeholder: "Type something", className: "inputField" }
134139 , H.input { type: "submit", value: "Submit", className: "submitBtn" }
135140 ]
@@ -151,4 +156,10 @@ formExample = H.div { className: "myForm" }
151156
152157 ]
153158
159+callbackRefExample :: Nullable Unit -> Effect Unit
160+callbackRefExample _ = pure unit
161+
162+cleanupRefExample :: Unit -> Effect (Effect Unit)
163+cleanupRefExample _ = pure (pure unit)
164+
154165x y = y.a + y.b + y.c