Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Library bundling, filesize, etc #68

Closed
mstenta opened this issue Apr 4, 2020 · 63 comments
Closed

Library bundling, filesize, etc #68

mstenta opened this issue Apr 4, 2020 · 63 comments

Comments

@mstenta
Copy link
Member

mstenta commented Apr 4, 2020

The bundled farmOS-map.js file is pretty big. As of this writing, it clocks in at just over half a megabyte in size.

Most of this size comes from including various modules from the OpenLayers library. The approach we've taken with farmOS-map is to essentially create a pre-bundled, pre-configured, OpenLayers map that includes all the things that farmOS itself needs for its maps, along with some wrappers and helper functions for making it easier for farmOS modules to do common things (like add a WKT or GeoJSON layer). This approach allows us to create a standalone farmOS-map.js file that can be included in a non-JS application (like farmOS/Drupal).

The main drawback of this approach is that it's an "all or nothing" library. You can't, for example, say "I want farmOS-map.js with just the WKT and GeoJSON layer support". This sort of selective bundling would be really useful in the context of JS applications like Field Kit, which doesn't need everything that is included in farmOS-map.

It would be worth doing a quick test where we strip out everything that Field Kit doesn't need, build that, and see how much of a difference it actually makes. Maybe the "extras" are not as much as the "core" OpenLayers code itself, which would need to be included anyway. That would be a good first thing to test.

If that does make a significant difference, the next challenge is thinking about how to restructure this library so that it can more easily be imported into another JS application in pieces. Here is a quick list of the "pieces" that could potentially be made optional:

  • Bundles "behaviors":
    • Google layers
    • Polygon/lineString measurement logic
    • Base layer localstorage memory
  • Controls
    • Geolocate button (provi
    • Edit control (for drawing/modifying shapes)
    • LayerSwitcher
    • FullScreen
    • Rotate
    • ScaleLine
    • Geocoder
  • Interactions
    • DragRotateAndZoom
    • PinchRotate
  • Layer types
  • Misc
    • Popups
@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

(FYI this has been something on our minds all along, but the discussion in #67 prompted creating this issue.)

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

Also wanted to follow up on @jgaehring's comment in #67 : #67 (comment):

I think this brings into question the efficacy of using the global window.farmOS.map namespace in general. I know that solution seems to work best in the Drupal context, but perhaps we want to find a way to leave that decision up to the consumer, at the very least, or perhaps an alternative. Because if we don't require that global by default, it becomes trivial for the consumer to use their chosen bundler (eg, Webpack) to tree-shake unneeded dependencies. Hence, it wouldn't be our concern how many kb TileArcGISRest adds to the bundle. @mstenta, I'd love to hear again how the global is used within the Drupal context, and explore alternatives.

The window.farmOS.map global by itself is not really the main cause of filesize, although it would certainly make sense to re-evaluate making that optional in general. The main culprit is the instance object, which includes methods like addLayer() (and all of the various OL imports for different layer types), and addBehavior() (which includes behaviors like Google layers (BIG), edit controls (medium/big?), polygon/lineString measurement (small/medium), and base layer memory (small)).

So I think the first thing we would need to think through is how to make it possible to selectively import layer types and behaviors. Those seem like the pieces that would offer the biggest "wins" in terms of overall filesize.

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

Ran a few quick tests of npm run build with various chunks of code commented out:

  • No change (current file size): 518987 bytes
  • Without behaviors (edit, google, measure, rememberLayer): 419656 bytes
  • Without behaviors and without "extra" layer types (cluster, wms, arcgis-tile): 409979 bytes

So based on that (assuming I didn't miss anything), it looks like the behaviors are the big culprit, responsible for ~+100K. I wouldn't be surprised if Google is the biggest chunk of that.

But even so... the library is still 400K even with all that stuff disabled.

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

If I also remove all "extra" default controls and interactions (LayerSwitcher, FullScreen, Rotate, ScaleLine, Geolocate, Geocoder, DragRotateAndZoom, and PinchRotate), the file size is: 349652 bytes

So those add ~50K.

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

I pushed these three removal commits to a filesize-tests branch in my fork: https://github.com/mstenta/farmOS-map/tree/filesize-tests

@jgaehring
Copy link
Member

The window.farmOS.map global by itself is not really the main cause of filesize, although it would certainly make sense to re-evaluate making that optional in general.

Just to clarify, I bring up the global object, not because that alone increases the filesize somehow, but because I believe it impairs Webpack's ability to automatically remove unused modules from the bundle when applications use farmOS-map. This is the "tree-shaking" concept: https://webpack.js.org/guides/tree-shaking/.

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

Removing ONLY the google behavior takes the library from 518987 bytes to 474351 bytes (difference of ~45K).

This seems like a good first "low-hanging fruit" to start with. Field Kit does not use Google layers.

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

I believe it impairs Webpack's ability to automatically remove unused modules from the bundle when applications use farmOS-map.

Oh interesting! So would Webpack be smart enough to NOT include Google code even though import OLGoogleMaps from 'olgm/src/olgm/OLGoogleMaps'; is in a file that IS included?

@jgaehring
Copy link
Member

So would Webpack be smart enough to NOT include Google code even though import OLGoogleMaps from 'olgm/src/olgm/OLGoogleMaps'; is in a file that IS included?

Correct. I think the most reasonable thing for us to do is just to make sure the library doesn't break tree-shaking for the application code using it, so it's totally in the hands of the consumer which features they use or don't use, and which features get bundled or don't. It's less aggravation and fewer decisions for us, and more control in the hands of the consumer.

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

The nice thing I've learned from these tests is that adding additional layer types (like ArcGIS Tile from #67 and potentially also WMTS from #63) don't really make a big difference. Removing cluster, arcgis-tile, and wms only shaved off 9677 bytes.

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

Correct. I think the most reasonable thing for us to do is just to make sure the library doesn't break tree-shaking for the application code using it,

Ok, I'd love to do a rough test of this in Field Kit to see if it reduces the filesize of that in the same way!

But... I think Field Kit is using the global window.farmOS.map currently, right? So we can't really test it without diving into refactoring. Correct?

@jgaehring
Copy link
Member

Yea, it would take some refactoring.

I wonder if it's worth trying to bundle the example index.html you have in this library to run tests?

@jgaehring
Copy link
Member

I also wonder if it's worth having two separate main.js's: one where the map object get attached to the global window object, and one where instead the map object is the default export; the former could be used in Drupal, while the latter in Field Kit.

I think there are probably far better strategies, where we could optimize the bundle for Drupal as well, but this strikes me as the easiest solution for right now. If I understood better how Drupal uses that global, and had time to think about, I'm sure we could optimize for the Drupal context as well.

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

I just ran one more test, where I only removed the pieces that Field Kit doesn't use/need (I think), including:

  • Google layers
  • Layer types: wms, arcgis-tile, cluster
  • Controls: geocoder, fullscreen

That brings the library size from 518987 bytes to 423106 bytes, which is a difference of: 95881 bytes.

What this tells me (if assumptions are correct) is that if Webpack's "tree-shaking" can be made to work, that is roughly how much we would reduce the size of Field Kit.

I ran a quick npm run build:web of the latest develop branch of Field Kit, and the filesize of dist/static/js/1.0565bf093a3a8cbb6505.js is 1512846 bytes.

So back of the envelope math (rough, and admittedly there is more to this): 1512846 - 95881 = 1416965 bytes which is a 6% decrease.

That's not nothing. But as of right now I think we have more important things to work on. Saving 96K won't make that much of a difference, I don't think.

So I think my vote would be to shelve these ideas for now, and come back to it in the future. What do you think @jgaehring ?

I pushed these commits to a second branch: filesize-tests-fieldkit https://github.com/mstenta/farmOS-map/tree/filesize-tests-fieldkit

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

I also wonder if it's worth having two separate main.js

That approach makes a lot of sense to me!

@jgaehring
Copy link
Member

So I think my vote would be to shelve these ideas for now, and come back to it in the future.

Totally fine! I just wanted to make sure I registered my concern about the global, and that we didn't take any drastic measures or reject PR's on the basis of bundle size, if there were better alternatives. Let's circle back to this when things slow down. I wouldn't mind shaving off that 6%. I think the other big contributor is Bootstrap, but that's another issue.

@mstenta
Copy link
Member Author

mstenta commented Apr 4, 2020

Agreed! And it's nice to have these numbers - they definitely put to rest my reservation about adding additional layer type options. That seems to be a very small contributor.

@mstenta
Copy link
Member Author

mstenta commented Apr 15, 2020

The issue of the window.farmOS.map global and how it affects Webpack's ability to "tree shake" came up again in #72. Here is a quick overview of how that is being used currently and why:

In the context of farmOS (Drupal), maps need to be displayed in various contexts. And each context will want to have different features enabled on the map. For example:

  • On the dashboard it shows a map of all areas, color coded by type, which you can click on to see details about that area.
  • On asset listing pages (eg: /farm/assets/animals) it shows a map with the current location of animal assets represented as orange dashed-line polygons, on top of "all areas" in gray.
  • On individual log records, a map is shown with the geometry stored on the log. And when you edit the log, a similar map is used but with the editor controls enabled.
  • In Plan records (and if the farm_plan_map module is enabled), a map is displayed with the geometries of areas and assets that are linked to that plan.

Furthermore, and most importantly: farmOS modules can add additional features to maps and/or add their own maps to other places in the UI. For example:

  • The farm_map_google module adds Google layers.
  • The farm_map_mapknitter module adds Mapknitter layers.
  • The farm_soil_nrcs module add the NRCS Soil Survey layer.
  • The farm_plan_map module adds a map to Plan records.

We have no way of know which modules will be installed. And a site admin can enable/disable modules whenever they want, without any "build" process. This gets to the other important point...

Drupal does not run Webpack* - All it can do is add pre-made JS files to a given page.

(*It may be possible to figure out a way to run Webpack from Drupal, but there are a lot more considerations there, including added requirements for hosting/installing/updating/etc.)

So, in the farmOS/Drupal context... we basically have a situation where there is a map on a page, and any enabled modules can add things to that map. But the only way they can do that is by adding a JS file to the page.

(Oh and one other "furthermore": you can potentially have multiple maps on a page.)

Thus, the window.farmOS.map global was born. :-)

This global provides a few things:

  • window.farmOS.map.instances is an array of map instances on the page. And "instance" is basically a JS object that farmOS-map defines, which has a map property (this is the actual OpenLayers map object), as well as some other properties and helper methods.
  • window.farmOS.map.create() is a function for creating new map instances. This takes a target (DOM element ID), creates a new "instance" object, and adds it to the window.farmOS.map.instances array.
  • window.farmOS.map.behaviors - This is an array of "behaviors", which are essentially just JS objects with an attach() method that is run when a map is created.

So, to outline how a map is built in farmOS:

  1. The map is added to a page via the PHP farm_map_build() function. This adds some JS files to the page, including farmOS-map.js, which creates the window.farmOS.map global.
  2. Other modules add their own JS files to the page, which add their "behaviors" to the window.farmOS.map.behaviors array.
  3. When the page is finished loading in the browser (when jQuery .ready() fires), the farmOS map instance(s) are created by running window.farmOS.map.create().
  4. When the create() method runs, it looks in window.farmOS.map.behaviors array, and runs the attach() method from each of them on each map instance.

Thus, modules can make modifications to maps by simply adding a JS file to the page.

That's the overview... hope it all makes sense. I'll save my "next steps" thoughts for future comments. I have some ideas... :-)

@mstenta
Copy link
Member Author

mstenta commented Apr 15, 2020

Next steps:

Apart from the farmOS/Drupal (and similar) context, the other context farmOS-map could be used in is as a component of another JavaScript application. In that context, farmOS-map would be managed as a dependency via npm, and bundled into a final JS application file via Webpack.

In that context, the ability to add behaviors to a map via window.farmOS.map.behaviors is NOT necessary.

The ability to create map instances IS still necessary. Currently that is done via window.farmOS.map.create() - but we can find a way to separate that out as well.

Then... there's the matter of methods on the instance objects. This is where a lot of the other OL library modules are being imported. We may want to think about how we can make those imports optional too.

With all that in mind, I'm curious to understand which pieces in particular are currently preventing Webpack's "tree shaking". It feels to me like the global window.farmOS.map is less of an issue, because it is relatively thin. The instance objects and their methods might be the bigger culprit. (?)

@symbioquine
Copy link
Collaborator

Another important consideration here is the effect of extending farmOS-map on the final page size. As an example, I created a module which provides a snapping grid for use with farmOS-map. There doesn't seem to be a way to "link" such a module such that it can leverage the library code that farmOS-map already bundles. This means that the independently bundled code for farm_map_snapping_grid is more than 200KB.

@mstenta
Copy link
Member Author

mstenta commented May 21, 2020

@symbioquine yea that's a great example of this tricky issue of bundling OpenLayers in farmOS-map, but wanting to re-use OpenLayers code in farmOS-map behaviors (or any other JS scripts) that are loaded dynamically on a page (not bundled with farmOS-map.js itself).

One idea there was to essentially expose some OpenLayers code on the window.farmOS.map object itself, so they could be reused by other scripts, post-build time. For example: window.farmOS.map.ol.source.Vector. Not sure I love that idea, but I can't think of a better alternative that ensures code isn't duplicated. We would also want to make sure that this is done in a way that it does not prevent the "tree-shaking" that @jgaehring describes in the cases where only pieces of farmOS-map are needed, and webpack can be used to bundle (eg: in Field Kit).

In farmOS itself, the considerations are different because we don't have the ability to bundle everything together. Modules can be added which provide their own pre-bundled JS files (like yours), and webpack is not part of the farmOS build/deployment process.

In the old days, you would just load ol.js on the page and that pulled in ALL of OpenLayers wholesale, which could then be re-used by any other script on the page (it created a window.ol global). I wonder if it would be worth considering making that an option again... eg: package a special version of farmOS-map.js that depends on ol.js being loaded on the page, and it uses that. The benefit of that is other scripts could use the full feature set of OpenLayers without duplicating that code in their own bundle. The downside, of course, is then we are loading the WHOLE OpenLayers library, which is probably bigger all around than all of these other options.

Those are all the "general" thoughts I have on the topic.... but getting back to your module specifically... I'm also curious if it would make sense to just merge that in as a behavior that farmOS-map itself provides. That would allow it to be bundled together. What do you think? Worth considering?

@symbioquine
Copy link
Collaborator

Those are all the "general" thoughts I have on the topic....

Those are great points. What about the idea of running the Webpack build on module installation? I think it was mentioned above, but I'm not sure I understood whether that concept was already thoroughly explored...

but getting back to your module specifically... I'm also curious if it would make sense to just merge that in as a behavior that farmOS-map itself provides. That would allow it to be bundled together. What do you think? Worth considering?

Absolutely! I only published it as a separate module to gather an initial round of feedback and get it into production for my use-case quicker.

I consider the current implementation a bit of a prototype. In particular I'd like feedback on the UI/UX choices that I made and whether the grid size normalization which I'm doing to convert between flat/spherical coordinates introduces too much error for folks in practice. I'll open an issue to track this potential integration in the farmOS-map issue queue.

@mstenta
Copy link
Member Author

mstenta commented May 21, 2020

What about the idea of running the Webpack build on module installation? I think it was mentioned above, but I'm not sure I understood whether that concept was already thoroughly explored...

It hasn't been thoroughly explored. But it definitely introduces complexity for self-hosting farmOS. I'd love to think it through though, perhaps in a separate issue, to map out all the considerations.

I'd also be curious to see if the Drupal community at large has put any thought into this, because it is a general Drupal deployment question in my mind. It's certainly possible, it would just come down to the trade-offs involved. And one of my goals is always trying to keep the hosting process relatively simple so it doesn't become more of a barrier to entry for self-hosting.

@symbioquine
Copy link
Collaborator

What about the idea of running the Webpack build on module installation? I think it was mentioned above, but I'm not sure I understood whether that concept was already thoroughly explored...

It hasn't been thoroughly explored. But it definitely introduces complexity for self-hosting farmOS. I'd love to think it through though, perhaps in a separate issue, to map out all the considerations.

I'd also be curious to see if the Drupal community at large has put any thought into this, because it is a general Drupal deployment question in my mind. It's certainly possible, it would just come down to the trade-offs involved. And one of my goals is always trying to keep the hosting process relatively simple so it doesn't become more of a barrier to entry for self-hosting.

It looks like there's already some existing work on this front in the larger Drupal community targeting >= D8; https://www.drupal.org/project/webpack Would it make sense to keep a tracking issue for that potential integration in the main farmOS issue queue?

@mstenta
Copy link
Member Author

mstenta commented Jun 1, 2020

Would it make sense to keep a tracking issue for that potential integration in the main farmOS issue queue?

Makes sense to me! Thanks for digging in @symbioquine !

@symbioquine
Copy link
Collaborator

  • farmOS-map.js is set to type: external so that it is never combined with other JS since that breaks the chunk loading. This should usually work, but might break if farmOS is hosted in a subpath instead of at /.

Actually it looks like a better strategy is using preprocess: false which produces similar results, but shouldn't break if farmOS were hosted at a subpath.

diff --git a/modules/core/map/farm_map.libraries.yml b/modules/core/map/farm_map.libraries.yml
index 82f0dc59..8a9928ab 100644
--- a/modules/core/map/farm_map.libraries.yml
+++ b/modules/core/map/farm_map.libraries.yml
@@ -5,8 +5,16 @@ farmOS-map:
     url: https://github.com/farmOS/farmOS-map/blob/master/LICENSE
     gpl-compatible: true
   js:
+    /libraries/farmOS-map/dist/ol.js:
+      minified: true
     /libraries/farmOS-map/dist/farmOS-map.js:
+      # Skip aggregating farmOS-map.js with other JS since that
+      # breaks the lazy loading of behavior chunks.
+      preprocess: false
       minified: true
+  css:
+    theme:
+      /libraries/farmOS-map/dist/ol.css: { }
   dependencies:
     - core/drupalSettings

@mstenta
Copy link
Member Author

mstenta commented May 10, 2021

I've reviewed all the proposed changes in #109 and they look really good @symbioquine ! I like where this is headed!

Perhaps it makes sense to organize/summarize the "next steps" (maybe in the PR description?) so we can be sure everything is accounted for... including relevant PRs for Field Kit and farmOS 2.x.

Just a couple of notes I made while reviewing:

  • We should include some instructions for updating from 1.x to 2.x, for anyone who's using farmOS-map v1 in their projects currently. I know there are a few out there aside from farmOS and Field Kit.
  • Minor nitpicks / coding standards:
    • Changelog items should end in periods.
    • There are some extra newlines in README.md.
    • Needs a newline above comment inside attachBehavior().
    • I haven't fully grokked the behavior sort logic - I wonder if we could add some comments, and maybe move it out to an include file? I like that main.js is so small/readable otherwise.

@mstenta
Copy link
Member Author

mstenta commented May 10, 2021

Would it make sense to add an agenda item to discuss these changes and next steps on the 5/12 monthly call (this Wednesday)? I don't think we have anything else planned.

Or, if we want to find a separate meeting time that works too. Would love to have both @paul121 and @jgaehring join.

@symbioquine
Copy link
Collaborator

Awesome, thanks for looking over it all @mstenta!

Perhaps it makes sense to organize/summarize the "next steps" (maybe in the PR description?) so we can be sure everything is accounted for... including relevant PRs for Field Kit and farmOS 2.x.

Definitely!

Just a couple of notes I made while reviewing: [...]

Will fix those.

Would it make sense to add an agenda item to discuss these changes and next steps on the 5/12 monthly call (this Wednesday)? I don't think we have anything else planned.

Or, if we want to find a separate meeting time that works too. Would love to have both @paul121 and @jgaehring join.

I'm open to either or both. If I don't hear a preference for a stand-alone meeting from @paul121 and @jgaehring here, I'll add it this evening to the Monthly Call agenda.

@symbioquine
Copy link
Collaborator

On a related note, in the last 36 hours or so I've been experimenting with a strategy that would allow us to have our cake and eat it too! Specifically, we can somewhat avoid the costs of loading all of OpenLayers while still having it all available to contrib modules.

The key to doing that is by creating an intermediate package which leverages a new feature provided by Webpack called Module Federation - kind of like dynamic linking. Naively using that feature would yield one huge chunk. However, with a bit of scripting, the intermediate package can expose each importable package from OpenLayers as a separate "module" in the resulting federated "container".

In short, this will allow dynamic/lazy loading of individual imports from OpenLayers as cachable chunks.

Of course, nothing is free and the cost to the strategy described above is that we'd be trading increased network requests and packaging complexity for reduced bandwidth. I think this may be a good tradeoff especially since one of the main contributors to OpenLayers has indicated that the all-in-one builds of OpenLayers are "legacy" and "[they] discourage its use". As such, it may be better to incur the cost of a breaking change now, moving to this module federation strategy where we would control the packaging than to have the 2.x contrib ecosystem built against something which OpenLayers considers legacy and discouraged.

@jgaehring
Copy link
Member

Awesome! I haven't had a chance to get caught up on this thread but eager to talk with y'all about it. Standalone meeting or monthly call is fine for me.

@symbioquine
Copy link
Collaborator

Sorry for the radio silence here. I've actually been putting in quite a bit of time on this, but I didn't have anything to report back until now...

I have been evaluating the Webpack Module Federation feature I mentioned above to determine whether it is worth the cost of increased packaging complexity.

BLUF¹

Module Federation is better than using the official legacy ol.js build.

For the examples tested, Module Federation can result in about 45% less JS data transferred over the network and 60% less JS data loaded client side compared with the official legacy ol.js build. That comes out to about 30% (~900ms) faster loading times in our "DSL network constrained" scenario.

Using module federation to package all of OpenLayers is (as expected) slower than the control case of including only the bits we need. For the map tested, this works out to about 45% (~640ms) longer loading times than the control in our "DSL network constrained" scenario.

Those loading time percentages above remain pretty consistent at different network speeds/latencies, but it is important to note that at 2G networks speeds, Module Federation could turn a 10s uncached page load into a 7s one - but is still worse than the 5s page load without including all of OpenLayers.

Details

I created a test bed to compare the performance of various packaging strategies; https://github.com/symbioquine/ol-federated/tree/main/examples

All examples render the same map scene - a stretch of OSM ocean with a LineString (code here);

image

I ran this performance test against each packaging strategy 10 times. The performance test gathers metrics for 5 scenarios (Unthrottled, Regular2G, Regular4G, DSL, SlowCPU) by loading the map twice - to get the uncached and cached behavior.

The packaging strategies differ only in their dependencies and webpack.config.js files;

  • with-simple-webpack-bundling: Control case demonstrating best case performance with simple Webpack bundling (does not include all of OpenLayers)
  • with-ol-externalized-to-static-ol-dot-js: Uses the official legacy ol.js build
  • with-webpack-naive-federation: Uses a naive strategy to employ Module Federation and defeat tree shaking for OpenLayers
  • with-webpack-naive-federation-fewer-chunks: Same as the previous one, but with optimization.splitChunks.{maxAsyncRequests, maxInitialRequests}=15
  • with-ol-federated: My attempt to be clever by splitting every OpenLayers package as a separate Module Federation exposed module

For the given map scene those scenarios yield the following amount of JS sent over the network;

image

Also, here's the breakdown of the number of JS resources involved in each;

image

And the total bytes decoded/decompressed;

image

The most important metric (I've found so far) is what I'm calling nav-to-net-idle which captures the time it takes between the start of navigating to the new page and when the last resources have finished loading. The test captures the screenshot above immediately after that - demonstrating that is the point is when the map is fully rendered. (If perhaps not fully interactive.)

image

Zooming in on the DSL-uncached scenario;

image

We can see that the static ol.js case (3s) takes almost twice as long as the ideal/control case (1.5s). We also see that the naive Webpack Federation strategy is on par with my attempt at more fine-grained federation.

Zooming in on the SlowCPU scenarios;

image

We can see that Module Federation out-performs even the control case. This probably warrants further investigation, but my guess would be that the browser is able to better parallelize loading with the increased chunk count. This may be suggestive of the expected performance on modern mobile devices that have more (slower) cores.


I also measured the time to first-contentful-paint, but discovered that it was a less representative metric than nav-to-net-idle since it occurs sooner - and before the map is fully rendered.

image


first-contentful-paint.csv
js-resource-count.csv
js-resource-total-decoded-bytes.csv
js-resource-total-transfer-bytes.csv
nav-to-net-idle.csv
2021_05_13_profiling_data_summarized.json.zip

symbioquine added a commit to symbioquine/farmOS-map that referenced this issue May 15, 2021
Ref: farmOS#68 (comment)

Will be squashed in final mergeable pull-request version.
@symbioquine
Copy link
Collaborator

I've reviewed all the proposed changes in #109 and they look really good @symbioquine ! I like where this is headed!

Perhaps it makes sense to organize/summarize the "next steps" (maybe in the PR description?) so we can be sure everything is accounted for... including relevant PRs for Field Kit and farmOS 2.x.

Just a couple of notes I made while reviewing:

* We should include some instructions for updating from 1.x to 2.x, for anyone who's using farmOS-map v1 in their projects currently. I know there are a few out there aside from farmOS and Field Kit.

* Minor nitpicks / coding standards:
  
  * Changelog items should end in periods.
  * There are some extra newlines in README.md.
  * Needs a newline above comment inside `attachBehavior()`.
  * I haven't fully grokked the behavior sort logic - I wonder if we could add some comments, and maybe move it out to an include file? I like that `main.js` is so small/readable otherwise.

I've addressed all these things - except for the "update instructions" part which is included in the new "Known Remaining Work" section of the PR description.

The next step is I need alignment on whether it makes sense to pursue the module federation strategy instead of externalizing to the legacy static ol.js OpenLayers dist. @mstenta / @jgaehring / @paul121 Let me know what additional data/answers you'd need to make that decision. I'm happy to have a meeting/call about it too if that would help...

@symbioquine
Copy link
Collaborator

The next step is I need alignment on whether it makes sense to pursue the module federation strategy instead of externalizing to the legacy static ol.js OpenLayers dist. @mstenta / @jgaehring / @paul121 Let me know what additional data/answers you'd need to make that decision. I'm happy to have a meeting/call about it too if that would help...

Just a quick status update here. I'm still looking for input on this point and am willing abandon it if there's significant pushback about the idea, but I have started exploring how best to use Module Federation for farmOS-map. It's looking a bit more complicated than I originally anticipated. Hopefully I'll have more details soon that can feed into the decision...

@mstenta
Copy link
Member Author

mstenta commented May 17, 2021

Incredible work @symbioquine - thanks for taking such a thorough and detailed look at all of this!

I will defer to you and @jgaehring and @paul121 on the best way forward, but at quick glance it sounds like the module federation strategy is the best from a performance perspective!

Curious to hear what other complexities you're finding too. I generally prefer simplicity unless there's a good reason (and bandwidth-constrained situations is a good reason), and the tradeoffs makes sense. "Long term maintenance" considerations are a big part of "complexity" as I define it - not necessarily just "how much extra code is involved" (although they are related).

I'm curious to understand a bit more about how it "works" in the farmOS context. I can read up a bit more on that myself, but if you have any good links/resources to bring me up to speed I'd welcome them! The naive sense I got was that it involves npm build outputting a bunch of separate JS files? Does the "main" JS file just get included on the page, and then automatically figure out how to load the others as needed? Is there anything special we need to do to make this work in the farmOS context? Sorry if this is obvious in the PR - I haven't had a chance to look closely.

Also, are there any considerations around browser compatibility we need to be aware of?

@symbioquine
Copy link
Collaborator

Curious to hear what other complexities you're finding too. I generally prefer simplicity unless there's a good reason (and bandwidth-constrained situations is a good reason), and the tradeoffs makes sense. "Long term maintenance" considerations are a big part of "complexity" as I define it - not necessarily just "how much extra code is involved" (although they are related).

Agreed that complexity is multi-faceted.

The biggest complexities I've been trying to overcome relate to how farmOS-map needs to cater to contrib modules which are written in plain JS or built with other bundlers that don't play nicely with Webpack.

At some level I'd love if we could just go with externalizing the OpenLayers dependency to the legacy ol.js build and calling it good. However, I think there's a non-negligible risk that the ol.js build may stop being vended officially (See openlayers/openlayers#12265) and there's a maintenance cost to standardizing on that dependency then shortly needing to change it or take over maintaining a build mechanism for ol.js. (Changing how contrib modules get access to OpenLayers would definitely be a breaking change and probably should only happen along with a major version bump.)

From that perspective the performance opportunity almost feels secondary.

I'm curious to understand a bit more about how it "works" in the farmOS context. I can read up a bit more on that myself, but if you have any good links/resources to bring me up to speed I'd welcome them!

The best resources right now are the high-level description which is present in the official Webpack documentation (https://webpack.js.org/concepts/module-federation/) and the examples at https://github.com/module-federation/module-federation-examples

One of the challenges with Module Federation is that dependency management in general is already a complex topic and it gets way more complex with dynamic linking and the amount of surface area represented by non-standardized NPM packaging practices.

Module Federation is quite new and the body of official/community documentation doesn't fully do it justice. The most pessimistic viewpoint is that the lack of documentation is a ploy to sell the primary contributors' book on the topic. That might be true, but it's also a very powerful feature and I expect the documentation gap to be pretty short-lived.

All that said, I'm including a "TL;DR;" summary version below.

The naive sense I got was that it involves npm build outputting a bunch of separate JS files? Does the "main" JS file just get included on the page, and then automatically figure out how to load the others as needed? Is there anything special we need to do to make this work in the farmOS context? Sorry if this is obvious in the PR - I haven't had a chance to look closely.

No worries, even if this were totally obvious from the PR it would be good to summarize it here for posterity.

There's a couple things going...

Webpack has a concept of "entrypoints" which are the JS files that are intended to be directly included via <script> tags. These entrypoints can also load additional JS files known as "chunks". Chunks could be manually included in the page with <script> tags or aggregated by a server-side framework such as Drupal, but mostly they're intended to be loaded asynchronously at runtime. That runtime loading is configurable but normally works via the fetch API (or a polyfill of it) and can occur eagerly in the background or lazily when the functionality chunks contain is needed.

Prior to the introduction of Module Federation, Webpack provided some mechanisms for designating which bundled dependencies go into which entrypoints and dynamically creating appropriately sized chunks. That all works pretty optimally in the context of a single Webpack build, but falls down when separately-built JS code would bundle the same dependencies.

Module Federation pulls the concept of package dependency and version management into the runtime layer. That allows two or more builds to both bundle overlapping shared dependencies, but put them into chunks - meaning they can potentially get deduplicated and only loaded once at runtime.

Using Module Federation necessarily creates a new entrypoint - another file that would need to be included via a <script> tag. It is possible to configure the build such that only that entrypoint is created, but that turns out to be somewhat limiting - more on that soon.

One challenge is that Webpack builds with Module Federation have (at least) two conflicting goals;

  • Make the code as small as possible through tree-shaking
  • Make the shared dependencies useful in avoiding loading the same thing multiple times

Since we know that we want all of OpenLayers available to contrib modules we need to tell Webpack not to tree-shake any of it. One mechanism I've found to defeat the tree-shaking is to build one of the precursors to the legacy ol.js artifact and include that. That isn't totally satisfactory since it still creates a dependency on the legacy ol.js build mechanism. I'm planning to try and find another solution to that problem.

We also want to somehow tell Webpack that any overlapping subsets of OpenLayers may be used and organize it chunks such that there's a reasonable number of chunks and most pages don't tend to need all of them.

As I indicated above, the biggest challenge is how to cater to contrib modules which aren't built with Webpack. It is possible to expose each public module in OpenLayers separately in the federated container. That allows for a trivial mechanism to access any part of OpenLayers - even from plain-JS/non-Webpack code(example) - but seems to produce way too many tiny chunks. Having so many chunks creates its own performance problems - as I demonstrated in my performance testing above.

What I'm exploring now is how to get Module Federation to work such that it automatically hits a sweet spot in terms of the number of chunks and also makes it trivial for arbitrary JS code to gain access to any part of OpenLayers.

@paul121
Copy link
Member

paul121 commented May 18, 2021

Hey @symbioquine apologies for my silence as well. I think this is my first time chiming in on this thread but I have been following the convo :-) This is great - appreciate all the research & time you've put into this! Especially the explanations!

Module Federation does seem to be a great option especially considering that the legacy ol.js build may become unsupported. FWIW this article helped me understand the motivation for Module Federation in a more conventional yet modern JS app. It pointed out some practical uses cases that were easy to relate with. I can see how this could be useful for other JS things (like Vue) that we've discussed using in farmOS... so maybe taking on Module Federation could be part of our long-term modular frontend strategy? 😃

I'm curious to understand a bit more about how it "works" in the farmOS context. ... Is there anything special we need to do to make this work in the farmOS context?

Same question, this hasn't quite clicked for me.. important we don't overlook this!

Webpack has a concept of "entrypoints" which are the JS files that are intended to be directly included via <script> tags. These entrypoints can also load additional JS files known as "chunks". Chunks could be manually included in the page with <script> tags or aggregated by a server-side framework such as Drupal, but mostly they're intended to be loaded asynchronously at runtime.

So for Drupal we would likely be aggregating these chunks together rather than being loaded asynchronously? I'm curious if we could get asynchronous loading to work, but it seems like it would require some configuration within the Module Federation plugin to specify where the chunks (remotes?) are located? Even if async isn't possible in Drupal there still seems to be worthwhile benefits with Module Federation, so not a deal breaker..

I guess I'm assuming each chunk would require a separate library definition in Drupal. This could get to be quite verbose but at a certain point it becomes necessary if the goal is to reduce how much is being loaded. I guess this is what the Drupal asset library is trying to accomplish too - collect all of the dependencies needed at runtime for a page load, aggregate, and hopefully cache that when possible. Since the core farmOS-map library will depend on many of the OL chunks this wouldn't add too much complexity for a third party module (arguably makes it much easier since they don't need to package OL themselves...)

What I'm exploring now is how to get Module Federation to work such that it automatically hits a sweet spot in terms of the number of chunks and also makes it trivial for arbitrary JS code to gain access to any part of OpenLayers.

So yea, this makes sense as a next step! I built the ol-federated library locally and seems like it's making ~330 "chunks" (if I'm interpreting this correctly?) Not sure how much more we can consolidate... 50? 20? 10? Maybe we could have a special case that chunks things even further for Drupal if this isn't ideal for other use cases.

A condensed version of the optimal Drupal libraries I'm imagining: https://gist.github.com/paul121/e3f701976bea216146e59289c4927a8e

Obviously async would be preferable to this... but I think the Drupal library system could work. Sure hard to beat the convenience of ol.js though.

@symbioquine
Copy link
Collaborator

I can see how this could be useful for other JS things (like Vue) that we've discussed using in farmOS... so maybe taking on Module Federation could be part of our long-term modular frontend strategy? smiley

Agreed.

I'm curious to understand a bit more about how it "works" in the farmOS context. ... Is there anything special we need to do to make this work in the farmOS context?

Same question, this hasn't quite clicked for me.. important we don't overlook this!

Webpack has a concept of "entrypoints" which are the JS files that are intended to be directly included via <script> tags. These entrypoints can also load additional JS files known as "chunks". Chunks could be manually included in the page with <script> tags or aggregated by a server-side framework such as Drupal, but mostly they're intended to be loaded asynchronously at runtime.

So for Drupal we would likely be aggregating these chunks together rather than being loaded asynchronously? I'm curious if we could get asynchronous loading to work, but it seems like it would require some configuration within the Module Federation plugin to specify where the chunks (remotes?) are located? Even if async isn't possible in Drupal there still seems to be worthwhile benefits with Module Federation, so not a deal breaker..

I guess I'm assuming each chunk would require a separate library definition in Drupal. This could get to be quite verbose but at a certain point it becomes necessary if the goal is to reduce how much is being loaded. I guess this is what the Drupal asset library is trying to accomplish too - collect all of the dependencies needed at runtime for a page load, aggregate, and hopefully cache that when possible.

Actually, (when code splitting is used) Webpack produces code that loads the chunks asynchronously at runtime - by default using the fetch API and assumes that the chunks are at the same location as the entrypoints.

Chunks are normally named with numbers or hashes - e.g. 773.js - and can be thought of as a private implementation detail of the entrypoint. Since it's up to Webpack to optimize the contents of the chunks, they can change from build-to-build. I only said they could be aggregated to demonstrate a characteristic of their loading behavior, not because it would be a good idea in general.

Having Drupal aggregate the chunks for each page would also defeat the network level request caching in the browser.

Instead Drupal would only know about the entrypoints. My lightweight testing has shown that Webpack's async chunk loading works fine in Drupal 9 that way as long as preprocess: false is set on the library definition for each entrypoint.

Since the core farmOS-map library will depend on many of the OL chunks this wouldn't add too much complexity for a third party module (arguably makes it much easier since they don't need to package OL themselves...)

A third party module shouldn't need to know anything about how OL is packaged other than the interface of how it is exposed. e.g. const VectorLayer = ol.layer.Vector; or const VectorLayer = await farmOS.map.olRequire('ol/layer/Vector');

A condensed version of the optimal Drupal libraries I'm imagining: https://gist.github.com/paul121/e3f701976bea216146e59289c4927a8e

I'm thinking something like that breakdown too - though as described above those chunks wouldn't need to be registered in Drupal, only the entrypoints (something like farmOS-map.js and farmOS-map-container.js) would be.

What I'm exploring now is how to get Module Federation to work such that it automatically hits a sweet spot in terms of the number of chunks and also makes it trivial for arbitrary JS code to gain access to any part of OpenLayers.

So yea, this makes sense as a next step! I built the ol-federated library locally and seems like it's making ~330 "chunks" (if I'm interpreting this correctly?) Not sure how much more we can consolidate... 50? 20? 10? Maybe we could have a special case that chunks things even further for Drupal if this isn't ideal for other use cases.

Yeah, that's what I'm trying to overcome. ol-federated itself is arguably my failed experiment at super fine-grained federation. Check out the examples folder though which is where I checked in my test harness for comparing strategies. Some of the examples in there leverage Module Federation, but create fewer chunks...

@mstenta
Copy link
Member Author

mstenta commented May 19, 2021

A little out of band, but wanted to get some recent discussions with @jgaehring and @wgardiner included here (for lack of a better place - maybe this deserves a separate issue)...

Outside of the farmOS context, we also want to be able to support projects that pull farmOS-map in as a node dependency. Right now, we publish to NPM, but there are some things we're doing that are not ideal:

  • window.farmOS.map
  • Consumers of the library need to reference code in src directly.

Field Kit is currently working around this, and SurveyStack (which @wgardiner is working on) will be doing the same.

One idea that @wgardiner suggested is to create an init function that wraps the logic for creating window.farmOS.map, so that it can be optional.

And @jgaehring mentioned in chat:

we should probably export something generic, that can be imported like a regular npm module, and then farmOS can just include a simple JS file that does nothing but attaches it to window
then we can figure out how to make Morgan's optimizations work best with that setup (and hopefully, such a setup will only make his optimizations easier, too)

@symbioquine what are your thoughts on these points, and how do they relate to these proposed changes? I know that the window.farmOS.map piece is already changing, but would an init function make sense too? And if we don't have a global, what does that mean for node-based apps using behaviors?

@mstenta
Copy link
Member Author

mstenta commented May 19, 2021

Actually, (when code splitting is used) Webpack produces code that loads the chunks asynchronously at runtime - by default using the fetch API and assumes that the chunks are at the same location as the entrypoints.

That's really cool - so what that means (if I'm understanding correctly) is that we would still just have a single "library" in farmOS (like we do for farmOS-map.js currently), which would represent the "entrypoint" script. But then that entrypoint script itself would automatically load any other scripts it needs, based on runtime requirements. Neat!

@symbioquine
Copy link
Collaborator

A little out of band, but wanted to get some recent discussions with @jgaehring and @wgardiner included here (for lack of a better place - maybe this deserves a separate issue)...

Outside of the farmOS context, we also want to be able to support projects that pull farmOS-map in as a node dependency. Right now, we publish to NPM, but there are some things we're doing that are not ideal:

* `window.farmOS.map`

* Consumers of the library need to reference code in `src` directly.

Thanks for bringing this up! I was trying to figure out when to work it into the discussion, but didn't want to distract from the ol.js/Federation question.

I do think that addressing how farmOS-map is packaged/exposed would be an auspicious thing to change as part of the 2.x major version since it necessarily involves breaking changes.

we should probably export something generic, that can be imported like a regular npm module, and then farmOS can just include a simple JS file that does nothing but attaches it to window
then we can figure out how to make Morgan's optimizations work best with that setup (and hopefully, such a setup will only make his optimizations easier, too)

@symbioquine what are your thoughts on these points, and how do they relate to these proposed changes? I know that the window.farmOS.map piece is already changing, but would an init function make sense too? And if we don't have a global, what does that mean for node-based apps using behaviors?

I don't have a ready answer other than that I'm excited to brainstorm with you folks to see if we can find a more coherent approach...

One thought is that by changing farmOS-map/src/main.js to have a default export, we make it possible for code created with other bundlers and/or targeting non-browser environments to do something like this;

import farmOSMap from 'farmOS-map/main';

farmOSMap.behaviors.myAwesomeBehavior = {
  attach(instance) {
    ...
  },
};

Of course, it's arguably a bad idea to mutate the things you import since that's akin to global state/variables. It might make sense for us to look at what plugin models isomorphic JS libraries are using. Maybe we can learn something from those...

Actually, (when code splitting is used) Webpack produces code that loads the chunks asynchronously at runtime - by default using the fetch API and assumes that the chunks are at the same location as the entrypoints.

That's really cool - so what that means (if I'm understanding correctly) is that we would still just have a single "library" in farmOS (like we do for farmOS-map.js currently), which would represent the "entrypoint" script. But then that entrypoint script itself would automatically load any other scripts it needs, based on runtime requirements. Neat!

Exactly! :) The main challenges in my opinion are;

  1. Calibrating the granularity of the chunks such that we don't shoot ourselves in the foot performance-wise in bypassing the Drupal aggregation.
  2. Exposing a reasonable/intuitive API for accessing the bundled OpenLayers library such that the chunk loading can happen opaquely and in a way that caters both to hand-written JS and bundled JS.

@symbioquine
Copy link
Collaborator

I've moved the conversation on NPM packaging, default exports, and the farmOS.map global to #113. I've also added that issue as an outstanding action item in my 2.x PR.

@symbioquine
Copy link
Collaborator

symbioquine commented May 30, 2021

Based on my 1.x performance baseline results and the learning that Google maps is the main driver of extraneous loading latency, I've rerun the benchmarks with Google maps and with/without the ol.js externalization;

Without Google maps;

diff --git a/examples/extended-behavior/static/index.html b/examples/extended-behavior/static/index.html
index 887fe81..eff1a52 100644
--- a/examples/extended-behavior/static/index.html
+++ b/examples/extended-behavior/static/index.html
@@ -21,7 +21,6 @@
   </head>
   <body>
     <div id="map"></div>
-    <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?v=3&key=AIzaSyBEB1xUvz8LG_XexTVKv5ghXEPaCzF9eng"></script>
     <script src="./ol.js"></script>
     <script src="./farmOS-map.js"></script>
     <script src="./farmOS-map-example-ext-behavior.js"></script>
@@ -31,7 +30,6 @@
         units: 'metric',
       }
       var myMap = farmOS.map.create("map", options);
-      myMap.addBehavior("google");
       myMap.addBehavior("edit").then(() => {
         myMap.addBehavior("measure", { layer: myMap.edit.layer });
         myMap.edit.wktOn("featurechange", console.log);
diff --git a/examples/simple-html-consumer/static/index.html b/examples/simple-html-consumer/static/index.html
index 33e796d..cf99e48 100644
--- a/examples/simple-html-consumer/static/index.html
+++ b/examples/simple-html-consumer/static/index.html
@@ -21,7 +21,6 @@
   </head>
   <body>
     <div id="map"></div>
-    <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?v=3&key=AIzaSyBEB1xUvz8LG_XexTVKv5ghXEPaCzF9eng"></script>
     <script src="./ol.js"></script>
     <script src="./farmOS-map.js"></script>
     <script src="./mapbox.js"></script>
@@ -30,7 +29,6 @@
         units: 'metric',
       }
       var myMap = farmOS.map.create("map", options);
-      myMap.addBehavior("google");
       myMap.addBehavior("edit").then(() => {
         myMap.addBehavior("measure", { layer: myMap.edit.layer });
         myMap.edit.wktOn("featurechange", console.log);
diff --git a/examples/test/index.spec.js b/examples/test/index.spec.js
index 26d7efe..1963006 100644
--- a/examples/test/index.spec.js
+++ b/examples/test/index.spec.js
@@ -36,8 +36,8 @@ exampleApps.forEach((app) => {
       expect(result).toEqual(true);
     }, 60 * 1000);
 
-    describe(`get metrics from 3 perf.test.js runs`, () => {
-      for (let index = 0; index < 3; index++) {
+    describe(`get metrics from 10 perf.test.js runs`, () => {
+      for (let index = 0; index < 10; index++) {
         it(`${app} run #${index}`, async () => {
           const env = Object.assign({}, process.env);
           env.TEST_PORT_NUM = await getPort({port: getPort.makeRange(3000, 3100)});

Without Google maps and without externalizing to ol.js;

diff --git a/examples/extended-behavior/static/index.html b/examples/extended-behavior/static/index.html
index 887fe81..21275c8 100644
--- a/examples/extended-behavior/static/index.html
+++ b/examples/extended-behavior/static/index.html
@@ -21,8 +21,6 @@
   </head>
   <body>
     <div id="map"></div>
-    <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?v=3&key=AIzaSyBEB1xUvz8LG_XexTVKv5ghXEPaCzF9eng"></script>
-    <script src="./ol.js"></script>
     <script src="./farmOS-map.js"></script>
     <script src="./farmOS-map-example-ext-behavior.js"></script>
     <script src="./mapbox.js"></script>
@@ -31,7 +29,6 @@
         units: 'metric',
       }
       var myMap = farmOS.map.create("map", options);
-      myMap.addBehavior("google");
       myMap.addBehavior("edit").then(() => {
         myMap.addBehavior("measure", { layer: myMap.edit.layer });
         myMap.edit.wktOn("featurechange", console.log);
diff --git a/examples/extended-behavior/webpack.config.js b/examples/extended-behavior/webpack.config.js
index 63175a3..2a6f5e4 100644
--- a/examples/extended-behavior/webpack.config.js
+++ b/examples/extended-behavior/webpack.config.js
@@ -28,18 +28,4 @@ module.exports = {
       ],
     }),
   ],
-  externals: function ({context, request}, callback) {
-    // Externalize all OpenLayers `ol` imports
-    if (/^ol(\/.*)?$/.test(request)) {
-      const modifiedRequest = request
-        // Remove '.js' suffix - if present
-        .replace(/\.js$/, "")
-        // Replace filesystem separators '/' with module separators '.'
-        .replace(/\//g, ".");
-      return callback(null, modifiedRequest);
-    }
-
-    // Continue without externalizing the import
-    callback();
-  },
 };
diff --git a/examples/minimal-html-consumer/static/index.html b/examples/minimal-html-consumer/static/index.html
index f2c97c8..4dfcb9c 100644
--- a/examples/minimal-html-consumer/static/index.html
+++ b/examples/minimal-html-consumer/static/index.html
@@ -21,7 +21,6 @@
   </head>
   <body>
     <div id="map"></div>
-    <script src="./ol.js"></script>
     <script src="./farmOS-map.js"></script>
     <script>
       var options = {
diff --git a/examples/simple-html-consumer/static/index.html b/examples/simple-html-consumer/static/index.html
index 33e796d..465765b 100644
--- a/examples/simple-html-consumer/static/index.html
+++ b/examples/simple-html-consumer/static/index.html
@@ -21,8 +21,6 @@
   </head>
   <body>
     <div id="map"></div>
-    <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?v=3&key=AIzaSyBEB1xUvz8LG_XexTVKv5ghXEPaCzF9eng"></script>
-    <script src="./ol.js"></script>
     <script src="./farmOS-map.js"></script>
     <script src="./mapbox.js"></script>
     <script>
@@ -30,7 +28,6 @@
         units: 'metric',
       }
       var myMap = farmOS.map.create("map", options);
-      myMap.addBehavior("google");
       myMap.addBehavior("edit").then(() => {
         myMap.addBehavior("measure", { layer: myMap.edit.layer });
         myMap.edit.wktOn("featurechange", console.log);
diff --git a/webpack.config.js b/webpack.config.js
index e0ecfd8..868bd08 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,6 +1,5 @@
 const webpack = require('webpack');
 const CopyWebpackPlugin = require('copy-webpack-plugin');
-const SaveRemoteFilePlugin = require('save-remote-file-webpack-plugin');
 
 const info = require('./package.json');
 
@@ -42,36 +41,11 @@ module.exports = {
   },
   plugins: [
     new webpack.BannerPlugin(`farmOS-map ${info.version}`),
-    new SaveRemoteFilePlugin([
-      {
-          url: 'https://raw.githubusercontent.com/openlayers/openlayers.github.io/master/en/v6.5.0/build/ol.js',
-          filepath: 'ol.js',
-          hash: false,
-      },
-      {
-        url: 'https://raw.githubusercontent.com/openlayers/openlayers.github.io/master/en/v6.5.0/css/ol.css',
-        filepath: 'ol.css',
-        hash: false,
-      },
-    ]),
     new CopyWebpackPlugin({
       patterns: [
         { from: './examples/simple-html-consumer/static' },
+        { from: './node_modules/ol/ol.css' },
       ],
     }),
   ],
-  externals: function ({context, request}, callback) {
-    // Externalize all OpenLayers `ol` imports
-    if (/^ol(\/.*)?$/.test(request)) {
-      const modifiedRequest = request
-        // Remove '.js' suffix - if present
-        .replace(/\.js$/, "")
-        // Replace filesystem separators '/' with module separators '.'
-        .replace(/\//g, ".");
-      return callback(null, modifiedRequest);
-    }
-
-    // Continue without externalizing the import
-    callback();
-  },
 };

1.x vs 2.x (without ol.js) Results

Full test output: 2021_05_30_2.x_perf_test_results_less_gmaps.txt

Some key observations;

nav-to-net-idle (ms)

  1.x Unthrottled 2.x Unthrottled 1.x Regular4G 2.x Regular4G 1.x Regular2G 2.x Regular2G
minimal-html-consumer/uncached 1013 1200 1546 1620 8761 8361
minimal-html-consumer/cached 971 973 970 973 2059 2053
simple-html-consumer/uncached 1467 1550 1544 1745 9028 9341
simple-html-consumer/cached 980 1084 1679 1302 2023 2055
extended-behavior/uncached 1367 1411 1541 1503 9346 9389
extended-behavior/cached 990 1091 1329 1302 2032 2053

image
image
image

transfer/decoded (bytes)

  1.x transferred 2.x transferred 1.x decoded 2.x decoded
minimal-html-consumer/uncached 142388 115662 566734 440896
minimal-html-consumer/cached 0 0 566734 440896
simple-html-consumer/uncached 147957 144425 579733 525956
simple-html-consumer/cached 0 0 579733 525956
extended-behavior/uncached 149230 145703 581207 527436
extended-behavior/cached 0 0 581207 527436

image

2.x without vs with Externalization to ol.js Results

Full test output: 2021_05_30_2.x_perf_test_results_less_gmaps_with_ol_dot_js.txt

Some key observations;

nav-to-net-idle (ms)

  2.x Unthrottled 2.x (with ol.js) Unthrottled 2.x Regular4G 2.x (with ol.js) Regular4G 2.x Regular2G 2.x (with ol.js) Regular2G
minimal-html-consumer/uncached 1200 1394 1620 1823 8361 13078
minimal-html-consumer/cached 973 973 973 973 2053 2049
simple-html-consumer/uncached 1550 1537 1745 1907 9341 13552
simple-html-consumer/cached 1084 1205 1302 1308 2055 2052
extended-behavior/uncached 1411 2031 1503 2046 9389 15190
extended-behavior/cached 1091 2071 1302 2058 2053 2073

image
image
image

transfer/decoded (bytes)

  2.x transferred 2.x (with ol.js) transferred 2.x decoded 2.x (with ol.js) decoded
minimal-html-consumer/uncached 115662 265576 440896 1061968
minimal-html-consumer/cached 0 0 440896 1061968
simple-html-consumer/uncached 144425 281015 525956 1101154
simple-html-consumer/cached 0 0 525956 1101154
extended-behavior/uncached 145703 282005 527436 1101790
extended-behavior/cached 0 0 527436 1101790

image

Discussion

For these tests I disabled Google maps integration (like the most recent 1.x performance tests) and tested with and without the externalization of OpenLayers to ol.js. What that means is that I'm independently testing the performance consequences of making behavior loading lazy/async and externalizing OpenLayers to ol.js.

The most interesting observation concerns the "extended-behavior" example. Without externalizing to ol.js that example involves 1.3KB/2KB JS transferred/decoded vs only 1KB/0.5KB JS transferred/decoded with externalizing to ol.js. However, surprisingly the performance penalty for loading the extra behavior is actually much worse at all levels of throttling with externalizing to ol.js.

Thus, it appears that - at least for modest amounts of duplicate bundling of OpenLayers functionality - it's better to just allow those to be duplicated than to try avoiding the duplication via externalizing to ol.js.

In theory Webpack Module Federation could yield the best of both worlds, but in practice I haven't found a way to configure it that yields performance similar to that which we get by bundling OpenLayers normally. In some cases I was able to beat the performance of externalizing to ol.js, but not by enough for me to feel comfortable recommending that as a viable path forward at the moment.

To summarize, my tentative conclusions are that farmOS-map 2.x should;

  • Do make behaviors async and use lazy chunks for the included behaviors - allows farmOS-map core behaviors to keep growing without necessarily incurring incremental performance costs for all use-cases
  • Do remove core support for Google maps - a related change should involve packaging up Google maps as an optional contrib module
  • Not externalize to ol.js - this is expensive from a performance perspective and would involve depending on a legacy packaging option for OpenLayers
  • Not adopt Webpack Module Federation for OpenLayers at this time - this seems elegant, but in practice the tooling isn't there yet to intelligently tune the chunks sizes for a large dependency like OpenLayers

@symbioquine
Copy link
Collaborator

This issue has gotten pretty large/unweildy and so did my original PR: #109

To simplify, I've opened #115 to track the progress of these changes. I recommend that we close this issue in favor of that one.

symbioquine added a commit to symbioquine/farmOS-map that referenced this issue Jun 1, 2021
**Why?** Better support alternative bundling strategies
and scenarios where having mutable state in the global
`window.farmOS.map` is ugly/limiting.

These changes should allow farmOS-map to also be used
as follows;

```sh
npm install @farmos.org/farmos-map@2
```

```js
import MapInstanceManager from '@farmos.org/farmos-map/MapInstanceManager';

const maps = new MapInstanceManager();

const map = maps.create('my-map');
```

Some background can be found in the following issues;

* farmOS#113
* farmOS#68
symbioquine added a commit to symbioquine/farmOS-map that referenced this issue Jun 3, 2021
**Why?** Better support alternative bundling strategies
and scenarios where having mutable state in the global
`window.farmOS.map` is ugly/limiting.

These changes should allow farmOS-map to also be used
as follows;

```sh
npm install @farmos.org/farmos-map@2
```

```js
import MapInstanceManager from '@farmos.org/farmos-map/MapInstanceManager';

const maps = new MapInstanceManager();

const map = maps.create('my-map');
```

Some background can be found in the following issues;

* farmOS#113
* farmOS#68
symbioquine added a commit to symbioquine/farmOS-map that referenced this issue Jun 3, 2021
**Why?** Better support alternative bundling strategies
and scenarios where having mutable state in the global
`window.farmOS.map` is ugly/limiting.

These changes should allow farmOS-map to also be used
as follows;

```sh
npm install @farmos.org/farmos-map@2
```

```js
import MapInstanceManager from '@farmos.org/farmos-map/MapInstanceManager';

const maps = new MapInstanceManager();

const map = maps.create('my-map');
```

Some background can be found in the following issues;

* farmOS#113
* farmOS#68
@mstenta
Copy link
Member Author

mstenta commented Sep 6, 2021

I think we can close this along with #113 - what do you think @symbioquine ? We've made great progress on these fronts, and this issue documents a lot of it - but I don't think it needs to remain open, does it?

@symbioquine
Copy link
Collaborator

Agreed. To summarize;

  • I made it so that new behaviors can be included in farmOS-map and only¹ incur performance costs when they are actually used. (¹Also arguably when building farmOS-map and installing farmOS, but those are mostly inconsequential.)
  • I showed that for many use-cases moderate amounts of duplicate OpenLayers JS is less costly than the things we might do to try and avoid it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

4 participants