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

Server-Side Rendering (SSR) API #7

Open
georges-gomes opened this issue Oct 29, 2020 · 38 comments
Open

Server-Side Rendering (SSR) API #7

georges-gomes opened this issue Oct 29, 2020 · 38 comments

Comments

@georges-gomes
Copy link

georges-gomes commented Oct 29, 2020

Context

Web Components have hydration or upgrade capabilities built-in.

Basic example:

<my-comp>
Loading...
</my-comp>

Until <my-comp> is registered (via Javascript), the browser will show Loading....

The recent advancements in Declarative Shadow DOM
whatwg/dom#831 give us the possibility to even pre-render shadow roots with encapsulated styles.

<my-comp>
  <template shadowroot="open">
    <style>
       .myshadowclass {
          ...
       }
    </style>
    <div class="myshadowclass">
        ...
    </div>
  </template>
</my-comp>

And this would be progressively enhanced by the custom element code.

Motivation

Ideally you want to have a single source code for your component that will management the pre-rending and the progressive enhancement.

This has been managed recently by "meta-frameworks" like Next.js, Nuxt or Sapper but these are very linked too the underlying technology, React, Vue and Svelte respectively.

There is an opportunity with Web Components to decouple the framework/lib used to build the components and the "meta-framework" used to orchestrate the pre-rendering and hydration.

In other words, you could have "meta-frameworks" (or 11ty plugins for that matter) that could be able to pre-render static forms of any Web Components no matter what the framework/lib was used to build them (LitElement, Stencil, or any of the 40+ others).

I think it makes sense to deal with this in user land.

Proposal

This is a proposal to get the ball rolling and discuss, in no way I think this is the perfect solution.

Define a method that SSR capable Custom Element would implement:

interface {
   render_ssr() : string
}

Frameworks/lib can automatically implement this so users only need to write a single render()-like method for both SSR and client side. It can provide a ssr flag for conditional rendering but this is up to the framework/lib to decide on the implementation.

"meta-frameworks" in charge of generating the static content would:

  • load page (including javascript)
  • discover custom-elements
  • instantiate custom-element classes
  • inject attrib and properties
  • call render_ssr()
  • stick it into place

Example

source index.html

<html>
...
Foo bar foo
<my-comp attr="true">
</my-comp>
...
</html>

index.html after generation by "meta-framework" by calling render_ssr() method.

<html>
...
Foo bar foo
<my-comp attr="true">
  <template shadowroot="open">
    <style>
       .myshadowclass {
          ...
       }
    </style>
    <div class="myshadowclass">
        ...
    </div>
  </template>
</my-comp>
...
</html>

Questions

How to push properties statically?

Can we use proven dot notation? Already used by many Web Component libs today.

<my-comp attr="true" .prop1="{ key: value }">
 ...
</my-comp>

Anything in .prop1 value would be evaluated and the result would go to property prop1.

Do we need a additional constructor constructor(for_ssr: boolean)?

To give full context to Web Component right at the beginning of the instance.
It would be safer I guess to deal with side-effects related to Attributes/Properties setters.

My 2 cents

@bahrus
Copy link

bahrus commented Nov 28, 2020

An alternative to

interface {
   render_ssr() : string
}

might be:

interface {
   render_ssr(blocks: string[], pipe: tbd);
}

Instead of returning a string, web components could either add their string output to blocks, or optionally "pipe" to the output stream, built around something like string-array, as demonstrated with flora.

@georges-gomes
Copy link
Author

@bahrus Yes, I agree, streaming the result will be more efficient.

@jaredcwhite
Copy link

This sounds very cool. I would like to add that it's important to be able to easily/quickly pass an HTML file in through a simple CLI tool and get all relevant SSR'd markup back…as someone who maintains a non-JS-based site generator (Bridgetown, built in Ruby), I'd need to spawn a separate Node process and hopefully the API to do so would be pretty easy to configure.

@kevinpschaaf
Copy link
Collaborator

kevinpschaaf commented Apr 22, 2021

On the Lit team, we've built out an SSR implementation using Declarative Shadow DOM (https://www.npmjs.com/package/@lit-labs/ssr) that builds on the ideas here for defining a protocol for interoperable server-rendering of web components.

While similar in spirit to the API's proposed above, we've explored some variations:

ElementRenderer interface

We've factored the logic for server-rendering a given web component (or web component base class) into a separate object with an abstract interface, rather than being defined as part of the CE class directly (the current implementation defines this as an abstract base class, but the proposal is just for its interface really).

  • Decoupling the server logic from the CE class avoids sending server-only code to client
  • Allows for rendering a CE without a full DOM environment on server (but does not preclude it either)
  • The ElementRenderer interface breaks the rendering of the component into a couple of operations: renderAttributes and renderShadow. This is because in the following example, rendering the items in red are the responsibility of the outer context rendering the element, while the items in blue are the responsibility of the custom element (and these are two distinct chunks of string to be emitted):
    image
  • We've defined the renderAttributes and renderShadowAPIs to return string iterables as a generator rather than a string, to enable incremental rendering. Again, this doesn't preclude synchronous rendering either.

defer-hydration attribute

Separate from the rendering interface, but related to the point about "How to push properties":

  • Because an element should (generally) not "hydrate" (aka claim the server rendered DOM and start reacting/updating it in responses to changes) until it has received the instance-specific configuration that was used on the server (which may include JS properties, in addition to attributes), we've explored emitting a defer-hydration attribute on a custom element that may have been configured with non-serializable properties during SSR, indicating it should not become interactive even after registration, until the defer-hydration attribute is removed. We're thinking this could become another "community protocol" that other WC libs implement.
  • It then becomes a concern for the outer scope of the element (the actor providing the configuration) to decide when to remove the defer-hydration attribute; for example after assigning any properties the element should have.
  • In particular, given the nature of how custom element imports are naturally ordered (a host generally imports definitions for the children it will use before then registering itself), "upgrade" of server-rendered elements tend to occur bottom-up, at least with the global CE registry today. This can lead to n^2 inefficient work up the tree, where the children initialize (and possibly update their DOM) to match their default state before receiving configuration overrides from the parent. The defer-hydration attribute helps solves this, such that while hosts may register children first, they won't become fully interactive until the host has itself upgraded, been given a chance to pass configuration/data to its children, and then removed the defer-hydration attribute.
  • This is related but somewhat orthogonal to the .prop1="{ key: value }" solution proposed above; I view deserializing JSON from an attribute into a property as a feature a given web component may choose to implement (and automatically doing the serialization during the SSR is a good example of what a class-specific ElementRenderer's renderAttributes might choose to do). But because serializing objects on each CE as JSON comes with downsides (redundancy when the same object reference is bound to multiple elements, breaking object references, etc.), it doesn't feel like a general solution to handling properties.

I plan to write both of these (ElementRenderer and defer-hydration) up as formal community protocol proposals, but wanted to get a few thoughts here first to see whether the approaches make sense.

@bahrus
Copy link

bahrus commented Apr 24, 2021

Maybe what I'm suggesting below is already covered by the interface and/or defer-hydration, but just wanted to make sure.

I'm thinking it would be nice to have a standard approach to allow the server to say, colloquially:

"I've rendered your shadow children for you already, based on some initial state (public and private props). Now that defer-hydration is (about to be?) lifted, here is a hydrating object that holds all the relevant property values / state that is consistent with what I rendered. You can thus circumvent your usual reactive rendering while merging in these initial property values. I.e., you will probably want to quietly set your current "state" based on what I'm passing you, via some kind of Object.assign() algorithm, while your (visual) reactive system is temporarily suspended. Then reenable your reactive rendering system, and respond as you normally would to subsequent prop changes."

Maybe having a standard property (or method?) for this purpose, that can be used consistently across all libraries, e.g. "serverHydrationProps" which can only be used once (during hydration). Could that help make SSR easier and faster?

@justinfagnani
Copy link
Member

@bahrus this is already possible and implemented in LitElement. When defer-hydration is removed, that provokes attributeChangedCallback which in LitElement causes the component to call hydrate() to prime the state instead of render().

i think how components and libraries actually implement this is going t be very, very specific to them, so as long as we have a signal to tell component to hydrate, we should be good.

This of course leaves open the question of whether or how to provide the initial rendered data to top-level components, but I think that's a separate issue, probably best solved by app-level orchestrators that might be the ones controlling the removal of the defer-hydration attribute.

@kevinpschaaf
Copy link
Collaborator

@bahrus

Maybe what I'm suggesting below is already covered by the interface and/or defer-hydration

Yes, the idea is that rather than provide initial state via something like serverHydrationProps, initial state should be provided by the normal interface to the element (properties & attributes), and then removing defer-hydration is the signal to the web component that has everything it needs to hydrate. In Lit, we use a combination of the defer-hydration attribute and having a this.shadowRoot at connectedCallback time to decide if and when to "hydrate" vs. create initial DOM anew.

@matthewp
Copy link
Contributor

matthewp commented Jun 28, 2021

Is ElementRenderer necessary? What is the use-case for needing this? The one that I can think of is to integrate with a scheduler. Is that the reason? Would be nice to clarify this point.

I see this as possibly an extra layer that libraries are going to need to implement and plain HTMLElements likely won't. It would be nice if we could keep the requirements as small as possible. Is there any reason why it can't be as simple as something like this?

const el = new MyElement();
el.connectedCallback();
await el[Symbol.for('wc.renderComplete')];

// It's done, get the HTML that you want.
const html = `<${el.localName}><template shadowroot="open">${el.shadowRoot.innerHTML}</template>${e.innerHTML}</${el.localName}>`;

This keeps the API to 1 property to implement (a symbol) and nothing else. The rest is normal custom element code.

If there are other reasons for ElementRenderer other than the scheduler I'd love to talk about those.


Note that this is a POC idea. The important part is not that this is a symbol or something else or if it's async or sync, just that there's a a single API, preferably on the element itself, the rest we can discuss later.

@justinfagnani
Copy link
Member

The main reasons are streaming and emulating less DOM.

We don't want to require that the entire element is rendered before sending bytes to the client, and we want to be able to await I/O on the server but still send bytes up until the await point.

Then we also want to allow renderers to be able to bypass the normal prop and attribute setting and rendering code if neccessary to avoid non-emulated DOM calls. The DOM shim we have in the Lit SSR package emulates very little of the DOM, so the lit-html renderer writes out strings directly rather than building any DOM tree.

@matthewp
Copy link
Contributor

I don't think you need a separate interface to enable streaming. That can be part of the idea I provided.

Since the ElementRenderer interface is already very close to HTMLElement, I don't see what advantage there is to making it separate. The same code could be in your HTMLElement shim instead. That shim would be valuable as an open-source package itself.

Since HTMLElement is a common interface, any custom element would already be following it. A SSR library could even assume that if the symbol doesn't exist, that rendering was done synchronously in connectedCallback (which is common for 0-dep custom elements). So for a good chunk of custom elements, no extra code would be required to get SSR support.

@kevinpschaaf
Copy link
Collaborator

innerHTML returns a string. The proposed ElementRenderer:renderShadow would return a string iterable (generator). That's what enables granular streaming without buffering the entire contents of the tree before emitting the first byte. Once you have that difference, sending any of the streaming string serialization code to the client doesn't make sense since that API would never be used on the client, hence defining it as a separate interface.

And note that implementing the ElementRenderer interface by emulating the full DOM and yielding the full innerHTML is still possible if that's what a given implementation wants to do (at the cost of giving up streaming); however, it's not possible to implement incremental streaming the other way around using innerHTML.

@matthewp
Copy link
Contributor

Let me provide another example since my previous one is creating confusion. The key point I'm making is not about what the exact shape of the API should be, but that it's provided on the element rather a separate object that I'm concerned few people are going to implement.

const el = new MyElement();
el.connectedCallback();

let gen = el[Symbol.for('well-known-ssr-symbol-who-cares-what-its-called')]();
for(let chunk of gen) {
  res.write(chunk);
}

Now we have streaming support.

@justinfagnani
Copy link
Member

justinfagnani commented Jun 28, 2021

I don't see a big difference between a separate object and the well-known symbol. Does it matter much whether the interface is overlaid on top of HTMLElement or is in a separate object? Elements would have to implement it either way.

And note that we don't want this interface to be on elements by default, since it's bloat to client-side code. ElementRenderers should never be near the client bundlers, nor need to be compiled or tree-shaken out.

@matthewp
Copy link
Contributor

I see two big differences:

  1. With the ElementRenderer interface you have to store a collection of renderers some where. Since an element always is owned by renderer why not just have the element (or its library) do the registration for you.
  2. The ElementRenderer interface is large. I'm assuming there's a need within lit-html to break these apart but I think this is an implementation detail of a rendering library and not something every element should need to worry about. All that is needed is bytes of HTML. The element should be in control of how those are stacked.

@kevinpschaaf
Copy link
Collaborator

@matthewp This is good feedback. I think you raise good tradeoffs that comes with design alternatives that we should talk through.

The interface we've proposed is based on putting a high value on (a) not requiring a full DOM environment on the server, and (b) not shipping server-only serialization code to the client. Are these things you're dismissing, or do you have other thoughts on how to mitigate them?

For example, how would a CE author prevent the implementation of el[Symbol.for('well-known-ssr-symbol')] from being sent to the browser where it would never be used? It seems like some bespoke build tooling to deal with that is required, and and that point is that better or worse than providing the SSR code in a parallel module that's only loaded on the server? (🤔 maybe export conditions could be used to select a version with the server API only when running on the server?)

Also, in your example you're showing running the client-side connectedCallback()... running the actual user connectedCallback on an element instance might need to assume a pretty large amount of the DOM is shimmed for whatever user code was put in there... at the limit it requires a full DOM. How would you address that concern with your proposal? The ElementRenderer interface provides an indirection such that the CE base class designer can choose how much or little of the DOM is required on the server.

@matthewp
Copy link
Contributor

@kevinpschaaf Thanks for breaking out the requirements like that. I agree with placing a high value on (a) and (b). Let me respond with why I think my approach works just as well as the interface:

For example, how would a CE author prevent the implementation of el[Symbol.for('well-known-ssr-symbol')] from being sent to the browser where it would never be used?

The same way the interface is not sent to the client, you would only load the code for the SSR symbol on the server. Currently Lit has you load a separate render function for the server. This function could install the symbol:

server-render.js

import { LitElement } from 'lit';

LitElement.prototype[Symbol.for('well-known-ssr-symbol')] = function() {
 // Use the interface or whatever
};

Lit uses a similar approach to installing support for its client hydration here.

In short, asking the user to load different code on the server is the solution to not bundling extra code on the client.

Also as you mentioned, export map conditionals is a good approach to this as well. I would already recommend that Lit consider using that to prevent the user from needing to apply that global DOM shim as a superdependency (or to load their code in a vm context).


Also, in your example you're showing running the client-side connectedCallback()... running the actual user connectedCallback on an element instance might need to assume a pretty large amount of the DOM is shimmed for whatever user code was put in there... at the limit it requires a full DOM. How would you address that concern with your proposal?

That pseudo-code was meant to demonstrate how a template engine might decide to render an element that doesn't have the well-known symbol. The engine might also just not support an element that omits the symbol. Either could be reasonable.

If the symbol exists then the symbol implementation would decide what element code is called. It might call connectedCallback or not, that depends on the assumptions of the library that is implementing ssr support.

I think you're getting at an assumption here that is peppered in this proposal, and I'm not sure it's accurate. It sounds like there is a concern with running element code, but you are already running element code. Lit's SSR renderer is calling an element's render function (here). This is just as likely to call a browser API as connectedCallback.

A user might call getAttribute in render(). They might call document.querySelector. The library is going to have to make the decision on what APIs they support based on what they expect a user might do (and what they feel is reasonable). At a certain point an element that wants to be isomorphic has to be written as such, with intent.

@kevinpschaaf
Copy link
Collaborator

Cool, agreeing on the goals is the hard part. The rest is just code. 😀

At a certain point an element that wants to be isomorphic has to be written as such, with intent.

I think this homes in on an important point re: isomorphism. It's one thing for a CE base class like Lit to say to its users ,"in order to make your code SSR compatible, make sure these methods are isomorphic" (or don't rely on the DOM, or whatever that may mean). But if the only way the SSR code has to interact with an element "instance" is though the actual CE interface (newing a CE instance, setting properties, attributes, running connectedCallback, etc.), then it effectively means all of the side-effects triggered from the standard CE interface must be runnable on the server. We found we didn't want to provide shims for those side-effects (i.e. creating DOM) to work on the server, since we don't actually want to create DOM on the server (we want strings, which in Lit we can get a lot more efficiently than by creating DOM and then re-serializing it back out).

Ultimately, on the client, setting properties & attributes and running callbacks should cause DOM to get created & updated; on the server setting properties & attributes and running callbacks should cause strings to be generated. Using the single client-side entrypoints (constructor, attributes, properties, callbacks) for both server and client either requires running the full client-side code on the server, or else having a branch in the implementation that's somehow selected on the server. ElementRenderer is proposed to be that "branch", just factored out of the element; it has a similar interface for a CE (e.g. setProperty, setAttribute, connectedCallback, etc.) but and avoids requiring the same code that runs on the client be responsible for creating the strings on the server.

I toyed around with an ElementRenderer implementation for PolymerElement that actually uses jsdom to let Polymer do all of its normal DOM work (which is all synchronous to its callbacks, btw) and serialize it out; that may be a reasonable choice for some libraries, but it's WAY less performant for a library that can be designed to avoid that; the idea here is that the abstraction/indirection gives a choice; it can facade directly through to a fully emulated DOM element, or provide a more optimized implementation that avoids such costly work.

I think if we're willing to assume more about how a given CE base class might implement itself, we could propose a more limited protocol, it just comes with the risk of assuming too much. But for sake of argument, we could provide a standard el.isServerRendering = true flag on the CE which is set on the server and used to branch / short circuit any side effects (like creating DOM) that shouldn't run on the server as a result of e.g. setting properties/attributes or running callbacks. That would also assume the user would guard any client-only code using that flag in user callback code.

This is maybe the best defense for ElementRenderer -- it assumes nothing, and lets the implementor make all the choices.

@justinfagnani
Copy link
Member

it assumes nothing

It's important to note how far this can go. Because an ElementRenderer is what's instantiated, and whether an actual HTMLElement instance is made is up to the renderer, even elements that call unsupported DOM APIs in constructors could be server-rendered with an independent ElementRenderer implementation.

Ultimately I don't see much of a difference between delegating rendering via a prototype property that should only be added on the server and via an external object that's only loaded on the server. Either way there's an interface that is in addition to the basic DOM APIs that's called only on the server. The APIs can be on the prototype or an object looked up by the prototype.

This difference in actual code is roughly:

LitElement.prototype[Symbol.for('well-known-ssr-symbol')] = function() {
 // Use the interface or whatever
};

vs:

renderers.register(LitElement.prototype, function() {
 // Use the interface or whatever
};

^ assuming lots about the renderer registry API, of course.

@matthewp
Copy link
Contributor

Let's put aside where the functionality lives at the moment - on the element or in some external registry, and focus on what is required to render an element. The other part of my proposal was that it was a single function/API that is needed to be called.

If I have an arbitrary element and I want to render it HTML, how do I do that with the ElementRenderer proposal? The only example I've seen of using an ElementRender is here: https://github.com/lit/lit/blob/main/packages/labs/ssr/src/lib/render-lit-html.ts

This is 786 lines of code. I don't think we're going to achieve the goal of an agnostic API if the user is required to write 786 lines of code to render an element. There will be Lit renderers that only render Lit elements like this one does, and FAST renderers that only render FAST elements, etc.

Is there a need for the user to control how HTML is generated for an element? Isn't that the purview of the element? A user of an element in the browser doesn't poke around an element's internal implementation so I'm not sure why it should do so here.

@justinfagnani
Copy link
Member

If I have an arbitrary element and I want to render it HTML, how do I do that with the ElementRenderer proposal?

Depends on the element. If you use a base class that has vended an ElementRenderer implementation you use it. If not you have some choices: 1) Most realistically: don't SSR that element. 2) Use some generic (and slow) ElementRenderer that reads from the element's shadow root's innerHTML. 3) implement a one-off renderer.

I say it's most realistic that you won't SSR the element because if the element doesn't vend some way of SSR'ing it, it most likely won't work correctly with declarative shadow DOM and hydration anyway, so what's the point of SSR'ing it?

This is 786 lines of code.

That code is the entire server-side lit-html implementation - the equivalent of React's renderToStream. It'll be different for other template systems, but yes, every template system may have to have its own renderer implementation. I think that's to be expected.

I wouldn't consider this poking around the element internals. This renderer is implementing a contract with the element very much in a similar way that the LitElement base class does. render() is called by the base class and it's result interpreted a certain way on the client. render() is called by the ElementRenderer and interpreted a certain way on the server.

I don't think we're going to achieve the goal of an agnostic API if the user is required to write 786 lines of code to render an element.

The point is to define an interface and there will be different implementations of that interface. Is 768 lines too much or too little for that? I don't know. I don't know how big renderToStream and other framework's SSR code is or whether we expect web component libraries SSR code to be bigger or smaller, but it will definitely be something.

I'm struggling to understand what the critique here is. There has to be SSR code. What's the alternative that eliminates that without cooperation from the element implementations?

@matthewp
Copy link
Contributor

The critique is that you shouldn't have to write a templating library to render an element. You should just be able to render the element. The same way that I don't need to write a templating library to render an element in the DOM. I just append it.

I think this proposal is starting from the perspective of you want to render a template and not the perspective of I want to render an element which I already have.

import MyElement from './my-element.js';

// How do I render 👆

For some perspective on where I'm coming from, here's where I'm using this proposal today. I have to compile a fake template literal array so I can feed it into lit's element renderer implementation.

Since I already have the element constructor, I would like to just render it directly. Without writing as much code as is contained within lit's implementation. Preferable 1 function call, because I only care about bytes and don't feel like I should be the one telling the element how to correctly serialize itself.

@justinfagnani
Copy link
Member

justinfagnani commented Jun 30, 2021

You shouldn't have to make a fake Lit template. The idea of the ElementRenderer interface is that it abstracts away what or whether the element is using a template library. You won't need the code in Lit's SSR implementation if you're not rendering a lit template, but it's basically a tautology that if you want to render a template you'll need template rendering code.

The lit-ssr code has both an ElementRenderer implementation and another entrypoint which can render lit-html templates because they recursively use each other. If you're starting from an element, you'll use the ElementRenderer, which will lead into a LitElementRenderer concrete impl, which will call into a lit-html template renderer, which will look up ElementRenderers when it renders custom elements.... This seems reasonable because sometimes you may need to render an element and sometimes you may need to render a template.

If what you're starting with is just an element class (you'll also need a tag name), using the API should look something like this:

import MyElement from './my-element.js';

// How do I render 👆

function* renderMyElement() {
  const renderer = renderers.get(MyElement);
  const instance = new renderer();
  yield `<my-element `;
  yield* instance.renderAttributes();
  const shadowContents = instance.renderShadow(renderInfo);
  if (shadowContents !== undefined) {
    yield '<template shadowroot="open">';
    yield* shadowContents;
    yield '</template>';
  }
  yield `</my-element>`;
}

@matthewp
Copy link
Contributor

@justinfagnani can you add the code for creating the renderers object? I assume this is a Map of constructors to ElementRenderers? What is calling renderers.set(MyElement, renderer)? Presumably my-element.js does that? Does it need to export a function so I can pass in my map? Some clarity around that would help the code example so we can discuss it's merits.

Also it looks like you're passing renderInfo which is not referenced anywhere else, is that a mistake?

Also the element's light DOM is not being rendered. Was that a mistake or intentional?

@justinfagnani
Copy link
Member

can you add the code for creating the renderers object?

We don't have the renderer registry very well defined (IMO) yet. We have a sketch of a list with a static matches(ctor) method we use for lookup, but it's not clear that's sufficient. Whatever the method, we need a way to get from a tag name or constructor to renderer.

One of the simplest but still flexible approaches is to look in a map iteratively based on the prototypes in the prototype chain. So basically a Map<Function, ElementRenderer> where you do:

let p = ctor.prototype;
do {
  if (renderer.has(p)) { return renderer.get(p); }
} while (p = Object.getPrototypeOf(p) !== HTMLElement)

What is calling renderers.set(MyElement, renderer)

I think you'll likely need to import SSR support libraries for the elements you're using. There can be support libraries for base classes or individual components, ie:

// register a renderer for all LitElements
renderers.set(LitElement.prototype, LitElementRenderer);

// register a renderer for MyElement specifically
renderers.set(MyElement.prototype, MyElementElementRenderer);

it looks like you're passing renderInfo which is not referenced anywhere else, is that a mistake?

renderInfo is an object that contains the open element stack. That's a detail that we have in lit-ssr that I don't think is quite fleshed out enough for an open protocol proposal yet. The open element stack is needed to detect top-level components, emulate events, and carries the renderers map. I could have removed it for simplicity, but left it as an indicator that there's some complexity TBD here...

the element's light DOM is not being rendered. Was that a mistake or intentional?

Intentional. The light DOM of an element is not rendered by the element but by its context - another element or the document. Same with non-reflected attributes.

If you know the attributes and light DOM you want to render, you could do it like this:

function* renderMyElement(attrs: Map<string, string>, contents: string) {
  const renderer = renderers.get(MyElement);
  const instance = new renderer();
  yield `<my-element `;
  yield [...attrs.entries()].map(([k,v]) => `${k}="${v}"`).join(' ');
  yield* instance.renderAttributes();
  const shadowContents = instance.renderShadow(renderInfo);
  if (shadowContents !== undefined) {
    yield '<template shadowroot="open">';
    yield* shadowContents;
    yield '</template>';
  }
  yield contents;
  yield `</my-element>`;
}

@matthewp
Copy link
Contributor

matthewp commented Jul 1, 2021

I think we should pick back up this discussion when the renderer registry is ready. I'm still very skeptical as this seems to all mirror the existing custom element APIs, so my thought is we should just use those. I'll withhold judgement until the registry is ready, but I can't imagine a world where this idea doesn't require a lot of library support, including the assumption that everyone is using a common registry library.

We probably need to take a step back and talk about some deeper issue which I think is getting in the way of answering the question on a rendering API, that is how do we figure out how to load an element on the server and the client without loading unnecessary code in either environment. I'll kick off that in another issue.

@matthewp
Copy link
Contributor

matthewp commented Jul 2, 2021

Started #17 to discuss this issue.

@bahrus
Copy link

bahrus commented Jul 8, 2021

I think I've put my finger on some questions/doubts about the solution:

  1. I can sort of see, in a fuzzy way, how the defer-hydration attribute, and everyone standardizing on it, could be helpful in coordinating establishing state consistent with what's on the server, in a bottom-up order, which is found to be optimal. Great, but...
  2. Presumably, along with this convention, must be a meta client-side library, probably something that lit provides(?), which manages this sequence of bottom-up removal of the attribute, just after setting the values obtained from the server, across all web component libraries. I'm guessing that library should be compatible with any web component library employing the proper use of defer-hydration. Also sounds great, but...
  3. I suspect (with almost no confidence) that that client-side library (call it the cs-hydrator library) may require being compatible with a server-side library (call it the ss-serializer library), and lit may provide one such server-side library, but potentially another variation of the cs-hydrator combined with an alternative ss-serializer could be developed for different server configurations. How this would work (with shadow DOM, especially, is murky in my mind). Is there no expectation of each web component implementing it's own bottom-up hydrating? That sounds wonderful... but... Maybe I'm overthinking things: May cs-hydrator simply deeply traverses the DOM tree, finding the lowest level defer-hydration attributes, and then just removes them from the bottom-up, and that's it? No passing in of non-JSON serializable data, for example?
  4. Which is all great. It seems like this is the Rolls-Royce solution. But what about providing an additional alternative "poor man's" solution also. If we could also agree to another reserved attribute name(suggestion: sync-props-from-server). So instead of:
<google-chart data='[["Month", "Days"], ["Jan", 31]]'></google-chart>

The server renders the chart, and provides the state thusly:

<google-chart sync-props-from-server='{"data": [["Month", "Days"], ["Jan", 31]]}'>
<!--- Server rendered content-->
</google-chart>

Then:

  1. A web component doesn't have to depend on assuming some fancy hydrator is present. It could for example just go ahead and set the values in state without reacting/rendering. It could still wait for a defer-hydration attribute to also be removed before attaching event handlers and/or reacting to state changes.
  2. Implementing the kind of bottom-up hydrating which is optimal could now be done, in a relatively straightforward way, by each web component library based on this attribute, with no assumptions about what the server does (other than providing the attribute value)

Apologies if these questions reflect a poor understanding of what is being proposed. I've clearly not studied how it has been implemented yet.

@kevinpschaaf
Copy link
Collaborator

Presumably, along with this convention, must be a meta client-side library, probably something that lit provides(?), which manages this sequence of bottom-up removal of the attribute, just after setting the values obtained from the server, across all web component libraries.

No, the desire in all of this is to define protocols for both server-rendering and client-hydration that enable SSR of a tree of arbitrarily nested custom elements built with heterogeneous custom element base classes -- without assuming an all-knowing coordinator either on the client or server. I think this is perhaps a topic that's been missed in this discussion so far.

Whether on the client or the server, a given custom element is created/managed by a "scope"; if a custom element is contained in another custom element's shadow root, that host custom element is the scope responsible for creating/managing any child elements within it and providing their properties/attributes. It's this "scope" of ownership that would be responsible for both adding the defer-hydration attribute (at server rendering time, via the ElementRenderer) and removing it (on the client, via the associated client-side libraries during hydration), since that ownership scope is what "knows" whether the child custom element needs to wait for non-serialized properties to be provided to it.

This is exactly how the ElementRenderer and defer-hydration proposals work together to avoid the assumption for needing an all-knowing library to manage the page, either on the server or client... on the server, if a given ElementRenderer sets non-serialized(/able) properties to child custom elements it's rendering in its <template shadowroot>, it can also add the defer-hydration attribute; and then on the client, the client-side libraries associated with that same ElementRenderer would be responsible for removing the attribute only after providing any non-serialized property inputs during that scope's hydration. The child custom element need not be of the same base class as the host element; it only need implement the defer-hydration attribute and wait until the attribute is removed before performing its initial hydration/render.

Apologies if these questions reflect a poor understanding of what is being proposed. I've clearly not studied how it has been implemented yet.

No worries, we clearly need to make up some simple, non-Lit examples of these concepts to show the mix-and-match interop we're envisioning between heterogeneous custom element base classes.

@bahrus
Copy link

bahrus commented Jul 10, 2021

Wow! I was way off in my understanding of how this would work. Thanks for clarifying (and not suing for slander 😄 ) .

I'd like to propose something else related to this. Maybe it's a little weird/wild, but let's see.

I know there is a proposal for streaming content as part of the JS api associated with a web component. All fine and good, especially for truly dynamic content.

But an alternative approach for a subclass of scenarios might be this:

Some web components are fairly deterministic based on the attributes. For example, suppose you are developing a web component that displays the reference api information for other web components, based on the custom-elements.json manifest file.

The web component markup might look like this:

<wc-reference-api custom-elements-href="http://unpkg.com/[email protected]/custom-elements.json"></wc-reference-api>

So as long as unpkg.com exists, this should always display the exact same content, since the version number is specified. Okay, there's one more variable in there -- the version of the library for wc-reference-api itself (which I assume, based on package-lock.json deterministically determines all the referenced versions). This is probably the wrong solution to that issue, but to simplify the discussion, I'm going to throw in the version of the library in npm as an attribute, and assume somehow we can enforce that:

<wc-reference-api wc-reference-api-version-no=3.2.1 custom-elements-href="http://unpkg.com/[email protected]/custom-elements.json></wc-reference-api>

I think we can agree now, that the content this should display will always be the same.

So an alternative approach to live-streaming server-side rendering this component is:

  1. Create a puppeteer (or playwright) script that loads a page with this markup.
  2. Specify the mode we are in to the web component, so it knows we are "dehydrating" the component, rather than hydrating it.
  3. The puppeteer / playwright script saves the rendered, ssr-ready html output to a file-based db, and a server based solution (built on top of nginx, for example) is able to stream that file, full of template/shadow attributes, defer-hydrate attributes to the browser, either as part of a larger page/stream, or individually as a fetch request, streamed into that slot of content. Maybe it generates a guid.
  4. Just thinking out load, a generic, reusable web component / server-side include tag, "wc-streamer" for example, requests that stream (either client-side or server side)
<wc-streamer guid=12603297-2e46-4e0b-a04a-6ce6b1e8d075></wc-streamer>

If wc-streamer is acted on on the server-side, it gets replaced by the (streamed) html cache from that file-based db.

If done client-side, a separate streamable fetch request could be made for the html.

So basically another attribute to specify this mode of rendering would seem to be helpful. (as-ssr is my suggestion, but there are probably much better suggestions):

<wc-reference-api wc-reference-api-version-no=3.2.1 custom-elements-href="http://unpkg.com/[email protected]/custom-elements.json" as-ssr></wc-reference-api>

Perhaps whatever this attribute is called, it could also be useful if using an isomorphic approach to the more dynamic scenario, where the library does the streaming, etc? I.e. a standard way of knowing when the component is being invoked on the "server" which could actually be puppeteer, rather than a streaming service, so it can tailor the content accordingly.?

Update: I suppose the alternative to this would be each web component library looking at the user agent to decide how to render.

@bahrus
Copy link

bahrus commented Jul 12, 2021

Would

interface {
   render_ssr() : string
}

be usable for any web components that makes any kind of promise based calls, like fetching a resource? I assume not?

@bahrus
Copy link

bahrus commented Jul 14, 2021

Going back to the sync-props-from-server='{"data": [["Month", "Days"], ["Jan", 31]]}' idea, I wonder if it has been ruled out as being less efficient? I think there's a possibility that in some cases it may be faster to distribute data through the tree, starting from a high level node -- i.e. no need to use the same technique at all levels, but at the top of judiciously chosen component hierarchies, just pass a large json string from the server into an attribute. One time parse, single object.assign at each level down, versus parsing lots of individual attributes.

I would think during the distribution, we wouldn't want to use the sync-props-from-server attribute, but a property instead, so if anything would help to be "standardized", it would probably be the property name, rather than the attribute. Or it could be a method, I suppose.

@bahrus
Copy link

bahrus commented Jul 14, 2021

I guess if defer-hydration is present, it could just do Object.assign(myCustomElement, mySubOject), so maybe no need for a common property.

@dgp1130
Copy link

dgp1130 commented May 22, 2022

I've been thinking about using trying to statically render web components as part of rules_prerender (still very early, don't judge me 😅 ) and wanted to share a couple challenges I've identified which I don't think have been brought up here yet. My main goal is around static site generation (SSG) rather than SSR, but I think supporting SSG is also part of the goal here since in theory they should be 90%+ the same.

Bundling

A static site generator is slightly different from SSR in that it not only prerenders application HTML , but also bundles and assembles all the associated resources (JavaScript, CSS, images, static resources, etc.). The current proposal is focused around rendering some custom element definition into HTML, but provides no insight into what resources will be necessary for the rendered content to actually function. For example, if I render <foo-bar />, then I also probably want to include the JS which contains its custom element definition.

I think the current proposal is assuming that this work has already been done, and for SSR that's probably the case since you wouldn't want to run a bundler during request time. For SSGs, there is a lot of value in being able to look at a render tree and extract all the components which were used to render it, and where their source code lives. The SSG can then bundle all of those JS resources so everything which is needed on the page is present at runtime.

My current approach to this is to have users author a special includeScript('path/to/my/script.js') in their render tree (an inelegant solution I'm hoping to improve) which inserts an HTML comment that effectively says "please include path/to/my/script.js in the client" and SSG tooling will automatically extract this comment and bundle the requested file along with everything else in the page.

The same problem applies to CSS, though is easy to workaround by inlining <style /> tags in the declarative shadow DOM. This is the state of the art today as I understand it, but this gets quite repetitive if you are using the same custom element many times on a single page (such as a list of web components). I'm hoping browsers will expose better functionality here in the future so we could define styles once and reuse them for all the relevant shadow roots on the page. If and when this happens, CSS will run into the same issue as JS, in that SSG tooling would need to know what CSS files are necessary and where in the render tree in order to take advantage of such optimizations.

I don't have a good solution to this problem, as runtime JS execution doesn't typically have a good reference to the source code it originated from. The only solution I can think of would be for ElementRenderer to expose import.meta in some capacity and placing it into the render tree. This would allow tooling to post-process the render tree and extract all the components that will be needed at runtime. Maybe you could do something similar with CSS modules / constructible style sheets to solve the CSS problem. Even that is imperfect when you consider compiled code that import.meta refers to the executing file not the original source code. For example, if your component was authored in TypeScript, then import.meta would not reflect that. Hypothetically this information is present in source maps and maybe SSG tooling to make some assumptions to try to map back the import.meta field to the original source file based on its build process.

In theory the problem also applies to any other resources used by the component at runtime. For example, if you render <img src="/foo.png" /> then something needs to build (if necessary), and place foo.png at the right location. I think this is fairly orthogonal to web components and probably not something to solved here, but it's one of the problems rules_prerender attempts to solve.

If we choose to scope this issue solely to SSR and ignore SSG use cases, then this problem mostly goes away. Though any SSR application will have the same problem of "how do I bundle my client-side web components in an optimal fashion"? Since customElements.define() presents a top-level side effect, there's no way to effectively tree shake unused components in an application. I think that means that without the capability to look at a render tree and extract all the used web components, users would be forced to include all their web components in every web page on their site (unless they manually curate which components are necessary on which page).

SSR-only web components

One thing I've been hoping to explore more is some form of SSR-only web components. In many simplistic cases, it may never be necessary to re-render a a web component from scratch and only hydrate and incrementally update the SSR'd DOM. Basically, I want to be able to write a component which works like this:

<!-- SSR this content somehow... -->
<my-counter>
  <template shadowroot="open">
    <span id="label">10</span>
    <button id="decrement">-</button>
    <button id="increment">+</button>
  </template>
</my-counter>
// my-counter.ts

class MyCounter extends HTMLElement {
  public connectedCallback(): void {
    // Hydrate from SSR'd content.
    const label = this.shadowRoot.querySelector('#label');
    let value = parseInt(label.textContent);

    // Bind event listeners to implement functionality.
    this.shadowRoot.querySelector('#decrement').addEventListener('click', () => {
      value--;
      label.textContent = value.toString(); // Update SSR'd DOM.
    });

    this.shadowRoot.querySelector('#increment').addEventListener('click', () => {
      value++;
      label.textContent = value.toString();
    });
  }
}

customElements.define('my-counter', MyCounter);

This component is completely SSR'd, with the only CSR-ing coming from the increment and decrement buttons which only update the label. There's no need to completely throw away the shadow content and rerender the entire component. This is great for user performance since it doesn't matter how complex rendering the component actually is, it could pull in a 10 MB library and run an hour long computation, the client-side component doesn't need to include that dependency. This particular example is overly simplified (hi memory leak 👋 ), but I do have a more complete example in rules_prerender.

My question here: Is / should this be a supported use case for SSR'd web components? I see two potential issues with this approach:

First, the SSR'd HTML content has to come from somewhere, and in this design I think we're assuming that would come from the web component itself (more on that in a bit). If the web component already supports rendering, then MyCounter would already have some kind of render() function which effectively does render the whole component from scratch. As a result, this turns into more of a tree shaking problem, how can a bundler identify whether or not that full render implementation is actually necessary? I think this also runs into the question of whether or not a web component should have an "SSR mode" which could be compiled in / out. I don't fully understand where the ElementRenderer implementation would live for a custom element (whether a sub-property of the custom element or directly implemented by it for example), but maybe this is something which can already be done with the original proposal? It's also worth pointing out that declarative shadow DOM only works with the initial parse of an HTML document, so whatever renders <template shadowroot="open"></template> only makes sense during SSR and shouldn't be included in the client side bundle. Of course, whatever renders into that declarative shadow root is probably useful on the client.

Second, is there an expectation or requirement that a web component must always be client-side renderable? In this particular example, <my-counter /> cannot be CSR'd from scratch. This breaks the invariant that document.body.appendChild(document.createElement('my-counter')); should work. Now in reality, that isn't really true anyways since any given component likely expects some input properties, which isn't generic and developers will always need to look at some form of documentation to have any idea how to instantiate a web component. However, pure SSR components can never work with this model, and it's not possible to throw away a web component and recreate it, even if you replace all attributes and properties. I don't know enough about how lit-html works, but I wonder if it would break some assumptions there or if templating systems like that would need to never re-create a custom element, only modify its host with any given changes.

Maybe the takeaway is that it doesn't make sense to have an SSR-only web component and that's just a bad idea, but I think it's worth discussing what invariants a web component has and whether CSR is one of them?

Splitting the web component definition

One approach I've been using in rules_prerender (which may be antithetical to SSR web components) is to split the SSR implementation from the client side implementation (at least at for entry points). This means that a web component is not responsible for SSR-ing itself, but a separate function is responsible for SSR-ing the web component. In practice, this looks something like:

// my-counter.prerender.ts

export function renderMyCounter(initialValue: number): string {
  return `
    <my-counter>
      <template shadowroot="open">
         <!-- ... -->
      </template>

      <!-- Inject a comment for tooling to load \`my-counter\`'s implementation on the client. -->
      ${includeScript('path/to/my-counter.js')}
    </my-counter>
  `.trim();
}
// my-counter.client.ts

export class MyCounter extends HTMLElement {
  // ...
}

customElements.define('my-counter', MyCounter);

This split in entry points means that it is not necessary to share code between the client and server implementations of a component and each piece can be as platform-specific as it wants to be. When users do want to share code, they could move that into a common my-counter.shared.ts file which gets used in both contexts (such as the typical render() function). That file would be subject to the constraint that it (and all its dependencies) must work in both environments.

My reasoning for this is that I've generally come around to the opinion that a server environment should never import browser-based JavaScript and vice versa. Once you've imported some JS you're executing top-level statements and pulling in all the transitive dependencies which were authored with the expectation of running in a browser. This often immediately necessitates DOM emulation and other browser polyfills which have no business in a server in the first place. If a browser-only dependency chooses to swap over one of its implementation details to use WebRTC in a patch release, that would be a breaking change for any SSR usages which now have to polyfill the WebRTC runtime to somehow gracefully fallback to a useful behavior. Having distinct entry points allow the different implementations to diverge as much as it makes sense to do so, while leveraging any shared functionality desired with strong boundaries between them.

Now I admit this is an extreme take on SSR'd components and is probably antithetical to the general direction of SSR'd web components discussed here where many folks would likely want to use a single class extends HTMLElement {} definition could somehow be made compatible with SSR. I don't know how useful it is to the overall discussion, but I wanted to bring it up as one potential approach to the problem of SSR-ing web components by very explicitly diverging the two implementations. I think it's a fair take to say that this doesn't align with the actual goals of SSR-ing web components because it doesn't use a single authoring format, though I would rather make that constraint explicit if that is the direction that we want to go here.

That's a lot of random thoughts and ideas, not sure how useful they are. Just something I wanted to share after spending a significant amount of time trying to make SSG'd web components useful.

@thescientist13
Copy link

thescientist13 commented Jun 13, 2022

Hey @dgp1130 ! 👋

I myself am new to this thread (and SSR + WCs) but had a chance to review your comment and although I'm not sure what the right answers are per se, I've been working on some projects around this topic too and have come to some of the same observations / questions as yourself. So, thought I would share my findings as well to see if it helps contribute to the conversation. 😃


A static site generator is slightly different from SSR in that it not only prerenders application HTML , but also bundles and assembles all the associated resources (JavaScript, CSS, images, static resources, etc.)...

When I think of SSR vs SSG, I have come to see it as just a distinction of when you build (up-front vs request time), and not so much how you build (SPA, Progressive Enhancement, all static content). Your site could need some pages entirely static, and some pages with a mix of client side interactivity. Either of those pages could be built at request time or up-front. If you're using caching, then executing your SSR application against some database could be as simple as a serverless function and at that point, you wouldn't be too far off from a static HTML document pre-built just sitting on a CDN; except now you could build it on-demand too!

To that effect,

  • In some cases yes, you want pure HTML for entirely static content, basically using WCs as a templating system at build time. In this case, you might not even want to use Declarative Shadow DOM / <template> tag.
  • Or, maybe you want some interactivity to come along with that client side code, to progressively enhance, based on some condition; either eagerly loading right away, or when scrolling the page, or maybe when clicking a button. Ideally, and to your point, we would want to build up from the existing HTML to avoid duplicate initial renders, and this is where Declarative Shadow DOM and hydration strategies come into play.

If in any scenario you need to ship some client side assets, you will likely need some sort of bundler / build / deploy step to make them available, so I don't think SSG or SSR eliminates any of that, it's more so if you need it in the first place. The application code should be immutable in either case, it's just the data that would be dynamic / mutable and so I think any good solution in a frontend focused SSR space should ideally try and provide a full-stack and fluid workflow between SSR <> SPA <> SSG. (easier said then done, naturally!)

So basically, I would want to use the same techniques / technology for my SSG or SSR or hybrid site and so that's how I am trying to think about about the spectrum of various rendering strategies.

I think the current proposal is assuming that this work has already been done, and for SSR that's probably the case since you wouldn't want to run a bundler during request time. For SSGs, there is a lot of value in being able to look at a render tree and extract all the components which were used to render it, and where their source code lives. The SSG can then bundle all of those JS resources so everything which is needed on the page is present at runtime.

While yes, you wouldn't bundle at request time, you do make a good point about knowing the dependency graph or otherwise knowing how to tell what JavaScript and related resources need to be loaded in what manner, and when, and for that I think it will mostly be the work of frameworks to facilitate progressive enhancement and hydration strategies. So if you need to build up from some initial pre-rendered HTML for your client side code, then that calculation does not necessarily have to be coupled to when it was rendered, IMO.

These sorts of hydration strategies vary in their tradeoffs, but you might want to check out these related proposals:

So my thinking is that standardizing on those P.E. / hydration hints would at least allow some consistency from framework to framework from a WC authoring perspective, but how they would glue the two half of the stacks together is likely going to be implementation specific, is my guess.

For example, a tool I've been working on called Web Components Compiler (WCC) which is intended to be used in a glue layer, as part of its API it returns metadata about the custom elements and the import chain used as part of the SSR process, and forwards (in this case) any key attributes, like a hydrate="true", which could then be used to load this custom element via an IntersectionObserver, while the other custom elements are loaded through a traditional <script> tag (eagerly), or not at all! Here is a demo repo for that (checkout serverless demo #3). (live video demo recording will be available soon)

const { html, metadata } = await renderToStringFromHTML(new URL('./src/layout.js', import.meta.url));

console.debug({ metadata });
/*
 * {
 *   metadata: [
 *     'wcc-footer': { instanceName: 'Footer', moduleURL: [URL], hydrate: 'true' },
 *     'wcc-header': { instanceName: 'Header', moduleURL: [URL] },
 *     'wcc-navigation': { instanceName: 'Navigation', moduleURL: [URL] }
 *   ]
 * }
 */

I still think there will be a place for bundlers, though with import maps and HTTP/2, for smaller graphs, going unbundled in production could be viable. But otherwise, likely developers will either need to glue these source code hints with things like Rollup, webpack, LitSSR, etc or adopt a WC friendly framework that will abstract that away from you especially for things like route based code splitting.

Hopefully through these community protocols, we can at least find a general "standard" everyone can work towards for maximum interop at the custom element / HTML level.

Once you've imported some JS you're executing top-level statements and pulling in all the transitive dependencies which were authored with the expectation of running in a browser. This often immediately necessitates DOM emulation and other browser polyfills...

Yeah, there is fine line between what to emulate on the server side and how much effort / work that takes before you're repeating what a browser does. This friction point happens in React, Svelte, et all SSR frameworks too, and so I think the general consensus is that the SSR work should be principally about about setting up the initial HTML to prepare the component for hydration. All other work like event handlers, DOM detection, loading 3rd party libs, etc should be saved for the client side, using the detection of a shadowRoot as the signal. Third party libraries could also be conditionally loaded using dynamic import, import().

I'm not sure if ElementRenderer details what DOM lifecycles, objects, etc should be required to be emulated, but perhaps this standard might worth its own discussion as well?

First, the SSR'd HTML content has to come from somewhere ... I think this also runs into the question of whether or not a web component should have an "SSR mode" which could be compiled in / out. I don't fully understand where the ElementRenderer implementation would live for a custom element (whether a sub-property of the custom element or directly implemented by it for example), but maybe this is something which can already be done with the original proposal? It's also worth pointing out that declarative shadow DOM only works with the initial parse of an HTML document, so whatever renders <template shadowroot="open"></template> only makes sense during SSR and shouldn't be included in the client side bundle. Of course, whatever renders into that declarative shadow root is probably useful on the client.

Yeah, I've been thinking about this as well because it sounds like you're referring to is partial hydration. As you say, if there is work on the server that doesn't have to be done again on the client, then why does it need to get shipped to the client and executed? Partial hydration is that technique from what I understand, to basically strip out the static parts at build time. Naturally, this is pretty tricky and also why there seems to be more of a proliferation of compiler driven frameworks out there like Svelte, Solid, Marko and Quik, that are smart enough to know to do this kind of work (and also React Server Components to a degree) but it has to be really built-in from the ground up. I have opened a discussion on something like this here to see if something like a static __hydrate__ method could be adopted that any sort of SSR framework could compile / dead-code eliminate for allowing a component to define its own hydration logic.

At that point, you're entering a sort of compiler land and so could be seen as opening the door to partial hydration strategies, which is something I would like to explore too.


Anyway, hope that helps with some of your comments, and apologies if there was anything I missed / didn't understand. ✌️

@dgp1130
Copy link

dgp1130 commented Jun 14, 2022

Howdy @thescientist13, I think you're mostly following what I'm trying to say and I think I agree with most of your response.

It's interesting to take a look at wc-compiler and Greenwood, both very interesting takes on the SSG formula. IIUC, it seems like you're approach is to try to run a web component definition on the server and ship the rendered output to the client. That's a unique approach I don't think I've seen, it seems like you need wc-compiler to track dependencies of the top-level web component being rendered and then load those sources into the client, which presents some interesting trade-offs pretty distinct from my approach.

When I think of SSR vs SSG, I have come to see it as just a distinction of when you build (up-front vs request time), and not so much how you build (SPA, Progressive Enhancement, all static content). Your site could need some pages entirely static, and some pages with a mix of client side interactivity. Either of those pages could be built at request time or up-front.

I think it's useful to disambiguate "build", specifically as relates to "render" in this context. I interpret SSG applications as one which generates/compiles its client side resources and renders the HTML when the developer hits "compile". I see SSR applications as ones which still generate/compile their resources when the developer hits "compile", however HTML rendering happens at request time. I think you're on the same page here, I just want to call that out explicitly.

I'm using "compile" in the traditional sense, but I do see how you can build an application on demand in a serverless environment on deploy, so really that "compile" step is "sometime before request time", where users don't pay any cost for slow operations. Basically, SSR apps render at request time, while SSG apps render before request time. The unique benefit of the SSG approach is that since they render in a less restrictive environment (no user facing SLOs), other client side resources can depend on and build from that render tree in a way which is impossible for SSR applications.

If in any scenario you need to ship some client side assets, you will likely need some sort of bundler / build / deploy step to make them available, so I don't think SSG or SSR eliminates any of that, it's more so if you need it in the first place. The application code should be immutable in either case, it's just the data that would be dynamic / mutable and so I think any good solution in a frontend focused SSR space should ideally try and provide a full-stack and fluid workflow between SSR <> SPA <> SSG. (easier said then done, naturally!)

Agreed that you still need a bundle step, the difference with SSG's is that the bundle step can depend on the render tree.

I also agree (in theory) with your point that code should be immutable and only data should be dynamic. In practice that's quite tricky given the highly dynamic nature of SSR and SSG. Unless you fully CSR the entire application, I don't think you can 100% align with that goal, so we need to find the appropriate place on the spectrum to meet user needs while still staying fast and secure. This is something I was recently struggling with in a related context.

Also agree that it's important for tools to help developers straddle the SSG / SSR / CSR line and all the gaps in between, something I'm still finding my way through as well.

While yes, you wouldn't bundle at request time, you do make a good point about knowing the dependency graph or otherwise knowing how to tell what JavaScript and related resources need to be loaded in what manner, and when, and for that I think it will mostly be the work of frameworks to facilitate progressive enhancement and hydration strategies. So if you need to build up from some initial pre-rendered HTML for your client side code, then that calculation does not necessarily have to be coupled to when it was rendered, IMO.

Sure, JS and other resources don't have to depend on the render tree, but I think there is a lot of value to it. Being able to pick and choose which resources are necessary for the client at render time gives more flexibility for optimized bundles and dead code elimination which I think are important to support.

Hydration is related but different, given that "when to hydrate" is a separate question from "what resources are needed to support hydration". SSR and SSG both need to answer the first question in the same way. They also both need to answer the second question, but in different ways. An SSR solution probably has some pre-built JS resources with perhaps some logic to pick between them, while an SSG solution has the opportunity to automatically build the resources needed to support the rendered page. I don't see this as a hydration problem, but rather as a resource problem, determining what resources are needed to successfully load a given component on the client.

So my thinking is that standardizing on those P.E. / hydration hints would at least allow some consistency from framework to framework from a WC authoring perspective, but how they would glue the two half of the stacks together is likely going to be implementation specific, is my guess.

Agree that those help answer the "when to hydrate" question, but I don't think they help with the "what resources are needed to support hydration" question.

Yeah, I've been thinking about this as well because it sounds like you're referring to is partial hydration. As you say, if there is work on the server that doesn't have to be done again on the client, then why does it need to get shipped to the client and executed? Partial hydration is that technique from what I understand, to basically strip out the static parts at build time. Naturally, this is pretty tricky and also why there seems to be more of a proliferation of compiler driven frameworks out there like Svelte, Solid, Marko and Quik, that are smart enough to know to do this kind of work (and also React Server Components to a degree) but it has to be really built-in from the ground up. I have opened a discussion on something like this here to see if something like a static __hydrate__ method could be adopted that any sort of SSR framework could compile / dead-code eliminate for allowing a component to define its own hydration logic.

At that point, you're entering a sort of compiler land and so could be seen as opening the door to partial hydration strategies, which is something I would like to explore too.

Actually this kind of relates to the blog post I linked earlier where I'm exploring if we can make partial hydration more viable without frameworks or custom tooling. It deals more with the component loading issue, rather than how to render or build the resources of that component. There's obviously a lot of work to make the component authoring experience smooth and intuitive.

Hope this is understanding your points as well as you understood mine, definitely appreciate your input and feedback here.

@thescientist13
Copy link

thescientist13 commented Jul 17, 2022

It's interesting to take a look at wc-compiler and Greenwood, both very interesting takes on the SSG formula. IIUC, it seems like you're approach is to try to run a web component definition on the server and ship the rendered output to the client. That's a unique approach I don't think I've seen, it seems like you need wc-compiler to track dependencies of the top-level web component being rendered and then load those sources into the client, which presents some interesting trade-offs pretty distinct from my approach.

Pretty much! The main goal is to be able to give a Next.js like API, but using a standards based templating / page / layout format; Web Components! 😃

import fetch from 'node-fetch';
import '../components/card.js';

export default class ArtistsPage extends HTMLElement {
  async connectedCallback() {
    const artists = await fetch('https://.../api/artists').then(resp => resp.json());
    const html = artists.map(artist => {
      return `
        <wc-card>
          <h2 slot="title">${artist.name}</h2>
          <img slot="image" src="${artist.imageUrl}" alt="${artist.name}"/>
        </wc-card>
      `;
    }).join('');

    this.innerHTML = `
      <h1>List of Artists: ${artists.length}</h1>
      ${html}
    `;
  }
}

// optionally define a custom element if you _do_ want the content wrapped in a tag and the JS included, for whatever reason
customElements.define('artists-page', ArtistsPage);

The JS can definitely be optional for static content and can be fine tuned from userland HTML. Like above, for these "layout" templates or page contents, where I don't want those to be forced into an inert <template> tag or depend on JavaScript, it can just spit out HTML and no Shadow Root if you don't set one. For the interactive bits though where I actually need the JS and want to leverage hydration, then by all means lean into it! With Greenwood / WCC, the hope is that it's just a matter of which API you (want to) use based on the content / behavior you are trying to deliver; innerHTML or attachShadow.

I would certainly like to be able to tackle the topics of concurrent rendering and streaming (maybe even reactivity!) very soon though, especially with along with my adventures in serverless + edge functions!

I also agree (in theory) with your point that code should be immutable and only data should be dynamic. In practice that's quite tricky given the highly dynamic nature of SSR and SSG. Unless you fully CSR the entire application, I don't think you can 100% align with that goal, so we need to find the appropriate place on the spectrum to meet user needs while still staying fast and secure. This is something I was recently struggling with in a related context.

Hmm yeah. That's an interesting point, re: code vs content. But I don't necessarily think loading some content (data) after the fact necessarily invalidates that per se. I think I was referring to source code immutability, not that the HTML could never be augmented post response.

I guess the way I think about it is was more from a distribution perspective; if only the content is changing (not the schema or the template of the blog post) then in that situation the (source) code could be seen is immutable with dynamic content. I suppose there is a case where an image upload blows out your layout, but I wouldn't necessarily count it as such. And just like with a DB, you change a column name or delete a row, you have to update the consumers as if it was a breaking change.

In your Twitter edit example I guess in my mind, as long as the Twitter API (response) is not changing, that WC can safely operate showing and editing tweets both on SSR, and in an infinite list after the fact, because the internals of your WC know what to do with that content schema.

I am intrigued by the thought experiment though for sure so will keep reviewing that post, and also entirely possible I could have been too literal / myopic in my original statement. 🙃

Agree that those help answer the "when to hydrate" question, but I don't think they help with the "what resources are needed to support hydration" question.

Good point. I think this is what Quik really leans into, and I think they solve it just by inlining everything into the HTML (i.e. where the reusability comes from?). Also, maybe worth looking into a new project that the 11ty project is spinning up called is-land?

Actually this kind of relates to the blog post I linked earlier where I'm exploring if we can make partial hydration more viable without frameworks or custom tooling. It deals more with the component loading issue, rather than how to render or build the resources of that component. There's obviously a lot of work to make the component authoring experience smooth and intuitive.

There's a lot I can relate to in that post, especially finally understanding how a <template> tag worked, lol. Definitely gave me some perspective on your code vs content discussion. 🤔

But I really like the demo you put together, I think HTML over the wire is / could be a really nice paradigm with Serverless / Edge, or even streaming. So instead of just one tweet, maybe an entire set of search results could be streamed over HTTP just sending fragments.

I definitely want to explore streaming next with WCC / Greenwood, and thinking now how powerful it would be for Greenwood to have routes for serving routes, and then functions as routes for serving fragments of data back for the post initial render like in your demo. Full-stack, isomorphic streaming HTML (fragments). Standards ftw! 🏆

Hope this is understanding your points as well as you understood mine, definitely appreciate your input and feedback here.

Yes, very much so and keep the great conversation coming! Going to review that post and code sample some more, for sure.

Not sure if you're in the WCCG Slack channel but feel free to join and reach out!

@dgp1130
Copy link

dgp1130 commented Jul 17, 2022

Hmm yeah. That's an interesting point, re: code vs content. But I don't necessarily think loading some content (data) after the fact necessarily invalidates that per se. I think I was referring to source code immutability, not that the HTML could never be augmented post response.

I guess the way I think about it is was more from a distribution perspective; if only the content is changing (not the schema or the template of the blog post) then in that situation the (source) code could be seen is immutable with dynamic content. I suppose there is a case where an image upload blows out your layout, but I wouldn't necessarily count it as such. And just like with a DB, you change a column name or delete a row, you have to update the consumers as if it was a breaking change.

In your Twitter edit example I guess in my mind, as long as the Twitter API (response) is not changing, that WC can safely operate showing and editing tweets both on SSR, and in an infinite list after the fact, because the internals of your WC know what to do with that content schema.

I'm not sure I'm fully following your point, but it might help to have a concrete example. Consider a use case where you want to render a user, and if they belong to a company to render the company name:

function renderUserPage(username: string, companyName?: string) {
  return `
    <my-user>${username}</my-user>
    ${companyName
      ? `<my-company>${companyName}</my-company>`
      : ``}
  `;
}

Note that my-user is always rendered, while my-company is only sometimes rendered.

In an SSR context, you have include the JS implementations of both of these components all the time, because any user might have a company. In an SSG context however, we can actually know which users have companies and don't need to include the definition for my-company in pages of users which don't have companies.

This is where code vs content gets blurry. The company name is certainly content (schema didn't change to use your terminology), but the effect of this is that the bundled JS is changed and pages with companies vs those without have different JS bundles.

To go back to the Twitter example you brought up, do changes in the prerendered HTML constitute a content or a code change? Consider using one SSR'd tweet and then pulling down a second. What changes between the two are reasonable and what changes are not? Some strawman examples:

  • Changing label text is clearly content.
  • Changing the JS definition of a component is clearly code (and kinda not possible since you can't redefine a component).
  • What about adding a new script tag because the user joined a company and now you need my-company?
  • What about including additional style or link tags?
  • What if you include iframe content?
  • What if any of the above happens happens as a result of an edited version of the first tweet?

In an interpreted environment like this, my takeaway is that content is code.

That doesn't mean we can't be intelligent about it and find useful boundaries which keep things secure and sane while staying flexible. XSS for example is the same problem from a security perspective,and CSP has a reasonable solution for it.

This content vs code distinction is partially where I see the difference between SSR and SSG. Since SSR happens after resource bundling the "code" is static, and only rendered HTML "content" can change. Obviously it could dynamically choose to include an extra script or style tag, but usually those are still pre-built. Essentially the code needs to support every kind of content you could possibly want to display.

But since SSG happens before resource bundling we have an opportunity to be more intelligent about that process and construct the ideal code necessary to support the specific content we actually want to display.

Now maybe this is a bad idea and we shouldn't do that in the first place or WC SSR isn't the right solution, and there's an argument to be made there. But that's why I wanted to bring it up in this thread, hoping to better define exactly what use cases we have in mind for this tech and thinking more holistically about the problem space.

But I really like the demo you put together, I think HTML over the wire is / could be a really nice paradigm with Serverless / Edge, or even streaming. So instead of just one tweet, maybe an entire set of search results could be streamed over HTTP just sending fragments.

I definitely want to explore streaming next with WCC / Greenwood, and thinking now how powerful it would be for Greenwood to have routes for serving routes, and then functions as routes for serving fragments of data back for the post initial render like in your demo. Full-stack, isomorphic streaming HTML (fragments). Standards ftw! 🏆

Glad you enjoyed the post and got some interesting ideas out of it! I don't want to get too off topic from SSR WC, but I'll just drop this link where I experimented with mixing SSG and SSR in the same page using rules_prerender.

https://twitter.com/develwoutacause/status/1448164824152567810?s=20&t=MHQyPoc-Efb9e91Q4DCiZA

It led to some interesting concurrency and streaming features. I'm not fully convinced they're good ideas, but would most helpful in your edge-worker scenario so might be interesting to you.

It didn't try to work with standard web components, but that is a longer-term goal I would have for that approach, and the current standard seems lacking in this regard tight now which is what drew me to this issue. Would love to have a means of making this work with standard web components (maybe wc-compiler can help there 😉).

Not sure if you're in the WCCG Slack channel but feel free to join and reach out!

Thanks, I wasn't aware there was a Slack for this. Just joined, we'll have to follow up on some of the off-topic pieces of this conversation there. 😃

@aralroca
Copy link

aralroca commented Sep 5, 2024

In the Brisa framework, to make SSR of the Web Components we make that what the developers write is the render function where it returns JSX together with signals, then this code is used in the client to be used inside the wrapper with the rest of the WC code. This is very useful for later in the server to be used with the jsx-runtime to render itself, when detecting that it is a web component, with the DSD. Take a look at this post https://aralroca.com/blog/reactive-web-components-with-ssr

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

9 participants