remix-run / remix

Build Better Websites. Create modern, resilient user experiences with web fundamentals.
https://remix.run
MIT License
29.28k stars 2.46k forks source link

RFC: Pending export #181

Closed ryanflorence closed 2 years ago

ryanflorence commented 3 years ago

Route Module Pending Component Export

There are two primary approaches to page transitions (ignoring suspense's ditched attempt at a third)

  1. Indefinitely wait on the old screen
  2. Transition immediately to spinners/skeleton

Right now Remix has picked (1), but with a new export to a route module, we could support both.

Today, if you have this, Remix will wait for all data to load before displaying the page

export function loader({ params }) {
  return User.find(params.userId);
}

export default function UserProfile() {
  let user = useRouteData();
  return <UserProfile user={user} />;
}

Like ErrorBoundary, we could add a Pending export:

export function loader({ params }) {
  return User.find(params.userId);
}

export function Pending() {
  return <UserProfileSkeleton />;
}

export default function UserProfile() {
  let user = useRouteData();
  return <UserProfile user={user} />;
}

If a route module exports a Pending component, Remix could switch to mode (2) and immediately display this screen when the location changes, dispalying <Pending/> until the route data all lands.

What about nested routes?

Remix will wait for any routes that don't export a Pending before displaying any other routes' Pending export. Some scenarios:

In summary, wait for all routes w/o pending, then transition.

So what?

On web and native, both types of transitions are common, and both have their tradeoffs depending on the data being fetched or the type of app you're building. In apps with very "app like" layouts with lots of persistent UI between location changes (rather than typical "pages" on the web with very little persistent UI), immediate transitions to skeleton UI is feels much better. For example, in Discord, it would feel weird to click on a channel and not go immediately to a shimmer/skeleton page. Conversely, we all know how terrible many webpages feel when clicking a link results in 12 spinners bouncing around before the page is built.

There's room for both transitions.

What's really interesting with Remix is that the Pending components in layouts can still render an outlet. This means that if a parent route's data is not as important as a child's you don't have to block the transition on it.

For example, load up a youtube video on a slower connection and you'll notice the primary content loads first, then the layout shows up around it.

Consider a typical master/detail view with these routes:

routes/
  - users.tsx
  - users/
    - index.tsx
    - $user.tsx

And let's say the UI has a sidebar of the users on the left, and the profile on the right:

  <Users>           <$User>
|----------------------------------|
|  bob     |       Bob Thornton    |
|  sally   |                       |
|  curtis  |                       |
...

The most important data at /users/bob-thornton is Bob's profile, not the user list.

So to get the users's profile displaying as fast as possible it could look like this:

// routes/users.tsx
export function loader() {
  return Users.findAll();
}

export function Pending() {
  return (
    <>
      <UsersSidebarSkeleton/>
      <Outlet/> {/* <-- Can still render an outlet! */}
    </>
  )
}

export default function UsersLayout() {
  let users = useRouteData()
  return (
    <>
      <UsersSidebar users={users}>
      <Outlet/>
    </>
  )
}
// routes/users/$userId.tsx
export function loader({ params }) {
  return Users.find(params.userId);
}

// no pending export we want to wait for this
export default function User() {
  let user = useRouteData()
  return (
    <UsersProfile user={user}>
  )
}

Now if you're looking at /recent-activity and click on a user's name, navigating to /users/sally-mae Remix will:

Implementation

sergiodxa commented 3 years ago

I love this idea, specially this

What's really interesting with Remix is that the Pending components in layouts can still render an outlet. This means that if a parent route's data is not as important as a child's you don't have to block the transition on it.

For example, load up a youtube video on a slower connection and you'll notice the primary content loads first, then the layout shows up around it.

I think for SSR the pending should be ignored, YouTube does respect it in the first paint (check https://youtu.be/dQw4w9WgXcQ) but most likely they are not doing SSR anyway. Maybe have a way to enable it for SSR in the remix.config.js but just ignoring it always for SSR it's not a bad default.

ryanflorence commented 3 years ago
export function Pending() {}
Pending.ssr = true
// default false
itsMapleLeaf commented 3 years ago

This is really elegant. I was actually thinking about how I'd get that "pending loading spinner UX" that I previously had in my client rendered app.

Would this also support a timeout, similar to timeoutMs in Suspense config? e.g. Show the previous UI for X amount of milliseconds, when that's passed, then show pending?

kentcdodds commented 3 years ago

Server rendering: if a route has pending...

Is there a way for my loader to know whether it's being called for SSR or from a client-side render? If it did, then I could decide whether I want to skip loading the perf-problematic request during SSR. Not sure I like that any better than the .ssr = true idea though 🤔

ryanflorence commented 3 years ago

Would this also support a timeout, similar to timeoutMs in Suspense config? e.g. Show the previous UI for X amount of milliseconds, when that's passed, then show pending?

I don't think so. The React team has backpedaled on that in suspense, too. It seems you really just want to "wait for this indefinitely" or "transition to spinners ASAP". One very tricky problem is when the timeoutMs expires 1ms before the data loads, lots of jank in that situation. I've got some ideas there (after timeout expires wait another timeout to let an animation complete even though data is ready to avoid the jank) but I'd rather just start simpler.

ryanflorence commented 3 years ago

@kentcdodds

Is there a way for my loader to know whether it's being called for SSR or from a client-side render?

I'm not sure how this helps (or what you're supposed to return from the loader?). We just need some config. Loaders are simply request in, response out. Configuring the behavior of a Remix transition/initial render isn't their job.

kentcdodds commented 3 years ago

I was just suggesting that if you didn't want the loader to run on SSR, it could just return null in that case.

ryanflorence commented 3 years ago

Ah gotcha. You can already return null from a loader and that means ... null. (You can return anything that can go to JSON.stringify(...)). So now your useRouteData() will change from whatever data was in there to null on the next render.

kentcdodds commented 3 years ago

Clearly I'm striking out on suggestions tonight 😅 I'll get back to doing the dishes and stop being a distraction 🤣

ryanflorence commented 3 years ago

Haha, no worries, it's good to help think about it from other angles. I think the core issue is that we're trying to figure out how to define the pending/transition behavior but loaders are just the data, so trying to give loaders the responsibility of defining both behavior and data is mixing responsibilities.

In this case the behavior (don't wait for this data and transition to pending if other loaders are ready) and the data (this route still has data!) are both needed. So returning null doesn't really make sense, we still care about the data, we just aren't going to wait for it.

mjackson commented 3 years ago

Alternative idea for a name: Fallback. Ofc, copied from the <Suspense fallback> naming.

It's more of a noun, like ErrorBoundary.

ryanflorence commented 3 years ago

Fallback to me made the most sense with suspense's "timeoutMs", so you "fallback to this if the timeout expires" where pending is like "This is the pending ui", no idea of "falling back" it's just the pending state.

I'm fine with either, but that's how I think about it.

ryanflorence commented 3 years ago

Oops, accidentally submitted.

Additionally, everything else about loading in remix is "pending", pendingLocation pendingFormSubmit etc. and a Pending export falls within the same set of features.

But again, I'm fine with either.

mjackson commented 3 years ago

Yeah, I'm fine with either too. The way I think about it is "fall back to this when we aren't ready to show the component yet, for whatever reason".

kiliman commented 3 years ago

@kentcdodds

Is there a way for my loader to know whether it's being called for SSR or from a client-side render? If it did, then I could decide whether I want to skip loading the perf-problematic request during SSR. Not sure I like that any better than the .ssr = true idea though 🤔

Although neat in theory, the issue would be that it would break the "no javascript" case as the client would never request the SSR data after initial render. I think for "heavy" loaders, you would want to implement a server-side cache and return that... even serving up stale data if it's not critical.

norman-ags commented 2 years ago

Not sure if it's a bad idea, will just throw it here:

Is it possible to have the loader to be similar to getInitialProps of next? Or maybe have an option that after the initial load, the succeeding call would be client-side.

export let loader = {} => {
 // logic
}
// something like this: 
loader.clientSideOnSucceedingCalls = true
sergiodxa commented 2 years ago

@norman-ags check https://github.com/remix-run/remix/issues/179