Skip to content

[Declarative Shadow DOM Style Sharing] Suboptimal streaming shadowrootadoptedstylesheets #1188

@dgp1130

Description

@dgp1130

I'm not convinced shadowrootadoptedstylesheets really meets the needs of streaming use cases. Often when rendering a component, you don't know what stylesheets you need until you've actually rendered the content which depends on it.

async function renderPage(): AsyncGenerator<string, void, void> {
  yield renderUserStyles();

  yield `<div>`;

  const user = await getUser(); // Deoptimization, blocking on this sooner than I should!

  yield '  <template shadowrootmode="open" shadowrootadoptedstylesheets="${user ? 'user' : ''}">';
  yield '    <div>Hello!</div>';
  if (user) yield renderUser(user);
  yield '  </template>';

  yield '`</div>';
}

function renderUser(user: User): string {
  return `
    <div class="user">Hello, ${user.name}!</div>
  `;
}

function renderUserStyles(): string {
  return `
    <style type="module" specifier="user">
      .user { color: red; }
    </style>
  `;
}

In this example, we shouldn't need know whether or not we're going to render the user template until we call renderUser, therefore I should be able to render Hello! immediately without blocking on await getUser().

However I can't because shadowrootadoptedstylesheet also depends on the result of getUser and comes before Hello! is rendered. This means I need to go out of my way to manually decide whether or not to include user in that attribute. This

  1. duplicates the logic of "should I include the user snippet" in two places
  2. requires advance knowledge of whether or not the user snippet will be included.

In simple cases, this isn't too bad or can be solved with trivial refactoring, however in more complicated scenarios and especially frameworks where the code rendering the <template> tag may be many layers of abstraction removed from the HTML it ultimately renders, we may genuinely not know what HTML is going to be sent or what stylesheets it will require. This may result in tooling being forced to hold up sending <template> and some internal content until it knows all the components it will render, which could delay content or outright break streaming entirely (if we need to wait for the whole template to render to know what stylesheets are needed).

As an alternative straw proposal for demonstrative purposes, we can allow declaring a dependency between a shadow root and a stylesheet anywhere inside it, not just its attributes. Consider a <style type="module" adoptedspecifier="${name}"> as a way to say "adopt the stylesheet with this specifier in the location this <style> is rendered". It might look like:

<!-- Declare a reusable stylesheet. -->
<style type="module" specifier="user">
  .user { color: red; }
</style>

<div>
  <template shadowrootmode="open">
    <div class="user">Hello, Devel!</div>

    <!-- Reference the stylesheet within the shadow DOM. -->
    <style type="module" adoptedspecifier="user"></style>
  </template>
</div>

Now we no longer need advance knowledge of what will be rendered within a shadow root in order to emit its <template> tag. We can just render adoptedspecifier whenever we discover a stylesheet dependency. Rewriting the original example:

async function renderPage(): AsyncGenerator<string, void, void> {
  yield renderUserStyles();

  yield `<div>`;

  yield '  <template shadowrootmode="open">';
  yield '    <div>Hello!</div>';

  const user = await getUser(); // Optimization, no longer blocking 'Hello!'
  if (user) yield renderUser(user);

  yield '  </template>';

  yield '`</div>';
}

function renderUser(user: User): string {
  return `
    <div class="user">Hello, ${user.name}!</div>

    <!-- Rendering a user component, just adopt its stylesheet! -->
    <style type="module" adoptedspecifier="user">
  `;
}

function renderUserStyles(): string {
  return `
    <style type="module" specifier="user">
      .user { color: red; }
    </style>
  `;
}

This example always emits the stylesheet but only conditionally emits the adoptedspecifier tag. We could further optimize this by only emitting the stylesheet the first time renderUser is called and then emitting the adoptedspecifier tag on all subsequent renders. That optimization can be done using the knowledge this system trivially has (tracking <template> and </template> emits and usage of stylesheets) and does not require any advanced foreknowledge of what components will be rendered by what shadow roots.

Of course, syntax is up to bikeshedding, I have no idea if <style adoptedspecifier="..."> is reasonable in the slightest and I'm sure there's better ways to design this. My point here is that we should get the mechanism for adopting stylesheets out of the <template> start tag and into its contents where we will actually be rendering the content which relies upon those styles.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions