Skip to content
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

Add support for clientLoader + Fallback #8092

Merged
merged 29 commits into from
Nov 29, 2023

Conversation

brophdawg11
Copy link
Contributor

@brophdawg11 brophdawg11 commented Nov 21, 2023

Depends on #8090

This will fail CI until we pin it to a React Router experimental release with the work in remix-run/react-router#11033

This is the third in a series of PRs to implement Client Data into a feature/client-data branch.

This PR adds support for clientLoader (which is the more complex of the two since clientAction doesn't need to worry about hydration flows).

  • If a route specifies a clientLoader by itself, we will not run the clientLoader on initial hydration
  • If a route specifies a clientLoader and sets clientLoader.hydrate=true, then we will call the clientLoader on hydration and display the deepest available HydrateFallback

Todo:

  • Integration tests
  • Docs + release notes in changeset

Copy link

changeset-bot bot commented Nov 21, 2023

🦋 Changeset detected

Latest commit: aed8736

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
@remix-run/dev Minor
@remix-run/react Minor
@remix-run/server-runtime Minor
@remix-run/testing Minor
@remix-run/cloudflare Minor
@remix-run/deno Minor
@remix-run/node Minor
@remix-run/cloudflare-pages Minor
@remix-run/cloudflare-workers Minor
@remix-run/architect Minor
@remix-run/express Minor
@remix-run/serve Minor
create-remix Minor
remix Minor
@remix-run/css-bundle Minor
@remix-run/eslint-config Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

This was referenced Nov 21, 2023
@brophdawg11 brophdawg11 changed the title Add support for clientLoader Add support for clientLoader + Fallback Nov 21, 2023
@sergiodxa
Copy link
Member

I understand that this Fallback component is kinda like the old Pending export RFC?

What happens if I export a Fallback but not a clientLoader? I imagine on SSR the component will render, but on a client-side navigation, will Remix renders the Fallback immediately while it waits for the server data? Or will I need to export a loader just do achieve that?

@brophdawg11
Copy link
Contributor Author

@sergiodxa Thanks for cross-linking that. Right now Fallback is only relevant during hydration when a clientLoader is present. Without a clientLoader the Fallback is irrelevant.

This same "navigate immediately" idea occurred to me though so I'm chatting with @ryanflorence on whether that's something we want to consider supporting as part of this work or something best kept as a separate undertaking. To some extent though, defer is the answer to "navigate immediately and show skeletons."

Comment on lines 126 to 178
// Full SSR - normal Remix behavior due to lack of Fallback components
await app.goto("/parent/child");
let html = await app.getHtml("main");
expect(html).toMatch("Parent Component");
expect(html).toMatch("Parent Server Loader");
expect(html).toMatch("Child Component");
expect(html).toMatch("Child Server Loader");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, I understand the flow will be

  1. SSR with server loaders
  2. Hydration with server data
    And client loader will be ignored until the user navigates back to this route client-side only?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah - clientLoader's are always called on SPA navigations. They are skipped on initial hydration by default, and called on hydration when a Fallback exists.

let app = new PlaywrightFixture(appFixture, page);

// Renders parent fallback on initial render and calls parent clientLoader
// Does not call child clientLoader due to lack of Fallback
Copy link
Member

@sergiodxa sergiodxa Nov 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a parent route fallback should enable child route client loaders to run, because the fallback can wait for any nested route client loader too.

Similar to how Suspense works, if I have:

<Suspense fallback={<Fallback />}>
  <ParentRoute>
    <ChildRoute />
  </ParentRoute>
</Suspense>

The Suspense will render the fallback until both ParentRoute and ChildRoute renders.

But thanks to <Outlet /> Remix can enable the ChildRoute to render inside the ParentRoute fallback component too, similar to this:

let child = (
  <Suspense fallback={<ChildFallback />}>
    <ChildRoute />
  </Suspense>
)

<Suspense fallback={<ParentFallback>{child}</ParentFallback>}>
  <ParentRoute>
    {child}
  </ParentRoute>
</Suspense>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I've gone back and forth on this - planning to talk it over with @mjackson and @ryanflorence. On one hand I really like the fact that the current implementation allows me to answer the "does this run on hydration" question from the same file. But what you are proposing is more in line with Suspense concepts - but it makes it a bit harder to track down since now you need to walk up the route hierarchy yourself.

I also don't want to introduce uneccesary calls - for example if a child clientLoader doesn't want to run on hydration but some parent does - then we'd call the child clientLoader for no reason. But we are planning to provide a way to know in the clientLoader if it's the hydration call or not (currently via a custom header) - so you could short circuit manually:

function clientLoader({ request, serverLoader }) {
  if (isHydration(request)) {
    return serverLoader();
  }
  // logic for subsequent SPA navigations
}

@hi-ogawa
Copy link
Contributor

Has there been any discussion regarding HMR feature with clientLoader and clientAction?
For vite, I'm thinking HMR might just work by adding these two into this acceptExports list:

let acceptExports = isRoute
? ["handle", "meta", "links", "shouldRevalidate"]
: [];

There is a corresponding test case here and It might be interesting to see what happens if there are clientLoader and clientAction exports.

"app/routes/known-route-exports.tsx": js`
import { useMatches } from "@remix-run/react";
export const meta = () => [{
title: "HMR meta: 0"
}]
export const links = () => [{
rel: "icon",
href: "/favicon.ico",
type: "image/png",
"data-link": "HMR links: 0",
}]
export const handle = {
data: "HMR handle: 0"
};
export default function TestRoute() {
const matches = useMatches();
return (
<div id="known-route-export-hmr">
<input />
<p data-hmr>HMR component: 0</p>
<p data-handle>{matches[1].handle.data}</p>
</div>
);
}
`,

@brophdawg11 brophdawg11 mentioned this pull request Nov 29, 2023
2 tasks
Comment on lines +279 to +282
clientLoader.hydrate=true
export function HydrateFallback() {
return <p>Parent Fallback</p>
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the fallback going to be called HydrateFallback? Also we will need to add clientLoader.hydrate = true to every clientLoader we want to run on hydration?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the fallback going to be called HydrateFallback

Yeah, we went with HydrateFallback for a few reasons:

  • It's only relevant for initial document hydration when you opt-into the clientLoader executing on hydration (via clientLoader.hydrate=true)
  • Fallback/ClientFallback are misleading because they might imply that it will render during subsequent SPA navs which is not the case
  • The verbiage aligns with clientLoader.hydrate and ReactDOM.hydrateRoot

Also we will need to add clientLoader.hydrate = true to every clientLoader we want to run on hydration?

Yeah:

  • The default behavior matches the current remix behavior. Every route has a client loader in Remix today which is just the fetch() call - and those do not run on hydration because we already have the data. Exporting a clientLoader is basically you taking more control over this existing client side loader - but it does not change the behavior.
  • We believe clientLoader as an optimization is the more frequent use case (basically where clientLoader doesn't change the shape of the returned data)
    • Client side caching
    • Hitting an API directly on a SPA nav and bypassing the Remix BFF network hop

We think the use case where you want to run on hydration is likely less frequent since it eliminates your ability to SSR content, introduces more potential for CLS, etc.

@sergiodxa
Copy link
Member

sergiodxa commented Nov 29, 2023

Question, let's say I define a clientLoader in root because I want to know the user timezone, but I want to keep rendering the rest of the UI for nested routes, could I render an <Outlet> in the HydrateFallback to keep rendering nested routes?

@sergiodxa
Copy link
Member

Another question, regarding TS, should we do useLoaderData<typeof loader>() or useLoaderData<typeof clientLoader>()? What happens if they are different?


// Throw an error if a clientLoader tries to call a serverLoader that doesn't exist
if (initialData === undefined) {
throw new Error(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something we want to throw on or something we want to just return null / undefined as if there wasn't a loader? Reason this stands out to me is because it makes it seem like implementing shared behavior could become more difficult / leak implementation details.

If I wanted to have a route template that ensured a clientLoader that dealt with a global caching strategy was exported by default from all new routes, they would also have to have a loader that just returned null or the route wouldn't work until one was added.

Does that sound right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this too and almost removed it since right now we just return null in fetchServerLoader. The thing that felt maybe slightly different was in the current case, it's the framework calling it and we have to have a loader to handle the JS/CSS module loads. In the new flow, it's the user calling a function erroneously so it felt maybe more confusing to let it fail silently.

I could absolutely be convinced to remove this error path though - curious what @mjackson and @ryanflorence think

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO if I call a route serverLoader but I don't have one, it should throw so I either catch it or the error bubbles and got reported to my Sentry

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it's essentially the same as if you manually did fetch('/route-without-loader?_data=routes%2Froute-without-loader') which would throw

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

err....scratch that. That doesn't throw but returns a 400 with You made a GET request to "/" but did not provide a loader for route "routes/_index", so there is no way to handle the request.

Insert debate here on whether fetch should throw on non-200 responses 😂

@brophdawg11
Copy link
Contributor Author

brophdawg11 commented Nov 29, 2023

Question, let's say I define a clientLoader in root because I want to know the user timezone, but I want to keep rendering the rest of the UI for nested routes, could I render an <Outlet> in the HydrateFallback to keep rendering nested routes?

@jacob-ebey and I talked about this too. Right now, Outlet is effectively a no-op in a HydrateFallback because we only render the matches down to the highest appropriate fallback. In theory we don't have to do this - but I think the primary concern is if you render deeper in the tree, you lose some happy path guarantees. For example, today, useRouteLoaderData("root")/useMatches()[0].data will always have data if you have a root loader. If we start rendering route components beneath the root route before it's loader data exists - we break those happy path flows. (FWIW, you do still need to be defensive to make TS happy)

If you have use-cases where you need to render deeper but you also want to get some client data in the root UI - I think I'd just grab that data synchronously in the root Component - not in clientLoader. This works for timezone detection, reading from localStorage/etc. I'm not sure there's a super compelling use-case we've seen yet where you need to block root hydration for clientLoader and also render deeper during SSR.

@brophdawg11
Copy link
Contributor Author

Another question, regarding TS, should we do useLoaderData() or useLoaderData()? What happens if they are different?

Depends on the use case:

  • If you are not hydrating - that implies your loader and clientLoader return identical shapes - you can just use useLoaderData<typeof loader>() there
  • If you are hydrating, that implies they return different shapes - use await serverLoader<typeof loader>() to get the server data (inside clientLoader), and then useLoaderData<typeof clientLoader>() to get the render data.

@brophdawg11 brophdawg11 force-pushed the brophdawg11/2-restructure-client-routes branch from df152c2 to 3595819 Compare November 29, 2023 21:05
Base automatically changed from brophdawg11/2-restructure-client-routes to feature/client-data November 29, 2023 21:06
@brophdawg11
Copy link
Contributor Author

Now that I think we're mostly feature complete in this series of PRs, I'm going to get this merged into the feature/client-data branch and I'll get a new PR opened for the feature. If there's continued discussions on the above threads we can do them over there (I'll link it once opened)

@brophdawg11 brophdawg11 merged commit feb321e into feature/client-data Nov 29, 2023
9 checks passed
@brophdawg11 brophdawg11 deleted the brophdawg11/3-client-loader branch November 29, 2023 21:12
@brophdawg11 brophdawg11 mentioned this pull request Nov 29, 2023
4 tasks
@brophdawg11
Copy link
Contributor Author

New Feature Branch PR: #8173

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants