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

The way to access current script or current root, from Shadow DOM #717

Closed
tomalec opened this issue Dec 14, 2017 · 12 comments
Closed

The way to access current script or current root, from Shadow DOM #717

tomalec opened this issue Dec 14, 2017 · 12 comments

Comments

@tomalec
Copy link
Contributor

tomalec commented Dec 14, 2017

Since

Document.currentScript must return null if the script element is in a shadow tree. See Issue #477.

There is no way for a script to access the elements in its own root. That, in my opinion, breaks the idea of encapsulation, and for sure renders script elements inside shadow root useless.

At least the scripts that have something to do with the shadow root they are in.

Why do I think it breaks encapsulation?

For me, Shadow DOM was the way to encapsulate a bunch of any HTML elements into a separate tree. Therefore, (in V0) I used <div> elements that does not obfuscate the global tree, <style> elements that affects only elements inside and does not bleed outside, <script> elements that could work with smaller document fragment, where I can use small set of scoped IDs, querySelect smaller tree and so on.

JS was never strictly encapsulated as it is working in the global context. That could be good and bad but I don't want that to be part of this discussion.
However, I was at least able to use a convention to support it. I could even force it by auditing, linting the scripts I put into my shadow, make sure they do not access document or global variables.

Right now, there is no (*) way to access this encapsulated/scoped document fragment from the script, and they only can access, querySelect global document. That means <script>s in "encapsulated" tree are able to access global tree, but cannot access encapsulated tree.

Compare such shadow roots:

  • pre-477
<button>Horn</button>
<script>
    document.currentScript.getRootNode().querySelector('button').addEventListener('click',()=>{alert('beep')});
</script>
  • post-477 1.
<button>Horn</button>
<script>
    document.querySelector('button').addEventListener('click',()=>{alert('beep')});
</script>
  • post-477 2.
<button>Horn</button>
<script>
    document.querySelector('#shadow-host1').shadowRoot.querySelector('#shadow-host2').shadowRoot
    /*....*/
    .addEventListener('click',()=>{alert('beep')});
</script>

The pre-477/v0 case was working perfectly, looked pretty clean. The only downside is that you had to remember to use document.currentScript.getRootNode() instead of document, but as I stated above this could be good and bad that you can make this conscious decision.

Then post-477 it's rather impossible to achieve such behavior. The first case simply doesn't work and most probably would make a random button beep.
The second one may work as long as you

  • really know where you are - usually, you don't,
  • all your shadow-including ancestors are open - disallows you to use closed shadow roots,
  • stamp it only once, unless you fix the traversing to the root every time you stamp it.

Also, it's extremely ugly and bug-prone.
What is worse it requires you to pierce through all the shadow roots.

// @rniwa maybe you would be interested in a problem that disallows closed shadows and requires piercing

"Having currentScript allows you to expose shadow root and break encapsulation again"

It does, but you can expose it in many other ways which are not disallowed. For example with custom elements, like in #477 (comment)

(*) "You can use custom element"

I can, but I think it's overkill. Consider the costs of cognitive load for developers, maintenance, performance, loading yet another polyfill of defining a custom element, creating an instance, considering all CE reactions just to access an element in my own scope and do something simple with it just once.
Imagine using full CustomElements machinery as an IIFE.

More use cases

I would say: all cases when you would like to do any kind of scripting in scoped encapsulated DOM. Especially those which are specific, unique, or just too simple, so where running CE machinery does not make sense.

If extrapolating the example above is not enough, and somebody would like to see more. I'm happy to provide more real-life, production-based use cases. But here I'd like to avoid blurring the picture and making the post to long to read.

Solutions

  1. Restore currentScript and use getRootNode().
    It's a simple solution from the perspective of web developer/ shadow root author. It's the same getRootNode that is used for any other custom or native element.
    I believe it should be also simple from implementers perspective, as it was there already in V0.
  2. Introduce currentRoot as proposed by @hayatoito.
    If for some reason currentScript is really so evil and worse from CE's this.getRootNode(), or external element.shadowRoot, that it has to be blocked. I think it's pretty self-explanatory and straightforward API, that would be also nice sugar on top of document.currentScript.getRootNode(). But I would expect some problems with such context-awareness, both in author developer experience as in implementation.
@annevk
Copy link
Collaborator

annevk commented Dec 14, 2017

Using encapsulation in a different way from how we use it for shadow trees makes this issue very confusing to read. I'm pretty sure we're not going to change what we decided on currentScript. And we're already tracking a replacement of sorts that preserves encapsulation (as used in the design of shadow trees): whatwg/html#1013.

@tomalec
Copy link
Contributor Author

tomalec commented Dec 14, 2017

Using encapsulation in a different way from how we use it for shadow trees ...

What is the way we use it for shadow trees? How my encapsulation differs?

Per spec:

At the same time, it is an encapsulation abstraction, so it has to avoid affecting the document tree.

That's what I actually try to state here. I need a <script> that's able to affect shadow tree. Currently, it's only able to affect document tree.


Speaking of whatwg/html#1013, it's for modules only, isn't it?

I think the case of regular scripts are way simpler as they do not introduce problems like multiple imports

@annevk
Copy link
Collaborator

annevk commented Feb 18, 2018

The specific problem with enabling document.currentScript is that it gives global-reachable pointer into the shadow tree. So if the currently executing script (that is in a shadow tree) calls some other global function that will now have access to the shadow tree. That violates encapsulation.

@annevk
Copy link
Collaborator

annevk commented Feb 18, 2018

The last time we discussed this there was no particular interest in enabling this for classic scripts. And I don't think we have a hook of sorts we could use for them either. I suspect that hasn't changed?

@rniwa
Copy link
Collaborator

rniwa commented Feb 19, 2018

I don't think we'd be interested in exposing currentRoot or currentScript in a classic script.

@TakayoshiKochi
Copy link
Member

TakayoshiKochi commented Feb 20, 2018

As @annevk 's #717 (comment), I don't think we revert the document.currentScript decision to return null for shadows.

If we pursue currentRoot, maybe implement DocumentOrShadowRoot.currentScriptRoot (which returns document or direct shadow root which contains running script element) and DocumentOrShadowRoot.currentScript and use the code

function getCurrentScript() {
  var root = document;
  while (root != root.currentScriptRoot)
    root = root.currentScriptRoot;
  return root.currentScript;
}

which is a similar approach to DocumentOrShadowRoot.activeElement.

But similar to what @rniwa stated above, I feel reluctant to add new features to classic script.

@annevk
Copy link
Collaborator

annevk commented Feb 20, 2018

@TakayoshiKochi that would leak shadow trees outside the "blessed" open shadow root shadowRoot IDL attribute. And also definitely not work for closed shadow trees, so therefore a non-starter I think in terms of getting agreement on it.

@TakayoshiKochi
Copy link
Member

Ah, yes, maybe root.currentScriptRootHost then (even weirder, and cannot point to document), and of course it doesn't work for closed shadows in any case.

@annevk
Copy link
Collaborator

annevk commented Feb 21, 2018

I'm going to close this given that something that only works for open shadow trees would not be acceptable given past discussions.

@annevk annevk closed this as completed Feb 21, 2018
@besworks
Copy link

besworks commented May 14, 2022

I've been doing a deep dive into this issue and believe that the solution is not to change how document.currentScript behaves but rather change how document itself behaves inside the shadowRoot. Consider this example :

let script = document.createElement('script');

script.textContent = `
  let inner = document.createElement('div');
  let root = inner.getRootNode();
`;

shadowRoot.append(script);

One might expect root == shadowRoot to be true but in reality, inner.ownerDocument == document because we used the createElement method of document to generate it.

If document instead behaved as a reference to the shadowRoot itself rather than the outer document, we should then be able to run the same script and get the expected result. If we accessed document.currentScript it should return the running script element in the shadowRoot and not break the encapsulation because the document object would be a this-like reference to our current context. So, if we used document.createElement it should create that element with the shadowRoot as it's owner, document.querySelector should query the shadow DOM only and document.host.ownerDocument would refer to the outer document if necessary. Also this could even refer directly to the host element.

We can kind of spoof this behavior in the following way :

<div id="test"></div>
<script type="module">
  const host = document.querySelector('#test');
  const shadowRoot = host.attachShadow({ mode:'closed' });
  shadowRoot.innerHTML += `<script>console.log(this);`;
  shadowRoot.innerHTML += `<script>console.log(document);`;
  const scripts = shadow.querySelectorAll('script');
  const scopedEval = (script) => Function('document', script).bind(host, shadowRoot, script)();
  scripts.forEach(script => scopedEval(script.innerHTML));
</script>

But, the ShadowRoot interface is not a direct extension of the Document interface. So createElement and many other methods/properties are undefined. You can shim any of these you need like :

shadowRoot.createElement = function() {
  return document.createElement(...arguments);
}

shadowRoot.body = shadow;

etc...

This is a bit cumbersome though and, of course, manually evaluating scripts in content injected via innerHTML like I've done here should not be done without caution. If you've ended up here your goal is likely to execute your own/trusted scripts but using this method will actually run ANY script injected into the shadowRoot. Obviously a native implementation would be safer, and preferred.

Either way though this provides more security than allowing the scripts to run in the global scope as they do now. Exposing only a limited stub in place of the Document interface can prevent the injected scripts from accessing the outer document at all, that is, if there weren't alternate routes to it like window.frames.document. Therefore, in some cases it may not be unreasonable to limit access to the host and window objects as well :

Object.defineProperty(shadowRoot, 'host', {});
Object.defineProperty(shadowRoot, 'ownerDocument', {});
const scopedEval = (script) => Function('document', 'window', script).bind({}, shadowRoot, {}, script)();

Ideal Behavior

Ultimately, it should be possible to declaratively provide template elements in the host document which can contain scripts that use consistent Document interface syntax and get executed in the shadowRoot context avoiding this hack altogether.

<div id="host"></div>

<template>
  <div id="test"></div>
  <script>
    console.log(
      window, // == window || {} as desired
      this, // == host element || {} as desired
      this.ownerDocument, // host document
      document, // == shadowRoot
      document.currentScript, // == this script element in the shadowRoot
      document.querySelector('#test'), // == <div id="test"></div> from shadowRoot
      document.querySelector('#test').ownerDocument, // == shadowRoot
      document.querySelector('#host') // == undefined
    );
  </script>
</template>

<script>
  let host = document.querySelector('#host');
  let shadow = host.attachShadow({ mode:'closed' });
  let temp = document.querySelector('template');
  shadow.append(temp.content.cloneNode(true));
</script>

Declaring those options would have to be done when creating the shadowRoot. Like this proposed example :

host.attachShadow({
  mode: 'closed',
  allow: {
    scripts: false, // to completely disable scripts from executing in the shadowRoot
    host: false, // to make this == {} inside the shadowRoot
    window: false // to make window == {} inside the shadowRoot
  }
});

With the default for all options being true.

@chrisodom
Copy link

@besworks I like the idea basically moving the document context to be the current custom element. I would also like to see this working for scripts other than textual but are referenced via source attribute:

<script src="url.to.some.script" crossorigin type="module>

@besworks
Copy link

@chrisodom that should be doable. You would just need to fetch the script and run the response through scopedEval. Something like this should work:

scripts.forEach(script => {
  let src = script.getAttribute('src');
  if (src) {
    fetch(src)
    .then(res => res.text())
    .then(s => scopedEval(s));
  } else {
    scopedEval(script.innerHTML);
  }
});

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

6 participants