remix-run / remix

Build Better Websites. Create modern, resilient user experiences with web fundamentals.

Home Page:https://remix.run

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

RFC: Static Pages / Browser Only Fetching

ryanflorence opened this issue · comments

Remix is pretty opinionated about always server rendering, but that's not really an opinion, it's just a starting point.

Remix's data loading, data mutation, and asset loading conventions around nested routes are useful whether you're server rendering, fetching data clientside only, or static rendering.

The following APIs will enable you to easily change which mode your route works with:

Build-Time Pre-Rendering

It seems the thing to do in this space is make up new acronyms. So we'll call this BTPR 😋. Just kidding, we'll just call it pre-rendering.

Inside of your remix config, you can define a prerender

export async function prerender() {
  let posts = (await getArticles()).map((a) => `/posts/${a.slug}`);
  return {
    context: {},
    paths: [...posts, "/about", "/team"],
  };
}

The loaders for these routes will be called:

// posts/$slug.tsx
export async function loader({ request, params, context }) {
  // - the request will be the same as if it were a normal request without headers
  // - the params will be the same
  // - context will *not* be `getLoadContext` but the prerender context.
}

This gives you the ability to fetch everything in prerender or let the loaders handle it:

// let the loader do it
import getPost from "../posts";

export async function loader({ request, params, context }) {
  return getPost(params.slug);
}

Or put it on context in prerender

export async function prerender() {
  let posts = await getPosts();
  let postsMap = new Map();
  for (let post of posts) {
    postsMap.set(post.id, post);
  }
  return {
    // Right here!
    context: { posts: postsMap },
    paths: [...posts, "/about", "/team"],
  };
}

And now a loader can just find it's post from context

export async function loader({ request, params, context }) {
  return context.posts.get(params.slug);
}

When Remix is bundling, it can render these pages and dump them into your public/ folder.

Clientside Data Fetching/Mutations

Remix calls your loaders serverside, even on script transitions. This is great for a few reasons:

  • You can server render dynamic data
  • You can write server-side only code (db connections, form validation, etc.)
  • You can greatly decrease the size of the payload sent over the wire to the user. Most APIs return HUGE objects. Having a "loader as a proxy" in front of your Remix UI is likely a significant performance improvement over calling the API directly from the browser.

That said, many databases allow you to connect to them in the browser, and some pages you don't care about SSR. Perhaps you're using clientside authentication with JWTs on pages you don't care to server render and you'd like to talk directly to your APIs from the browser w/o the Remix server in the middle (like FaunaDB, Hasura, or Firestore).

If you export a clientLoader, then Remix will use it instead of the loader on script transitions.

export function clientLoader({ request, params }) {
  return fetch("/api/whatever", {
    headers: {
      Auth: `Bearer ${sessionStorage.jwt}`,
    },
  });
}

There is no context because the server isn't involved, but we can still provide a request that is nearly identical to what the loader would get (just no headers, pretty much). Note that in place of cookies and sessions apps we use the browser's built-in sessionStorage.

And of course you can have a clientAction as well.

export function clientAction({ request, params }) {}

One image in our mind we keep when building Remix is the idea of "levers" you can pull to change how your app is delivered w/o having to change much (or anything) about your application code. For example, removing <Scripts/> and your app continues to function the same except with full page document loads.

A lever here could be a sessionStorage abstraction with the same API as our HTTP session storage. Consider the cookie session storage:

import { createCookieSessionStorage } from "remix";

let storage = createCookieSessionStorage();
let session = await getSession(cookie);
session.set("jwt", jwt);
session.unset("jwt");
session.flash("message", "...");
session.flash("message", "...");

We could have the same API for browser sessionStorage

import { createBrowserSessionStorage } from "remix";

// this is all superfluous, but hang with me
let storage = createBrowserSessionStorage();
let session = await getSession();

// now we've got the same API whether you're using HTTP sessions and server side
// data loading, or browser sessions and client side loading
session.set("jwt", jwt);
session.unset("jwt");
session.flash("message", "...");
session.flash("message", "...");

This means an app could theoretically write the code like this:

// assume this storage commits data to a database, not in the cookie
import { getSession } from "../customStorage";

export function action({ request }) {
  let body = new URLSearchParams(await request.text());
  let errors = validate(body);
  if (errors) {
    let session = await getSession(request.headers.get("Cookie"));
    session.set("errors", errors);
    return redirect("/some/form");
  }
}

export function loader({ request }) {
  let session = await getSession(request.headers.get("Cookie"));
  return session;
}

And then switch it to this:

// just change the import!
import { getSession, commitSession } from "../browserStorage";

export function clientAction({ request }) {
  let body = new URLSearchParams(await request.text());
  let errors = validate(body);
  if (errors) {
    let session = await getSession(request.headers.get("Cookie"));
    session.set("errors", errors);
    return redirect("/some/form");
  }
}

export function clientLoader({ request }) {
  let session = await getSession(request.headers.get("Cookie"));
  return session;
}

Of course, the request won't have a "Cookie" header, but the browser storage can just ignore it (and the app code should probably delete that part) but the point is this is another lever you can pull to change where your code is running without having to really change your code at all.

All the other tools in Remix will work identically: usePendingLocation, usePendingFormSubmit, the data diffing for loaders, etc.

What about both?

There's the possibility that you could export both loader and clientLoader. What do we do? Use loader for document requests and clientLoader for script requests?

I think this opens up far too many questions and is hard to pin down a solid use-case. Considering an authenticated user to get the same result on the server and the client would require a lot of code to have them authenticated in both places. Or even just a database connection, you'd need to establish it on the client and the server.

This is much easier to think about (and no doubt implement) if it's either/or.

Alternative API

Instead of changing the name of the exported function we could add a property to the function:

export function loader() {
  // ...
}
loader.target = "client";

Now it really is just a lever to change where the code runs, it also makes it impossible to define a loader and clientLoader.


What do you think?

About prerendering, do you think it would be possible to support both SSR and prerendering at the same time? I'm thinking on something like preprender the most popular pages of a website (e.g. popular products, latest articles, etc.) and keep SSR for the rest, another thing is that I could store the date (or Remix could do it automatically when the prerender happen) and in the loader verify "was this page prerendered in the last week? then continue using the same data/html, is it older? then do SSR" this way you can use preprendering mostly for a few pages, this will only work if prerendering is "call the loader at build time and SSR at runtime".

Another feature that could be interesting is if a route doesn't need any loader (like an about page) then prerender it automatically. If the root has a loader then this will not happen thought and it's super common to have one there so maybe doesn't deserver the effort to implement this. Also because of the nested layouts this would mean detect if not only the leaf route but any other parent route doesn't have a loader.


About client loader, what would happen when the user opens a new browser tab and goes to a page with a client loader, what will the user see while the loader is running client-side? Right now, the user browser will take care of showing a spinner, but with the loader working client-side Remix would need to send an empty or almost empty page, download the component and client-loader code and then call the loader, once finish render the page. Should there be a way to export a fallback page to render server-side in those cases?

export function Fallback() {
  return <h1>Loading...</h1>
}

Something like that, it could also be called Skeleton, but fallback makes more sense in React since Suspense uses that word. With this Remix can SSR the fallback/skeleton of that route (inside any parent layout if needed) and then after calling the client-loader switch to the actual page.

Another option could be to provide a way for the page to detect if it is still pending to be rendered.

export default function View() {
  let isPending = useIsPendingClientLoaderOnFirstCall() // name TBD
  if (isPending) return <h1>Loading...</h1>
  return <ActualUI />
}

Great ideas and questions @sergiodxa, I just pulled up some old notes and created a new issue about Pending exports that I think would solve the problem well (#181).

About client loader, what would happen when the user opens a new browser tab and goes to a page with a client loader,

Coming back to this, I need to think this through some more, but I think maybe we console.warn if a route has a client loader w/o a Pending export (and render null for them). Could even do like ErrorBoundary so you really only need one Pending in root.tsx.

The hook would work, but one thing I really hate in React is having to add loading/error/success branching in every component. And then when you need that data for other hooks you have to split the component up into separate components anyway. I like that in Remix your default component can always plan on having data.

The hook would work, but one thing I really hate in React is having to add loading/error/success branching in every component. And then when you need that data for other hooks you have to split the component up into separate components anyway. I like that in Remix your default component can always plan on having data.

The Pending export is way better, I also hate the branching inside components, that’s something Suspense improved a lot by moving the fallback and error handling to the parent.

Next.js uses the hook approach for ISR support so you do

let { isFallback } = useRouter()

To know if you have to render a fallback for a new page or not and I hated using it. Remix should be better at this and the Pending export is the way.

For what it's worth, if you decide to go with either/or I definitely prefer the loader.target = "client" API. It's simpler and makes it clear that this is an either/or thing.

Re: pre-rendering

This all sounds great. I really like the idea of providing the context object up front when pre-rendering. There's a ton of flexibility there.

My main concern would be what we'd need to do in our starter templates to make sure that all of our supported hosts know how to serve the HTML pages we put in the public dir. They probably all already know how to serve public/index.html at / and public/about.html at /about (w/o the .html ext), but it's worth double-checking.

An interesting experiment would be to do an entire site like this and deploy it to an S3 bucket and see what routing rules we need to setup on the bucket to get the site to be served correctly.

Idea: exports.preRender = string | string[] | fn

Maybe instead of a function, exports.preRender could also just be a path/array of paths/path "globs"

exports.preRender = ["/about", "/team"];

You wouldn't get to provide context here, but you might not need it on simple static pages.

Idea: pre-rendering with path "globs"

exports.preRender could also just be a path/array of path "globs"

exports.preRender = ["/about", "/blog/*"];
// or, to pre-render the whole site:
exports.preRender = "*";

This would ofc be an error if you had any route paths with dynamic segments in the matched set of routes since we can't just guess them. But it could be nice if you just had a bunch of static routes in a folder somewhere.

Future idea: Add S3 as option in @remix-run/init

We could automatically dump the right config needed to pre-render every single page on the site (exports.preRender = "*" in remix.config.js?) and also include a setup that will let you deploy to S3 easily. Or maybe base it on GitHub Pages (sucks, but it's popular) or a more capable static host like surge.sh

Re: client-side loader/action

I'm not 100% sure about the use cases for this yet, but just had one quick piece of feedback:

Instead of changing the name of the exported function we could add a property to the function

One thing to keep in mind is that our compiler relies on static analysis of the code to identify the elements of a route module. Keeping clientLoader a top-level symbol instead of loader with a loader.target string property would make this more consistent with what we're already doing and considerably easier to prune e.g. clientLoader functions out of server-side code without quasi-evaluating it.

I love this API and the flexibility it'll bring to Remix.

This looks clunky however:

loader.target = "client";

I'd much prefer the clientLoader function, even if Remix throws a run-time error. One idea could be to implement Remix specific lint rules for eslint [ala Rule of Hooks] that help the developer figure out which is best for particular scenarios (& to hint that both can't be used together).

If you add the Build-Time Pre-Rendering feature, please allow using runtime and build-time data fetching in the same route.

Build-time: Get static data of the route, like translations, an article data, etc.
Run-time: Get dynamic data of the route which depends on the user

If you have both it will not really pre-render the HTML at build, just fetch the data and store it in the context so the loader can use it (or not if it's too old)

I started a discussion thread not realizing this issue was already open:
#1312 (comment)

I think it would be most beneficial to have a @Shared() decorator and allow the loader and action to be shared between the client and the server.

I think what I wrote and what is being proposed here is somewhat different. I think there is tremendous value in executing a loader/action on a document request (on the server) and then executing that same loader/action on the client for a transition. The client has to make an HTTP request anyways why not bypass the proxy?

How would you use a decorator there if they are only usable with classes and their methods? Also they are not standard yet.

Also there’s a huge benefit of running loaders or actions only server side, you can recover extra data, in your example the list of gists only use two properties but each gist from the GitHub API comes with a lot, you could remove them on the server and send less data to the browser, you lose this if the loader runs client side

How would you use a decorator there if they are only usable with classes and their methods?

Ugh. that is weird. ok well maybe a loader.target = "shared"; could work?

Also there’s a huge benefit of running loaders or actions only server side, you can recover extra data, in your example the list of gists only use two properties but each gist from the GitHub API comes with a lot, you could remove them on the server and send less data to the browser, you lose this if the loader runs client side

Sure, but you're making a trade-off between amount of data being transferred and cacheability of that data. GitHub can cache that resource on an edge server indefinitely and purge it whenever it changes. If I am forced to proxy the request, that cache is effectively useless since the user is forced to make a request to my origin server (or I run the entire app at the edge, or I handle the caching and purging myself, which may be impossible).

Regardless, I don't think you can definitively say that less data transfer === less latency. Indeed, it may be the exact opposite depending on how far (geographically) the request has to travel in order to get a response. I think it would be more prudent to let engineers make that assessment on a per-loader/action basis.

I find it rather odd that if you build a single-page application with React, everything from a 3rd party API is fetched on the client, but if you use a framework that enables SSR (Remix, Next.js, Gatsby, etc.) then all of the sudden all 3rd party API requests are forced to be made on the server (if you want SSR as well).