solid icon indicating copy to clipboard operation
solid copied to clipboard

Partial Hydration

Open ryansolid opened this issue 3 years ago • 12 comments

This is the last core feature missing in our SSR story. Truth be told outside of Marko most libraries aren't doing amazing here. We can too consider a more manual approach here at first.

I think the key innovation would be to follow Marko's footsteps and recognize there in fact 3 partial hydration modes rather than 2 other libraries are aware of. There is a middle mode that make us considerably more efficient at this. This is uniquely possible given the granular non-component tied approach used here. This is not my innovation read https://medium.com/@mlrawlings/maybe-you-dont-need-that-spa-f2c659bc7fec for a high-level on sub-component hydration.

  1. Static - top level starting place. No JS needed.
  2. Stateful - These are the island entry points. We need to have their reactive system on the client but not their templates, as they can be updated but never rendered. This lets us not ship their template strings which can be massive savings in components that are mostly static. It also means we can have potentially more static children under stateful components that we can ship no JS for.
  3. Client - these fall under dynamic inserts like control flow and require all client code since you are responsible for re-render.

There is a direct conflict between Partial Hydration and SPAs. Client-side routing and global context are both things that do not play nicely with Partial Hydration as they put dynamicism at the top-level. Routing pulls mode 3 up and stores pull mode 2 up. Vue is looking at a solution with multi-bundles for the same page. This can work but also has the side effect of ultimately loading component code twice. A necessity for this SPA upgrade, but I think that if you need to load all that router/store code initially anyway probably diminishing returns. The smaller code split library is going to make bigger impact.

So weighing this with understanding MPA + Portals probably better architecture here but no reason not to support.

ryansolid avatar Nov 15 '20 09:11 ryansolid

I’ve been looking more deeply at existing solutions over the last couple weekends, mostly to get a sense of how they work, partly to have something mentally engaging while I spend physically necessary time away from my desk (so this is also all based on phone reading and mentally evaluating what I think code does).

Based on my understanding of what Solid compiles, some or all of this may be totally irrelevant. I still haven’t dove a deep dive into actually using Solid for a real project, but it seems pretty clear from both the output I’ve seen and some of your recent articles the boundaries between functions and reactivity aren’t necessarily 1:1 with app source.

One really interesting solution I found in The Guardian’s (Preact) implementation is that they effectively separate their server/client render trees. Their server render tree is basically inert until something in its hierarchy is relevant to hydration. They have two different approaches to hydration, one they specifically call “islands” which are distinct calls to render targeting specific DOM nodes, and essentially micro-frontends. The other is a lot more compelling to me, because it allows shared context as if mounted like a standard SPA, by projecting hydration targets into the server-rendered DOM as Portals.

Other approaches I’ve seen and pondered are oriented around render strategies that bypass whatever reconciliation APIs from the underlying library and optionally defer to child components to withdraw that bypass (and wow I know a lot more of React/Preact internals now). These approaches are really clever but brittle because of course those APIs are internal/unstable.

At least at a glance, I think Solid is actually in a really good position to address this because of its template approach to hydration. Unless I’m woefully misunderstanding the distinction between points 2/3, a really quick win, if manual dev time hydration markers are acceptable, would be to allow devs to specify what should hydrate and skip that initial template render otherwise.

eyelidlessness avatar May 23 '21 23:05 eyelidlessness

I do have some updates here. Not completely on our way but when hydrating from the top-level document we don't ship the template. That means if you use JSX to say render from the <html> tag and just throw a couple interactive components in it Solid won't ship most of the template and since it's static it will have very little code. I could do a bit more here as I'm essentially treating this as category 2 instead of category 1. But it's a nice savings nonetheless. I implemented it to remove extra overhead of rendering from the Document in SPA's but in an MPA you could leverage it further and see a bigger positive benefit. I've also added a compiler hint of $ServerOnly that just skips shipping the template in the client bundle. So in theory if you wanted to things manually you could reduce pretty much all the static template views from your client-side bundle.

ryansolid avatar Aug 05 '21 03:08 ryansolid

@ryansolid I’m excited to see this and to look at how it works! Is it reasonable to assume most of this lives in dom-expressions? Or is it something Solid is doing specifically?

eyelidlessness avatar Aug 06 '21 04:08 eyelidlessness

Yes mostly in DOM Expressions.. It isn't anything too tricky. I just look for the <HTML> tag. I use it here: https://github.com/ryansolid/solid-ssr-workbench although that example is a SPA and not fully leveraging this.

Look at the Client Side rendering with Hydration output for: https://playground.solidjs.com/?hash=-919501911&version=1.0.7

I'm still starting from the root and walking to the components, but there is no Template shipped for the whole top level template.

ryansolid avatar Aug 06 '21 08:08 ryansolid

I did a big dump of my current thinking in Discord that I will copy here.


It is interesting because there are a few different approaches with SPAs. I'm not concerned with the MPA case that much. I think things like Astro or manual islands are adequate there and they will benefit from any of the SPA work we do. But that perspective also acknowledges certain types of optimizations are off the table, maybe don't make sense.High level there are a few potential areas of optimization here:

1. Manual Wrappers (difficulty: medium, value: narrow)

If you know a Component is not going to be interactive in the browser we could just not ship the code. We already have a <NoHydrate> but we could easily apply a $ServerOnly on a component and have it just not ship to the browser. Of course if the browser ever tried to re-rerun it well you are in trouble. $ServerOnly on templates just means the templates and this would just TreeShake it out of the compiled output. Really only good for top level things. It's basically the opposite of Astro's Islands. Instead of server-only by default its client by default unless you opt into server things.

Similarly we could have more components like lazy that load on other types of interactions. But again this only has value on the initial rendered page. So it would basically detect if you are hydrating to hold off loading the script. But it seems like too much of a potential for client waterfalls in the non-hydrating case. It's basically the Qwik problem when navigating to a new page. You don't want to introduce lazy loading boundaries you wouldn't otherwise have. Tying this to router sections seems more reasonably since those already lazy load but at that point who are we fooling. You don't want the majority of your initial page under a lazy loaded section.

So most these optimizations are great for MPAs and don't apply to SPAs unless you know exactly what you are doing. And only apply apply to a very small part of the page.

2. Component-level Code Reductions (difficulty: hard, value: wide)

Remove templates(ie the HTML strings we clone) unneeded in the browser. This can often save 50+% of components code even if they are stateful. However you will eventually need them if ever re-rendered in the browser. Without making all rendering async pretty hard to lazy load templates once you go to render. Async rendering has a whole bunch of other issues. Your best bet is predict what templates will be needed at the sort of change and do loading at that point before applying the change.. like a Transition. For now it's easy enough to apply $ServerOnly on templates manually but this a stop gap.

Reduce code to walk to interactive parts of JavaScript. Related to above a component with no interactivity has basically no code in Solid other than the template to clone, except the walks to any child interactive parts. We write walks in our compiler as it is a lot faster to traverse firstChild, nextSibling than like document.querySelector but also a lot more code. To get to individual nodes we need a reference. However for things like component inserts maybe we already have that reference from our registry of hydratable templates (data-hk). It's not that simple as looking up those elements parents as multiple templates can exist under each hole in our template so having the parent know that at compile time seems very difficult.

3. Static Routes/Route Sections (difficulty: easy, value: medium)

This isn't unlike (1) but instead of assuming any sort of granularity you just don't load the bundle like ever unless client rendering. In so it could only apply to things without dynamically rendered sections. No <Show> or <For>. A lot of sites are like that. You just go that section is static and leave it at that.

I think the trick here would be to use progressive enhancement like techniques. Like if every navigation is just anchors you could have a global event listeners that could handle those without full page refresh. Like let's pretend we still load the framework and router on the page, but not the code to render the main content of the page. You could intercept all anchor clicks and feed them into the router similar to the Router's <Link> tag without rendering <Link> tags everywhere. In so you could have client side routing and just not load the JavaScript on initial load for the page sections. For something like a Hacker News style site that would reduce the initial code by a couple KB. But like the second you need interactivity this disappears. Add upvoting well now you need the JavaScript. Making it more granular can help in some cases but we don't want more lazy boundaries.

4. Static Cross Template Analysis (difficulty: very hard, value: pretty narrow)

Identify components and decide if they are stateful and which props they use in stateful way. Write this to metadata associated with each module that can be read from other modules. Look at the imports of your components to read from child component metadata. Using this information basically manually do the first part of (1).

Of course identifying what is stateful or re-rendered in the client is tricky. Might need to look for symbols in all imports (ie.. look at custom primitives for createSignal etc..) Even if there was an easy way to identify based on usage hard to know. Like is something a signal because it takes a while to load initially (resource) or because it actually changes. We can tell by createResource's arguments whether it's dynamic, but calls to mutate or refetch would also determine potential dynamic updates. In general this is a place where more language like Template DSL could help identify these things easier. Obvious there is the option of have a convention like React's use____ but even that isn't full proof in a granular sense. Course grain that means this component can update. Fine grained not every returned value is reactive.

More so even with this knowledge there needs to be a sort of running tally because non-stateful components are still needed to be shipped when under a stateful one. Without the means to break up the compiled output of components into multiple separately importable pieces you aren't getting that much in highly interactive Apps. And the limits of (2) still apply in a SPA.

5. Server Components (difficulty: hard, value: wide)

Forget the whole SPA framing and instead view a SPA a multiple MPAs. Then you get to apply those optimizations easier perhaps. Downside is you need a backend that supports it. And in a sense you are still sending everything to the browser. This is just a way to makes the lazy loading problem easier. The API side of server components can be solved a billion ways already.. Remix Forms, Blitz RPCs etc.. But the template loading piece might be more interesting. In many way this is the other side of the coin to (2). It solves the problem by just avoiding it for the most part.

This could be attractive but the basis here is built around diffing in React. In Solid it would want to be granular.. like only send the holes. Instead of sending like a VDOM you'd send keyed HTML snippets I imagine. Every slot the server was aware of would need to keyed. I mean there is the naive.. lets blow this out with innerHTML and reinsert Child component references that probably would be good enough for most things. But in general these component templates would need different compilation.


In general (2) is most aligned with Solid's goals that has wider application. It isn't partial hydration in the classic sense but if we aren't far from reducing the non-template code into just the dynamic things that need to be there and if your component is static that could amount to almost nothing. Otherwise we are looking at Server Component like things. But I worry that is too complicated for very little gain. If a Solid component scales on interactivity rather than size it really is a lot of work to lazy load some templates.

ryansolid avatar Dec 16 '21 22:12 ryansolid

Whatever happened to this effort?

aadamsx avatar Apr 20 '22 02:04 aadamsx

Well after I posted I went to sort of see what sort of impact no. 2 above could have. $ServerOnly is pretty minimal in many cases on size and while reducing the walks can help to trim size at this level still felt like a lot of effort.

So since January I started digging deep into every project on the MPA side of things as no. 5 above was the only other really widely viable approach here. The simple message for people trying to follow along is that partial hydration is basically impossible for SPAs. However, MPAs + client swapping of server sections can be pretty SPA-like. But it means a change of mentality as the typical client components become a smaller part of the application. My take-away is we are talking about a paradigm change. Adding Partial Hydration is a game-changer not only for architecture but mental model. So I'm no longer thinking of this as adding a feature. Chasing small wins might be a strategy but I'm thinking bigger picture here.

So what does this new paradigm look like? Well as far as I can determine there are 3 key pieces:

1. Server Routing w/o Reload

Something like Turbo perhaps, but ideally something smarter. We really only want to send HTML for newly rendered portions and rather use JSON for any granular changes. So this is really some sort of hybrid format. Rather than client rendering for bigger navigations, we server render + hydrate. Solid's rendering is already basically cloning + hydrate so it isn't that much of a stretch. Something like nested routing seems ideal. But keep in mind we may still need to update small parts of the page that are server-rendered, but needs to be updated. Most server routing is clunky but I'm hopeful.

This simple demo shows the promise of the approach: https://server-nested-routing-mk2.rturnq.workers.dev/

2. Partial Hydration

A means to subdivide server from browser code. This impacts both code size and data serialization. One thing I've witnessed is data serialization is a real cost we need to be conscious of and having to send it across the wire twice is not great. The easiest way to avoid that is send it once in the HTML by allowing certain portions of the page to only be rendered on the server. And this has the benefit of not needed JS to render that portion of the page in the browser either.

I've looked at this a bit as has @LXSMNSYC . Basically doing something like Astro except it's all JSX. Honestly this is still on the table but I think until there is potential to work on the routing relying on tools like Astro make a ton of sense here. MPAs are a completely different architecture and Solid fits in so nicely. Same with Slinkity, Iles, and who knows Qwik or Marko are both considering this sort of thing. Until we want do something ambitious here that would maintain SPA-like benefits I don't see the point, and even then these other solutions may get there first. And let them, since realistically going this path is like wrapping Solid in a different framework. Even React Server Components are like that really, with current React being in the Islands.

3. Resumability

So what can we do? We can improve hydration to the point it has basically no cost. I'm unclear if serialization costs will be worth it, but we can do better to reduce hydration costs. I've seen how it is possible to basically have no work done on browser page load. Regardless of how partial things are or how much JS we load we can make sure not to heavily impact TTI. This is available to SPAs if they want it and fits in with Solid's vision. Today we are basically the fastest at Server Rendering and Client Rendering. This would make us the fastest at Hydration regardless of how we bundle things.

What I've identified is that all 3 of these can be attacked somewhat independently but they all play together. And ultimately balance each other. And we will get to all of them. But my gut is we start on resumability or related ways to reduce hydration costs because we will need that regardless. And we work with partners focused on the partial hydration side of things, projects like Astro. And once we can do that, we can look at routing and re-evaluate appropriately.

ryansolid avatar Apr 20 '22 04:04 ryansolid

After having a working PoC I made 6 months ago, I manage to pull a working Partial Hydration implementation by only using SolidJS components through rigidity. Of course, this requires bundler and compiler setups to achieve, but the best part here is how SolidJS' renderId option in hydrate helped to achieving this. I'm still working on trying to minimize the required output on HTML and JS to make this work.

lxsmnsyc avatar Jul 28 '22 05:07 lxsmnsyc

Yeah, I should make an update.

Partial Hydration

A recent realization we've had is that renderIds and having a "NoHydration" mechanism actually makes making Islands relatively trivial in Solid. Solid's hydration is pretty unique in that we use JSX and don't use a VDOM. This has an interesting characteristic in that you can create JSX anywhere and just insert it, but we don't have a VDOM so there is no reconcile pass. So we need to be able to support out-of-order rendering as the order things appear in the HTML is not guaranteed to be the order they are created. And since we aren't a component system at runtime we need to mark template boundaries. The solution has been to give them data-hk attributes to give them a hierarchical id. Basically store the hierarchical structure in the HTML itself. We have been doing this for years but didn't think very much of it.

However, with the introduction of being able to pass the renderId prefix into the hydrate call with Solid 1.3 hydrating any portion of the page is pretty easy. This wasn't my initial intent as it was more to identify different Islands in Astro by letting them use their id generation we could namespace different islands and prevent collisions during async hydration or where Solid was responsible for serializing resources. But this is very powerful when leveraging our own prefixes. So we can fully render the page in SSR, and then just pinpoint hydrate sections. If we pass in the hierarchical id of the parent to the hydrate call it will hydrate as normal from that location.

The last piece which is usually a little tricky is the insertion of static children. That's how we can build recursive patterns and save on data duplication. But Solid has 2 things going for it. Firstly we deal in real DOM nodes. So there is no issue with the children just being the DOM children. We can insert them as is. And we already have <NoHydration> mechanism to not hydrate children. So stopping hydration to be able to pick up again takes no extra consideration.

Most of the challenge with this comes down to bundling and bootstrapping. Which for something like this isn't that big of a deal. So from where I'm sitting Partial Hydration isn't really a core feature, but we've done pretty well to make this very buildable on top. There could be an argument for more primitives in core to enable this easier but we need to review these as they would likely rely pretty heavily on the bundler. We've toyed with an island wrapper very similar to lazy. And there is still room to optimize the output of the HTML to remove unneeded Ids and whatnot, but this probably is a lower priority improvement.

Hybrid Routing

We've made a couple of prototypes of putting this approach to Islands and Routing together and it works pretty well on small demos. Things like Hackernews. There is still a question of if there is a way to merge these worlds. The biggest area of research has to be around handling the nested data functions and the use of route context.

See Islands assume no shared root so you only need to worry about the props that are passed in. And if things only render on the server then you don't even need to worry about those. But once you enable certain things to render on the client then the data needs to be present. So flipping modes is not great. But maybe dedicated pages?

This is fine as long as you are fine with anything in a global context always being serialized. But it could cause some interesting rules for route data since it too can be hierarchical. Like maybe server-rendered routes could contain no client-rendered routes because those clients may require different data from the parent route than it originally rendered. You can always put Islands on server-rendered routes as their props are explicit figuring out context is a new sort of challenge.

There is also the question of things that are server-rendered and never update in the client but could update on the server. Like you might not need to render all the comments of a post on the client, so you don't even ship components to render comments. But now there is a new comment. You could load the code at that point to render it, but would you need the Data now for all the components to reconcile it declaratively? Or are you better off going back to the server? And if so are we replacing huge chunks of HTML (but preserving the islands), or are we receiving patch instructions to only update what is needed?

This area is still a big ??? But it is essential for where things need to head for bridging the gap for Solid. In so I think this guides the conversation on what the solution ultimately is here. Whether Islands are sufficient or whether we branch into newer territory.

Resumability

No big news here yet, but we've started the conversation on the V2 reactive system, and this is going to be a big part of the design. Even if we don't go all the way look at this as a way of using more information on the initial server render to reduce hydration costs. I'm pretty firmly going to base decisions here based on benchmarks because there are tradeoffs and we need to understand the impact.


EDIT: Note on Islands. Small caveat. It occurs to me that the conditional rendering of static children that aren't initially present on the server render may be a gap. All server-rendered static content would need to be present from SSR in order to collect the nodes. But if they are initially present, showing and hiding should not be a problem. If this is an issue one would could come up with their own mechanism to render not visible children and hoist them out at hydration. Maybe using a template element. Since you won't be hydrating the children anyway. But this probably requires specific knowledge of the hierarchy and might lend to using some sort of custom element to mark child boundaries.

ryansolid avatar Jul 28 '22 18:07 ryansolid

Propabbly have already seen this but I'll put it out there anyway, Deno's new Fresh UI lib uses Islands as well (with Preact). Might be a good reference point for someone.

aadamsx avatar Jul 29 '22 17:07 aadamsx

Deno's new Fresh UI lib uses Islands as well

Caveat: there is no client-side routing in Fresh (i.e. pages only ever render on the server). So while it’s a great choice for many projects—great templating DX via Preact, 0 client-side JS by default, "sprinkle" in JS as needed (i.e. islands), etc.—it doesn’t fit the requirements for all projects (e.g. SPA-like).

tylerbrostrom avatar Jul 30 '22 12:07 tylerbrostrom

@tylerbrostrom he was mentioning it for the sake of the "Partial Hydration" topic :)

@aadamsx I've made a repo to collect resources about Partial Hydration and Islands Architecture, if you or anyone is interested, please check it out: https://github.com/LXSMNSYC/awesome-islands

lxsmnsyc avatar Jul 30 '22 13:07 lxsmnsyc

I think this issue has run its course. There is more we can do here for sure. As of today we have only accomplished the 1st and 3rd modes of my original post in an ergonomic way. Not only saving shipping the code but also saving the data serialization costs. As well as supporting streaming, and Context sharing. Technically the 2nd mode is possible with $ServerOnly but it isn't automated or very useful.

The combination of Partial Hydration and Nested Hybrid Routing is showing great potential. See this simple Hackernews Demo that combines them both into a small ~5kb package for a site that would be ~15kb with SolidStart usually and for reference is typically around 70kb for any React Framework.

With Solid 1.6 we now provide tools for meta-frameworks to implement partial hydration on top of Solid and that is where we leave this issue. We can pick up more specific issues as needed in the future.

Thank you all for your input and feedback over the years.

ryansolid avatar Oct 20 '22 09:10 ryansolid

$ServerOnly on templates just means the templates and this would just TreeShake it out of the compiled output. Really only good for top level things. It's basically the opposite of Astro's Islands. Instead of server-only by default its client by default unless you opt into server things.

This sounds like RSC--except RSC comes with bloat in the form of JSON to hydrate react, would it be better to have markers instead that delineate where the hydration starts and ends instead of shipping JSON?

For example, a component has an indicator that it is partially updated at ref#123 and its children will only need to be updated by the framework all other elements do not need a JS hydration state, etc. so skip mounting/hydrating except the target ref/

I believe that was the end goal of RSC but it the JSON bloat to hydrate components on client side instead of skipping the hydration/rearchitecture there is a JS penalty for it.

end result is the best of both worlds with the no hydration unless necessary approach: spa and a traditional SPA behavior.

MANTENN avatar Jan 23 '24 04:01 MANTENN