preactjs / preact-ssr-prepass

Drop-in replacement for react-ssr-prepass
49 stars 7 forks source link

`render` throws `Promise` for parameterized lazy components #55

Open dgp1130 opened 1 year ago

dgp1130 commented 1 year ago

I've been experimenting with Preact SSR and Suspense and tried to use preact-ssr-prepass but found render would always throw a Promise. I minified my test case and discovered this has to do with precise usage of lazy. I have a complete minimal repro here.

Essentially, this repo makes three attempts at defining a lazy component evaluated with Suspense. The first attempt looks like this:

function LazyComponent(): VNode {
    return <div>Hello, World!</div>;
}

const Comp = lazy(async () => LazyComponent);

// render as `<Suspense fallback={undefined}><Comp /></Suspense>`.

This attempt renders as you would expect, but also is kind of unnecessary. Let's get a little more complex with attempt 2.

function LazyComponent2(): VNode {
    const Comp2 = lazy(async () => {
        return () => <span>Hello, World!</span>;
    });

    return <Comp2 />;
}

// Render as `<Suspense fallback={undefined}><LazyComponent2 /></Suspense>`.

In this attempt we've moved the lazy call inside the component function to provide a bit more encapsulation. This attempt fails at render time and throws a Promise object directly with no error message. Not sure exactly what's wrong with this pattern, but clearly putting lazy inside the function breaks it. Maybe lazy can't be called at render time?

Let's try attempt 3, which is really just a justification for why you'd want to do this in the first place:

function ParameterizedLazyComponent({ id }: { id: number }): VNode {
    const Comp3 = lazy(async () => {
        const name = await getNameById(id); // Call an async function with a prop value.
        return () => <div>Hello, {name}!</div>;
    });

    return <Comp3 />;
}

// Does some async work, exactly what is not important here.
async function getNameById(id: number): Promise<string> {
    await new Promise<void>((resolve) => {
        setTimeout(resolve, 100);
    });

    return `Name #${id}`;
}

// Render as `<Suspense fallback={undefined}><ParameterizedLazyComponent id={1} /></Suspense>`.

This is the same as attempt 2, except it actually does some meaningful async work. This also fails with the same thrown Promise. Ultimately this is really what I want to do, invoke an async operation with a parameter which comes from a function prop. The only way I can see of to do this is to move the lazy call inside the component so it closes over the prop. However this pattern appears to break preact-ssr-prepass. lazy doesn't appear to provide any arguments to its callback, so I don't see any other obvious way of getting component prop data into the async operation.

I am new to Preact (and not terribly familiar with the React ecosystem in general) so apologies if this has a well-known answer. This feels like a rendering bug given that it is throwing a Promise without any error message. If there's a different pattern for developing parameterized lazy components, please let me know. As it stands, I don't see an obvious workaround here which does what I need it to do.

JoviDeCroock commented 1 year ago

Basically what you are doing here return () => <div>Hello, {name}!</div>; is referred to as component trashing. The reference to the component will always be new, if we for instance do the following at the top-level (not inside a component hence similarly you will call lazy on every render and trash the reference)

const NameComponent lazy(async () => ('./x.js'));

In the above lazy is used to code-split out the NameComponent so this will optimise your bundle. When you want to use a render-as-you-fetch approach as you are doing in the above you can do something along the likes of

const promiseCache = {};

const App = ({ id }) => {
  const [name, setName] = useState(() => {
    if (!promiseCache[id]) {
      return (promiseCache[id] = getNameById(id).then(name => {
        delete promiseCache[id];
        setName(name);
      }))
    } else {
      return promiseCache[id].then(name => {
        setName(name);
      })
    }
  })

  if (name.then) throw name;

  return <NameComponent name={name} />
}

So with lazy you can lazy-load components so they don't add bundle-size, this can be handy for routes/... a little example from my blog, what lazy does behind the hood is what I added within the function body of App it creates a Promise and throws it to the closest Suspense boundary which will render the fallback until the promise completes. The promiseCache is intended so we have an append-only cache of results that can be shared among components so we can resolve these boundaries at the same time.

I hope this helps!

EDIT: with signals I have created a wrapper in the past that is in RFC, in case you want to try something like that https://github.com/preactjs/signals/compare/async-resource?expand=1

EDIT2: I also made a small demo in the past how this approach can help Preact look at resumed hydration because it can hydrate up until a thrown promise and resume after. The useApi part abstracts that latter throw/fetch mechanism

developit commented 1 year ago

@JoviDeCroock's answer is the correct one - this isn't really what lazy() is for. You can contort it into the right shape using a cache if you want:

const cache = new Map();
function cachedLazy(load, parameters = []) {
  const cacheKey = parameters.join('\n'); // or JSON.stringify or whatever you prefer

  const cached = cache.get(cacheKey);
  if (cached) return;

  const comp = lazy(() => load(...parameters));
  cache.set(cacheKey, comp);
  return comp;
}

Usage:

function ParameterizedLazyComponent({ id }: { id: number }): VNode {
    const Comp3 = cachedLazy(async (id) => {
        const name = await getNameById(id); // Call an async function with a prop value.
        return () => <div>Hello, {name}!</div>;
    }, [id]);

    return <Comp3 />;
}
dgp1130 commented 1 year ago

Thank you both for the quick response. I think I'm understanding now. I didn't realize my tree was getting rendered twice from preact-ssr-prepass and preact-render-to-string together and required component references to be stable.

Is lazy only intended for lazy loading components, not their data? Should it only be used for the documented case of:

const Comp = lazy(async () => await import('./my-component.js'));

I get the impression I'm using the wrong primitive for what I want, but I'm not seeing an obvious alternative.

I was able to get something working with a modified form of your cachedLazy suggestion. Although I'm a little worried this will leak memory given that components are never removed the cache. I suppose they could be removed on the second read, assuming that a component will always be rendered exactly twice (not sure if that's a safe assumption to make). I feel like that might still leak memory if a component threw an error on the second render and blocked other components from being looked up in the cache and removed? Would this approach be safe for a long-lived server or a program which renders a large number of async components?

const cache = new Map();
function cachedLazy(load, parameters = []) {
  const cacheKey = parameters.join('\n'); // or JSON.stringify or whatever you prefer

  const cached = cache.get(cacheKey);
  if (cached) {
    cache.delete(cacheKey); // Remove from the cache when read.
    return cached;
  }

  const comp = lazy(() => load(...parameters));
  cache.set(cacheKey, comp);
  return comp;
}

Not sure how useful or actionable this issue is, feel free to close if it isn't. My immediate thought of potential outcomes from this would be:

JoviDeCroock commented 1 year ago

I encourage you to re-read my message, your use-case is most definitely supported but it's an in-render process.

preact-ssr-prepass will traverse the tree until no more promises are thrown hence you can combine loading lazy and an in-render promise for data.

My example is simplified but you can create a top-level PromiseCache by doing for instance:

const render = () => {
  prepass(
    <CacheProvider><App />
  )
}

especially the useApi part caters to that where the cache is provided only for pre-rendering. This is a data-cache and all the components themselves are lazily loaded.

dgp1130 commented 1 year ago

Ah ok, IIUC, you're saying I really shouldn't be using lazy at all and instead throw the Promise to await the task and use the cache to pick up where it left off. To rewrite this in my own style and confirm my understanding:

const map = new Map<number, string>();

function ParameterizedLazyComponent({ id }: { id: number }): VNode {
    const name = map.get(id);

    // If we didn't previously cache a name, then we need to do the async work to get it.
    // We throw the `Promise` so the nearest `<Suspense />` waits and rerenders when it is resolved.
    // On subsequent renders, `name` is found.
    if (!name) {
        throw getNameById(id).then((name) => { map.set(id, name); });
    }

    return <div>Hello, {name}!</div>;
}

Am I following this correctly? Is there any documentation on this throw Promise pattern? Trying to search more about this, I was surprised to find that there don't seem to be common patterns for doing this kind of thing. React's documentation seems to handwave a lot of this complexity away and talk about "Suspense-enabled data fetching libraries". Does that just mean they throw Promise objects?

While this seems to work, I noticed that your approach is caching the Promise directly which could be necessary if the component is rendered twice before the Promise resolves. Is that possible? I'm not seeing that happen in my simple case, but maybe more complex component trees can do that?

I tried an alternative approach with useState which felt more idiomatic:

function ParameterizedLazyComponent({ id }: { id: number }): VNode {
    const [ name, setName ] = useState<string | undefined>();

    if (!name) {
        throw getNameById(id).then((n) => { setName(n); });
    }

    return <div>Hello, {name}!</div>;
}

Unfortunately this doesn't actually work. The component renders three times (two from preact-ssr-prepass, and the third from preact-render-to-string). The first render initializes the state, the second reads it, but then the third does not retrieve the preexisting state and calls getNamedById again. I'm guessing that's working as intended since these are two different packages which don't inherently share state outside the VDom tree.

Regarding the memory leak, I also tried deleting the item from the cache after it is read, but that doesn't work because there are three renders, not two. Deleting the cache item after the first read breaks the third render. I suspect the number of renders wouldn't be consistent anyways due to a varying number and structure of other components suspending. So I'm still not clear on how to deal with the memory leak there?

To back up a bit, my actual objective is a static-site generator with Preact/JSX as the templating engine (called @rules_prerender). My goals here are two fold:

  1. Do some async work in the documentation site. In this case, it is reading a markdown file and converting it to HTML to embed inside a Preact component, which I was hoping to use <Suspense /> for.
  2. Figure out common patterns for users of my SSG toolchain to leverage for their own async work.

It feels like maybe the better approach is to do a fetch-then-render architecture so I avoid the <Suspense /> altogether? It's certainly feasible for my use case and might make more sense than <Suspense /> in general. Particularly given that fallback content is kind of meaningless in this context, I always want to wait for the fully rendered page.

Based on Preact's documentation, React's note that data fetching is not really supported with Suspense, and your own blog post it seems like data fetching with <Suspense /> isn't a very well-trodden path at the moment? I would love to do a fetch-as-you-render approach which seemed to be the objective with <Suspense /> and would have the ideal performance for SSR/SSG and potentially work with streaming, but it seems like this isn't really feasible today?

Sorry, I'm starting to ramble and talk about higher-level problems I need to solve for @rules_prerender. Just trying to give some context if that's helpful at all. Feel free to close this issue since "parameterized lazy components" doesn't really seem like the right mechanism for solving the more general async data loading problem I actually have.

JoviDeCroock commented 1 year ago

Well if you want to make this into a reusable piece it is most definitely possible, i.e. like the linked useApi I had in both previous messages you can provide something like that to your consumers. This can facilitate the whole render-as-you-fetch during the server.

Yes, your component is execute multiple times...

Preact-ssr-prepass executes the components until no more promises throw

There is a different option here where you for instance allow your users to provide a function that asynchronously gets the data, some loader-like pattern as Suspense for me is more for SPA-like applications.

Data-fetching libraries like urql indeed implement throwing Promises, I think one of the personal reasons why I have not written a lot about Suspense would be that it's a compat feature and there should be better primitives that I have yet to figure out. Like the asyncComputed I linked in my other message.

I feel your pain here though hence I am very open to ideate with you.

Btw, have you checked out fresh might be a good source of inspiration as well