diff --git a/elm.cabal b/elm.cabal index fa8ae5242..39653e37e 100644 --- a/elm.cabal +++ b/elm.cabal @@ -229,6 +229,7 @@ Executable lamdera Lamdera.CLI Lamdera.CLI.Live + Lamdera.CLI.Live.Output Lamdera.CLI.Login Lamdera.CLI.Check Lamdera.CLI.Deploy diff --git a/ext-sentry/Ext/Sentry.hs b/ext-sentry/Ext/Sentry.hs index 954d0f8f0..22cca5f69 100644 --- a/ext-sentry/Ext/Sentry.hs +++ b/ext-sentry/Ext/Sentry.hs @@ -15,13 +15,15 @@ import Ext.Common data Cache = Cache { jsOutput :: MVar BS.ByteString + , htmlWrapper :: MVar (BS.ByteString, BS.ByteString) } init :: IO Cache init = do mJsOutput <- newMVar "cacheInit!" - pure $ Cache mJsOutput + mHtmlWrapper <- newMVar ("cacheInit!", "cacheInit!") + pure $ Cache mJsOutput mHtmlWrapper getJsOutput :: Cache -> IO BS.ByteString @@ -29,11 +31,20 @@ getJsOutput cache = readMVar $ jsOutput cache -asyncUpdateJsOutput :: Cache -> IO BS.ByteString -> IO () -asyncUpdateJsOutput (Cache mJsOutput) recompile = do +getHtmlOutput :: Cache -> IO BS.ByteString +getHtmlOutput cache = do + (htmlPre, htmlPost) <- readMVar $ htmlWrapper cache + js <- getJsOutput cache + pure $ htmlPre <> js <> htmlPost + + +asyncUpdateJsOutput :: Cache -> IO (BS.ByteString, BS.ByteString, BS.ByteString) -> IO () +asyncUpdateJsOutput (Cache mJsOutput mHtmlWrapper) recompile = do trackedForkIO "Ext.Sentry.asyncUpdateJsOutput" $ do takeMVar mJsOutput - !bs <- track "recompile" $ recompile - putMVar mJsOutput bs + takeMVar mHtmlWrapper + (!pre, !js, !post) <- track "recompile" $ recompile + putMVar mJsOutput js + putMVar mHtmlWrapper (pre, post) System.Mem.performMajorGC pure () diff --git a/extra/Lamdera/CLI/Live.hs b/extra/Lamdera/CLI/Live.hs index e9d462cb3..04c086182 100644 --- a/extra/Lamdera/CLI/Live.hs +++ b/extra/Lamdera/CLI/Live.hs @@ -212,7 +212,10 @@ lamderaLocalDev = refreshClients (mClients, mLeader, mChan, beState) = - SocketServer.broadcastImpl mClients "{\"t\":\"r\"}" -- r is refresh, see live.js + SocketServer.broadcastImpl mClients "{\"t\":\"r\"}" -- r is refresh, see live.ts + +reloadClientsJS (mClients, mLeader, mChan, beState) = + SocketServer.broadcastImpl mClients "{\"t\":\"j\"}" -- j is refresh, see live.ts serveWebsocket root (mClients, mLeader, mChan, beState) = diff --git a/extra/Lamdera/CLI/Live/Output.hs b/extra/Lamdera/CLI/Live/Output.hs new file mode 100644 index 000000000..970936ceb --- /dev/null +++ b/extra/Lamdera/CLI/Live/Output.hs @@ -0,0 +1,88 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +module Lamdera.CLI.Live.Output where + +import qualified Data.ByteString.Builder as B +import Data.Monoid ((<>)) +import qualified Data.Name as Name +import Text.RawString.QQ (r) + +import qualified Lamdera +import qualified Lamdera.Live +import qualified Lamdera.UiSourceMap + + +-- For when we already have the combined JS from cache and just need to wrap the HTML around it +parts :: FilePath -> Name.Name -> B.Builder -> (B.Builder, B.Builder, B.Builder) +parts root moduleName javascript = + let + name = Name.toBuilder moduleName + + (hasCustom, customHead) = Lamdera.unsafe $ Lamdera.Live.lamderaLiveHead root + htmlHead = + if hasCustom + then + customHead + else + "" <> name <> "" + + combinedjs = js root moduleName javascript + + pre = [r| + + + + + + + |] <> htmlHead <> [r| + + + + +

+
+
+
+
+|]
+
+  in
+  (pre, combinedjs, post)
+
+
+-- The Elm app, `lamdera live` JS harness (live.ts) and UI sourcemaps combined into a single JS snippet
+js :: FilePath -> Name.Name -> B.Builder -> B.Builder
+js root moduleName javascript =
+  let
+    name = Name.toBuilder moduleName
+  in
+  [r|
+try {
+|] <> javascript <> [r|
+|] <> Lamdera.Live.lamderaLiveSrc <> Lamdera.UiSourceMap.src <> [r|
+  setupApp("|] <> name <> [r|", "elm")
+}
+catch (e)
+{
+  // document.body.classList.add("boot-unhandled-js-error");
+  // // display initialization errors (e.g. bad flags, infinite recursion)
+  // var header = document.createElement("h1");
+  // header.style.fontFamily = "monospace";
+  // header.innerText = "Initialization Error";
+  // var pre = document.createElement("pre");
+  // document.body.insertBefore(header, pre);
+  // pre.innerText = e;
+  throw e;
+}
+|]
+
+
+-- For when we need to generate the HTML and combined JS from scratch
+-- html :: FilePath -> Name.Name -> B.Builder -> B.Builder
+-- html root moduleName javascript =
+--   wrapjs root moduleName (js root moduleName javascript)
diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs
index dfeb97517..5bf2a553f 100644
--- a/extra/Lamdera/Injection.hs
+++ b/extra/Lamdera/Injection.hs
@@ -145,72 +145,95 @@ inspect graph =
   debugHaskellPass "graphModifications keys" (graph & Map.filterWithKey pick & Map.toList & take 5) graph
 
 
+data OutputType = LamderaBackend | LamderaFrontend | LamderaLive | NotLamdera deriving (Eq)
+
+
 source :: Mode.Mode -> Mains -> B.Builder
 source mode mains =
   let
-    isBackend = mains & mainsInclude ["Backend", "LBR"]
-    isLocalDev = mains & mainsInclude ["LocalDev"]
+    outputType =
+      if mains & mainsInclude ["Backend", "LBR"] then
+        LamderaBackend
+      else if mains & mainsInclude ["Frontend", "LFR"] then
+        LamderaFrontend
+      else if mains & mainsInclude ["LocalDev"] then
+        LamderaLive
+      else
+        NotLamdera
   in
-  B.byteString $ Text.encodeUtf8 $ injections isBackend isLocalDev
+  B.byteString $ Text.encodeUtf8 $ injections outputType
+
+
+injections :: OutputType -> Text
+injections outputType =
+  case outputType of
+    -- NotLamdera was added when we fixed the hot loading of a new app version in the browser.
+    -- The frontend version of the injections – which used to be what you got when using the
+    -- lamdera compiler for non-lamdera things – then became much more complicated.
+    -- In order not to break elm-pages (which calls `app.die()`) we preserved the old frontend
+    -- injections. Note that the `.die()` method is incomplete: It does not kill all apps completely
+    -- and does not help the garbage collector enough for a killed app to be fully garbage collected.
+    NotLamdera ->
+      [text|
+    function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder)
+      {
+        var result = A2(_Json_run, flagDecoder, _Json_wrap(args ? args['flags'] : undefined));
 
+        // @TODO need to figure out how to get this to automatically escape by mode?
+        //$$elm$$core$$Result$$isOk(result) || _Debug_crash(2 /**/, _Json_errorToString(result.a) /**/);
+        $$elm$$core$$Result$$isOk(result) || _Debug_crash(2 /**_UNUSED/, _Json_errorToString(result.a) /**/);
 
-injections :: Bool -> Bool -> Text
-injections isBackend isLocalDev =
-  let
-    previousVersionInt =
-      -- @TODO maybe its time to consolidate the global config...
-      (unsafePerformIO $ lookupEnv "VERSION")
-        & maybe "0" id
-        & read
-        & subtract 1
-
-    previousVersion = show_ previousVersionInt
-
-    isBackend_ =
-      if isBackend
-        then "true"
-        else "false"
-
-    runUpdate =
-      if isLocalDev
-        then
-          [text|
-            var pair = A2(update, msg, model);
-          |]
-        else
-          [text|
-            try {
-              var pair = A2(update, msg, model);
-            } catch(err) {
-              if (isBackend) { bugsnag.notify(err); }
-              return;
-            }
-          |]
-
-    exportFns =
-      if isBackend
-        then
-          [text|
-            var fns =
-              { decodeWirePayloadHeader: $$author$$project$$LamderaHelpers$$decodeWirePayloadHeader
-              , decodeWireAnalytics: $$author$$project$$LamderaHelpers$$decodeWireAnalytics
-              , getUserModel : function() { return model.userModel }
-              }
-          |]
-        else
-          [text|
-            var fns = {}
-          |]
-
-    shouldProxy =
-      onlyIf isLocalDev
-        [text|
-          shouldProxy = $$author$$project$$LocalDev$$shouldProxy(msg)
-        |]
-  in
-  [text|
+        var managers = {};
+        var initPair = init(result.a);
+        var model = (args && args['model']) || initPair.a;
+
+        var stepper = stepperBuilder(sendToApp, model);
+        var ports = _Platform_setupEffects(managers, sendToApp);
+
+        var upgradeMode = false;
+
+        function sendToApp(msg, viewMetadata)
+        {
+          if (upgradeMode) {
+            // No more messages should run in upgrade mode
+            _Platform_enqueueEffects(managers, $$elm$$core$$Platform$$Cmd$$none, $$elm$$core$$Platform$$Sub$$none);
+            return;
+          }
+
+          var pair = A2(update, msg, model);
+          stepper(model = pair.a, viewMetadata);
+          _Platform_enqueueEffects(managers, pair.b, subscriptions(model));
+        }
+
+        if ((args && args['model']) === undefined) {
+          _Platform_enqueueEffects(managers, initPair.b, subscriptions(model));
+        }
+
+        const die = function() {
+          // Stop all subscriptions.
+          // This must be done before clearing the stuff below.
+          _Platform_enqueueEffects(managers, _Platform_batch(_List_Nil), _Platform_batch(_List_Nil));
 
-    var isBackend = $isBackend_ && typeof isLamdera !== 'undefined';
+          managers = null;
+          model = null;
+          stepper = null;
+          ports = null;
+          _Platform_effectsQueue = [];
+        }
+
+        return ports ? {
+          ports: ports,
+          gm: function() { return model },
+          eum: function() { upgradeMode = true },
+          die: die,
+          fns: {}
+        } : {};
+      }
+      |]
+
+    LamderaBackend ->
+      [text|
+    var isLamderaRuntime = typeof isLamdera !== 'undefined';
 
     function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder)
       {
@@ -229,41 +252,46 @@ injections isBackend isLocalDev =
 
         var pos = 0;
 
-        //console.log('managers', managers)
-        //console.log('ports', ports)
-
-        var dead = false;
-        var upgradeMode = false;
-
         function mtime() { // microseconds
-          if (!isBackend) { return 0; }
+          if (!isLamderaRuntime) { return 0; }
           const hrTime = process.hrtime();
           return Math.floor(hrTime[0] * 1000000 + hrTime[1] / 1000);
         }
 
+        var buriedTimestamp = null;
+
         function sendToApp(msg, viewMetadata)
         {
-          if(dead){ return }
-          if (upgradeMode) {
-            // console.log('sendToApp.inactive',msg);
-            // No more messages should run in upgrade mode
-            // @TODO redirect messages somewhere
-            _Platform_enqueueEffects(managers, $$elm$$core$$Platform$$Cmd$$none, $$elm$$core$$Platform$$Sub$$none);
+          if (buriedTimestamp !== null) {
+            const elapsed = Date.now() - buriedTimestamp;
+            // This tries to turn `HomePageMsg (WeatherWidgetMsg (WeatherReportReceived WeatherReport))`
+            // into `"HomePageMsg WeatherWidgetMsg WeatherReportReceived"`.
+            // The idea is that if the timeout for forwarding messages isn't enough, we want to know what
+            // message somebody used that took even longer, but without reporting the entire msg.
+            // Note that in `--optimize` mode, the above string would become something like `"1 3 6"`,
+            // but it's better than nothing.
+            let msgName = '(unknown message)';
+            if (msg.$) {
+              msgName = msg.$;
+              let current = msg;
+              while (current.a && current.a.$ && !current.b) {
+                current = current.a;
+                msgName = msgName + ' ' + current.$;
+              }
+            }
+            bugsnag.notify(new Error('Got message ' + elapsed + ' ms after app was buried: ' + msgName));
             return;
           }
-          //console.log('sendToApp.active',msg);
-
-          $shouldProxy
 
           var serializeDuration, logDuration = null;
           var start = mtime();
 
-          $runUpdate
+          var pair = A2(update, msg, model);
 
           const updateDuration = mtime() - start;
           start = mtime();
 
-          if (isBackend && loggingEnabled) {
+          if (isLamderaRuntime && loggingEnabled) {
             pos = pos + 1;
             const s = $$author$$project$$LBR$$serialize(msg);
             serializeDuration = mtime() - start;
@@ -272,49 +300,321 @@ injections isBackend isLocalDev =
             logDuration = mtime() - start;
           }
 
-          // console.log(`model size: ${global.sizeof(pair.a)}`);
-          // console.log(pair.a);
-
           stepper(model = pair.a, viewMetadata);
-          //console.log('cmds', pair.b);
           _Platform_enqueueEffects(managers, pair.b, subscriptions(model));
-
-          const stepEnqueueDuration = mtime() - start;
-
-          if (isBackend) {
-            //console.log({serialize: serializeDuration, log: logDuration, update: updateDuration, stepEnqueue: stepEnqueueDuration})
-          }
         }
 
         if ((args && args['model']) === undefined) {
           _Platform_enqueueEffects(managers, initPair.b, subscriptions(model));
         }
 
-        $exportFns
+        var fns =
+          { decodeWirePayloadHeader: $$author$$project$$LamderaHelpers$$decodeWirePayloadHeader
+          , decodeWireAnalytics: $$author$$project$$LamderaHelpers$$decodeWireAnalytics
+          , getUserModel : function() { return model.userModel }
+          }
 
         const die = function() {
-          //console.log('App dying');
+          // In case there still are any pending commands, setting this flag means
+          // that nothing happens when they finish.
+          buriedTimestamp = Date.now();
 
-          // Needed to stop Time.every subscriptions.
-          // This must be done before clearing the stuff below.
-          _Platform_enqueueEffects(managers, _Platform_batch(_List_Nil), _Platform_batch(_List_Nil));
-
-          managers = null;
+          // The app won't be garbage collected until all pending commands are done.
+          // We can reclaim most memory immediately by manually clearing the model early.
           model = null;
-          stepper = null;
-          ports = null;
-          _Platform_effectsQueue = [];
+
+          // On the frontend, we have to clear the effect managers, since they prevent sendToApp from being GC:ed,
+          // which prevents the whole app from being GC:ed. On the backend, it does not seem to.
+          // We still do it here for consistency (it doesn't hurt).
+          _Platform_effectManagers = {};
         }
 
+        // On the frontend, clearing args helps garbage collection. On the backend, it does not seem to.
+        // We still do it here for consistency (it doesn't hurt).
+        args = null;
+
         return ports ? {
           ports: ports,
-          gm: function() { return model },
-          eum: function() { upgradeMode = true },
           die: die,
           fns: fns
         } : {};
       }
-  |]
+      |]
+
+    -- LamderaFrontend or LamderaLive
+    _ ->
+      let
+        shouldProxy =
+          onlyIf (outputType == LamderaLive)
+            [text|
+              shouldProxy = $$author$$project$$LocalDev$$shouldProxy(msg)
+            |]
+      in
+      [text|
+    function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder)
+      {
+        var result = A2(_Json_run, flagDecoder, _Json_wrap(args ? args['flags'] : undefined));
+
+        // @TODO need to figure out how to get this to automatically escape by mode?
+        //$$elm$$core$$Result$$isOk(result) || _Debug_crash(2 /**/, _Json_errorToString(result.a) /**/);
+        $$elm$$core$$Result$$isOk(result) || _Debug_crash(2 /**_UNUSED/, _Json_errorToString(result.a) /**/);
+
+        var managers = {};
+        var initPair = init(result.a);
+        var model = initPair.a;
+
+        // We'll temporarily overwrite these variables or functions.
+        var F2_backup = F2;
+        var _Browser_window_backup = _Browser_window;
+        var _VirtualDom_virtualize_backup = _VirtualDom_virtualize;
+        var _VirtualDom_applyPatches_backup = _VirtualDom_applyPatches;
+        var _VirtualDom_equalEvents_backup = _VirtualDom_equalEvents;
+
+        // stepperBuilder calls impl.setup() (if it exists, which it does only for
+        // Browser.application) as the first thing it does. impl.setup() returns the
+        // divertHrefToApp function, which is used to create the event listener for
+        // all  elements. That divertHrefToApp function is constructed using F2.
+        // Here we override F2 to store the listener on the DOM node so we can
+        // remove it later. _VirtualDom_virtualize is called a couple of lines
+        // later, so we use that to restore the original F2 function, so it can do
+        // the right thing when the view function is called.
+        F2 = function(f) {
+          return function(domNode) {
+            var listener = function(event) {
+              return f(domNode, event);
+            };
+            domNode.elmAf = listener;
+            return listener;
+          };
+        };
+
+        // To get hold of the Browser.Navigation.key, and to be able to remove the popstate and hashchange listeners.
+        _Browser_window = {
+          navigator: _Browser_window_backup.navigator,
+          addEventListener: function(eventName, listener) {
+            _Browser_navKey = listener;
+            _Browser_window_backup.addEventListener(eventName, listener);
+          },
+        };
+
+        // When passing in the last rendered VNode from a previous app:
+        if (args && args.vn) {
+          // Instead of virtualizing the existing DOM into a VNode, just use the
+          // one from the previous app. Html.map messes up Elm's
+          // _VirtualDom_virtualize, causing the entire thing inside the Html.map
+          // to be re-created even though it is already the correct DOM.
+          _VirtualDom_virtualize = function() {
+            F2 = F2_backup; // Restore F2 as mentioned above.
+            return args.vn;
+          };
+
+          _VirtualDom_applyPatches = function(rootDomNode, oldVirtualNode, patches, eventNode) {
+            if (patches.length !== 0) {
+              _VirtualDom_addDomNodes(rootDomNode, oldVirtualNode, patches, eventNode);
+            }
+            _VirtualDom_lastDomNode = _VirtualDom_applyPatchesHelp(rootDomNode, patches);
+            // Restore the event listeners on the  elements:
+            var aElements = _VirtualDom_lastDomNode.getElementsByTagName('a');
+            for (var i = 0; i < aElements.length; i++) {
+              var domNode = aElements[i];
+              domNode.addEventListener('click', _VirtualDom_divertHrefToApp(domNode));
+            }
+            return _VirtualDom_lastDomNode;
+          }
+
+          // Force all event listeners to be re-applied:
+          _VirtualDom_equalEvents = function() {
+            return false;
+          }
+        } else {
+          _VirtualDom_virtualize = function(node) {
+            F2 = F2_backup; // Restore F2 as mentioned above.
+            return _VirtualDom_virtualize_backup(node);
+          };
+        }
+
+        var stepper = stepperBuilder(sendToApp, model);
+
+        // Restore the original functions and variables.
+        F2 = F2_backup; // Should already be restored by now, but just in case.
+        _Browser_window = _Browser_window_backup;
+        _VirtualDom_virtualize = _VirtualDom_virtualize_backup;
+        _VirtualDom_applyPatches = _VirtualDom_applyPatches_backup;
+        _VirtualDom_equalEvents = _VirtualDom_equalEvents_backup;
+
+        if (args && args.vn) {
+          // Html.map puts `.elm_event_node_ref = { __tagger: taggers, __parent: parent_elm_event_node_ref }`
+          // on the DOM node for its first non-Html.map child virtual DOM node. __parent is a reference to
+          // the .elm_event_node_ref of a parent Html.map further up the tree. Modifying one modifies the other,
+          // since they are the same object. The top-most Html.map nodes have __parent set to the sendToApp function.
+          // All the __tagger should be updated now to stuff from the new app, but the __parent sendToApp still points
+          // to the old app, so we need to update it to the current app.
+          // This relies on that fact that we do `List.map (Html.map FEMsg) body`. If that weren't the case, we could
+          // have to crawl the tree recursively to find the top-most Html.map nodes.
+          for (var i = 0; i < _VirtualDom_lastDomNode.childNodes.length; i++) {
+            var element = _VirtualDom_lastDomNode.childNodes[i];
+            if (element.elm_event_node_ref && typeof element.elm_event_node_ref.p === 'function') {
+              element.elm_event_node_ref.p = sendToApp;
+            }
+          }
+        }
+
+        var ports = _Platform_setupEffects(managers, sendToApp);
+
+        var buriedTimestamp = null;
+
+        function sendToApp(msg, viewMetadata)
+        {
+          if (buriedTimestamp !== null) {
+            const elapsed = Date.now() - buriedTimestamp;
+            let msgName = '(unknown message)';
+            if (msg.$) {
+              msgName = msg.$;
+              let current = msg;
+              while (current.a && current.a.$ && !current.b) {
+                current = current.a;
+                msgName = msgName + ' ' + current.$;
+              }
+            }
+            window.lamdera.bs.notify(new Error('Got message ' + elapsed + ' ms after app was buried: ' + msgName));
+            return;
+          }
+
+          $shouldProxy
+
+          var pair = A2(update, msg, model);
+          stepper(model = pair.a, viewMetadata);
+          _Platform_enqueueEffects(managers, pair.b, subscriptions(model));
+        }
+
+        _Platform_enqueueEffects(managers, initPair.b, subscriptions(model));
+
+        // Stops the app from getting more input, and from rendering.
+        // It doesn't die completely: Already running cmds will still run, and
+        // hit the update function, which then redirects the messages to the new app.
+        const die = function() {
+          // Render one last time, synchronously, in case there is a scheduled
+          // render with requestAnimationFrame (which then become no-ops).
+          // Rendering mutates the vdom, and we want those mutations.
+          stepper(model, true /* isSync */);
+
+          // Remove Elm's event listeners. Both the ones added
+          // automatically on every  element, as well as the ones
+          // added by using Html.Events.
+          var elements = _VirtualDom_lastDomNode.getElementsByTagName('*');
+          for (var i = 0; i < elements.length; i++) {
+            var element = elements[i];
+            if (element.elmAf) {
+              element.removeEventListener('click', element.elmAf);
+              delete element.elmAf;
+            }
+            if (element.elmFs) {
+              for (var key in element.elmFs) {
+                element.removeEventListener(key, element.elmFs[key]);
+              }
+              delete element.elmFs;
+            }
+            // Leave element.elm_event_node_ref behind, because the first
+            // render in the new app crashes otherwise. It contains references
+            // to the old app, but all of that should go away after the first
+            // render, and the element.elm_event_node_ref.p stuff above.
+          }
+
+          // Remove the popstate and hashchange listeners.
+          if (_Browser_navKey) {
+            _Browser_window.removeEventListener('popstate', _Browser_navKey);
+            _Browser_window.removeEventListener('hashchange', _Browser_navKey);
+            // Remove reference to .a aka .__sendToApp which prevents GC.
+            delete _Browser_navKey.a;
+          }
+
+          // Stop rendering:
+          stepper = function() {};
+
+          // Note that subscriptions are turned off in Elm (by returning Sub.none)
+          // in upgrade mode, and the update function stops doing its regular business
+          // and just forwards messages instead.
+
+          return _VirtualDom_lastVNode;
+        }
+
+        // This can't be done in the die function, because then it's not possible to
+        // trigger an outgoing port to redirect messages. This is supposed to be called
+        // when all pending commands are done.
+        const bury = function() {
+          // In case there still are any pending commands, setting this flag means
+          // that nothing happens when they finish.
+          buriedTimestamp = Date.now();
+
+          // The app won't be garbage collected until all pending commands are done.
+          // We can reclaim most memory immediately by manually clearing the model early.
+          model = null;
+
+          // Clear effect managers, since they prevent sendToApp from being GC:ed,
+          // which prevents the whole app from being GC:ed.
+          _Platform_effectManagers = {};
+        };
+
+        // Clearing args means the flags (like the passed in model) can be GC:ed (in the new app).
+        args = null;
+
+        return ports ? {
+          ports: ports,
+          die: die,
+          bury: bury,
+        } : {};
+      }
+
+    // Keep track of the last VNode rendered so we can pass it to the next app later.
+    var _VirtualDom_lastVNode = null;
+    function _VirtualDom_diff(x, y)
+    {
+      _VirtualDom_lastVNode = y;
+      var patches = [];
+      _VirtualDom_diffHelp(x, y, patches, 0);
+      return patches;
+    }
+
+    // Keep track of the reference to the latest root DOM node so we can perform cleanups in it later.
+    var _VirtualDom_lastDomNode = null;
+    function _VirtualDom_applyPatches(rootDomNode, oldVirtualNode, patches, eventNode)
+    {
+      if (patches.length === 0)
+      {
+        return (_VirtualDom_lastDomNode = rootDomNode);
+      }
+
+      _VirtualDom_addDomNodes(rootDomNode, oldVirtualNode, patches, eventNode);
+      return (_VirtualDom_lastDomNode = _VirtualDom_applyPatchesHelp(rootDomNode, patches));
+    }
+
+    // In Elm, Browser.Navigation.Key is a function behind the scenes. It is passed and called here.
+    // In Lamdera, the Key becomes an object after a Wire roundtrip, so we just take the key as a "password"
+    // but then call the actual function ourselves.
+    var _Browser_navKey = null;
+    var _Browser_go = F2(function(key, n) {
+      return A2($$elm$$core$$Task$$perform, $$elm$$core$$Basics$$never, _Scheduler_binding(function() {
+        n && history.go(n);
+        _Browser_navKey();
+      }));
+    });
+    // $$elm$$browser$$Browser$$Navigation$$back is not a direct assignment so it does not need to be replaced.
+    var $$elm$$browser$$Browser$$Navigation$$forward = _Browser_go;
+    var _Browser_pushUrl = F2(function(key, url) {
+      return A2($$elm$$core$$Task$$perform, $$elm$$core$$Basics$$never, _Scheduler_binding(function() {
+        history.pushState({}, "", url);
+        _Browser_navKey();
+      }));
+    });
+    var $$elm$$browser$$Browser$$Navigation$$pushUrl = _Browser_pushUrl;
+    var _Browser_replaceUrl = F2(function(key, url) {
+      return A2($$elm$$core$$Task$$perform, $$elm$$core$$Basics$$never, _Scheduler_binding(function() {
+        history.replaceState({}, "", url);
+        _Browser_navKey();
+      }));
+    });
+    var $$elm$$browser$$Browser$$Navigation$$replaceUrl = _Browser_replaceUrl;
+      |]
 
   --   // https://github.com/elm/bytes/issues/20
   --   // but the fix below as suggested causes this problem:
diff --git a/extra/Lamdera/Live.hs b/extra/Lamdera/Live.hs
index a45c56300..1cbd2fff2 100644
--- a/extra/Lamdera/Live.hs
+++ b/extra/Lamdera/Live.hs
@@ -15,7 +15,7 @@ import Data.FileEmbed (bsToExp)
 import qualified System.Directory as Dir
 
 import Lamdera
-import qualified Lamdera.Relative
+import qualified Lamdera.Relative as Relative
 import qualified Ext.Common
 
 
@@ -26,18 +26,18 @@ lamderaLiveSrc =
       then do
         Lamdera.debug $ "🗿  Using debug mode lamderaLive"
         userHome <- Dir.getHomeDirectory
-        let overrideRoot = userHome  "dev/projects/lamdera-compiler/extra"
-            overridePath = overrideRoot  "live.js"
-            overridePathBuilt = overrideRoot  "dist/live.js"
+        overrideRoot <- Relative.findDir "extra"
+        overridePath <- Relative.requireFile $ overrideRoot  "live.ts"
+        overridePathBuilt <- Relative.requireFile $ overrideRoot  "dist/live.js"
 
         exists <- doesFileExist overridePath
         if exists
           then do
-            Lamdera.debug $ "🗿 Using " ++ overridePathBuilt ++ " for lamderaLive"
+            Lamdera.debug $ "🗿 Compiling " ++ overridePath ++ " to " ++ overridePathBuilt ++ " for lamderaLive"
             Ext.Common.requireBinary "npm"
             Ext.Common.requireBinary "esbuild"
-            Ext.Common.bash $ "cd " <> overrideRoot <> " && npm i && esbuild " <> overridePath <> " --bundle --minify --target=chrome58,firefox57,safari11,edge16 > " <> overridePathBuilt
-            -- Ext.Common.bash $ "cd " <> overrideRoot <> " && npm i && esbuild " <> overridePath <> " --bundle --target=chrome58,firefox57,safari11,edge16 > " <> overridePathBuilt
+            -- Ext.Common.bash $ "cd " <> overrideRoot <> " && npm i && esbuild " <> overridePath <> " --bundle --minify --target=chrome58,firefox57,safari11,edge16 > " <> overridePathBuilt
+            Ext.Common.bash $ "cd " <> overrideRoot <> " && npm i && esbuild " <> overridePath <> " --bundle --target=chrome58,firefox57,safari11,edge16 > " <> overridePathBuilt
             overrideM <- readUtf8Text overridePathBuilt
             case overrideM of
               Just override -> do
@@ -67,4 +67,4 @@ lamderaLiveHead root = do
 
 lamderaLive :: BS.ByteString
 lamderaLive =
-  $(bsToExp =<< runIO (Lamdera.Relative.readByteString "extra/dist/live.js"))
+  $(bsToExp =<< runIO (Relative.readByteString "extra/dist/live.js"))
diff --git a/extra/live.js b/extra/live.ts
similarity index 80%
rename from extra/live.js
rename to extra/live.ts
index 53f229548..eaa21f9ef 100644
--- a/extra/live.js
+++ b/extra/live.ts
@@ -1,8 +1,18 @@
 // This file needs to be minified into dist for release:
 // cd extra
-// esbuild live.js --bundle --minify --target=chrome58,firefox57,safari11,edge16 > dist/live.js
+// esbuild live.ts --bundle --minify --target=chrome58,firefox57,safari11,edge16 > dist/live.js
 // `lamdera live` does this automatically LDEBUG mode
 
+declare global {
+  var elmPkgJsIncludes: any;
+  var Elm: any;
+}
+declare global {
+  interface Window {
+    setupApp: (name: string, elid: string) => void;
+  }
+}
+
 import * as Sockette from 'sockette';
 import * as Cookie  from 'js-cookie';
 
@@ -10,16 +20,16 @@ var clientId = ""
 const sessionId = getSessionId()
 var connected = false
 var disconnectedTime = null
-var bufferOutbound = []
-var bufferInbound = []
+var bufferOutbound: any[] = []
+var bufferInbound: any[] = []
 
-var leaderId = null
-var nodeType = "f"
+var leaderId: string | null = null
+var nodeType: 'f' | 'l' = "f"
 
 // Null checking as we might be on an error page, which doesn't initiate an app
 // but we still want the livereload to function
-var app = null
-var initBackendModel = null
+var app: { ports: any } | null = null
+var initBackendModel: DataView | null = null
 
 var msgHandler = function(e) {
   const d = JSON.parse(e.data)
@@ -108,24 +118,24 @@ window.setupApp = function(name, elid) {
     if (app !== null) { return } // Don't init when already initialised
     // console.log(`booting with`, { c: clientId, s: sessionId, nt: nodeType, b: initBackendModel })
 
+    let root = document.getElementById(elid)
+
     if (name !== "LocalDev") {
       console.warn('Not a Lamdera app, loading as normal Elm.')
-      app = name.split('.').reduce((o,i)=> o[i], Elm).init({ node: document.getElementById(elid) })
-      if (document.getElementById(elid)) {
-        document.getElementById(elid).innerText = 'This is a headless program, meaning there is nothing to show here.\n\nI started the program anyway though, and you can access it as `app` in the developer console.'
-      }
+      app = name.split('.').reduce((o,i)=> o[i], Elm).init({ node: root })
+      if (root) root.innerText = 'This is a headless program, meaning there is nothing to show here.\n\nI started the program anyway though, and you can access it as `app` in the developer console.'
       return;
     }
 
     app = Elm[name].init({
-      node: document.getElementById(elid),
+      node: root,
       flags: { c: clientId, s: sessionId, nt: nodeType, b: initBackendModel }
     })
-    if (document.getElementById(elid)) {
-      document.getElementById(elid).innerText = 'This is a headless program, meaning there is nothing to show here.\n\nI started the program anyway though, and you can access it as `app` in the developer console.'
-    }
+
+    if (root) root.innerText = 'This is a headless program, meaning there is nothing to show here.\n\nI started the program anyway though, and you can access it as `app` in the developer console.'
+
     // window.app = app
-    app.ports.send_ToFrontend.subscribe(function (payload) {
+    app?.ports.send_ToFrontend.subscribe(function (payload) {
       if (payload.b !== null) {
         payload.b = bytesToBase64(payload.b)
       }
@@ -133,17 +143,17 @@ window.setupApp = function(name, elid) {
       msgEmitter(payload)
     })
 
-    app.ports.save_BackendModel.subscribe(function (payload) {
+    app?.ports.save_BackendModel.subscribe(function (payload) {
       payload.b = bytesToBase64(payload.b)
       payload.f = (payload.f) ? "force" : ""
       msgEmitter(payload)
     })
 
-    app.ports.send_EnvMode.subscribe(function (payload) {
+    app?.ports.send_EnvMode.subscribe(function (payload) {
       msgEmitter(payload)
     })
 
-    app.ports.send_ToBackend.subscribe(function (bytes) {
+    app?.ports.send_ToBackend.subscribe(function (bytes) {
       var b64 = bytesToBase64(bytes)
       // console.log(`[S] ToBackend`, { t:"ToBackend", s: sessionId, c: clientId, b: b64 })
       msgEmitter({ t:"ToBackend", s: sessionId, c: clientId, b: b64 })
@@ -159,19 +169,44 @@ window.setupApp = function(name, elid) {
   msgHandler = function(e) {
 
     // console.log(`got message`,e)
-    let d = null;
+    let d: null
+      | { t: 'r' }
+      | { t: 'j' }
+      | { t: 's', c: string, l: string }
+      | { t: 'e', l: string }
+      | { t: 'ToBackend', s: string, c: string, b: string }
+      | { t: 'ToFrontend', c: string, b: DataView | null }
+      | { t: 'p', b: string }
+      | { t: 'q', r: string, i: string | null, j: string | null }
+      | { t: 'c', s: string, c: string }
+      | { t: 'd', s: string, c: string }
+      | { t: 'x' }
+
     try {
       d = JSON.parse(e.data)
     } catch(err) {
       console.log(err, e.data);
       return;
     }
+    if (!d) return console.warn(`unexpected empty parsed message`, e)
 
     switch(d.t) {
       case "r":
+        // @DEPRECATED Server issued page reload, being replaced by hot reloads
         document.location.reload()
         break;
 
+      case "j":
+        // Javascript hot reload directive, compilation has started on the server
+        // and we're going to inject a script tag to load the new JS which will hang
+        // until the compilation is done. This also causes browser native UI's to indicate
+        // page load activity which is a nice indicator to users that something is happening.
+        const script = document.createElement('script');
+        script.src = '/_x/js';
+        document.head.appendChild(script);
+        // debugger
+        break;
+
       case "s": // setup message, will get called again if websocket drops and reconnects
         clientId = d.c
         if (app !== null) { app.ports.setClientId.send(clientId) }
@@ -201,7 +236,7 @@ window.setupApp = function(name, elid) {
 
       case "ToBackend":
         // console.log(`[R] ToBackend`, d)
-        app.ports.receive_ToBackend.send([d.s, d.c, base64ToBytes(d.b)])
+        app?.ports.receive_ToBackend.send([d.s, d.c, base64ToBytes(d.b)])
         break;
 
       case "ToFrontend":
@@ -212,7 +247,7 @@ window.setupApp = function(name, elid) {
           if (d.b !== null) {
             d.b = base64ToBytes(d.b)
           }
-          app.ports.receive_ToFrontend.send(d)
+          app?.ports.receive_ToFrontend.send(d)
         } else {
           // console.log(`dropped message`, d)
         }
@@ -246,24 +281,24 @@ window.setupApp = function(name, elid) {
             }
           }
 
-          app.ports.rpcOut.subscribe(returnHandler)
+          app?.ports.rpcOut.subscribe(returnHandler)
 
           if (d.i) { d.i = JSON.parse(d.i); }
           if (d.j) { d.j = JSON.parse(d.j); }
 
-          app.ports.rpcIn.send(d)
+          app?.ports.rpcIn.send(d)
 
           // Is there a nicer way to do this?
           waitUntil(() => {
             return done
           }, 10000)
           .then((result) => {
-            app.ports.rpcOut.unsubscribe(returnHandler)
+            app?.ports.rpcOut.unsubscribe(returnHandler)
             msgEmitter(response)
           })
           .catch((error) => {
             console.log(error)
-            app.ports.rpcOut.unsubscribe(returnHandler)
+            app?.ports.rpcOut.unsubscribe(returnHandler)
           });
 
         } catch (error) {
@@ -315,12 +350,9 @@ var DEFAULT_TIMEOUT = 5000;
 
 function waitUntil(
   predicate,
-  timeout,
-  interval
+  timeout = DEFAULT_TIMEOUT,
+  interval = DEFAULT_INTERVAL
 ) {
-  var timerInterval = interval || DEFAULT_INTERVAL;
-  var timerTimeout = timeout || DEFAULT_TIMEOUT;
-
   return new Promise(function promiseCallback(resolve, reject) {
     var timer;
     var timeoutTimer;
@@ -342,7 +374,7 @@ function waitUntil(
           clearTimers();
           resolve(result);
         } else {
-          timer = setTimeout(doStep, timerInterval);
+          timer = setTimeout(doStep, interval);
         }
       } catch (e) {
         clearTimers();
@@ -350,11 +382,11 @@ function waitUntil(
       }
     };
 
-    timer = setTimeout(doStep, timerInterval);
+    timer = setTimeout(doStep, interval);
     timeoutTimer = setTimeout(function onTimeout() {
       clearTimers();
-      reject(new Error('Timed out after waiting for ' + timerTimeout + 'ms'));
-    }, timerTimeout);
+      reject(new Error('Timed out after waiting for ' + timeout + 'ms'));
+    }, timeout);
   });
 }
 
@@ -414,7 +446,7 @@ function bytesToBase64(bytes_) {
   return base64;
 }
 
-function base64ToBytes(b64) {
+function base64ToBytes(b64: string): DataView {
   return new DataView(Base64Binary.decodeArrayBuffer(b64))
 }
 
diff --git a/extra/readme.md b/extra/readme.md
index 18fdfc4a8..76cc2604d 100644
--- a/extra/readme.md
+++ b/extra/readme.md
@@ -151,13 +151,13 @@ See `Sanity.hs` for other helpers.
 Run `./removeSanity.sh` to revert the debugging changes!
 
 
-### live.js
+### live.ts
 
 Used by `lamdera live`, this file needs to be packaged with parcel into `/extra/dist/live.js` and then inlined by the compiler.
 
 To package whenever changes are made to this file:
 
-- Run the esbuild instructions at the top of ./extra/live.js.
+- Run the esbuild instructions at the top of ./extra/live.ts.
 - Modify the `extra/Lamdera/Live.hs` file in some way (`x = 1` works well) to ensure it will get recompiled
 - Then re-run the main build with `stack install`.
 
@@ -173,4 +173,4 @@ The `$(...)` syntax is invoking Template Haskell.
 
 ⚠️ Because unchanged files aren't recompiled, you might need to add an `x = 1` to the bottom of the `.hs` file to force a change, and thus the expression to be re-evaluated. If you find you've updated a static file, but the complied binary still has the old one, this is likely the reason why.
 
-In development, using `LDEBUG=1` will cause `~/dev/projects/lamdera-compiler/extra/dist/live.js` to be dynamically included + rebuilt, helpful when working on it with `lamdera live` to see changes right away, see logic in `Lamdera.Live.lamderaLiveSrc`.
+In development, using `LDEBUG=1` will cause `~/dev/projects/lamdera-compiler/extra/dist/live.js` to be dynamically rebuild + included, helpful when working on it with `lamdera live` to see changes right away, see logic in `Lamdera.Live.lamderaLiveSrc`.
diff --git a/terminal/src/Develop.hs b/terminal/src/Develop.hs
index d4f2217ba..e2ff3b280 100644
--- a/terminal/src/Develop.hs
+++ b/terminal/src/Develop.hs
@@ -41,13 +41,14 @@ import qualified Stuff
 
 import Lamdera
 import qualified Lamdera.CLI.Live as Live
+import qualified Lamdera.CLI.Live.Output
 import qualified Lamdera.Constrain
 import qualified Lamdera.ReverseProxy
 import qualified Lamdera.TypeHash
 import qualified Lamdera.PostCompile
 
 import qualified Data.List as List
-import Ext.Common (trackedForkIO, whenDebug)
+import Ext.Common (trackedForkIO, whenDebug, builderToBs)
 import qualified Ext.Filewatch as Filewatch
 import qualified Ext.Sentry as Sentry
 import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar, TVar)
@@ -93,19 +94,28 @@ runWithRoot root (Flags maybePort) =
           -- Fork a recompile+cache update
           Sentry.asyncUpdateJsOutput sentryCache $ do
             debug_ $ "🛫  recompile triggered by: " ++ show events
-            harness <- Live.prepareLocalDev root
+            harnessPath <- Live.prepareLocalDev root
             let
               typesRootChanged = events & filter (\event ->
                      stringContains "src/Types.elm" event
                   || stringContains "src/Bridge.elm" event
                 ) & length & (\v -> v > 0)
+
+              elmCodeChanged = events & filter (\event ->
+                     stringContains ".elm" event
+                ) & length & (\v -> v > 0)
             onlyWhen typesRootChanged Lamdera.Constrain.resetTypeLocations
-            compileToBuilder harness
-          -- Simultaneously tell all clients to refresh. All those HTTP
+            -- compileToHtml harnessPath
+            compileToParts harnessPath
+
+
+          -- Simultaneously tell all clients to fetch new JS. All those HTTP
           -- requests will open but block on the cache mVar, and then release
           -- immediately when compilation is finished, effectively "pushing"
           -- the result to waiting browsers without paying the TCP+HTTP cost again
-          Live.refreshClients liveState
+          -- and giving the users a browser native loading indicator which we
+          -- wouldn't have if we waited for compilation then just pushed via Websocket
+          Live.reloadClientsJS liveState
 
       -- Warm the cache
       recompile []
@@ -123,8 +133,9 @@ runWithRoot root (Flags maybePort) =
 
       Live.withEnd liveState $
        httpServe (config port) $ gcatchlog "general" $
+        serveElmJS sentryCache
         -- Add /public/* as if it were /* to mirror production, but still render .elm files as an Elm app first
-        Live.serveLamderaPublicFiles root (serveElm sentryCache)
+        <|> Live.serveLamderaPublicFiles root (serveElm sentryCache)
         <|> (serveFiles root sentryCache)
         -- @WARNING, `serveDirectoryWith` implicitly uses Dir.getCurrentDirectory. We can't change '.' to `root` as it freaks out
         -- with the fully qualified path. And we can't use `System.Filepath.makeRelative` because it refuses to introduce `../`.
@@ -243,14 +254,22 @@ serveElm :: Sentry.Cache -> FilePath -> Snap ()
 serveElm sentryCache path =
   do  guard (takeExtension path == ".elm")
       modifyResponse (setContentType "text/html")
+      html <- liftIO $ Sentry.getHtmlOutput sentryCache
+      writeBS html
+
+serveElmJS :: Sentry.Cache -> Snap ()
+serveElmJS sentryCache =
+  do  path <- getSafePath
+      guard (path == "_x/js")
+      modifyResponse (setContentType "text/javascript")
       js <- liftIO $ Sentry.getJsOutput sentryCache
       writeBS js
 
 
-compileToBuilder :: FilePath -> IO BS.ByteString
-compileToBuilder path =
+compileToHtml :: FilePath -> IO BS.ByteString
+compileToHtml harnessPath =
   do
-      result <- compile path
+      result <- compileHtmlBuilder harnessPath
 
       pure $
         BSL.toStrict $
@@ -272,13 +291,38 @@ compileToBuilder path =
                     Exit.toJson $ Exit.reactorToReport exit
 
 
+compileToParts :: FilePath -> IO (BS.ByteString, BS.ByteString, BS.ByteString)
+compileToParts harnessPath =
+  do
+      result <- compilePartsBuilder harnessPath
+
+      case result of
+          Right (pre, js, post) ->
+            pure (builderToBs pre, builderToBs js, builderToBs post)
+
+          Left exit -> do
+            -- @LAMDERA because we do AST injection, sometimes we might get
+            -- an error that actually cannot be displayed, i.e, the reactorToReport
+            -- function itself throws an exception, mainly due to use of unsafe
+            -- functions like Prelude.last and invariants that for some reason haven't
+            -- held with our generated code (usually related to subsequent type inference)
+            -- We print out a less-processed version here in debug mode to aid with
+            -- debugging in these scenarios, as the browser will just get zero bytes
+            -- debugPass "serveElm error" (Exit.reactorToReport exit) (pure ())
+            pure
+              ( (builderToBs $ Help.makePageHtml "Errors" $ Just $ Exit.toJson $ Exit.reactorToReport exit)
+              , ""
+              , ""
+              )
+
+
 serveElm_ :: FilePath -> FilePath -> Snap ()
 serveElm_ root path =
   do  guard (takeExtension path == ".elm")
       modifyResponse (setContentType "text/html")
       liftIO $ atomicPutStrLn $ "⛑  manually compiling: " <> path
 
-      result <- liftIO $ compile (root  path)
+      result <- liftIO $ compileHtmlBuilder (root  path)
       case result of
         Right builder ->
           writeBuilder builder
@@ -296,8 +340,8 @@ serveElm_ root path =
             Exit.toJson $ Exit.reactorToReport exit
 
 
-compile :: FilePath -> IO (Either Exit.Reactor B.Builder)
-compile path =
+compileHtmlBuilder :: FilePath -> IO (Either Exit.Reactor B.Builder)
+compileHtmlBuilder path =
   do  maybeRoot <- Stuff.findRootHelp $ FP.splitDirectories $ FP.takeDirectory path
       case maybeRoot of
         Nothing ->
@@ -316,6 +360,26 @@ compile path =
                 return $ Html.sandwich root name javascript
 
 
+compilePartsBuilder :: FilePath -> IO (Either Exit.Reactor (B.Builder, B.Builder, B.Builder))
+compilePartsBuilder path =
+  do  maybeRoot <- Stuff.findRootHelp $ FP.splitDirectories $ FP.takeDirectory path
+      case maybeRoot of
+        Nothing ->
+          return $ Left $ Exit.ReactorNoOutline
+
+        Just root ->
+          BW.withScope $ \scope -> Stuff.withRootLock root $ Task.run $
+            do  details <- Task.eio Exit.ReactorBadDetails $ Details.load Reporting.silent scope root
+                artifacts <- Task.eio Exit.ReactorBadBuild $ Build.fromPaths Reporting.silent root details (NE.List path [])
+
+                Lamdera.PostCompile.check details artifacts Exit.ReactorBadBuild
+                Lamdera.TypeHash.buildCheckHashes artifacts
+
+                javascript <- Task.mapError Exit.ReactorBadGenerate $ Generate.dev root details artifacts
+                let (NE.List name _) = Build.getRootNames artifacts
+                return $ Lamdera.CLI.Live.Output.parts root name javascript
+
+
 
 -- SERVE STATIC ASSETS
 
diff --git a/test/Test.hs b/test/Test.hs
index c20a2d2f3..0c1899a82 100644
--- a/test/Test.hs
+++ b/test/Test.hs
@@ -106,8 +106,8 @@ For more information on how to use the GHCi debugger, see the GHC User's Guide.
 -- target = buildTestHarnessToProductionJs
 -- target = checkProjectCompiles
 -- target = previewProject
--- target = liveReloadLive
-target = Test.all
+target = liveReloadLive
+-- target = Test.all
 -- target = rerunJust "Lamdera.Evergreen.TestMigrationHarness -> .full first - (WithMigrations 2)"
 -- target = checkUserConfig
 -- target = Test.Wire.buildAllPackages