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

unpoly as ECMAScript module #136

Open
JeremiePat opened this issue Sep 12, 2020 · 15 comments
Open

unpoly as ECMAScript module #136

JeremiePat opened this issue Sep 12, 2020 · 15 comments

Comments

@JeremiePat
Copy link

It would be a very nice improvement if unpoly could be available as a standard ECMAScript module in addition to the simple IIFE bundle.

This can be achieved independently than #124 but rewriting unpoly as a set of ES modules would be the nicest way to resolve both those issues.

@adam12
Copy link
Member

adam12 commented May 19, 2021

Hi @JeremiePat,

What kind of improvements do you feel this would bring? and are you familiar enough with CoffeeScript 1.x to know if this is easily achievable?

I'm inclined to close this for now and possibly re-visit after v2 is released. WDYT?

@adam12 adam12 self-assigned this May 19, 2021
@jpic
Copy link

jpic commented Jul 26, 2021

Can Coffescript transpile without resolving imports? (maybe transpiling a bundle per file?)

In this case, you can import by path and the browser will understand it: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules

Paths must be resolvable by the browser ofc

Not sure about any improvement it would bring though

@triskweline
Copy link
Contributor

CoffeeScript is currently being removed file-by-file in master.

This is done to make the project more accessible to contributors, and to ship separate ES5 and ESNext builds.

Shipping a third ESM build is not super high on my long list of priorities now, as it offers no practical benefit to the window.up global.

@JeremiePat
Copy link
Author

Hi! To give some quick reason on the why ESM would be nice to me (I bet some people could have other reason for that)

  1. That play nice with current and future building pipeline for JS (the ESM standard is now main stream in all browsers, as well as server side with Node or Deno so all the tooling around JS is now following that future proof path)
  2. It allows to isolate unpoly from the global scope which can prevent overloading issues.
  3. As I use ESM for all my code base on all my project now, It feels always awkward to work with libraries that are not ESM based. Especially, in addition/contradiction/complement with the first point, if unpoly was available as ESM I could be able to get rid of a building pipeline for my future projects (which I'm doing to achieve point 2 here).

@triskweline
Copy link
Contributor

Totally understand the desire for consistency (1). I'm also using ESM modules at work, but as a library maintainer I need to find the fewest number of builds that work for the largest number of people.

Regarding (2), Unpoly sets a single property window.up. This is the overloading issue you need to tolerate with until someone builds ESM support.

Regarding (3), Unpoly has never required a build step. In contrast, ESM modules force you to have a build step. At least until import maps have browser support. Even then funneling many small modules over HTTP may be too slow.

@johndwells
Copy link

I've come to this thread by way of this discussion: #252

And just want to add my +1 to seeing this issue addressed in due course.

(For the record, I do import Unpoly and bundle it into a module. On the whole it's worked fine, and only recently, as indicated in that mentioned thread, have I run into "issues" with this approach.)

I admire Unpoly's ability to get up and running with a simple <script> tag, no build step required - much in the same way that AlpineJS can (Unpoly & AlpineJS play really, really well together) - but there are definitely advantages to being able to bundle them together and fine-tune the final production files.

In fact, unless I'm not thinking straight, there's no clean way to use Unpoly as a normal <script> tag in conjunction with a larger application built as a module, if any of the code in said module intends to operate on Unpoly. As in:

<script src="url-to-cdn-unpoly.js"></script>
<script type="module" src="app-from-build.js"></script>

That app-from-build.js wouldn't be able to do any unpoly configuring, couldn't define event hooks, etc. This is because it is downloaded and parsed on a separate thread - so even if unpoly.js was downloaded (or accessed via cache) and parsed first, the module code would not have shared scope/access to unpoly.

The choice would be to a) not build the app JS as an es6 module at all, or b) write separate JS either as a file or inline that includes any unpoly-related code.

Neither of those are appealing. I want to embrace Unpoly as a part of the site, and leverage it in deep ways - for example, to use up.log.puts() and up.log.warn() etc across my app, to give me an easy way in the console to enable/disable logging as needed (as opposed to a bunch of console.log()s that always write to the console). Or have multiple files with separate concerns, for example one that watches for up:history:pushed events so that it can be tracked as a pageview.

And ultimately I want to follow performance best practices, which includes efficient script loading. module/defer/async are important tools in that endeavor.

I consider Unpoly to be the FAR superior choice among libraries offering HTML-over-the-wire functionality. But I would suggest that adoption will be hindered as long as it remains "officially" incompatible as an es6 module.

(I'm sorry that I can't offer code contributions to this effort, my JS knowledge is just too weak!)

@triskweline
Copy link
Contributor

triskweline commented Aug 11, 2021

We're conflating three different things in this thread.

1. Unpoly as an ES module

The original request by @JeremiePat was for a build that exports Unpoly's API as an ES6 module. In this model Unpoly would not define a window.up global. Instead of calling up.layer.open() your code would do something like this:

import { layer } from 'unpoly'
layer.open()

While I understand the desire for this, it's not super high on my personal list of priorities, as explained above.

2. <script type="module">

A second request by @johndwells was to support loading Unpoly via a <script type="module"> element. While this can be used to load an ES6 module, it is often used to ship a one build to modern browsers (using recent JS language) features and another build to legacy browsers like IE11 (dumbed down to ES5):

<script type="module" src="build.esnext.js"> <!-- only modern browsers will load this -->
<script nomodule src="build.es5.js"> <!-- only modern browsers will ignore this -->

This also works if build.esnext.js is not actually an ES6 module.

Targeting modern browsers with <script type="module"> is something I do want to see working in Unpoly. I hate every unnecessary byte of ES5 that we ship for the 1% of IE11 users.

3. <script defer>

The reason why <script type="module"> doesn't currently work with Unpoly is that this behaves like <script defer>, meaning that the script will be loaded at a random point in the future, independent of the document's lifecycle. Unpoly currently initializes on the DOMContentLoaded event, but there's no guarantee that deferred scripts have loaded by then.

It would also be great to support loading via <script defer>, since that's a good performance optimization in general. Scripts without a [defer] or [type=module] attribute pause the DOM parser until the script has loaded and executed. This will delay your first meaningful paint by a few milliseconds.

Why delayed initialization needs some hard decisions

Unpoly currently initializes on the DOMContentLoaded event. In a world without deferred scripts, this is a great moment to initialize. We know that both Unpoly and all user scripts have fully loaded. If a user script has configured Unpoly, we know it has already happened.

In a world with deferred scripts, we don't know when to initialize. When Unpoly is called, additional user scripts may or not load after us. We also cannot initialize prematurely since there is user configuration like up.log.config.banner or up.link.config.preloadSelectors that must have happened before initialization, or that configuration would be ignored for the initial render pass.

Of course one way to fix this would be to require users to tell Unpoly when all scripts have loaded. So somewhere in a user script it would say:

up.boot()

Unfortunately this would break every single Unpoly app that currently relies on Unpoly initializing automatically by just including a script tag. It's also a nice property of any library when it can be installed by simply including a script tag.

We could also go another route and force defer users to to define something like window.UNPOLY_BOOT = 'manual' so Unpoly knows to wait for up.boot(). But I'm not crazy about the developer experience of this either.

@adam12 adam12 removed their assignment Aug 12, 2021
@johndwells
Copy link

Apologies for hijacking @JeremiePat's original request. Hopefully it's nonetheless a productive discussion, but feel free to cut me off if I'm breaking protocols...

Thanks @triskweline for this explainer. FWIW, AlpineJS faces a similar issue re: "knowing when to initialize", and addresses it by building to multiple targets (one for CDN, one for modules):

https://github.com/alpinejs/alpine/blob/main/scripts/build.js

The 2 build targets each have their own entry points, where the only difference is that the CDN target auto-initializes Alpine:

import Alpine from './../src/index'

window.Alpine = Alpine

queueMicrotask(() => {
    Alpine.start()
})

Whereas the module target does not:

import Alpine from './../src/index'

export default Alpine

Leaving it to the user to initialize as part of their module logic (as per https://alpinejs.dev/essentials/installation#as-a-module):

import Alpine from 'alpinejs'

window.Alpine = Alpine

Alpine.start()

It's an elegant solution imo! It would be a breaking change to those of us importing Unpoly as a module, but seeing as that's not officially supported, it seems "acceptable". :)

@triskweline
Copy link
Contributor

@johndwells Thank you for pointing out how Alpine solves this! It's always super insightful to see how other libraries approaches a problem.

While this may end up the way we're solving this, I'm not quite ready to give up on a solution that does not require an additional build variant. We already have variants for ES5 and ES2020 and we will eventually have a variant for ES modules. Every time we add another axis to that matrix (like auto-loading or not) we multiply the number of builds.

@triskweline
Copy link
Contributor

After some experimentation there may be a away to support loading via <script defer> (or <script type="module">) that doesn't break API.

When I earlier said that there is no guarantee when <script defer> is executed, I confused that with <script async>. It is actually well defined when deferred scripts are executed:

  • after the DOM was parsed (document.readyState === 'interactive')
  • but before the DOMContentLoaded event
  • in the same order as <script defer> tags appear in the DOM, even if earlier scripts take longer to load than later scripts.

So if Unpoly would initialize on DOMContentLoaded, it would probably work with deferred scripts.

But didn't I also say that Unpoly initializes on DOMContentLoaded? Well yes, but there is an optimization where Unpoly initializes immediately when document.readyState !== 'loading' during execution. This was originally done to cover the case that Unpoly was somehow executed after DOMContentLoaded. But as shown above, deferred scripts run in a weird moment where document.readyState === 'interactive', but before DOMContentLoaded. Hence Unpoly initializes before subsequent <script> tags have executed.

We could probably get deferred initialization working by removing that optimization and always wait for DOMContentLoaded.

@johndwells
Copy link

Well yes, but there is an optimization where Unpoly initializes immediately when document.readyState !== 'loading' during execution

Ah hah! So that's why my testing last night wasn't yielding expected results - because like you said, I understood defer to honor script order and be executed before DOMContentLoaded. I had been looking at that onReady() method but didn't understand the significance of testing for document.readyState.

@triskweline
Copy link
Contributor

There may even be a way to support <script async>, which has no guarantees when it is executed:

  • When Unpoly is loaded via a <script async> tag, don't boot automatically. Instead users must explicitely call up.boot() when they're done configuring Unpoly. Possible print a helpful error if Unpoly if the user did not boot by the load event (all async scripts have finished by then).
  • If a user does not use an async script but still wants to boot manually, we could support an annotation on the script tag:
    <script src="unpoly.js" up-boot="manual">
  • Refuse to boot twice in case the user calls up.boot() more than once.
  • Expose an event that tells other async scripts that Unpoly has booted and its API is now available for use. Separate async scripts is actually a case where an ES6 module would shine, since the user script could just depend on the Unpoly script.

@triskweline
Copy link
Contributor

The upcoming version 2.3 will support loading with <script defer> and <script async>.

I'm leaving this issue open, as it was originally about a separate ESM build for Unpoly.

@johndwells
Copy link

@triskweline I've been watching your work on the other branch, thank you so much for giving these issues your time and care!

@triskweline
Copy link
Contributor

You're welcome :)

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

No branches or pull requests

5 participants