Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid capturing onValue closure in useSubscription forever #31

Merged
merged 2 commits into from
Jul 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.10.3

### Changed

- fixed a bug in `useSubscription` that kept the first `onValue` closure
forever, potentially resulting in using stale state values captured by the
closure.

## 0.10.2

### Changed
Expand Down
1 change: 1 addition & 0 deletions packages.dhall
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ let upstream =
sha256:81881d9e15484551b4293ab0a2639355f38d0cab1dfa49a077b5f1af374c292a

in upstream
with elmish.version = "v0.12.0"
6 changes: 3 additions & 3 deletions src/Elmish/Hooks/UseEffect.purs
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ useEffect_ name f deps runEffect =
pure newDeps
, view: \_ dispatch ->
useEffectLifeCycles
{ componentDidUpdate: dispatch <?| \(prevDeps :: Opaque.Opaque "deps" a) ->
if Opaque.unwrap prevDeps /= deps then
{ componentDidUpdate: dispatch <?| \prevDeps ->
if Opaque.unwrap @"deps" prevDeps /= deps then
Just deps
else
Nothing
, deps: (Opaque.wrap deps :: _ "deps" _)
, deps: Opaque.wrap @"deps" deps
} $
render unit
}
Expand Down
13 changes: 9 additions & 4 deletions src/Elmish/Hooks/UseSubscription.purs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Elmish.Hooks.UseSubscription
import Prelude

import Effect.Aff (Aff)
import Elmish.Component (ComponentName(..))
import Elmish.Component (ComponentName(..), forkVoid)
import Elmish.Hooks.Type (Hook, HookType, mkHook)
import Elmish.Subscription (Subscription)
import Elmish.Subscription as Sub
Expand All @@ -32,7 +32,12 @@ foreign import data UseSubscription :: Type -> HookType
useSubscription :: ∀ a. Subscription Aff a -> (a -> Aff Unit) -> Hook (UseSubscription a) Unit
useSubscription subscription onValue =
mkHook (ComponentName "UseSubscription") \render ->
{ init: subscription # Sub.quasiBind onValue # Sub.subscribe identity
, update: \_ _ -> pure unit
, view: \_ _ -> render unit
{ init: do
Sub.subscribe identity subscription
pure unit
, update: \_ value -> do
forkVoid $ onValue value
pure unit
, view: \_ _ ->
render unit
}
4 changes: 4 additions & 0 deletions test.dhall
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ in conf // {
sources = conf.sources # [ "test/**/*.purs" ],
dependencies = conf.dependencies #
[ "arrays"
, "avar"
, "control"
, "datetime"
, "effect"
, "elmish-html"
, "elmish-testing-library"
, "foldable-traversable"
, "foreign"
, "nullable"
, "spec"
, "tailrec"
]
}
5 changes: 5 additions & 0 deletions test/Main.purs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,17 @@ import Test.Spec.Assertions (shouldEqual, shouldNotEqual)
import Test.Spec.Assertions.String (shouldContain)
import Test.Spec.Reporter.Spec (specReporter)
import Test.Spec.Runner (runSpec)
import Test.UseEffect as UseEffect
import Test.UseSubscription as UseSubscription

main :: Effect Unit
main = launchAff_ $ runSpec [specReporter] spec

spec :: Spec Unit
spec = do
UseEffect.spec
UseSubscription.spec

describe "naming" do
describe "component" do
let
Expand Down
60 changes: 60 additions & 0 deletions test/UseEffect.purs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
module Test.UseEffect where

import Prelude

import Data.Time.Duration (Milliseconds(..))
import Data.Tuple.Nested ((/\))
import Effect.Aff (delay)
import Effect.Aff.Class (liftAff)
import Effect.Class (liftEffect)
import Elmish ((<|))
import Elmish.HTML.Styled as H
import Elmish.Hooks as Hk
import Elmish.Test (click, find, testElement, text, (>>))
import Test.Spec (Spec, describe, it)
import Test.Spec.Assertions (shouldEqual)

spec :: Spec Unit
spec = do
describe "useEffect" do
it "calls the most current closure" do
let component = Hk.component Hk.do
effectRuns /\ setEffectRuns <- Hk.useState 0
clicks /\ setClicks <- Hk.useState 0

-- This here is the tricky part. The second argument of `useEffect'`
-- is a closure that captures `effectRuns`. If the same closure was
-- called every time, the value of `effectRuns` would always be
-- zero, so it would always be calling `setEffectRuns 1`. But if the
-- the close from the most recent run is called, the value would be
-- up to date.
--
-- To test this we use the second effect (two lines below) that
-- writes the values of `clicks` and `effectRuns` to the `output`
-- mutable cell, and then we check that, after a few button clicks,
-- the cell has the right values.
Hk.useEffect' clicks \_ -> liftEffect $
setEffectRuns $ effectRuns + 1

Hk.pure $ H.fragment
[ H.div "clicks" $ show clicks
, H.div "effectRuns" $ show effectRuns
, H.button_ "" { onClick: setClicks <| clicks + 1 } ""
]

assertOutput expected = do
liftAff $ delay $ Milliseconds 10.0
find ".clicks" >> text >>= (_ `shouldEqual` show expected.clicks)
find ".effectRuns" >> text >>= (_ `shouldEqual` show expected.effectRuns)

testElement component do
find "button" >> click
assertOutput { clicks: 1, effectRuns: 1 }

find "button" >> click
assertOutput { clicks: 2, effectRuns: 2 }

find "button" >> click
liftAff $ delay $ Milliseconds 1.0
find "button" >> click
assertOutput { clicks: 4, effectRuns: 4 }
66 changes: 66 additions & 0 deletions test/UseSubscription.purs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
module Test.UseSubscription where

import Prelude

import Control.Monad.Rec.Class (forever)
import Data.Array ((:))
import Data.Time.Duration (Milliseconds(..))
import Data.Tuple.Nested ((/\))
import Effect.Aff (delay)
import Effect.Aff.AVar as AVar
import Effect.Aff.Class (liftAff)
import Effect.Class (liftEffect)
import Elmish.HTML.Styled as H
import Elmish.Hooks as Hk
import Elmish.Subscription (Subscription(..))
import Elmish.Test (find, testElement, text, (>>))
import Test.Spec (Spec, describe, it)
import Test.Spec.Assertions (shouldEqual)

spec :: Spec Unit
spec = do
describe "useSubscription" do
it "calls the most current closure" do
source <- liftAff AVar.empty

let subscription = Subscription \dispatch -> do
void $ forever do
value <- AVar.take source
liftEffect $ dispatch value
pure $ pure unit

let component = Hk.component Hk.do
receivedValues /\ setReceivedValues <- Hk.useState []

-- This here is the tricky part. The second argument of `useEffect'`
-- is a closure that captures `effectRuns`. If the same closure was
-- called every time, the value of `effectRuns` would always be
-- zero, so it would always be calling `setEffectRuns 1`. But if the
-- the close from the most recent run is called, the value would be
-- up to date.
--
-- To test this we use the second effect (two lines below) that
-- writes the values of `clicks` and `effectRuns` to the `output`
-- mutable cell, and then we check that, after a few button clicks,
-- the cell has the right values.
Hk.useSubscription subscription \value -> liftEffect $
setReceivedValues $ value : receivedValues

Hk.pure $
H.div "" $ show receivedValues

assertReceivedValues expected = do
liftAff $ delay $ Milliseconds 10.0
find "div" >> text >>= (_ `shouldEqual` show expected)

testElement component do
liftAff $ AVar.put "one" source
assertReceivedValues ["one"]

liftAff $ AVar.put "two" source
assertReceivedValues ["two", "one"]

liftAff $ AVar.put "third" source
liftAff $ delay $ Milliseconds 1.0
liftAff $ AVar.put "cuatro" source
assertReceivedValues ["cuatro", "third", "two", "one"]
Loading