Skip to content

Commit b43eb3c

Browse files
authored
Avoid capturing ComponentDef in a closure for a long time (#83)
1 parent e634940 commit b43eb3c

File tree

4 files changed

+99
-19
lines changed

4 files changed

+99
-19
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## 0.12.0
9+
10+
### Changed
11+
12+
- Fixed a bug that allowed `ComponentDef` to be captured in closures for a long
13+
time, which could lead to using stale values in complex scenarios where
14+
`ComponentDef` is not constant, but depends on arguments. See [#83](https://github.com/collegevine/purescript-elmish/pull/83).
15+
16+
- **Breaking**: Changed the order of arguments of `bindComponent`
17+
818
## 0.11.4
919

1020
### Added

src/Elmish/Component.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export function withFreshComponent(f) {
1515

1616
export var instantiateBaseComponent = React.createElement;
1717

18+
export const instancePropDef = component => () => component.props.def
19+
1820
function mkFreshComponent(name) {
1921
class ElmishComponent extends React.Component {
2022
constructor(props) {

src/Elmish/Component.purs

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -255,31 +255,38 @@ withTrace def = def { update = tracingUpdate, view = tracingView }
255255
-- | "renders" it as a React DOM element, suitable for passing to
256256
-- | `ReactDOM.render` or embedding in a JSX DOM tree.
257257
bindComponent :: msg state
258-
. BaseComponent -- ^ A JS class inheriting from React.Component to serve as base
259-
-> ComponentDef msg state -- ^ The component definition
258+
. BaseComponent state msg -- ^ A JS class inheriting from React.Component to serve as base
260259
-> StateStrategy state -- ^ Strategy of storing state
260+
-> ComponentDef msg state -- ^ The component definition
261261
-> ReactElement
262-
bindComponent cmpt def stateStrategy =
262+
bindComponent cmpt stateStrategy = \def -> -- Explicit lambda to make sure `def` isn't captured by closures under `where`
263263
runFn2 instantiateBaseComponent cmpt
264-
{ init: initialize
264+
{ def
265+
, init: let (Transition s _) = def.init in (stateStrategy { initialState: s }).initialize
265266
, render
266-
, componentDidMount: runCmds initialCmds
267+
, componentDidMount: let (Transition _ cmds) = def.init in runCmds cmds
267268
, componentWillUnmount: setUnmounted true <> stopSubscriptions
268269
}
269270
where
270-
Transition initialState initialCmds = def.init
271+
getState component = do
272+
Transition s _ <- instancePropDef component <#> _.init
273+
(stateStrategy { initialState: s }).getState component
271274

272-
{ initialize, getState, setState } = stateStrategy { initialState }
275+
setState component newState callback = do
276+
Transition s _ <- instancePropDef component <#> _.init
277+
(stateStrategy { initialState: s }).setState component newState callback
273278

274279
render :: ReactComponentInstance -> Effect ReactElement
275280
render component = do
276281
state <- getState component
277-
pure $ def.view state $ dispatchMsg component
282+
view <- instancePropDef component <#> _.view
283+
pure $ view state $ dispatchMsg component
278284

279285
dispatchMsg :: ReactComponentInstance -> Dispatch msg
280286
dispatchMsg component msg = unlessM (getUnmounted component) do
281287
oldState <- getState component
282-
let Transition newState cmds = def.update oldState msg
288+
update <- instancePropDef component <#> _.update
289+
let Transition newState cmds = update oldState msg
283290
setState component newState $ runCmds cmds component
284291

285292
runCmds :: Array (Command Aff msg) -> ReactComponentInstance -> Effect Unit
@@ -320,7 +327,7 @@ construct :: ∀ msg state
320327
construct def = do
321328
stateStorage <- liftEffect dedicatedStorage
322329
pure $ withFreshComponent \cmpt ->
323-
bindComponent cmpt def stateStorage
330+
bindComponent cmpt stateStorage def
324331

325332
-- | Monad transformation applied to `ComponentDef'`
326333
nat :: m n msg state. (m ~> n) -> ComponentDef' m msg state -> ComponentDef' n msg state
@@ -362,8 +369,8 @@ wrapWithLocalState :: ∀ msg state args
362369
-> args
363370
-> ReactElement
364371
wrapWithLocalState name mkDef =
365-
runFn2 withCachedComponent name \cmpt args ->
366-
bindComponent cmpt (mkDef args) localState
372+
runFn2 withCachedComponent name \cmpt ->
373+
bindComponent cmpt localState <<< mkDef
367374

368375
-- | A unique name for a component created via `wrapWithLocalState`. These names
369376
-- | don't technically need to be _completely_ unique, but they do need to be
@@ -397,14 +404,15 @@ newtype ComponentName = ComponentName String
397404

398405
-- Props for the React component that is used as base for this framework. The
399406
-- component itself is defined in the foreign module.
400-
type BaseComponentProps =
401-
{ init :: ReactComponentInstance -> Effect Unit
407+
type BaseComponentProps state msg =
408+
{ def :: ComponentDef msg state
409+
, init :: ReactComponentInstance -> Effect Unit
402410
, render :: ReactComponentInstance -> Effect ReactElement
403411
, componentDidMount :: ReactComponentInstance -> Effect Unit
404412
, componentWillUnmount :: ReactComponentInstance -> Effect Unit
405413
}
406414

407-
type BaseComponent = ReactComponent BaseComponentProps
415+
type BaseComponent state msg = ReactComponent (BaseComponentProps state msg)
408416

409417
-- This is just a call to `React.createElement`, but we can't use the
410418
-- general-purpose `createElement` function from `./React.purs`, because it
@@ -413,7 +421,7 @@ type BaseComponent = ReactComponent BaseComponentProps
413421
-- possible to make this type passable to JS by using `Foreign` and maybe even
414422
-- `unsafeCoerce` in places, but I have decided it wasn't worth it, because this
415423
-- is just one place at the core of the framework.
416-
foreign import instantiateBaseComponent :: Fn2 BaseComponent BaseComponentProps ReactElement
424+
foreign import instantiateBaseComponent :: state msg. Fn2 (BaseComponent state msg) (BaseComponentProps state msg) ReactElement
417425

418426
-- | On first call with a given name, this function returns a fresh React class.
419427
-- | On subsequent calls with the same name, it returns the same class. It has
@@ -423,9 +431,12 @@ foreign import instantiateBaseComponent :: Fn2 BaseComponent BaseComponentProps
423431
-- This is essentially a hack, but not quite. It operates in the grey area
424432
-- between PureScript and JavaScript. See comments on `ComponentName` for a more
425433
-- detailed explanation.
426-
foreign import withCachedComponent :: a. Fn2 ComponentName (BaseComponent -> a) a
434+
foreign import withCachedComponent :: a state msg. Fn2 ComponentName (BaseComponent state msg -> a) a
427435

428436
-- | Creates a fresh React component on every call. This is similar to
429437
-- | `withCachedComponent`, but without the cache - creates a new component
430438
-- | every time.
431-
foreign import withFreshComponent :: a. (BaseComponent -> a) -> a
439+
foreign import withFreshComponent :: a state msg. (BaseComponent state msg -> a) -> a
440+
441+
-- Retrieves the `this.props.def` from the given component
442+
foreign import instancePropDef :: state msg. ReactComponentInstance -> Effect (ComponentDef msg state)

test/LocalState.purs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ module Test.LocalState (spec) where
33
import Prelude
44

55
import Data.Array (length)
6-
import Elmish ((<|))
6+
import Effect.Aff (Milliseconds(..), delay)
7+
import Effect.Aff.Class (liftAff)
8+
import Elmish (fork, (<|))
79
import Elmish.Component (ComponentName(..), wrapWithLocalState)
810
import Elmish.HTML.Styled as H
911
import Elmish.Test (clickOn, exists, find, findAll, forEach, nearestEnclosingReactComponentName, testComponent, text, (>>))
@@ -61,6 +63,35 @@ spec = describe "Elmish.Component.wrapWithLocalState" do
6163
findAll ".t--counter" <#> length >>= shouldEqual 2
6264
findAll ".t--counter" >>= forEach (nearestEnclosingReactComponentName >>= shouldEqual "Elmish_Counter")
6365

66+
it "calls the correct closure of `update` when dispatching events" $
67+
testComponent closureOuter do
68+
find ".t--count" >> text >>= shouldEqual "0"
69+
find ".t--increment" >> text >>= shouldEqual "10"
70+
clickOn ".t--inc"
71+
find ".t--count" >> text >>= shouldEqual "10"
72+
find ".t--increment" >> text >>= shouldEqual "10"
73+
clickOn ".t--increase-increment"
74+
find ".t--count" >> text >>= shouldEqual "10"
75+
find ".t--increment" >> text >>= shouldEqual "11"
76+
clickOn ".t--long-inc"
77+
liftAff $ delay $ Milliseconds 20.0
78+
find ".t--count" >> text >>= shouldEqual "21"
79+
find ".t--increment" >> text >>= shouldEqual "11"
80+
81+
-- We're going to initiate a "long inc" and while it's in flight, we're
82+
-- going to increase increment. If the closure that captured the previous
83+
-- value of `increment` survives until after the long inc is done, the
84+
-- resulting count will be incorrectly set at 21 + 11 = 32. If the old
85+
-- value of `increment` wasn't captured and the fresh value is used, the
86+
-- count will be 21 + 12 = 33.
87+
clickOn ".t--long-inc"
88+
liftAff $ delay $ Milliseconds 5.0
89+
clickOn ".t--increase-increment"
90+
find ".t--count" >> text >>= shouldEqual "21"
91+
find ".t--increment" >> text >>= shouldEqual "12"
92+
liftAff $ delay $ Milliseconds 15.0
93+
find ".t--count" >> text >>= shouldEqual "33"
94+
6495
where
6596
wrappedCounter =
6697
wrapWithLocalState (ComponentName "Counter") \c ->
@@ -89,3 +120,29 @@ spec = describe "Elmish.Component.wrapWithLocalState" do
89120
"IncInitialCount" -> pure state { initialCount = state.initialCount + 1 }
90121
_ -> pure state
91122

123+
closureOuter =
124+
{ init: pure { increment: 10 }
125+
, update: \state -> case _ of
126+
"IncreaseIncrement" -> pure state { increment = state.increment + 1 }
127+
_ -> pure state
128+
, view: \state dispatch ->
129+
H.fragment
130+
[ H.button_ "t--increase-increment" { onClick: dispatch <| "IncreaseIncrement" } "."
131+
, H.p "t--increment" $ show state.increment
132+
, closureInner state.increment
133+
]
134+
}
135+
136+
closureInner = wrapWithLocalState (ComponentName "Inner") \increment ->
137+
{ init: pure { count: 0 }
138+
, update: \state -> case _ of
139+
"Inc" -> pure state { count = state.count + increment }
140+
"LongInc" -> fork (delay (Milliseconds 10.0) $> "Inc") *> pure state
141+
_ -> pure state
142+
, view: \state dispatch ->
143+
H.fragment
144+
[ H.button_ "t--inc" { onClick: dispatch <| "Inc" } "."
145+
, H.button_ "t--long-inc" { onClick: dispatch <| "LongInc" } "."
146+
, H.p "t--count" $ show $ state.count
147+
]
148+
}

0 commit comments

Comments
 (0)