-
Notifications
You must be signed in to change notification settings - Fork 12
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
Comments
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. |
@bahrus Yes, I agree, streaming the result will be more efficient. |
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. |
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:
|
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:
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? |
@bahrus this is already possible and implemented in LitElement. When defer-hydration is removed, that provokes 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 |
Yes, the idea is that rather than provide initial state via something like |
Is 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.
|
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. |
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 |
And note that implementing the |
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. |
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. |
I see two big differences:
|
@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 Also, in your example you're showing running the client-side |
@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:
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 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).
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 A user might call |
Cool, agreeing on the goals is the hard part. The rest is just code. 😀
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 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. I toyed around with an 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 This is maybe the best defense for |
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. |
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. |
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?
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.
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? |
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. |
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>`;
} |
@justinfagnani can you add the code for creating the Also it looks like you're passing Also the element's light DOM is not being rendered. Was that a mistake or intentional? |
We don't have the renderer registry very well defined (IMO) yet. We have a sketch of a list with a static 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 let p = ctor.prototype;
do {
if (renderer.has(p)) { return renderer.get(p); }
} while (p = Object.getPrototypeOf(p) !== HTMLElement)
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);
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>`;
} |
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. |
Started #17 to discuss this issue. |
I think I've put my finger on some questions/doubts about the solution:
<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:
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, 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 This is exactly how the
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. |
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:
<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. |
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? |
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. |
I guess if defer-hydration is present, it could just do Object.assign(myCustomElement, mySubOject), so maybe no need for a common property. |
I've been thinking about using trying to statically render web components as part of BundlingA 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 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 The same problem applies to CSS, though is easy to workaround by inlining 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 In theory the problem also applies to any other resources used by the component at runtime. For example, if you render 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 SSR-only web componentsOne 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 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 Second, is there an expectation or requirement that a web component must always be client-side renderable? In this particular example, 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 definitionOne approach I've been using in // 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 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 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. |
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. 😃
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,
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.
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 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.
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 I'm not sure if
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 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. ✌️ |
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.
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.
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.
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.
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.
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. |
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 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!
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. 🙃
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?
There's a lot I can relate to in that post, especially finally understanding how a 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! 🏆
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! |
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 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 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:
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 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.
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 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 😉).
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. 😃 |
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 |
Context
Web Components have hydration or upgrade capabilities built-in.
Basic example:
Until
<my-comp>
is registered (via Javascript), the browser will showLoading...
.The recent advancements in Declarative Shadow DOM
whatwg/dom#831 give us the possibility to even pre-render shadow roots with encapsulated styles.
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
Define a method that SSR capable Custom Element would implement:
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 assr
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:
render_ssr()
Example
source
index.html
index.html
after generation by "meta-framework" by callingrender_ssr()
method.Questions
How to push properties statically?
Can we use proven
dot
notation? Already used by many Web Component libs today.Anything in
.prop1
value would be evaluated and the result would go to propertyprop1
.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
The text was updated successfully, but these errors were encountered: