dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
34.9k stars 9.85k forks source link

Blazor: can't pass reference to element when hosted in shadow DOM #54648

Open curia-damiano opened 4 months ago

curia-damiano commented 4 months ago

Is there an existing issue for this?

Describe the bug

My customer is developing a solution where Blazor WASM components are exposed as web components and used in an external web framework, that uses shadow DOM for isolation.

Because the Blazor application needs to manipulate part of the DOM, we have found this solution:

We have developed a POC that can reproduce the problem. I attach it to this issue. The POC is configured for shadow DOM, and so it has the bug.

To remove shadow DOM, and show the expected behavior:

Expected Behavior

The shadow root is required by the hosting architecture where we are going to host the web components.

We would be keen to modify our approach to pass the reference to the elements back to JavaScript.

Steps To Reproduce

Run the attached POC.

With shadow DOM, we get this output:

shadowdom_error

Without shadow DOM, we get the correct output:

noshadowdom_ok

Exceptions (if any)

No response

.NET Version

8.0.100, with latest stable updates

Anything else?

No response

curia-damiano commented 4 months ago

BlazorApp1_ShadowRoot.zip

This is the POC

SteveSandersonMS commented 4 months ago

Locating arbitrary elements inside unknown shadow roots is a broader problem that goes beyond Blazor. For example, see threads like this: https://stackoverflow.com/questions/57813144/how-to-select-element-inside-open-shadow-dom-from-document

The underlying challenge is that shadow piercing support was removed from browsers, and so there isn't a performant way for Blazor to locate a referenced element without knowing about which shadow root it's inside. The only automatic way I think it could be done is if Blazor exhaustively walked every possible shadow root recursively until it found the referenced element, which would be bad for performance.

An immediate, well-performing alternative you could use is, instead of using ElementReference, render some GUID value in an attribute on your target element, and pass the GUID as a string in your JS interop call. Then your JS-side code can use whatever technique it likes to locate the target element, based on your own knowledge of your application's shadow root structure.

For example, if you know the shadow root is on some element with ID my-shadow-root, and the target element has an attribute my-identifier with value some-guid-here, then you could locate it using:

const elem = document.querySelector("#my-shadow-root").shadowRoot.querySelector("[my-identifier=some-guid-here]");

As you can see, being able to do this comes down to knowing about the shadow root structure which is custom to your application. I don't see a clear automatic way that Blazor could do it without that knowledge, but if anyone has a technique in mind, please let us know!

SteveSandersonMS commented 4 months ago

I suppose one possible way it could be handled automatically is if, instead of IJSRuntime being a common value across your whole Blazor WebAssembly application, there was some IComponentJSRuntime that was specific to each component. Then when it passes ElementReference, it could do the lookup by first getting the DOM location of the component's render output, and resolving the element reference relative to the shadow root containing that location.

However this would be very non-obvious to developers (that they need to use an entirely different JS interop API) so it not necessarily an improvement over the alternative suggested above.

Or yet another possibility is if we changed ElementReference itself so that internally, it embedded the ID of the component that rendered the element. Then this info could also be supplied to the client-side code, which already has ID->Element mappings for component roots.

curia-damiano commented 4 months ago

Hi @SteveSandersonMS , thank you very much for you observations. About changing way of doing JSInterop, this is not an issue for us (I've already partially converted to .NET 7 JSImport attributes for improved performances) - in fact we need to pass this DOMElement, because with the attributes this is not possible, so we pass it once and save it in JavaScript. I will discuss with the customer tomorrow and then let you know.

If you think that this issue can't be solved by Blazor, and we need to change the approach in our code, feel free to close it.

DIem137 commented 4 months ago

I suppose one possible way it could be handled automatically is if, instead of IJSRuntime being a common value across your whole Blazor WebAssembly application, there was some IComponentJSRuntime that was specific to each component. Then when it passes ElementReference, it could do the lookup by first getting the DOM location of the component's render output, and resolving the element reference relative to the shadow root containing that location.

However this would be very non-obvious to developers (that they need to use an entirely different JS interop API) so it not necessarily an improvement over the alternative suggested above.

Or yet another possibility is if we changed ElementReference itself so that internally, it embedded the ID of the component that rendered the element. Then this info could also be supplied to the client-side code, which already has ID->Element mappings for component roots.

Hi @SteveSandersonMS, thanks for the hints, we've implemented a solution that stores a map with guid:HTMLElement which the JSinterop can then use to query the right DOM context , and this works fine as per instantiating the component within the shadow DOM. This said, I find it would be very interesting to pursue the IComponentJSRuntime: would it not be a natural extension of rootComponents that allows them to be used in an agnostic context? Please bare in mind I've got a limited experience with Blazor itself and am currently focusing exactly on such kind of context, so I am definitively biased :) . If I understood well an IComponentJSRuntime would allow to:

  1. have separate scripts for different components provided by the same WASM;
  2. maintain a correct knowledge of the window context and the DOM's topography, including all its fragments/shadowRoots, by Blazor (as duly recommended in the documentation here). I am concerned that mapping via JS would lead to Blazor having a misaligned set of references and therefore erratic behavior in situations such as element updates & removal (I'll try extend the demo to demonstrate such concerns, but they can be intended for scenarios such as update and disposal of rootComponets ), with associated risk of polluting the browser memory. Regards,

Davide

SteveSandersonMS commented 4 months ago

Glad to hear you were able to implement a solution!

have separate scripts for different components provided by the same WASM;

You can already have separate scripts (if you mean JS modules) for different components. Can you clarify what limitation you're trying to overcome? Do you have sample code?

maintain a correct knowledge of the window context and the DOM's topography

Yes that is what I was thinking, however I later realised it's likely not necessary to have a special JS runtime type for this, when we could embed the necessary information into ElementReference and hence make it work transparently anyway.

DIem137 commented 4 months ago

Hi,

You can already have separate scripts (if you mean JS modules) for different components. Can you clarify what limitation you're trying to overcome? Do you have sample code?

I haven't encountered this limitations nor yet have looked into that, just assuming that if I'm declaring several rootComponents I might as well need want to get some scripts to be scoped to such elements, but probably given my limited knowledge I'm missing over solutions already available within the current implementation, I'll try it out and get back with a demo if I find anything substantiating this.

Yes that is what I was thinking, however I later realised it's likely not necessary to have a special JS runtime type for this, when we could embed the necessary information into ElementReference and hence make it work transparently anyway.

Yes, but I see a further limitation down the line: what if (as per our scenario) the shadowRoot is nested within n levels of shadowRoots of which Blazor does not know of? In that case the parent element's Id would be of little use, correct? Just to give an example:

Given the DOM:

<body>
  <my-custom-layout-component>
    // shadow-root(1)
      <my-blazor-hosting-component>
        // shadow-root(2)
          <div id="thisIsMyBlazorHostElement">
          </div>
      <my-blazor-hosting-component>
  </my-custom-layout-component>
</body>

Even if Blazor had got the reference to thisIsMyBlazorHostElement it would be unable to traverse the DOM and perform any operation over it, and this would be particularly detrimental if it needs listening for the rootComponent removal either through mutation event or observer.

Thanks again

D.

SteveSandersonMS commented 4 months ago

Yes, but I see a further limitation down the line: what if (as per our scenario) the shadowRoot is nested within n levels of shadowRoots of which Blazor does not know of? In that case the parent element's Id would be of little use, correct?

I wasn't proposing to use any element IDs, though I appreciate it was not totally clear from my description!

As long as Blazor is already able to render the component at all, then it must have a way of identifying its exact location in the DOM based on its component ID (which is a Blazor-specific number, not an id attribute in the DOM). I'm just proposing to include the component ID (i.e., the numerical ID) in the ElementReference payload so the same mechanism can be used to locate the host component, which immediately takes us into whatever shadow root it lives in, however many levels deep that is.

DIem137 commented 3 months ago

Thank you Steve, your proposal is now clear to me. It looks to me that this would also allow to clean DotNet references created for the host element when passed in the Blazor.rootComponents.add method (upon implementing an IDisposeAsync method), am I on the right track? Do you envision an extension of the Blazor.rootComponents interface to allow imperative removal of these references from JS?

Blazor.rootComponents.add(hostElement1, 'MyBlazorComponent');
Blazor.rootComponents.remove(hostElement1);

// or
const hostInstance = await Blazor.rootComponents.add(hostElement1, 'MyBlazorComponent');
Blazor.rootComponents.remove(hostInstance);
SteveSandersonMS commented 3 months ago

If I'm understanding the question correctly, I think we already do support that:

const instance = Blazor.rootComponents.add(hostElement1, 'MyBlazorComponent');
...
instance.dispose(); // Removes it