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

Adding scriptElement.exports for configuration use cases #7367

Open
domenic opened this issue Nov 24, 2021 · 16 comments
Open

Adding scriptElement.exports for configuration use cases #7367

domenic opened this issue Nov 24, 2021 · 16 comments
Labels
addition/proposal New features or enhancements topic: script

Comments

@domenic
Copy link
Member

domenic commented Nov 24, 2021

(Credit to @dvoytenko for this proposal.)

Problem statement

It's a common pattern to serve most of your JavaScript from CDNs, but use an inline script for configuration settings. E.g.

<script>
self.configuration = {
  deviceType: "tier1"
  /* ... */
};
</script>
<script src="https://cdn.example.net/script.js">

where https://cdn.example.net/script.js could end up using self.configuration.

This setup is fragile, as it relies on the web page author to ensure the inline script appears before any CDN-included scripts, and and doesn't play well with async="" which might cause scripts to execute early.

Additionally, some authors would prefer to use modules for this, so that the dependency between script.js and the global configuration is explicit.

Proposal

We could add an exports property to HTMLScriptElement. This was mentioned previously in #2235, but without use cases. Then we could have this setup:

<!-- Order no longer matters; async="" is fine -->
<script type="module" async src="https://cdn.example.net/script.mjs">

<script type="module" async id="configuration">
export const deviceType = "tier1";
// ...
</script>

Where script.mjs contains

const { deviceType } = await (await waitForElement("#configuration")).exports;

and waitForElement is a developer-written helper utility that uses a MutationObserver to wait for the specified element to appear.

Note that exports itself would be a promise, because in the general case the inline module might itself use top-level await. That's not what's going on in our configuration-based example, so in our case the promise will immediately fulfill, but it seems like the right primitive at the spec level.

There could also be a level of indirection, so that script.mjs doesn't need its own waitForElement function. For example if script.mjs did

import { deviceType } from "./configuration.mjs";

where https://cdn.example.net/constants.mjs contains some default configuration values, then the page itself could use an import map to remap https://cdn.example.com/configuration.mjs to its own script that looks like the following:

const { deviceType } = await (await waitForElement("#configuration")).exports;
export deviceType;

Alternative considered

Another way of accomplishing this, which is less powerful but potentially easier to use, would be to introduce the ability to directly import an inline script. Something like the following:

import { deviceType } from "document:configuration";

The problem with this idea is that the semantics of resolving document:configuration specifiers is tricky:

  • The most natural thing would be that resolving does a synchronous lookup in the current state of the document, and fails if there is no element with the specified ID. However, then you basically go back to the current state of things, just with modules: you'll still have problems if your inline module isn't before any CDN-provided modules, or if you use async="". You could combine it with waitForElement("#configuration") + dynamic import() like so:

    await waitForElement("#configuration");
    const { deviceType } = await import("document:configuration");

    but this is not much of a win.

  • Alternately, we could try to specify a semantic where if the element with such an ID doesn't exist yet, we wait until it does before finishing module resolution. This would be nice to use, but it breaks some existing properties of modules, such as how they execute in order. I.e., we would have to delay the resolution and fetching of script.mjs's dependencies until an element with the appropriate ID appears in the tree, and then we would have to go skip the usual ordering to execute that element's inline script (and any dependencies) immediately. Or, we could end up waiting indefinitely, if no such element appears. Also, these strange semantics an be caused deep in the tree, by any import statement. So this seems bad.

Combined with the idea that there might be speculative future HTML modules-related use cases for an exports property, per #2235, probably this alternative is not a good direction and we should do exports instead.

Details

We've said exports should be a promise. What about in the non-module script case? It could be a forever-pending promise, or a promise already resolved with null. Or maybe it could be null, instead of a promise? Web IDL might make the latter impossible currently...

This feature makes inline JSON and CSS modules useful. Should we consider allowing them at the same time?

/cc @whatwg/modules

@domenic domenic added addition/proposal New features or enhancements topic: script labels Nov 24, 2021
@bmeck
Copy link

bmeck commented Nov 24, 2021

Personally this seems a decent feature to add, but I would think having it vary between module and classic Scripts as a bit odd. If the getter resolves a Promise to a module namespace object it is somewhat handy since it ~= the return type of import(). Right now you cannot even reference a classic Script on the web via import in any form to my knowledge. I'd somewhat expect it to throw in that case since it simply isn't something you can do rather than something that is expected to be used. null would at least give errors when trying to access properties and expose a potential problem in a way that is actionable to fix, but a never ending Promise would just seem to hang, unresponsive.

I don't have a strong preference but think whatever the value is seems fine due to export function then() {} allowing it to be an arbitrary value rather than explicitly the namespace object if that makes sense.

@Kaiido
Copy link
Member

Kaiido commented Nov 26, 2021

Not my area of expertise, but for me one of the pain points of configuration objects as they work currently is the global's pollution and the possible conflicts between them.
From the examples here I have the impression that this problem would just be moved to the DOM element's ids map, since it seems libraries authors would still be responsible for defining the "configuration" element's id.
Also I find relying on authors to write waitForElement correctly quite risky, there are many ways to mess this up, and it seems a bit sketchy to ask them to do so.

So I was wondering if taking the problem the other way around and using an import attribute could be a possible alternative, where authors would write

<script type="module" async src="https://cdn.example.net/script.mjs" import="./configuration.js">

or even, by using same-document URL reference

<!-- here page authors can use whatever id they want -->
<script type="module" id="my-config">
  export const deviceType = "tier1";
  // ...
</script>
<script type="module" async src="https://cdn.example.net/script.mjs" import="#my-config">

I'm sure there are a lot of things involved here that I am missing, but as an author I think I'd prefer such a design, even if it means having to place my inline configuration script before the library's script.

@domenic
Copy link
Member Author

domenic commented Nov 29, 2021

I think allowing any ordering is the key value of this proposal, so alternate solutions are best considered with that constraint in mind. See the "Alternative considered" section for another approach that doesn't meet that constraint, and why the authors who originated this proposal are not fond of it.

@dvoytenko
Copy link

I think the proposal can address the global namespace problem and remove the need to author the waitForElement calls by hand. I'd approach it this way:

  1. Identify configurable constants, which could be in a single module or in many modules.
  2. Create a Babel plugin that would rewrite imports of configurable constants to some form of await waitForElement('#configuration').exports.
  3. Output all configurable constants into DOM as a <script id="configuration" type="module"></script> tag.

@bmeck
Copy link

bmeck commented Nov 29, 2021

I'm a bit curious if there even is a global namespace problem given Shadow DOM. My main thought is if these can be injected as slots it seems to take away a bunch of the concerns, for example:

<x-editor>
  <script slot="config" type="module">/*inline works, but so would src=*/</script>
</x-editor>

This likely would still need some glue if using something like a generic CDN since I'm not familiar with how this could let x-editor easily just include a <script> from a CDN without snooping around to find the root but it does seem doable even without needing a pre-processing step.

@domenic domenic added the agenda+ To be discussed at a triage meeting label Nov 29, 2021
@Kaiido
Copy link
Member

Kaiido commented Nov 30, 2021

I think allowing any ordering is the key value of this proposal

I have trouble to see why it's so hard to maintain the ordering of two script elements that it would dismiss any other issues with the presented use-case. Could you share an example scenario?

To be clear, the import="" proposition would require this ordering only for inline scripts, and from what I can tell it wouldn't be affected by async="", all that matters is that the element is already in the DOM when parsing the same-document URL reference import="#my-config".
For external references import="./config.mjs" there is no ordering problem, the external file is fetched just as if there was an actual import * as config from "LOCAL_PATH/config.mjs" in the CDN served script.mjs.

@devsnek
Copy link

devsnek commented Nov 30, 2021

I like this proposal a lot, especially since it can easily match the return value of import(). I think personally the script case being wrapped up in the promise makes sense to me, since you can then handle that logic the same way. if you want to check "synchronously" you can read the element's type attribute right?

@hiroshige-g
Copy link
Contributor

Some random thoughts...

Another alternative in my mind is

  • At #prepare-a-script, an inline module script with id=foo is added to the module map with the URL document:foo. foo here is the id attribute of the script element at the time of #prepare-a-script.
  • When we write import { deviceType } from "document:foo";, then the specifier is resolved immediately to document:foo (using ordinal absolute-URL parsing). If an inline script with id=foo is not yet added to the DOM (i.e. there are no entries for document:foo in the module map), fetching (rather than specifier resolution) waits for document:foo to appear in the module map, just like waiting for external scripts.

This is to give names to inline scripts on the module maps, and is somewhat similar to the second one of "Alternative considered". This alleviates some of the concerns (e.g. module fetching is more regularly working and isn't blocked by to-be-added inline scripts), while sharing the other concerns. For example, the following scripts result in 2 1 in the console, altering the inline module script evaluation order by import 'document:foo'.

<script type="module">import 'document:foo'; console.log(1);</script>
<script type="module" id="foo">console.log(2);</script>

(Also this looks like fetching document:foo from HTML/DOM, which might look like layering violation, while in its details this is purely implemented in the HTML spec.)

@hiroshige-g
Copy link
Contributor

Some other (minor) thoughts on exposing scriptElement.exports:

  • Currently script elements are not used as accessors to evaluated module scripts, and scriptElement.exports adds a new case where DOM mutations after #prepare-a-script are not no-op for script evaluations (e.g. id attribute changes).
  • We have to handle the cases where script elements are moved between documents after evaluation. i.e. When <script type="module" id="configuration"> is added and evaluated in the main Document and then moved to an iframe, what should the exports value of the script element be? (Probably it should be null or other invalid values, while the script evaluation itself isn't canceled).

@domenic
Copy link
Member Author

domenic commented Jan 5, 2022

I have trouble to see why it's so hard to maintain the ordering of two script elements

The reason this is being brought up is based on sites maintained by multiple teams, where the final HTML document is assembled via many layers in the stack (e.g. framework, application developers, server-injected HTML, and even CDN-injected changes). The value proposition of this proposal is allowing dependencies to cross these layers without the different teams having to cooridnate on an ordering.

@domenic
Copy link
Member Author

domenic commented Jan 5, 2022

For example, the following scripts result in 2 1 in the console, altering the inline module script evaluation order by import 'document:foo'.

Yeah, this seems unfortunate to me.

adds a new case where DOM mutations after #prepare-a-script are not no-op for script evaluations (e.g. id attribute changes).

I don't quite understand this point. There are tons of DOM mutations after prepare-a-script that impact the rest of the page, if the rest of the page is inspecting the HTMLScriptElement's JavaScript properties. For example, changing id or class DOM attributes will change what the id and class properties return.

We have to handle the cases where script elements are moved between documents after evaluation

Hmm. I'd be happy to null out the value in such cases, just because generally I find moving scripts between documents to be problematic and am happy to cripple those cases as much as possible (like we've already done for fetching). But I'm not sure I see it as being very important. That is, I don't think anything bad would happen if we continued to expose the same value for exports.

@domenic
Copy link
Member Author

domenic commented Jan 5, 2022

For example, the following scripts result in 2 1 in the console, altering the inline module script evaluation order by import 'document:foo'.

Yeah, this seems unfortunate to me.

Hmm. Upon reconsidering maybe it's not terrible. We can already get a similar effect today, where an import statement in one module "reorders" the execution of other modules on the page, in this sort of scenario:

<script type="module" src="a.mjs"></script>
<script type="module" src="b.mjs"></script>
// a.mjs
import "./b.mjs";

console.log(1);
// b.mjs

console.log(2);

i.e. this will output 2 1 in a similar fashion.

The remaining problem is that it's much easier to indefinitely hang your application this way. The failure case where import "./something.mjs" doesn't have a corresponding file on the server will quickly cause the module graph to error out. But the failure case where import "document:something" doesn't have (and never has) a corresponding element in the DOM will cause the module graph to hang indefinitely. (Or until some timeout, which I guess we could spec, but that doesn't sound great for page load predictability.)

I'd be curious to hear others' thoughts. It certainly is a nice bit simpler to do import { deviceType } from "document:configuration" than do const { deviceType } = await (await waitForElement("#configuration")).exports;. In particular it would remove the need for a Babel plugin mentioned in #7367 (comment).

@dvoytenko
Copy link

@domenic I think either option is good. Some Babel plugin work would be required in either case. I do think that scriptElement.exports has a generic value beyond this use case.

@justinfagnani
Copy link

Are there any thoughts on how to make progress here?

Accessing a module is pretty important for use cases like declarative components: #2235 (comment)

@justinfagnani
Copy link

justinfagnani commented Mar 23, 2024

It's very difficult to polyfill declarative custom elements that allow customization via script, or to implement Vue-like single-file components, without this proposal.

Many of the userland implementation have a roughly similar shape:

<define-element name="my-element">
  <template>...</template>
  <script type="module">
    export default class extends HTMLElement {
      // customizations here...
    }
  </script>
</define-element>

but the only way to get to the "export" of the <script> tag is to put them into some kind of global map:

<define-element name="my-element" custom-class="abc123">
  <template>...</template>
  <script type="module">
    import {register} from './register.js';
    register('abc123', class extends HTMLElement {
      // customizations here...
    });
  </script>
</define-element>

which is obviously cumbersome and error-prone.

The proposal to add an exports Promise on the script tag seems pretty simple, obvious, and immediately useful to me. Could it move forward separately from any feature to import from the document?

@keithamus
Copy link
Contributor

I’d be happy to prototype this in a browser, with blessing from a vendor representative.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
addition/proposal New features or enhancements topic: script
Development

No branches or pull requests

9 participants