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

Split the skeleton template loaders to separate primary and secondary data functions #2130

Merged
merged 9 commits into from
Jun 5, 2024

Conversation

blittle
Copy link
Contributor

@blittle blittle commented May 17, 2024

The goal here is to make the template more self documenting:

  1. Force people to consider up front what to defer and what not to
  2. Default to a Promise.all() and a comment within queries to encourage parallel requests
  3. The secondaryData function is not async, so people can't await.

Checklist

  • I've read the Contributing Guidelines
  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've added a changeset if this PR contains user-facing or noteworthy changes
  • I've added tests to cover my changes
  • I've added or updated the documentation

Copy link
Contributor

shopify bot commented May 17, 2024

Oxygen deployed a preview of your bl-template-loaders branch. Details:

Storefront Status Preview link Deployment details Last update (UTC)
optimistic-cart-ui ✅ Successful (Logs) Preview deployment Inspect deployment June 5, 2024 4:56 PM
Skeleton (skeleton.hydrogen.shop) ✅ Successful (Logs) Preview deployment Inspect deployment June 5, 2024 4:56 PM
subscriptions ✅ Successful (Logs) Preview deployment Inspect deployment June 5, 2024 4:56 PM
custom-cart-method ✅ Successful (Logs) Preview deployment Inspect deployment June 5, 2024 4:56 PM
third-party-queries-caching ✅ Successful (Logs) Preview deployment Inspect deployment June 5, 2024 4:56 PM
vite ✅ Successful (Logs) Preview deployment Inspect deployment June 5, 2024 4:56 PM

Learn more about Hydrogen's GitHub integration.

Copy link
Contributor

@frandiox frandiox left a comment

Choose a reason for hiding this comment

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

While I like the convention to avoid sequential requests, this might make it a bit harder to create data dependencies: load primary data, then load secondary data using some primary data as argument. For example, get recommended products from a product.id in demo-store.

What about only extracting secondaryData and keeping primaryData in the original place so that the loader itself doesn't become a dummy function and you can pass information around? We keep the Promise.all in place but in the loader:

export async function loader(args: LoaderFunctionArgs) {
  // Fire secondary data requests early to do them in parallel
  // if they don't depend on primary data.
  const secondaryData = loadSecondaryData(args);
  
  // Load primary data in parallel:
  const [header] = await Promise.all([
    context.storefront.query(HEADER_QUERY, {
      cache: context.storefront.CacheLong(),
      variables: {
        headerMenuHandle: 'main-menu', // Adjust to your header menu handle
      },
    }),
    // Add other queries here, so that they are loaded in parallel
  ]);
  
  // Delay secondary data until here if it depends on primary data,
  // or it's loaded conditionally depending on primary data:
  if (!header) throw new Response();
  const secondaryData = loadSecondaryData(args, header);
  
  // You can keep looking at loader's return statement to see primary and static data:
  return {
    ...secondaryData,
    header,
    publicStoreDomain: context.env.PUBLIC_STORE_DOMAIN,
  }
}

Just trying to find something that feels Remixy and flexible but still gives enough nuances to avoid waterfalls 🤔

@blittle
Copy link
Contributor Author

blittle commented May 23, 2024

@frandiox I like it. Will discuss in the hangout today and report back.

@blittle
Copy link
Contributor Author

blittle commented May 23, 2024

@frandiox a few thoughts after discussion:

  1. Keeping primaryData and secondaryData in separate functions makes them slightly more self documenting and obvious.
  2. Adding an extra function also creates more indirection.
  3. Either route we go, seems maybe there is question about the function naming secondaryData and primaryData.

@blittle blittle force-pushed the bl-template-loaders branch 2 times, most recently from 6aa8f3c to a03a5d3 Compare May 29, 2024 21:26
Copy link
Contributor

@frandiox frandiox left a comment

Choose a reason for hiding this comment

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

Should we add some messaging in loadDeferredData about not throwing in that function? Like, avoid doing things like throw Error or throw redirect. Plus, perhaps adding .catch to the promises?

Otherwise we would have ugly unhandled rejections that might interfere with critical data 🤔 -- unsure about this though, can never remember what's the actual behavior around this in Remix loaders.

templates/skeleton/app/root.tsx Outdated Show resolved Hide resolved
examples/classic-remix/app/root.tsx Outdated Show resolved Hide resolved
@blittle
Copy link
Contributor Author

blittle commented May 30, 2024

@frandiox updated! Thx for the review!

Copy link
Contributor

@frandiox frandiox left a comment

Choose a reason for hiding this comment

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

I think this convention is great but it makes an issue with unhandled rejections a bit worse because we are firing "uncontrolled" requests before starting streaming.

For example, I'm testing with an artificial error for a deferred query. Specifically, adding the following to this line (make sure you build/dev packages/hydrogen):

    if (query?.includes('footer')) {
      throwErrorWithGqlLink({
        ...errorOptions,
        errors: [{message: 'artificial error for footer query'}],
      });
    }

Which, when loading / it shows this correctly from the root loader:

image

The page is also rendered in the browser without the footer. However, the tab hangs in some kind of infinite loop of errors in the dev tools:

image

This type of issue is now worse because deferred requests might throw even before Remix starts streaming (because we call loadDeferredData before loadCriticalData).


I think we should adopt a safer strategy by catching errors in deferred data requests and make them nullable in the frontend:

image image

The good news is that now that deferred data is all located in 1 function, it's easier to add the convention of .catch(() => null) or whatever we choose there!
We could print errors automatically (we already do this for GraphQL errors but not for network errors) so that they don't even need to call console.error?

Or we brainstorm other things like a storefront.safeQuery(...) that doesn't throw or something. But kind of nice to keep it without abstraction as .catch(() => null) I think?

@michenly
Copy link
Contributor

michenly commented May 31, 2024

interesting...I am encountering the same "infinite loop of errors in the dev tools" while doing something for Single Fetch. How timely!

@blittle
Copy link
Contributor Author

blittle commented May 31, 2024

@frandiox I think there's something unrelated causing the inf loop. I update Footer.tsx with <Await resolve={footerPromise} errorElement={<h1>it broke</h1>}> and it goes away. Additionally, swallowing the async query error is probably undesirable, because the dev might still want to do something with it in the browser. The fail state still gets serialized to the browser. The real issue here is if there's a synchronous error in loadDeferredData. But in that case, adding a simple .catch() isn't going to help. The only way to make sure that doesn't happen would be to wrap the whole thing in a sync try catch 🤔

@frandiox
Copy link
Contributor

frandiox commented Jun 3, 2024

I think there's something unrelated causing the inf loop. I update Footer.tsx with <Await resolve={footerPromise} errorElement={<h1>it broke</h1>}> and it goes away.

@blittle Yes, the error is probably not introduced here. But I think these changes increase the chance of showing it because we are not handling thrown errors in deferred data... I think?

Say there's an asynchronous error thrown from loadDeferredData while we await for loadCriticalData. Remix didn't have the chance to grab the promises of the deferred data yet because we didn't return from the loader (still awaiting loadCriticalData). What happens in this situation? I think it depends on the JS runtime (and its version), but it could even exit the process in something like Node.js .

Additionally, swallowing the async query error is probably undesirable, because the dev might still want to do something with it in the browser. The fail state still gets serialized to the browser.

I'm not sure if this is true? Maybe I'm misunderstanding it. If this is a 400-500 error or a network error, we don't return anything from storefront.query... we throw instead. If we throw and the user is not calling .catch, it will turn into an unhandled rejection that won't go to the browser.

That's why I'm suggesting we add .catch and return null instead, so that the browser gets the null and the user can do something with it (hide UI or show a fallback). Of course we could return a "default query result" from the .catch instead of null but I'm not sure if that's useful in a template?

-- GraphQL errors, on the other hand (status 200) will get serialized and there's no issue here. But the .catch won't swallow those because they are returned, not thrown.

The real issue here is if there's a synchronous error in loadDeferredData. But in that case, adding a simple .catch() isn't going to help. The only way to make sure that doesn't happen would be to wrap the whole thing in a sync try catch 🤔

We ensure sync errors don't happen in storefront.query source code. I think that should be enough? But the issue I'm referring to is asynchronous but still thrown.

@michenly
Copy link
Contributor

michenly commented Jun 3, 2024

First of all: being more defensive and adding catch for error is always a good thing 👍

But there are defin something going on with the remix upgrade in meantime as well.
I just went back to pre Remix 2.9 of our repo, create the same error and gets an Application Error in the browser.

Pre Remix 2.9
pre 2 9

Remix 2.9
remix 2 9

Much less scary then the infinite loop error that crash your browser. Since we cant really catch ALL the error ever, I am putting myself on the task to figure out a better way to handle this over all in our application.

@michenly
Copy link
Contributor

michenly commented Jun 3, 2024

Issue logged with Remix: remix-run/remix#9553
I think I will revert my PR #2152

@blittle
Copy link
Contributor Author

blittle commented Jun 3, 2024

@frandiox I assumed this wouldn't be an issue because we added this https://github.com/Shopify/hydrogen/pull/1318/files and subsquently this: https://github.com/Shopify/hydrogen/blob/main/packages/hydrogen/src/utils/callsites.ts#L43

So all promises from our API clients should handle uncaught promise exceptions?

@blittle
Copy link
Contributor Author

blittle commented Jun 3, 2024

First of all: being more defensive and adding catch for error is always a good thing 👍

Being defensive is good, but adding catch statements in the wrong layer prevents the error from being consumed.

@blittle
Copy link
Contributor Author

blittle commented Jun 3, 2024

Additionally, I can't get the express/node example to break with an uncaught promise exception from a loader:

export async function loader() {
  return defer({
    test: new Promise((resolve) => {
      throw new Error('it broke');
    }),
  });
}
export default function Index() {
  const {test} = useLoaderData();

  return (
    <>
      <Await resolve={test} errorElement={<div>another broke</div>}>
        {({test}) => <div>{test}</div>}
      </Await>
    </>
  );
}

It seems to me that remix now immediately adds a catch to promises passed to defer. If I add one outside the loader, the process does crash. So far in my testing it appears the best solution is to let the promise errors serialize to the browser and handle them there.

@frandiox
Copy link
Contributor

frandiox commented Jun 4, 2024

I assumed this wouldn't be an issue because we added this https://github.com/Shopify/hydrogen/pull/1318/files and subsquently this: https://github.com/Shopify/hydrogen/blob/main/packages/hydrogen/src/utils/callsites.ts#L43

That .catch still throws an error though, so the unhandled piece should continue 🤔

Being defensive is good, but adding catch statements in the wrong layer prevents the error from being consumed.

I guess we need to decide what's the right layer to consume the error. I'm not sure if sending an error for secondary data it to the browser is much better than a null, at least in production... I don't think the fallback component normally takes the error into account? Might be wrong though!

Additionally, I can't get the express/node example to break with an uncaught promise exception from a loader:

The situation would be more like this:

image

However, it looks like in latest Remix and workerd, unhandled rejections are indeed getting caught:

image image

So maybe we're good to go if we add errorElement to the suspense as you mentioned somewhere else?

… data functions

The goal here is to make the template more self documenting:

1. Force people to consider up front what to defer and what not to
2. Default to a `Promise.all(`) and a comment within queries to encourage parallel requests
3. The `secondaryData` function is _not_ async, so people can't await.
Copy link
Contributor

@michenly michenly left a comment

Choose a reason for hiding this comment

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

Looks like we might be missing 'Set-Cookie': await context.session.commit() in root. Unless its remove on purpose.

@michenly michenly merged commit 6bffcd4 into main Jun 5, 2024
12 checks passed
@michenly michenly deleted the bl-template-loaders branch June 5, 2024 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants