Search code examples
javascriptweb-componentshadow-domlitconstructable-stylesheet

Styles not preserved when moving Lit element to new document


The Problem


So I have a Lit element I am trying to move to a new document. I can get it to move and all of the functionality is still in place, as well as the communicated between it and the original document. However it is missing all of its styles.

For instance if I have a simple Lit element as such:

  @customElement("my-el")
  export class customEl extends LitElement {

     popout () {
         const newWindow = window.open('', '_blank', 'title=Search Controls,height=1447,width=405,top=0,…o,status=no,menubar=no,scrollbars=no,resizable=no');
         newWindow.document.body.appendChild( newWindow.document.adoptNode( this ) );
     }

     static styles = css`
       .custom-el {
          background-color: #666;
          color: #fff;
        }

        .custom-el .custom-el_text {

           padding: 15px;
           border: 1px solid red;
           font-family: "Comic Sans", "Arial", "Helvetica", sans-serif;
           font-size: 44px;
           font-weight: 900;
        }
    `;

    render() {

        return html`
          <div class="custom-el">
            <p class="custom-el_text">Hello World</p>
          </div>
       `;
    }
}

If the popout method is called, the element is moved to the new window with everything intact except the styles.

What I've Tried


Digging into the Lit NPM package I tried tracking down where it was setting the adoptedStyleSheets property. I didn't find it on window, document, renderRoot, or shadowRoot. I was hoping that I could somehow migrate the adopted stylesheet from one document to another.

Using importNode instead of adoptNode. This results in re-setting the state of the element, which is highly undesirable for my use case, but also gives the error Failed to set the 'adoptedStyleSheets' property on 'ShadowRoot': Sharing constructed stylesheets in multiple documents is not allowed. I figure this is probably the root of why the styles aren't moved over, but I'm not sure what to do about it.

What I Need


Need a way to move element from one document to another while preserving state, functionality, and styles. State and functionality are covered with the current method, but styles are not preserved. Looking for a way to maintain all three while opening the element in a new window (popup).

Working Example


You can find a working example of this problem here: https://codepen.io/Arrbjorn/pen/bGaPMBa


Solution

  • The Source of the Problem


    So the issue here is that the spec for constructed stylesheets does not support sharing constructed stylesheets across documents, hence the error Failed to set the 'adoptedStyleSheets' property on 'ShadowRoot': Sharing constructed stylesheets in multiple documents is not allowed.

    For more information on why this decision was made you can read the original discussion here: https://github.com/WICG/construct-stylesheets/issues/23

    The Solution


    There is, however, a workaround for web components that need to be opened in a new document, whether it is an iframe, a new window, or anything else. We can address this desired behavior in the adoptedCallback function for our web component. Here is an example of what that might look like:

    import { CSSResultGroup, supportsAdoptingStyleSheets } from "lit";
    
    adoptedCallback(){
    
        // If the browser supports adopting stylesheets
        if (supportsAdoptingStyleSheets) {
    
            // If the styles is an array of CSSResultGroup Objects
            // This happens when styles is passed an array i.e. => static styles = [css`${styles1}`, css`${styles2}`] in the component
            if ( ((this.constructor as typeof LitElement).styles as CSSResultGroup[]).length ) {
    
                // Define the sheets array by mapping the array of CSSResultGroup objects
                const sheets = ((this.constructor as typeof LitElement).styles as CSSResultGroup[]).map( s => {
    
                    // Create a new stylesheet in the context of the owner document's window
                    // We have to cast defaultView as any due to typescript definition not allowing us to call CSSStyleSheet in this conext
                    // We have to cast CSSStyleSheet as <any> due to typescript definition not containing replaceSync for CSSStyleSheet
                    const sheet = (new (this.ownerDocument.defaultView as any).CSSStyleSheet() as any);
    
                    // Update the new sheet with the old styles
                    sheet.replaceSync(s);
    
                    // Return the sheet
                    return sheet;
                });
    
                // Set adoptedStyleSheets with the new styles (must be an array)
                (this.shadowRoot as any).adoptedStyleSheets = sheets;
                
            } else {
    
                // Create a new stylesheet in the context of the owner document's window
                // We have to cast defaultView as any due to typescript definition not allowing us to call CSSStyleSheet in this conext
                // We have to cast CSSStyleSheet as <any> due to typescript definition not containing replaceSync for CSSStyleSheet
                const sheet = (new (this.ownerDocument.defaultView as any).CSSStyleSheet() as any);
        
                // Update the new sheet with the old styles
                sheet.replaceSync( (this.constructor as typeof LitElement).styles );
        
                // Set adoptedStyleSheets with the new styles (must be an array)
                (this.shadowRoot as any).adoptedStyleSheets = [ sheet ];
            }
        }
    }
    

    What is this doing?

    This block of code references the original styles of your custom element and creates a new CSSStyleSheet (or CssStyleSheet[]) in the context of the new document and applies them to the element. This way we aren't sharing the original styles to a new document, we are using a new stylesheet created BY the new document.

    Optimizing


    If, like me, you are using this in many components that may be moved to another document you can abstract this out into a utility function that can be imported into any component and called during adoptedCallback.

    The Function

    import { CSSResultGroup, supportsAdoptingStyleSheets } from "lit";
    
    // This function migrates styles from a custom element's constructe stylesheet to a new document.
    export function adoptStyles ( shadowRoot: ShadowRoot, styles: CSSResultGroup | CSSResultGroup[], defaultView: Window) {
    
        // If the browser supports adopting stylesheets
        if (supportsAdoptingStyleSheets) {
    
            // If the styles is an array of CSSResultGroup Objects
            // This happens when styles is passed an array i.e. => static styles = [css`${styles1}`, css`${styles2}`] in the component
            if ( (styles as CSSResultGroup[]).length ) {
    
                // Define the sheets array by mapping the array of CSSResultGroup objects
                const sheets = (styles as CSSResultGroup[]).map( s => {
    
                    // Create a new stylesheet in the context of the owner document's window
                    // We have to cast defaultView as any due to typescript definition not allowing us to call CSSStyleSheet in this conext
                    // We have to cast CSSStyleSheet as <any> due to typescript definition not containing replaceSync for CSSStyleSheet
                    const sheet = (new (defaultView as any).CSSStyleSheet() as any);
    
                    // Update the new sheet with the old styles
                    sheet.replaceSync(s);
    
                    // Return the sheet
                    return sheet;
                });
    
                // Set adoptedStyleSheets with the new styles (must be an array)
                (shadowRoot as any).adoptedStyleSheets = sheets;
                
            } else {
    
                // Create a new stylesheet in the context of the owner document's window
                // We have to cast defaultView as any due to typescript definition not allowing us to call CSSStyleSheet in this conext
                // We have to cast CSSStyleSheet as <any> due to typescript definition not containing replaceSync for CSSStyleSheet
                const sheet = (new (defaultView as any).CSSStyleSheet() as any);
        
                // Update the new sheet with the old styles
                sheet.replaceSync(styles);
        
                // Set adoptedStyleSheets with the new styles (must be an array)
                (shadowRoot as any).adoptedStyleSheets = [ sheet ];
            }
        }
    }
    

    Calling the function from the component

    adoptedCallback() {
    
        // Adopt the old styles into the new document
        adoptStyles( this.shadowRoot!, (this.constructor as typeof LitElement).styles!, this.ownerDocument.defaultView! )
    }