solidjs / solid

A declarative, efficient, and flexible JavaScript library for building user interfaces.
https://solidjs.com
MIT License
32.24k stars 920 forks source link

Add low-level suspense primitive #1877

Open thetarnav opened 1 year ago

thetarnav commented 1 year ago

A low-level suspense primitive would be useful for libraries to pause effects in "offscreen" branches.

Currently Suspense is not only a component-only primitive, but it is tied to resources, and by extend to routing, SSR, transitions, etc. It also assumes needing a fallback branch, which is not always needed. Because of that it cannot be freely used in libraries, without affecting the rest of the app as a side effect.

For example in transition libraries like motionone and solid-transition-group, when we use <Transition> with mode="out-in", we need to wait for the previous element finish his exit animation, before the newly rendered element can be added to the DOM. But solid doesn't know that the new element is only kept in memory, and not yet appeared on the page, so the updates queue will proceed normally, calling all onMount callbacks, where we expect to deal with elements connected to the DOM. An issue with more details: https://github.com/solidjs-community/solid-transition-group/issues/34

Also if we wish to keep some roots in memory—e.g. to avoid recreating the same elements when filtering a large array, or displaying search highlights, or implementing a root pool primitive—there is no way to simply prevent then from running some side effects.

If we tried to use <Suspense> to suspend those branches, any resource read under it will also trigger it, possibly breaking the intended behavior of the app—by not showing a fallback in an expected place, or causing a transition (transaction) to be exited sooner (<Transition> is commonly used for wrapping rendered routes).

Code example `createSuspense` using `Suspense`: https://playground.solidjs.com/anonymous/21cef751-8f37-4354-8a63-0aa475d54e64 ```ts function createSuspense(when: Accessor, fn: () => T): T { let value: T, resolve = noop; const [resource] = createResource( () => when() || resolve(), () => new Promise((r) => (resolve = r)), ); Suspense({ // @ts-expect-error children don't have to return anything get children() { createMemo(resource); value = fn(); }, }); return value!; } ```

In @fabiospampinato's oby this is solved by having a low-level suspense primitive that simply takes a function to suspend and a boolean signal to inform if the branch should be suspended or not, and returns the value directly, similar to createRoot. When the condition signal is true, all effects will be suspended, while resources ignore it and keep the lookup for Suspense they can trigger.

Code Something like this could *maybe* be implemented currently as this: (although I'm not sure if resources won't trigger it anyway) ```ts function suspense(when: Accessor, fn: () => T): T { const SuspenseContext = getSuspenseContext(), store = { effects: [] as Computation[], resolved: false, inFallback: when }; let result!: T; SuspenseContext.Provider({ value: store, get children() { result = fn(); createMemo(() => { if (!when()) { store.resolved = true; resumeEffects(store.effects); } return result; }); return undefined; } }) as any; return result; } ```
fabiospampinato commented 1 year ago

Definitely worth adding, imo.

There's another related primitive that looks like it's kinda necessary, suspended, which basically tells you when entering and exiting suspense. That's useful because under an active suspense boundary side effects should be paused, but side effects and "createEffect" instances are not the same thing, "createEffect" merely wraps a side effect, it's not one.

For example in the code below the actual side effect is the "fn" function, suspending the "createEffect" will do nothing to the actual side effect, this requires some manual pausing/resuming of the interval.

createEffect ( () => {
  const intervalId = createInterval ( fn, 100 );
  onCleanup ( () => {
    clearInterval ( intervalId );
  });
});
ryansolid commented 1 year ago

Yeah Ive written about this a bit here: https://hackmd.io/NDrB5kP2QjGUrwYo2DwQ0w

I don't think Suspense is the lowest primitive here. It has very specific behaviors. Behaviors I don't think should change really, but it isn't the only problem of its kind.

That being said transition group issue sounds like I made a poor implementation. Likely the only one available to me at the time. I do like that bounded flushing also seems like the solution here.

Ultimately I think we solve this, but as something independent of Suspense. More fundamental.

katywings commented 1 year ago

I think this issue might be related, since its also about the timing of Suspense vs transition-group: https://github.com/solidjs-community/solid-transition-group/issues/38

fabiospampinato commented 1 year ago

There seems to be another interesting component that this would unlock: one that doesn't render a fallback branch at all, it just suspends children, which makes the unsuspension cheaper, potentially by a lot.

Tronikelis commented 1 year ago

I am porting react's swr library to solid solid-swr and without createResource I can't add suspense support to my hooks. Just want to mention that this would help me add suspense support to solid-swr 👍

If anybody is interested https://github.com/Tronikelis/solid-swr

Tronikelis commented 1 year ago

This is how I am currently abusing the createResource hook to enable suspense for my library, it works but feels like such a hack though:

/**
 * monkey-patches the `createResource` solid hook to work with `useSWR`
 */
const useSWRSuspense: typeof useSWR = (key, options) => {
    const swr = useSWR(key, options);

    let resolveResource: undefined | ((val?: never) => void);

    const [resource] = createResource(() => {
        return new Promise<undefined>(r => {
            resolveResource = r;
        });
    });

    // not in an createEffect, because suspense disables them
    (async () => {
        await swr._effect(); // this never throws
        resolveResource?.();
    })().catch(noop);

    const dataWithResource = () => {
        resource();
        return swr.data();
    };

    return {
        ...swr,
        data: dataWithResource,
    };
};

export default useSWRSuspense;