Static Server Side Rendering + hydration with Preact WMR, zero Twind runtime in production builds #147
Replies: 13 comments 17 replies
-
I am not using Twind's off-the-shelf Preact and WMR "use with" packages, but the technical underpinnings are identical: |
Beta Was this translation helpful? Give feedback.
-
I use the standard / official Preact WMR package, which provides sufficient hooks into the build system to inject Twind's "style extraction" mechanics: |
Beta Was this translation helpful? Give feedback.
-
An update before I sign off for the day: The goal of my Preact WMR experiment is to remove the need for the Twind runtime on the client side in production builds. This requires that each statically pre-rendered HTML page contains the cumulated sum of all "possible" Twind-generated classes / styles, by which I mean: not just the CSS used by the page itself (i.e. on first render, as well as post-hydration user interaction / local state updates), but also the CSS potentially needed when navigating to another page handled by the client-side router (i.e. the point at which the website effectively becomes a Single Page Application). Side note: async lazy-loaded routes may in fact be designed to fetch their own styles "on the fly" (i.e. CSS computed ahead of time via static SSR, instantiated on the client as needed) ... but let's ignore this architectural case for now. In my original implementation, the aggregated Twind CSS was serialized into the Quick note about the order of CSS selectors in Twind's aggregated stylesheet: this is mostly dictated by Twind in the stylesheet generated for the current page. However, merged additions are not necessarily inserted according to Twind's prescribed order, which in principle could be problematic. However in practice this is safe thanks to CSS selector specificity. Now, in my improved implementation, the "critical" CSS is generated separately from the "rest", for each individual routed page. On the client side, the "critical" CSS is loaded immediately with the document markup (it is a blocking operation, the <link rel="preload" href="./twind.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="./twind.css"></noscript> Overall I am quite satisfied with this approach, and I think that the gains justify the tradeoffs (in my usage scenario, anyway). The main constraint is that JSX The other downside is that the build process is quite convoluted, as this involves invoking Twind as part of WMR's build process, to be precise whilst pre-processing source files. I will provide further details when I release code + documentation, but in a nutshell: the TSX/JSX files are statically analysed to transform Twind expressions ahead of time to a simple object structure that exposes the raw Tailwind CSS classes (e.g. Twind's group syntax is expanded). In fact, I must make sure this approach works with Twind's recent introduction of "dynamic" classnames. Sorry for all the talking and the lack of code ... I promise I will release actual source code, when it's ready :) |
Beta Was this translation helpful? Give feedback.
-
I haven't mentioned actual numbers yet: right now the bytes economy in the resulting frontend is circa 60 KB of statically-minified, but uncompressed code (I mean, HTTP gzip compression). This size saving corresponds to Twind's default runtime plus the Typography and Form plugins, in my case (I will no doubt add plugins to my Twind configuration as my design needs evolve). This may not seem much relative to other bundle / resource sizes, but in my case this is worth the effort :) |
Beta Was this translation helpful? Give feedback.
-
The Twind runtime (including Like I said before, I think this is a perfectly acceptable payload and runtime cost to pay, given Twind's feature set. Plus, there are build options to generate stylesheets ahead of time (e.g. extraction of "critical-ish" CSS using Twind CLI), so that Twind's just-in-time CSS generation can be deferred without causing FOUC (Flash Of Unstyled Content). NOTE: the treemap visualization is misleading here, in that Twind appears proportionally huge in the vendor space, when really it is my project that is tiny (i.e. not representative of a a real-world production website). That being said, based on experience from previous projects, eventually I don't envision this particular one to include large JS dependencies, so Twind would probably remain one of the larger chunks, relatively speaking (this is a moot point in my case, as this Rollup chunk is only used for static SSR, it is not loaded in the front end on the client side, at all). |
Beta Was this translation helpful? Give feedback.
-
Interesting and fun development. Out of curiosity, I wanted to implement some type of "island architecture" in this Preact WMR experiment. I managed to make this work using a hybrid approach. Reminder of my primary design goals: static SSR resulting in zero Twind runtime on the client side, and crucially: non-overlapping separation between critical CSS used for dry render (for each SSR-discovered route), vs. lazy-loaded styles for post-hydration use (i.e. SPA runtime). The technique is a bit "hacky", but it seems to work reliably: I use At first I assumed that mixing two "lazy" techniques inside the same component tree would be problematic, but after reading the source code of both and running some tests, I actually think it is safe thanks to the "suspend boundaries" that intercept thrown exceptions and orchestrate re-renders. Lazy-loaded "islands" of content can be loaded manually by the user (e.g. an "expand" button as part of an HTML I suppose this can be called "progressive / partial hydration"? The tricky part was to make sure that the lazy component "islands" are SSR'ed just like lazy routes (i.e. code split for optimal bundle fragmentation and network caching), with their own Tailwind stylesheet rules excluded from the critical CSS. To achieve this, I implemented the same hack used for the I am not open-sourcing my code yet because there are several key moving pieces at the moment. Notably, the logic behind WMR's isomorphic router/lazy is likely to change. My code is a real mess based on a locally-built branch of WMR. Quite a few references, if you're curious :) |
Beta Was this translation helpful? Give feedback.
-
I finally managed to distil my Preact WMR web app (the one with zero-runtime Twind) to a bare minimum that I am happy to share with the community. Here is a repository with some code, a demo, and even some documentation! :) |
Beta Was this translation helpful? Give feedback.
-
I migrated my small demo to Twind v1. I improved the README that describes the techniques used to achieve my "zero runtime" design goals: I posted the following screen capture which illustrates one of the main benefits of zero-runtime Twind ... yes, I know, this gives a grossly exaggerated impression due to the relative size of dependencies :) My Twind SSR/SSG demo will intentionally remain super small (much like a reduced test case), and right now it only includes basic |
Beta Was this translation helpful? Give feedback.
-
Here is a super-reduced network waterfall report and performance flamechart that illustrate the principle of parallelised fetch of JS and secondary CSS (immediately after the initial fetch and parse of the HTML + inline critical styles). Note the relative timing of web vitals: |
Beta Was this translation helpful? Give feedback.
-
@sastan I hope you don't mind, I juxtaposed the Twind logo and the Preact WMR logo at the top of my README: Also, I felt I initially didn't do a good-enough job at explaining my rationale for wanting to eliminate Twind's runtime from the client-side Javascript bundle. Twind is plenty lightweight and performant, so I didn't want to give the wrong impressions. I updated my README to better articulate my motivations. Cheers 👍 |
Beta Was this translation helpful? Give feedback.
-
Incremental update: in my old (private) demo, I was using I wasn't feeling comfortable (because of under-the-hood, potentially-conflicting Preact Note that although based on the same principle of "thrown Promise exception", So, in this new (public) demo, I am now using only Preact WMR's The fact that I am not using More info in the README: https://github.com/danielweck/preact-wmr-twind-zero/blob/main/README.md (notably, an explanation of the "sub router" trick used to separate async lazy components styles from the "critical" stylesheet ... it is quite a hack, but it makes sense when you think about it with your head twisted backwards and up-side-down ;) |
Beta Was this translation helpful? Give feedback.
-
Project update: I have just integrated the new Rust-written ParcelCSS minifier / optimiser ( https://github.com/parcel-bundler/parcel-css ) Twind already generates "optimised" CSS code, so in principle the gains should be quite minimal. However in practice (well, in my project anyway) the build pipeline generates "critical" and "secondary" stylesheets resulting from the CSS code produced by a variety of Twind constructs: ParcelCSS includes sophisticated heuristics to shrink CSS code, such as identifying redundant / duplicate selectors ... which exist in my sample project due to using a So I am seeing some appreciable size reduction, mostly in the "critical" inline CSS stylesheet (i.e. the one that's embedded in the static HTML route), as the Source code reference: https://github.com/danielweck/preact-wmr-twind-zero/blob/main/wmr-post-prerender-twind.mjs |
Beta Was this translation helpful? Give feedback.
-
Hello :) In addition to using Twind runtime only server-side (or more precisely, at build time during whole-site SSG), I am now also using a technique to (1) avoid unnecessary client-side hydration of "static" markup islands, whilst preserving hydration consistency (no potential mismatch), and (2) avoid shipping the compiled hydrator code which effectively duplicates the markup that's already rendered in the HTML. Technique (1) prevents wasting compute cycles and consuming memory / increasing garbage collection. Technique (2) minimises network load at the critical initial render stage. Crucially, Twind classes and corresponding critical + secondary stylesheets must be generated without fail. As the no-hydration strategy applies to the client side only (where Twind is not executed anyway) this approach works out of the box in my project: If you're interested, I am copying the relevant section from the README here (in a nutshell: I use lazy-loaded / async import'ed components to selectively fetch from the network only during route navigation in SPA mode, in combination with a neat Preact trick that skips subtree hydration without causing hydration mismatch): "Static" content islands PreactWMR prerendered builds perform ahead-of-time SSG, and in this project there is no live SSR. HTML markup is generated either at build time (on the "server", so to speak), or by the web browser client during hydration, at which point some Javascript code effectively duplicates the information already encoded in the HTML source, but injects event handlers, etc. In cases where the content is "static" (i.e. frozen HTML islands not affected by dynamic variables or user interaction, as regular components typically are), the duplicated component tree in the Javascript bundle causes unnecessary network load, in addition to wasting compute cycles, and also consuming more memory / increasing garbage collection (at hydration time). To work around this problem, here we use a neat Preact trick that prevents a component subtree from being hydrated, whilst maintaining hydration consistency (i.e. no potential mismatch). See source code for technical details (search for Next, we implement a technique to avoid shipping compiled JSX to the client unnecessarily. We wrap a lazy component / async import inside a fragment that conditionally renders Naturally, once the web app becomes a SPA (immediately after hydration), route navigation can occurs and consequently any such static HTML island needs to be reconstructed into the live HTML document. In other words, the corresponding lazy-loaded / async-import'ed component must be fetched (or obtained from local cache), and rendered. This is standard Preact WMR stuff, no additional hack is required for this to work. We just use a conditional to determine exactly when a component must render Just for fun, this project also implements the edge-case of embedding / nesting static content islands inside lazy-loaded components. As expected, |
Beta Was this translation helpful? Give feedback.
-
Hello!
First of all: huge thanks to the Twind project(s) maintainers! The feature-set is great, the API is very flexible, and the performance is pretty good too :)
I hope that the community will grow!
To the point: I managed to negate the need for the Twind runtime entirely in my small frontend project (nothing too complex at the moment, just a set of statically-generated web pages, with some lazy-loaded routes / dynamically-imported components).
I think that some of the techniques I used may be of interest to other developers, so I am planning on documenting things. Right now my code is not in a presentable state. This is a spare time project so I cannot give an ETA.
I find that Twind is a good match for Preact WMR, in the sense that the performance of the Twind runtime (instant Tailwind CSS generation!) is compelling in a development environment where Hot Module Reload / Fast Refresh is near-instant, even with a TypeScript codebase.
In production builds, Preact WMR generates a static fileset for each individual route (including lazy-loaded routes / dynamic module imports). Although Twind can be used for static Server Side Rendering (see the various "use with Twind" Preact / WMR / Next / "render-to-string" integrations), I my project I really wanted to eliminate the need for the Twind runtime during hydration. I realise that this may be seen as a "micro optimisation", but every byte counts in the overall user experience (especially on mobile devices over poor network connections).
I am not just talking about Twind's combined Javascript/CSS bundle size (+plugins), but also about reducing the computational costs incurred during page hydration: behind the scenes, Twind must parse every CSS class token, check duplicates, sort styling rules / order for selector precedence, update the stylesheet / CSSOM, etc. In my usage scenario, this performance penalty (however small) is not justified, as the pre-rendered documents are already populated with all the necessary styles.
In my admittedly-limited use case, the CSS runtime is not only redundant at hydration stage, it is also unnecessary during subsequent user interactions (i.e. not just "hover" / "focus" states, but also mutations of the
class
props in the virtual DOM which occur when the application state changes, causing re-renders). In my scenario, the "critical" CSS bundle shipped in pre-rendered webpages is not just for "above the fold", it covers the entire Single Page Application that comes to life after the initial hydration of a pre-rendered document (well, obviously this excludes dynamic routes / lazy-loaded components which may import their own styles after the initial content render).So, in my next post(s) I will try to articulate some technical / architectural details, but first I must give my source code a good cleanup, so I can share code snippets. Final note: this is just an experiment at this stage, but I am probably going to use a similar project structure to quickly scaffold other small front end projects.
Beta Was this translation helpful? Give feedback.
All reactions