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

Introduce pointer to <script> element in module scripts #1013

Open
annevk opened this issue Apr 8, 2016 · 119 comments
Open

Introduce pointer to <script> element in module scripts #1013

annevk opened this issue Apr 8, 2016 · 119 comments
Labels
addition/proposal New features or enhancements topic: script

Comments

@annevk
Copy link
Member

annevk commented Apr 8, 2016

There was an offline discussion about document.currentScript and shadow trees and the suggestion came up to solve it in a different way for module scripts so that the global object would not be involved and the value would only be accessible to the script currently executing.

@domenic domenic added the addition/proposal New features or enhancements label Apr 8, 2016
@domenic
Copy link
Member

domenic commented Apr 8, 2016

I think this would belong in the "module meta" idea. It was detailed in more detail somewhere, but alluded to in whatwg/loader#38. @dherman might know more. The idea would be something like:

import { currentScript, url } from this module;

At other times people have suggested using metaproperties on import (currently the only metaproperty in the language is new.target; this would introduce a few more):

import.meta.currentScript; // or just import.currentScript?

Unfortunately the base extensibility hook here kind of needs a TC39 proposal for someone to champion, which is a whole process.

domenic added a commit that referenced this issue Apr 11, 2016
They no longer set document.currentScript, or fire beforescriptexecute
and afterscriptexecute events.

Closes #997. See #1013 for a future alternative to
document.currentScript, and #943 for more discussion of the events.
domenic added a commit that referenced this issue Apr 13, 2016
They set document.currentScript to null, and no longer fire
beforescriptexecute and afterscriptexecute events.

Closes #997. See #1013 for a future alternative to
document.currentScript, and #943 for more discussion of the events.
domenic added a commit that referenced this issue Apr 14, 2016
They set document.currentScript to null, and no longer fire
beforescriptexecute and afterscriptexecute events.

Closes #997. See #1013 for a future alternative to
document.currentScript, and #943 for more discussion of the events.
annevk pushed a commit that referenced this issue Apr 15, 2016
Make module scripts modify less global script state. They set document.currentScript to null, and no longer fire beforescriptexecute and afterscriptexecute events.

Closes #997. See #1013 for a future alternative to document.currentScript, and #943 for more discussion of the events.
annevk added a commit that referenced this issue Apr 22, 2016
This makes <script> elements work when used in shadow trees. The
beforescriptexecute and afterscriptexecute events won’t work, since
they are already disabled for modules and only makes sense together
with currentScript, which cannot be made to work for shadow scripts.
See #1013 for details.

Fixes #762.
@annevk annevk mentioned this issue Apr 22, 2016
domenic pushed a commit that referenced this issue Apr 22, 2016
This makes <script> elements work when used in shadow trees. The
beforescriptexecute and afterscriptexecute events won’t work, since
they are already disabled for modules and only makes sense together
with currentScript, which cannot be made to work for shadow scripts.
See #1013 for details.

Fixes #762.
annevk added a commit that referenced this issue Apr 23, 2016
This makes <script> elements work when used in shadow trees. The
beforescriptexecute and afterscriptexecute events won’t work, since
they are already disabled for modules and only makes sense together
with currentScript, which cannot be made to work for shadow scripts.
See #1013 for details.

Fixes #762.
domenic pushed a commit that referenced this issue Apr 26, 2016
This makes <script> elements work when used in shadow trees.

Note that document.currentScript is set to null while running a <script>
in a shadow tree; see #1013 for details.

This takes care of most of #762, but it remains to make the "load" event
scoped, so we'll leave that issue open for now.
@matthewp
Copy link

Is it correct to say that document.currentScript is not set, even for module scripts with no imports like:

<script type="module">
  document.currentScript == null;
</script>

Even if it were set in this case, I'd still want something similar to currentScript within imported modules. One use-case I have is to insert a template directly after the module script.

I like a variant of import.currentScript personally; I think introducing a special meta would invite questions like whether it is mutable or not. Maybe a better name came be thought of than currentScript (is it really current?) like ownerScript perhaps.

@domenic
Copy link
Member

domenic commented Oct 26, 2016

That's correct.

It should really be current; I don't see why it wouldn't be. We're still kind of waiting on TC39 to figure out the import metaproperty stuff though.

@domenic
Copy link
Member

domenic commented Nov 10, 2016

It's worth mentioning that we have a path forward here without waiting on TC39, which is to reserve a special module name. So then we'd do something like

import { currentScript, url } from "js:context";

@guybedford
Copy link
Contributor

import.currentScript sounds ideal here. With the dynamic import landed, perhaps a spec here can start to gain momentum. Landing features such as these soon will help significantly to avoid fragmentation of the adoption path features.

Note also feature detection here also seems tricky. I assume typeof import.currentScript wouldn't work, so it would just fall back to a syntax error if it isn't supported?

@Constellation
Copy link
Member

It's not directly related to this, I would like to note that I would like to have the similar thing in the module script in workers. See detailed thing why I would like to get it even in the workers. in tc39/proposal-dynamic-import#37.

@domenic
Copy link
Member

domenic commented Feb 22, 2017

I think we should spec something here soon that people are willing to ship. Node.js is also interested in getting agreement on this.

I'd like to propose

import { url, currentScript } from "js:context";

to get the current script URL (which could be that of a HTML file for inline scripts), and the current <script> element (which could be null if no <script> is currently executing).

How does that sound to implementers? /cc @whatwg/modules

@bmeck
Copy link

bmeck commented Feb 22, 2017

"js:context" looks fine but we are open to bikeshedding from Node.js side.

@bicknellr
Copy link
Contributor

Maybe it would be worth carving out the entire js: protocol-like-prefix for future stuff like this?

@domenic
Copy link
Member

domenic commented Feb 22, 2017

It effectively already is carved out, since fetching any URL that starts with js: will fail per today's specs.

@ajklein
Copy link
Contributor

ajklein commented Feb 22, 2017

Hate to tie this in to other bike-shedding, but it would be good if whatever naming scheme we choose matches TC39's naming scheme for internal modules (if they do arise).

@rniwa
Copy link

rniwa commented Feb 22, 2017

That seems like such a hack. There is a proposal for global which is encountering some compatibility issue. Perhaps global should also live there?

@bmeck
Copy link

bmeck commented Feb 22, 2017

@rniwa how is it a hack if it needs context to the environment it was invoked from (in this case, the location of import or import())?

@domenic
Copy link
Member

domenic commented Feb 22, 2017

I don't think global makes sense to put in the context-specific module, but people have proposed putting it in a built-in module before. I'd suggest discussing that in the global repo.

@rniwa, putting global aside, I can't tell if you think the import { url } from "js:context" proposal is good or not?

@rniwa
Copy link

rniwa commented Feb 23, 2017

I don't like it. Conceptually, what you're trying to figure out the information about the current module, not about "js:context". Why is the url of "js:context" not "js:context"?

@bmeck
Copy link

bmeck commented Feb 23, 2017

@rniwa you are grabbing an export of "js:context" named url, I don't understand why you would think it is the url of "js:context" given people can do things like export let url = "foo" in modules. Would a name different from url be sufficient?

@matthewp
Copy link

"js:context" is a contextual module specifier. I can see the confusion because it looks like a URL and not a specifier. Given that it is a specifier distinct to the module loading it, what does it resolve to? What's the algorithm?

Let's pretend for a second that the algorithm resolves to "js:context:https://example.com/foo.js" where "https://example.com/foo.js" is the importing module. Would this mean that, I could do:

import { url, currentScript } from "js:context:https://example.com/bar.js" 

To grab another module's url/currentScript? Or would this be restricted?

@d3x0r
Copy link

d3x0r commented Jun 11, 2021

Why can't we just know the current node the script is in? Especially if it's the top level script being included... certainly for imports of imported scripts the currentNode shouldn't apply...

'use import.meta' is only helpful if the source of document.currentScript was... but I'd like to know what the parent node is of the current script to know what part of the HTML DOM to search with something like

<html>
<head>
</head>
<body>

<div id="profileContainer"></div>

</body>

<script type="module">

const rootNode = document.currentScript.parentNode;
/* get something... */ rootNode.querySelector("#profileContainer") 


</script>
</html>

Otherwise I guess I can select all scripts and somehow figure out if one is 'me'?

@samthor
Copy link

samthor commented Jun 11, 2021

@d3x0r you say "top-level script", but read this comment (and there's others in the thread above) as to why that's a fuzzy concept here—that top-level script is only evaluated once, no matter how many times you include it:

<script type="module" src="entrypoint.js"></script>
<script type="module" src="entrypoint.js"></script>
<script type="module"> import './entrypoint.js'; </script>

So what's the currentScript?

Yes, for the inline case I guess there'll only ever be one script. But at that point it seems like you already know "where you are". Just my 2c.

@d3x0r
Copy link

d3x0r commented Jun 11, 2021

the first two are their own tag; the third, entrypoint.js doesn't get the script...

https://github.com/d3x0r/sack.vfs/tree/master/tests/objstore/userDb/ui/profile Is what I was playing with.

Part ...
index.html imports a script, and in that case, because the 'profile.js' can't get document... does the document lookup on itself, and this is a root sort of page, so it is obvious to find the location
profile.js Imports a utility library that makes popup windows, and extends that class, so when initialized loads the popup content from a .html page
profileForm.html The HTML template to fill the popup frame with; also has script tags which are modules, and I would expect their script tags to be themselves if they were src="script.js"; it loads profileForm.js
profileForm.js The JS part of the form; this needs to find the controls in profileForm.html ; and this is the script that wants to know it's relative location - which are tags in profileForm.html, or the container that ends up filled with profileForm.html.

The popup library fillFormURL(popup,url) function does a fetch of the URL, and then sets the .innerHTML of a div within the popup (which are approx. 5 nested frames), The script tags that are found are replaced with script tags that are the same, but will actually end up loading.

The index.html will eventually be some other service, which just includes "login.js" sort of script, and provide a 'login-with' sort of functionality... so there's no telling what the outside framing will really be.

certainly I can hear people saying 'don't do that' without a single answer to 'why' so... religious opinions aside... that's what I was attempting to do.

As a workaround <script> tags with textContent can have information prepended to them on the replace function to add a unique ID for the script itself, which allows scripts in the HTML to find their relative root... but cannot from any .js file itself; as would be loaded by a src attribute.

@rniwa
Copy link

rniwa commented Jun 11, 2021

the first two are their own tag; the third, entrypoint.js doesn't get the script...

I'm not sure what do you mean there. In the example given, entrypoint.js will execute exactly once. Please go read the whole issue, especially #1013 (comment) and the subsequent discussion. It's not productive to keep repeating the same discussion over and over.

@whaaaley
Copy link

I would also like a way to get the ShadowRoot object from with in the shadow DOM's own scripts. Wasn't this the whole point of encapsulation? Am I missing something? I honestly don't know.

@besworks
Copy link

besworks commented May 14, 2022

@whaaaley and others, maybe this will help you out.

I can't remember where/when I came up with this method but it definitely belongs in this thread :

connectedCallback() {
  const scopedEval = (script) => Function(script).bind(this)();
  const scripts = this.shadowRoot.querySelectorAll('script');
  scripts.forEach(s => scopedEval(s.innerHTML));
}

This runs any script elements in the shadowRoot of a custom element with the host object as the scope. Here's a more thorough example using a closed shadowRoot.

@bathos
Copy link

bathos commented May 14, 2022

@besworks the number of reasons that’s unsound are too many to enumerate. it may suit a particular case but should not be promoted as a general solution.

@besworks
Copy link

besworks commented May 14, 2022

@bathos, obviously the correct solution in most cases would be to simply not use script tags inside your shadow dom at all but for those few cases that it's necessary (like importing a clunky old library) this trick is like magic. The snippet above is clearly a simplified demonstration. As you can see in the jsfiddle I used #private class properties and methods to make sure this cannot be executed from outside the component. It also works with a closed shadow root so there's no worry about script injection that way. Can you think of any other ways this could be abused?

@whaaaley
Copy link

whaaaley commented May 15, 2022

The reason I want this is because I've been experimenting with different ways to load random components built with modern frameworks into shadow roots. Assuming you're using some kind of virtual dom framework in the parent application you could do something like this with a global.

function React () {
  // eslint-disable-next-line no-return-assign
  return <ShadowRoot id='react'>
    {window._reactRef = <div id='root'></div>}
    <script type='module'>
      {`
        import 'https://unpkg.com/[email protected]/umd/react.production.min.js'
        import 'https://unpkg.com/[email protected]/umd/react-dom.production.min.js'

        const root = ReactDOM.createRoot(window._reactRef.node)

        root.render(
          React.createElement(React.StrictMode, {},
            React.createElement('div', {}, 'hello from react')
          )
        )
      `}
    </script>
  </ShadowRoot>
}

I started to wonder though. Why is this inside of script modules undefined? But this usually refers to window. Would it not make way more sense for this to return some type of object pertaining to the module, and if that script is loaded within a shadowRoot, then it would have a property like this.shadowRootDocument??? Or even just this.document for compatibility with old libraries?

I mean don't mind me. I don't know much about the details of the module system, but ergonomics wise, and for symmetry with how you'd do this without shadowDOM, this makes sense to me over something to do with the import statement. Maybe this is naive, but to me it sounds to me like import.meta is just a weird syntax to make up for the lack of this inside scoped contexts like a shadowDOM. Maybe someone can educate me on why this is the case. I'd actually like to know.

@besworks
Copy link

@whaaaley, I've done some experimenting with various ways to inject scripts into shadowRoots as well and just posted a very thorough overview of this problem in WICG/webcomponents#717 (comment)

I believe this discussion more appropriately belongs in that thread, even though it's closed. The document.currentScript issue discussed here seems related but is actually a byproduct of what you're describing, which is ultimately an issue with how the evaluated scripts are scoped.

@Jamesernator
Copy link

but to me it sounds to me like import.meta is just a weird syntax to make up for the lack of this inside scoped contexts like a shadowDOM.

Kind've, basically this was used in all sorts of ways in classic scripts, which is why strict module and modules narrowed their uses to be more the usual intent of this (i.e. the object for methods). import.meta was added as a way to more clearly associate something per-module rather than overloading this to have yet another special-cased behaviour.

The advantage of having a separate thing, is that import.meta can even be used inside methods that might have a different this (i.e. in classes).

It would be plausible to add import.meta.script for inline scripts, but not for src scripts as in general many <script src= could point to the same module (or the module might've even been loaded with import(...)). (And exposing the first script, would lead to very race-condition prone code).

If HTML modules ever get implemented, there would be a similar inline-only script feature (import.meta.document) that is pretty similar, so it wouldn't be particularly strange to expose a import.meta.scriptElement for inline scripts.

@mk-pmb
Copy link

mk-pmb commented May 15, 2022

import.meta.scriptElement sounds reasonable to me.

@whaaaley
Copy link

I'm down for any solution that will give me a path to get access to the shadow root document. Is there any chance we can get access to the current document in import.meta as well? It seems to me the primary reason for wanting scriptElement is just so we can traverse upwards to the current document because that's how it used to be done in v0 of shadow dom api... So why not just add import.meta.currentDocument which refers to the document the script is loaded in and avoid the traversal step all together? Then there's also no confusion with which script element to return in the case of multiple script imports. Let the developers query it themselves once they have the current document.

@whaaaley
Copy link

whaaaley commented May 15, 2022

@besworks I read your post in the other thread. I can see both ideas being correct. However, I think the path of least resistance is probably slapping more stuff onto import.meta. Though I did really like your idea of configuring how shadowRoot scripts work with attachShadow options. That should definitely be expanded on as well.

@besworks
Copy link

besworks commented May 15, 2022

I agree that import.meta.currentDocument would be a good property to have. As long as it actually returned the shadowRoot and not the document that the shadowRoot is connected to. I think that import.meta.scriptElement would definitely be useful too. Then we could do import.meta.scriptElement.ownerDocument or access siblings, etc. These would be great for many use cases, but the way scripts in a shadowRoot are currently evaluated, their ownerDocument is actually the host document. So implementing either of these properties alone is not enough. I believe more work needs to be done to make the ShadowRoot interface behave more like a wrapper for the Document interface instead of just the DocumentFragment interface which it currently extends.

@whaaaley
Copy link

@besworks The host document or the host node? 🤔 There's some stuff I could do with a host node to get some desired behavior at least. But not with a host document.

@besworks
Copy link

besworks commented May 15, 2022

Here's an example of what I mean :

<div id="host"></div>
<script type="module">
  let host = document.querySelector('#host');
  let shadow = host.attachShadow({ mode:'closed' });
  let script = document.createElement('script');
  script.textContent = `console.log(document.querySelector('#host'))`;
  shadow.append(script); console.log(script.ownerDocument);
</script>

You might expect that after the script was appended to the shadowRoot it would no longer be part of the host document. But this not the case. The shadowRoot is not actually a separate Document but rather a DocumentFragment contained as a property of the host element. We have full access to the host document scope and every element in it but no access to the scope the script is actually connected to, unless you drill down into it from outside, which you can't do if you use a closed shadowRoot.

So, even if you were to access the hypothetical import.meta.currentDocument from inside a shadowRoot, you would still get a reference to the document that contains your component and not the shadowRoot like you want unless special handling was put in place for this scenario. This may still prove to be the best route and less complex than messing with the ShadowRoot interface, though I do still believe there is room for improvement there.

One technique I experimented with involved building a virtual document to process my scripts in with the intention of appending the output to the shadowRoot afterwards. This didn't work out because according to section 8.1.3.4 of the spec

Scripting is disabled for a node when its node document browsing context is null.

Which is exactly the case when using document.implementation.createHTMLDocument() and DOMParser() and the same with new Document() ...

So, with all that in mind, the best workaround I've come up with, is the spooky dark magic method that always seems to ruffle everyone's feathers. But it gets the job done and, if used responsibly, overcomes all of these problems.

@mk-pmb
Copy link

mk-pmb commented May 16, 2022

It seems to me the primary reason for wanting scriptElement is just so we can traverse upwards

I for one want to scriptElement.getAttribute(…).

@WickyNilliams
Copy link

not sure if it's entirely related, but adding a use case here anyway.

i'm trying to write some isolated code examples for a documentation site. i wanted to use declarative shadow dom for this, so styles and IDs do not leak out. but i also wanted to include some scripts as part of demos e.g.

<div class="example">
  <template shadowrootmode="open">
    <style>
      button {
        color: red;
      }
    </style>
    <button>click me</button>
    <script>
      // how to select the button?
    </script>
  </template>
</div>

e.g. here i wanted to log a click on the button. but there's no easy way to get a reference to the button which doesn't completely detract from the example itself. it would be nice if there were something like currentDocument, though i am not precious about any particular solution.

@MajPay
Copy link

MajPay commented Oct 11, 2024

One of my favourite patterns (because its very robust) is to do the following:

<script data-foo="someOptionAddedByServerSideTemplatingEngine">
(function(options){
  console.log(options.foo);
})(document.currentScript.dataset);
</script>

I know - this seems complicated but - trust me - i have use-cases, where this is the most robust way (dynamic generation of script tags, template engines, escaping, ide code handling, lsp support, and so on).

But with this pattern i am stuck to regular script types. I would really like to convert this to

<script type="module" data-foo="someOptionAddedByServerSideTemplatingEngine">
const options = import.meta.currentScript.dataset;
console.log(options);
</script>

@sgentle
Copy link

sgentle commented Nov 8, 2024

Would having import.meta.scriptElement only defined for inline module scripts be a viable solution?

It seems to me that "what script element caused the loading of this module?" and/or "what script elements are connected to this module in the import graph?" are more complex and less useful questions to ask compared to "what script element contains this code?"

Conceptually, I think of <script type="module" src="foo.js"> as importing a module defined elsewhere, whereas <script type="module">console.log("hello, world")</script> defines a module in the current document. The definition for the former lives at a URL that can be introspected with import.meta.url. The definition for the latter lives in a HTMLScriptElement that can be introspected with import.meta.scriptElement.

Does that make sense?

@justinfagnani
Copy link

Would having import.meta.scriptElement only defined for inline module scripts be a viable solution?

I think this makes some sense on the surface, but there's a big hazard with things changing so much depending on whether the script is inline. Tools may change a script from inline to external or vice-versa and break things.

You would get roughly the same effect if import.meta.scriptElement was defined only for the module directly imported by a script tag, and maybe only during the top-level evaluation of the module. Then if a script were made external, it would still work, since presumably it's also only imported once.

@sgentle
Copy link

sgentle commented Nov 9, 2024

As @rniwa has pointed out, though, there isn't really a well-defined answer to "what script tag imported this module?" (nor, even, for "what module imported this module?") because a module can be imported from a bunch of different places at different times. That's why I think focusing on where the module is defined is a more well-founded approach.

The hazard of module behaviour changing depending on whether the script is inline applies to import.meta.url as well... I think that's just an unavoidable consequence of this kind of reflection. Presumably bundlers have to emulate import.meta.url too, right?

For external modules, this pattern might be reasonable:

<script type="module">
import something from 'module.js'
something(import.meta.scriptElement)
</script>

The 1:N definition:import relationship has to get dealt with somewhere, but this way your something function can have whatever application-level semantics make sense.

The only other answer I can think of would be some kind of import hook that a module could use to know when it's been imported and what by? That seems like a whole can of worms though...

@mk-pmb
Copy link

mk-pmb commented Nov 9, 2024

I could imagine a world in which the script tag has a getter for the exports of the imported module and we could use

<script type="module" src="http://example.net/opaque.js" onload="
  const scriptTag = this;
  scriptTag.getExport('default').then(m => m.install(scriptTag));
">/* … arbitrary data, e.g. config settings … */</script>

Maybe we can even convince browsers to support a run shorthand for a method name exposed on the default export, so we could shorten the above to:

<script type="module" src="http://example.net/opaque.js" run="install">/* …data… */</script>

Or when it's empty, it means that the default export is expected to be the entrypoint function, i.e.

<script type="module" src="http://example.net/opaque.js" run>/* …data… */</script>

would be the same as

<script type="module" src="http://example.net/opaque.js" onload="
  const scriptTag = this;
  scriptTag.getExport('default').then(m => m(scriptTag));
">/* …data… */</script>

I think the four bytes " run" could become one of webdev's most beloved HTML additions. (In before "you can even omit the space!")

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