-
Notifications
You must be signed in to change notification settings - Fork 358
Description
Problem
If you:
- Subscribe to
portAinsubscriptions - Call
portBinupdateat the same time - And
portBsends data toportA
… will portA get that data? Will it be subscribed in time?
From an Elm programmer’s perspective it sure looks like the port will be subscribed and should get the data, if you read your subscriptions function (see the SSCCE at the bottom).
The real answer is: It depends. And it depends on what order some generated JavaScript happens to end up in!
The really confusing thing for an Elm programmer is that if you try to debug this with Debug.log in the Elm code and console.log in the JavaScript code (for the ports), it looks like things should be happening. You can see that app.ports.portA.send() is indeed called, but no message is ever seen on the Elm side, even though a Debug.log in subscriptions clearly shows that we do indeed call portA. And the Debug.log happens before the console.log so the order looks correct, too. (Then in reality, things can happen in the opposite order, which explains why nothing happens.)
Use case
I stumbled upon this at work. Our app has multiple pages. Each page has its own Model, Msg, init, update, subscriptions and view, and at the top level we delegate to the current page.
On one page I wanted to get something from local storage when the page initializes. So in init I call a port that reads from local storage, and replies on another port. In subscriptions I subscribe to that other port. Unfortunately, I never got a reply there. It turned out to be because I used the same local storage ports on other pages, and that affected the compiled code order.
Why it happens
This is how Elm calls update and subscriptions:
var pair = A2(update, msg, model);
stepper(model = pair.a, viewMetadata);
_Platform_enqueueEffects(managers, pair.b, subscriptions(model));It gives the Cmds and Subs to _Platform_enqueueEffects, which passes them on to _Platform_dispatchEffects, which looks like this:
function _Platform_dispatchEffects(managers, cmdBag, subBag)
{
var effectsDict = {};
_Platform_gatherEffects(true, cmdBag, effectsDict, null);
_Platform_gatherEffects(false, subBag, effectsDict, null);
for (var home in managers)
{
__Scheduler_rawSend(managers[home], {
$: 'fx',
a: effectsDict[home] || { __cmds: __List_Nil, __subs: __List_Nil }
});
}
}It creates one single effectsDict and calls _Platform_gatherEffects twice – once for the Cmds and once for the Subs. _Platform_gatherEffects assigns properties on effectsDict. In this case the properties are the names of the ports.
Then there’s a loop: for (var home in managers). And inside the loop we read from effectsDict[home]. In this case, we have two ports. The order they are executed in comes down to the iteration order of managers. managers is created in _Platform_setupEffects:
function _Platform_setupEffects(managers, sendToApp)
{
var ports;
// setup all necessary effect managers
for (var key in _Platform_effectManagers)
{
var manager = _Platform_effectManagers[key];
if (manager.__portSetup)
{
ports = ports || {};
ports[key] = manager.__portSetup(key, sendToApp);
}
managers[key] = _Platform_instantiateManager(manager, sendToApp);
}
return ports;
}Here there’s another loop: for (var key in _Platform_effectManagers). We assign to managers[key] in the loop. So the iteration order of managers comes from the iteration order _Platform_effectManagers.
In the case of ports, the _Platform_outgoingPort and _Platform_incomingPort functions assign _Platform_effectManagers[name] (where name is the port name):
function _Platform_outgoingPort(name, converter)
{
_Platform_checkPortName(name);
_Platform_effectManagers[name] = {
__cmdMap: _Platform_outgoingPortMap,
__converter: converter,
__portSetup: _Platform_setupOutgoingPort
};
return _Platform_leaf(name);
}
function _Platform_incomingPort(name, converter)
{
_Platform_checkPortName(name);
_Platform_effectManagers[name] = {
__subMap: _Platform_incomingPortMap,
__converter: converter,
__portSetup: _Platform_setupIncomingPort
};
return _Platform_leaf(name);
}Those functions are called by generated code which can look like so (taken from the below SSCCE):
var $author$project$PortSubCmdDemoMinimal$getFromLocalStorage = _Platform_outgoingPort(...);
var $author$project$PortSubCmdDemoMinimal$gotTextFromLocalStorage = _Platform_incomingPort(...);Those lines look like they simply define something at first glance, but remember that they also have the side effect of mutating _Platform_effectManagers. And iteration order of JavaScript objects is based on the insertion order.
Generated definitions seem to be ordered topologically or something. The order is not guaranteed. So depending on where you call those ports, the order can shift.
This means that if you use a port in a new place, you can accidentally break a usage of that port in a completely different part of the app.
Solution?
Process subscriptions before commands, so that the subscriptions are ready for the commands producing data.
SSCCE
port module PortSubCmdDemoMinimal exposing (main)
import Browser
import Html exposing (Html)
import Html.Events
port getFromLocalStorage : { debug : String } -> Cmd msg
port gotTextFromLocalStorage : (String -> msg) -> Sub msg
type alias Model =
{ page : Page
}
type Page
= Home
| Contact String
init : () -> ( Model, Cmd Msg )
init () =
( { page = Home }
-- Flip to the `getFromLocalStorage` line here to trigger the bug.
, Cmd.none
-- , getFromLocalStorage { debug = "via init" }
-- In this case, calling `getFromLocalStorage` here is useless.
-- But imagine adding another `getFromLocalStorage` call in a bigger app
-- and suddenly the original use breaks. Oops!
)
type Msg
= PressedGoToHomePage
| PressedGoToContactPage
| GotLocalStorage String
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case Debug.log "msg" msg of
PressedGoToHomePage ->
( { model | page = Home }
, Cmd.none
)
PressedGoToContactPage ->
-- This text is supposed to be immediately replaced via the two ports.
( { model | page = Contact "❌ Oops! We never got anything from local storage :(" }
, getFromLocalStorage { debug = "via PressedGoToContactPage" }
)
-- Imagine there being a `ContactPage` model with `ContactPage.Msg` that has
-- `GotLocalStorage`. Then the message handling wouldn’t look as silly :)
-- I wanted to keep things simple for the demo, though.
GotLocalStorage text ->
case model.page of
Home ->
( model, Cmd.none )
Contact _ ->
( { model | page = Contact text }, Cmd.none )
subscriptions : Model -> Sub Msg
subscriptions model =
case model.page of
Home ->
Sub.none
Contact _ ->
-- Imagine this being `ContactPage.subscriptions |> Sub.map ContactPageMsg`.
gotTextFromLocalStorage GotLocalStorage
view : Model -> Html Msg
view model =
case model.page of
Home ->
Html.div []
[ Html.h1 [] [ Html.text "Home page" ]
, Html.button [ Html.Events.onClick PressedGoToContactPage ]
[ Html.text "Go to contact page" ]
]
Contact text ->
Html.div []
[ Html.h1 [] [ Html.text "Contact page" ]
, Html.button [ Html.Events.onClick PressedGoToHomePage ]
[ Html.text "Go to home page" ]
, Html.p [] [ Html.text ("Text from local storage: " ++ text) ]
]
main : Program () Model Msg
main =
Browser.element
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PortSubCmdDemoMinimal</title>
</head>
<body>
<script src="elm.js" id="node"></script>
<script>
const app = Elm.PortSubCmdDemoMinimal.init({ node });
app.ports.getFromLocalStorage.subscribe(({debug}) => {
console.log("app.ports.getFromLocalStorage:", debug);
app.ports.gotTextFromLocalStorage.send("✅ fake text from LocalStorage");
});
</script>
</body>
</html>