From dd629623ac9e8ddae77e9a7f8e414368f193c988 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 7 Jul 2024 17:32:31 +0200 Subject: [PATCH 01/18] Remove unused methods on Elm app instance --- extra/Lamdera/Injection.hs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index dfeb97517..1735bcb48 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -232,9 +232,6 @@ injections isBackend isLocalDev = //console.log('managers', managers) //console.log('ports', ports) - var dead = false; - var upgradeMode = false; - function mtime() { // microseconds if (!isBackend) { return 0; } const hrTime = process.hrtime(); @@ -243,14 +240,6 @@ injections isBackend isLocalDev = 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); - return; - } //console.log('sendToApp.active',msg); $shouldProxy @@ -308,8 +297,6 @@ injections isBackend isLocalDev = return ports ? { ports: ports, - gm: function() { return model }, - eum: function() { upgradeMode = true }, die: die, fns: fns } : {}; From e71514965785ed9f1e8ef70e7ffea139bda499fc Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 7 Jul 2024 18:11:57 +0200 Subject: [PATCH 02/18] More elaborate injection for frontend upgrades --- extra/Lamdera/Injection.hs | 276 +++++++++++++++++++++++++++++++------ 1 file changed, 232 insertions(+), 44 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 1735bcb48..a26566b0e 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -166,11 +166,6 @@ injections isBackend isLocalDev = previousVersion = show_ previousVersionInt - isBackend_ = - if isBackend - then "true" - else "false" - runUpdate = if isLocalDev then @@ -178,29 +173,24 @@ injections isBackend isLocalDev = 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 = {} - |] + if isBackend + then + [text| + try { + var pair = A2(update, msg, model); + } catch(err) { + bugsnag.notify(err); + return; + } + |] + else + [text| + try { + var pair = A2(update, msg, model); + } catch(err) { + return; + } + |] shouldProxy = onlyIf isLocalDev @@ -208,10 +198,9 @@ injections isBackend isLocalDev = shouldProxy = $$author$$project$$LocalDev$$shouldProxy(msg) |] in - [text| - - var isBackend = $isBackend_ && typeof isLamdera !== 'undefined'; - + if isBackend + then + [text| function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder) { var result = A2(_Json_run, flagDecoder, _Json_wrap(args ? args['flags'] : undefined)); @@ -252,7 +241,7 @@ injections isBackend isLocalDev = const updateDuration = mtime() - start; start = mtime(); - if (isBackend && loggingEnabled) { + if (loggingEnabled) { pos = pos + 1; const s = $$author$$project$$LBR$$serialize(msg); serializeDuration = mtime() - start; @@ -268,26 +257,24 @@ injections isBackend isLocalDev = //console.log('cmds', pair.b); _Platform_enqueueEffects(managers, pair.b, subscriptions(model)); - const stepEnqueueDuration = mtime() - start; + //const stepEnqueueDuration = mtime() - start; - if (isBackend) { - //console.log({serialize: serializeDuration, log: logDuration, update: updateDuration, stepEnqueue: stepEnqueueDuration}) - } + //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'); - - // 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)); - + // @TODO: It's unclear if nulling these does anything useful. What are we trying to achieve when dying on the backend? managers = null; model = null; stepper = null; @@ -301,7 +288,208 @@ injections isBackend isLocalDev = fns: fns } : {}; } - |] + |] + else + [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 be able to remove the popstate and hashchange listeners. + var historyListenerCleanups = []; + _Browser_window = { + navigator: _Browser_window_backup.navigator, + addEventListener: function(eventName, listener) { + _Browser_window_backup.addEventListener(eventName, listener); + historyListenerCleanups.push(function() { + _Browser_window_backup.removeEventListener(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. + + // It's a bit weird to mutate the args object that has been passed in, + // but the VNode from the previous app can have references to the old + // app functions through Html.lazy, preventing the old app from being + // garbage collected, so we don't want to keep a reference to it. + var lastVNode = args.vn; + delete args.vn; + + return lastVNode; + }; + + _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; + + var ports = _Platform_setupEffects(managers, sendToApp); + + //console.log('managers', managers) + //console.log('ports', ports) + + function sendToApp(msg, viewMetadata) + { + //console.log('sendToApp.active',msg); + + $shouldProxy + + $runUpdate + + stepper(model = pair.a, viewMetadata); + //console.log('cmds', pair.b); + _Platform_enqueueEffects(managers, pair.b, subscriptions(model)); + } + + _Platform_enqueueEffects(managers, initPair.b, subscriptions(model)); + + const die = function() { + //console.log('App dying'); + + // Render one last time, synchronously, in case their is a scheduled + // render with requestAnimationFrame (which then become no-ops). + // Rendering mutates the vdom, and we want those mutations. + stepper(model, true /* isSync */); + var toReturn = _VirtualDom_lastVNode; + + // 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; + } + } + + // Remove the popstate and hashchange listeners. + for (var i = 0; i < historyListenerCleanups.length; i++) { + historyListenerCleanups[i](); + } + + // Clear these new things we've added. + _VirtualDom_lastVNode = null; + _VirtualDom_lastDomNode = null; + historyListenerCleanups = []; + + // 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 toReturn; + } + + return ports ? { + ports: ports, + die: die, + } : {}; + } + + // 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)); + } + }(this)); + |] -- // https://github.com/elm/bytes/issues/20 -- // but the fix below as suggested causes this problem: From acbf73a9a0eb7a1da630eebc30547340261ec98a Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sat, 13 Jul 2024 00:23:04 +0200 Subject: [PATCH 03/18] Remove accidentally copy-pasted piece at end of injection --- extra/Lamdera/Injection.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index a26566b0e..f58bcd7c9 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -488,7 +488,6 @@ injections isBackend isLocalDev = _VirtualDom_addDomNodes(rootDomNode, oldVirtualNode, patches, eventNode); return (_VirtualDom_lastDomNode = _VirtualDom_applyPatchesHelp(rootDomNode, patches)); } - }(this)); |] -- // https://github.com/elm/bytes/issues/20 From 46b803a69d4874089f1b6fb7980528da39ba3dbc Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sat, 13 Jul 2024 15:06:45 +0200 Subject: [PATCH 04/18] Fix Html.map --- extra/Lamdera/Injection.hs | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index f58bcd7c9..80e8b0b13 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -348,15 +348,7 @@ injections isBackend isLocalDev = // to be re-created even though it is already the correct DOM. _VirtualDom_virtualize = function() { F2 = F2_backup; // Restore F2 as mentioned above. - - // It's a bit weird to mutate the args object that has been passed in, - // but the VNode from the previous app can have references to the old - // app functions through Html.lazy, preventing the old app from being - // garbage collected, so we don't want to keep a reference to it. - var lastVNode = args.vn; - delete args.vn; - - return lastVNode; + return args.vn; }; _VirtualDom_applyPatches = function(rootDomNode, oldVirtualNode, patches, eventNode) { @@ -393,6 +385,30 @@ injections isBackend isLocalDev = _VirtualDom_applyPatches = _VirtualDom_applyPatches_backup; _VirtualDom_equalEvents = _VirtualDom_equalEvents_backup; + if (args && args.vn) { + // It's a bit weird to mutate the args object that has been passed in, + // but the VNode from the previous app can have references to the old + // app functions through Html.lazy, preventing the old app from being + // garbage collected, so we don't want to keep a reference to it. + delete 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); //console.log('managers', managers) @@ -438,6 +454,10 @@ injections isBackend isLocalDev = } 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. From a0029bb8a8e1af35158728748f94633ce38f4d44 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sat, 13 Jul 2024 16:34:52 +0200 Subject: [PATCH 05/18] Fix Browser.Navigation.Key --- extra/Lamdera/Injection.hs | 43 ++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 80e8b0b13..7349b26b2 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -328,15 +328,12 @@ injections isBackend isLocalDev = }; }; - // To be able to remove the popstate and hashchange listeners. - var historyListenerCleanups = []; + // 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); - historyListenerCleanups.push(function() { - _Browser_window_backup.removeEventListener(eventName, listener); - }); }, }; @@ -402,7 +399,7 @@ injections isBackend isLocalDev = // 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") { + if (element.elm_event_node_ref && typeof element.elm_event_node_ref.p === 'function') { element.elm_event_node_ref.p = sendToApp; } } @@ -461,14 +458,15 @@ injections isBackend isLocalDev = } // Remove the popstate and hashchange listeners. - for (var i = 0; i < historyListenerCleanups.length; i++) { - historyListenerCleanups[i](); + if (_Browser_navKey) { + _Browser_window.removeEventListener('popstate', _Browser_navKey); + _Browser_window.removeEventListener('hashchange', _Browser_navKey); } // Clear these new things we've added. _VirtualDom_lastVNode = null; _VirtualDom_lastDomNode = null; - historyListenerCleanups = []; + _Browser_navKey = null; // Stop rendering: stepper = function() {}; @@ -508,6 +506,33 @@ injections isBackend isLocalDev = _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 From 7b4d315f7492dc8bc9f7eb48107bd0878fb32d83 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 14 Jul 2024 16:06:35 +0200 Subject: [PATCH 06/18] Make sure the old app can be GC:ed --- extra/Lamdera/Injection.hs | 39 ++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 7349b26b2..1f7312d30 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -383,12 +383,6 @@ injections isBackend isLocalDev = _VirtualDom_equalEvents = _VirtualDom_equalEvents_backup; if (args && args.vn) { - // It's a bit weird to mutate the args object that has been passed in, - // but the VNode from the previous app can have references to the old - // app functions through Html.lazy, preventing the old app from being - // garbage collected, so we don't want to keep a reference to it. - delete 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, @@ -411,9 +405,14 @@ injections isBackend isLocalDev = //console.log('managers', managers) //console.log('ports', ports) + var isBuried = false; + function sendToApp(msg, viewMetadata) { - //console.log('sendToApp.active',msg); + if (isBuried) { + console.warn('Got message after app was buried:', msg); + return; + } $shouldProxy @@ -426,6 +425,9 @@ injections isBackend isLocalDev = _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() { //console.log('App dying'); @@ -461,13 +463,10 @@ injections isBackend isLocalDev = 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; } - // Clear these new things we've added. - _VirtualDom_lastVNode = null; - _VirtualDom_lastDomNode = null; - _Browser_navKey = null; - // Stop rendering: stepper = function() {}; @@ -478,9 +477,25 @@ injections isBackend isLocalDev = return toReturn; } + // 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() { + // Clear effect managers, since they prevent sendToApp from being GC:ed, + // which prevents the whole app from being GC:ed. + _Platform_effectManagers = {}; + // In case there still are any pending commands, setting this flag means + // that nothing happens when they finish. + isBuried = true; + }; + + // 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, } : {}; } From 8477d92abbca7e785d34af5447e6dac98e576433 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 14 Jul 2024 16:21:43 +0200 Subject: [PATCH 07/18] Update TODO comment --- extra/Lamdera/Injection.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 1f7312d30..f82f44f7d 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -274,7 +274,8 @@ injections isBackend isLocalDev = const die = function() { //console.log('App dying'); - // @TODO: It's unclear if nulling these does anything useful. What are we trying to achieve when dying on the backend? + // @TODO: Compare to the frontend die and bury functions. Investigate what needs to be done here, and measure memory usage. + // Even if this function isn't ideal, clearing the model should at least go a long way towards not leaking too much memory. managers = null; model = null; stepper = null; From f048e85e481ed8f326349f50209009c5962212a3 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Mon, 15 Jul 2024 09:20:58 +0200 Subject: [PATCH 08/18] Add back isLamdera check --- extra/Lamdera/Injection.hs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index f82f44f7d..883c4f91c 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -201,6 +201,8 @@ injections isBackend isLocalDev = if isBackend then [text| + var isLamderaRuntime = typeof isLamdera !== 'undefined'; + function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder) { var result = A2(_Json_run, flagDecoder, _Json_wrap(args ? args['flags'] : undefined)); @@ -222,7 +224,7 @@ injections isBackend isLocalDev = //console.log('ports', ports) function mtime() { // microseconds - if (!isBackend) { return 0; } + if (!isLamderaRuntime) { return 0; } const hrTime = process.hrtime(); return Math.floor(hrTime[0] * 1000000 + hrTime[1] / 1000); } @@ -241,7 +243,7 @@ injections isBackend isLocalDev = const updateDuration = mtime() - start; start = mtime(); - if (loggingEnabled) { + if (isLamderaRuntime && loggingEnabled) { pos = pos + 1; const s = $$author$$project$$LBR$$serialize(msg); serializeDuration = mtime() - start; From da8d1999d4ddf22d7df278d06d3445d3a43939b6 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sat, 20 Jul 2024 22:24:46 +0200 Subject: [PATCH 09/18] Clear model early, track late cmds with bugsnag, finish die() for backend --- extra/Lamdera/Injection.hs | 42 +++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 883c4f91c..17b70a191 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -229,9 +229,14 @@ injections isBackend isLocalDev = return Math.floor(hrTime[0] * 1000000 + hrTime[1] / 1000); } + var isBuried = false; + function sendToApp(msg, viewMetadata) { - //console.log('sendToApp.active',msg); + if (isBuried) { + bugsnag.notify(new Error('Got message after app was buried: ' + (msg.$ || '(unknown message)'))); + return; + } $shouldProxy @@ -275,16 +280,24 @@ injections isBackend isLocalDev = } const die = function() { - //console.log('App dying'); - // @TODO: Compare to the frontend die and bury functions. Investigate what needs to be done here, and measure memory usage. - // Even if this function isn't ideal, clearing the model should at least go a long way towards not leaking too much memory. - managers = null; + // In case there still are any pending commands, setting this flag means + // that nothing happens when they finish. + isBuried = true; + + // 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, die: die, @@ -413,7 +426,7 @@ injections isBackend isLocalDev = function sendToApp(msg, viewMetadata) { if (isBuried) { - console.warn('Got message after app was buried:', msg); + window.lamdera.bs.notify(new Error('Got message after app was buried: ' + (msg.$ || '(unknown message)'))); return; } @@ -484,12 +497,17 @@ injections isBackend isLocalDev = // trigger an outgoing port to redirect messages. This is supposed to be called // when all pending commands are done. const bury = function() { - // Clear effect managers, since they prevent sendToApp from being GC:ed, - // which prevents the whole app from being GC:ed. - _Platform_effectManagers = {}; // In case there still are any pending commands, setting this flag means // that nothing happens when they finish. isBuried = true; + + // 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). From 927198f2aeda19788d76a0f296ee969026af4711 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sat, 20 Jul 2024 22:39:04 +0200 Subject: [PATCH 10/18] Measure how late cmds are --- extra/Lamdera/Injection.hs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 17b70a191..4f07e3d8e 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -229,12 +229,13 @@ injections isBackend isLocalDev = return Math.floor(hrTime[0] * 1000000 + hrTime[1] / 1000); } - var isBuried = false; + var buriedTimestamp = null; function sendToApp(msg, viewMetadata) { - if (isBuried) { - bugsnag.notify(new Error('Got message after app was buried: ' + (msg.$ || '(unknown message)'))); + if (buriedTimestamp !== null) { + const elapsed = Date.now() - buriedTimestamp; + bugsnag.notify(new Error('Got message ' + elapsed + ' ms after app was buried: ' + (msg.$ || '(unknown message)'))); return; } @@ -282,7 +283,7 @@ injections isBackend isLocalDev = const die = function() { // In case there still are any pending commands, setting this flag means // that nothing happens when they finish. - isBuried = true; + 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. @@ -421,12 +422,13 @@ injections isBackend isLocalDev = //console.log('managers', managers) //console.log('ports', ports) - var isBuried = false; + var buriedTimestamp = null; function sendToApp(msg, viewMetadata) { - if (isBuried) { - window.lamdera.bs.notify(new Error('Got message after app was buried: ' + (msg.$ || '(unknown message)'))); + if (buriedTimestamp !== null) { + const elapsed = Date.now() - buriedTimestamp; + window.lamdera.bs.notify(new Error('Got message ' + elapsed + ' ms after app was buried: ' + (msg.$ || '(unknown message)'))); return; } @@ -499,7 +501,7 @@ injections isBackend isLocalDev = const bury = function() { // In case there still are any pending commands, setting this flag means // that nothing happens when they finish. - isBuried = true; + 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. From 9b31fdd59974f41728572a0e4e000135c9375137 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sat, 20 Jul 2024 22:57:43 +0200 Subject: [PATCH 11/18] Better msg name detection --- extra/Lamdera/Injection.hs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 4f07e3d8e..aa0e206ac 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -235,7 +235,16 @@ injections isBackend isLocalDev = { if (buriedTimestamp !== null) { const elapsed = Date.now() - buriedTimestamp; - bugsnag.notify(new Error('Got message ' + elapsed + ' ms after app was buried: ' + (msg.$ || '(unknown message)'))); + 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; } @@ -428,7 +437,16 @@ injections isBackend isLocalDev = { if (buriedTimestamp !== null) { const elapsed = Date.now() - buriedTimestamp; - window.lamdera.bs.notify(new Error('Got message ' + elapsed + ' ms after app was buried: ' + (msg.$ || '(unknown message)'))); + 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; } From b16f2ee29b3c1da7989c3aeb17ce91f43d05b6af Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 21 Jul 2024 11:15:42 +0200 Subject: [PATCH 12/18] Simplify away now unnecessary toReturn variable --- extra/Lamdera/Injection.hs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index aa0e206ac..fd91688c1 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -467,11 +467,10 @@ injections isBackend isLocalDev = const die = function() { //console.log('App dying'); - // Render one last time, synchronously, in case their is a scheduled + // 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 */); - var toReturn = _VirtualDom_lastVNode; // Remove Elm's event listeners. Both the ones added // automatically on every element, as well as the ones @@ -510,7 +509,7 @@ injections isBackend isLocalDev = // in upgrade mode, and the update function stops doing its regular business // and just forwards messages instead. - return toReturn; + return _VirtualDom_lastVNode; } // This can't be done in the die function, because then it's not possible to From 6b946af3a44e81e828d4ffda26c2b316888605d0 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 21 Jul 2024 22:47:38 +0200 Subject: [PATCH 13/18] elm-pages support --- extra/Lamdera/Injection.hs | 165 ++++++++++++++++++++++++++----------- 1 file changed, 115 insertions(+), 50 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index fd91688c1..f85286da4 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -145,17 +145,27 @@ 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 :: Bool -> Bool -> Text -injections isBackend isLocalDev = +injections :: OutputType -> Text +injections outputType = let previousVersionInt = -- @TODO maybe its time to consolidate the global config... @@ -166,40 +176,113 @@ injections isBackend isLocalDev = previousVersion = show_ previousVersionInt + -- @TODO: Simplify once we know why there’s a try-catch. + -- Kinda silly to match on outputType twice. runUpdate = - if isLocalDev - then + case outputType of + LamderaBackend -> + -- @TODO: Add comment about why we are swallowing errors + -- for non-lamdera runtime. Also, why explicitly catch errors? + -- Bugsnag would catch them anyway? + -- https://github.com/lamdera/compiler/commit/7bd9d403954c09f24bf0e57a86210e8e5c1a97ee + [text| + try { + var pair = A2(update, msg, model); + } catch(err) { + if (isLamderaRuntime) bugsnag.notify(err); + return; + } + |] + + LamderaFrontend -> + -- @TODO: Add comment about why we are swallowing errors + [text| + try { + var pair = A2(update, msg, model); + } catch(err) { + return; + } + |] + + -- LamderaLive or NotLamdera + _ -> [text| var pair = A2(update, msg, model); |] - else - if isBackend - then - [text| - try { - var pair = A2(update, msg, model); - } catch(err) { - bugsnag.notify(err); - return; - } - |] - else - [text| - try { - var pair = A2(update, msg, model); - } catch(err) { - return; - } - |] shouldProxy = - onlyIf isLocalDev + onlyIf (outputType == LamderaLive) [text| shouldProxy = $$author$$project$$LocalDev$$shouldProxy(msg) |] in - if isBackend - then + 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) /**/); + + 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; + } + + $runUpdate + + 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)); + + 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'; @@ -220,9 +303,6 @@ injections isBackend isLocalDev = var pos = 0; - //console.log('managers', managers) - //console.log('ports', ports) - function mtime() { // microseconds if (!isLamderaRuntime) { return 0; } const hrTime = process.hrtime(); @@ -248,8 +328,6 @@ injections isBackend isLocalDev = return; } - $shouldProxy - var serializeDuration, logDuration = null; var start = mtime(); @@ -267,16 +345,8 @@ 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; - - //console.log({serialize: serializeDuration, log: logDuration, update: updateDuration, stepEnqueue: stepEnqueueDuration}) } if ((args && args['model']) === undefined) { @@ -315,7 +385,9 @@ injections isBackend isLocalDev = } : {}; } |] - else + + -- LamderaFrontend or LamderaLive + _ -> [text| function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder) { @@ -425,12 +497,8 @@ injections isBackend isLocalDev = } } - var ports = _Platform_setupEffects(managers, sendToApp); - //console.log('managers', managers) - //console.log('ports', ports) - var buriedTimestamp = null; function sendToApp(msg, viewMetadata) @@ -455,7 +523,6 @@ injections isBackend isLocalDev = $runUpdate stepper(model = pair.a, viewMetadata); - //console.log('cmds', pair.b); _Platform_enqueueEffects(managers, pair.b, subscriptions(model)); } @@ -465,8 +532,6 @@ injections isBackend isLocalDev = // 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() { - //console.log('App dying'); - // 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. From 1b4c6cb5b26e0597ff4d632ede11754a90e74836 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Mon, 22 Jul 2024 00:11:35 +0200 Subject: [PATCH 14/18] Remove unnecessary try-catch around update, according to discussion --- extra/Lamdera/Injection.hs | 42 +++----------------------------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index f85286da4..112e1f9b7 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -176,40 +176,6 @@ injections outputType = previousVersion = show_ previousVersionInt - -- @TODO: Simplify once we know why there’s a try-catch. - -- Kinda silly to match on outputType twice. - runUpdate = - case outputType of - LamderaBackend -> - -- @TODO: Add comment about why we are swallowing errors - -- for non-lamdera runtime. Also, why explicitly catch errors? - -- Bugsnag would catch them anyway? - -- https://github.com/lamdera/compiler/commit/7bd9d403954c09f24bf0e57a86210e8e5c1a97ee - [text| - try { - var pair = A2(update, msg, model); - } catch(err) { - if (isLamderaRuntime) bugsnag.notify(err); - return; - } - |] - - LamderaFrontend -> - -- @TODO: Add comment about why we are swallowing errors - [text| - try { - var pair = A2(update, msg, model); - } catch(err) { - return; - } - |] - - -- LamderaLive or NotLamdera - _ -> - [text| - var pair = A2(update, msg, model); - |] - shouldProxy = onlyIf (outputType == LamderaLive) [text| @@ -250,8 +216,7 @@ injections outputType = return; } - $runUpdate - + var pair = A2(update, msg, model); stepper(model = pair.a, viewMetadata); _Platform_enqueueEffects(managers, pair.b, subscriptions(model)); } @@ -331,7 +296,7 @@ injections outputType = var serializeDuration, logDuration = null; var start = mtime(); - $runUpdate + var pair = A2(update, msg, model); const updateDuration = mtime() - start; start = mtime(); @@ -520,8 +485,7 @@ injections outputType = $shouldProxy - $runUpdate - + var pair = A2(update, msg, model); stepper(model = pair.a, viewMetadata); _Platform_enqueueEffects(managers, pair.b, subscriptions(model)); } From fbbef524c0c2a5b5962d390503148a83950ea1dd Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Mon, 22 Jul 2024 00:13:16 +0200 Subject: [PATCH 15/18] Move shouldProxy closer to its only usage --- extra/Lamdera/Injection.hs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 112e1f9b7..8b5d411a5 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -175,12 +175,6 @@ injections outputType = & subtract 1 previousVersion = show_ previousVersionInt - - shouldProxy = - onlyIf (outputType == LamderaLive) - [text| - shouldProxy = $$author$$project$$LocalDev$$shouldProxy(msg) - |] in case outputType of -- NotLamdera was added when we fixed the hot loading of a new app version in the browser. @@ -353,6 +347,13 @@ injections outputType = -- 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) { From 27f9d4ca50ce442f4402f8e8795eaded5eaea71b Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Mon, 22 Jul 2024 00:15:17 +0200 Subject: [PATCH 16/18] Remove unused let-ins --- extra/Lamdera/Injection.hs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 8b5d411a5..944cd8b99 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -166,16 +166,6 @@ source mode mains = injections :: OutputType -> Text injections outputType = - let - previousVersionInt = - -- @TODO maybe its time to consolidate the global config... - (unsafePerformIO $ lookupEnv "VERSION") - & maybe "0" id - & read - & subtract 1 - - previousVersion = show_ previousVersionInt - in 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 From a910ab0450e0cc802bac71bf0baf39b7a0f611fa Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Wed, 14 Aug 2024 08:08:47 +0200 Subject: [PATCH 17/18] Add comment to msg unrolling --- extra/Lamdera/Injection.hs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 944cd8b99..5bf2a553f 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -264,6 +264,12 @@ injections outputType = { 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.$; From 3b6d23e663151b1e0279a66a4138fffa7dff2821 Mon Sep 17 00:00:00 2001 From: Mario Rogic Date: Wed, 21 Aug 2024 18:21:38 +1000 Subject: [PATCH 18/18] WIP start preparing to use new reload behaviour in live --- elm.cabal | 1 + ext-sentry/Ext/Sentry.hs | 21 +++++-- extra/Lamdera/CLI/Live.hs | 5 +- extra/Lamdera/CLI/Live/Output.hs | 88 ++++++++++++++++++++++++++ extra/Lamdera/Live.hs | 16 ++--- extra/{live.js => live.ts} | 104 ++++++++++++++++++++----------- extra/readme.md | 6 +- terminal/src/Develop.hs | 88 ++++++++++++++++++++++---- test/Test.hs | 4 +- 9 files changed, 266 insertions(+), 67 deletions(-) create mode 100644 extra/Lamdera/CLI/Live/Output.hs rename extra/{live.js => live.ts} (80%) 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/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