ivi icon indicating copy to clipboard operation
ivi copied to clipboard

Blueprints-based server-side rendering

Open katyo opened this issue 5 years ago • 10 comments

I have a components which should be rendered asynchronously.

For example, under first rendering it triggers data loading (using props) and returns progress. When data is available it invalidates component to trigger re-rendering using loaded data.

I have an issue with implementing of server-side rendering.

I think the blueprint-based rendering can help to implement isomorphic apps.

Of course there is still one unsolved problem: how to notify server about end of all asynchronous operations.

katyo avatar Mar 22 '19 13:03 katyo

I see three possible solutions:

  1. Using normal rendering (with "browser" target) with something like undom (easy way).
  2. Introducing thin platform layer in ivi (like a rax's driver-api) (hard way).
  3. Introducing new target called "blueprint" in ivi which will implement blueprints-based rendering.

katyo avatar Mar 22 '19 18:03 katyo

I think that it is possible to use the same "SSR" target and implement blueprints-based rendering with a different function. Maybe something like this:

const result = await renderSSR( // Just a random function name for now
  Main(), // Root component
  onUpdate, // Signal that will be used to notify when it should perform dirty checking
  onFinish, //  Signal that will be used to notify when it should serialize to string
);

With onUpdate signal it will be possible to move server-side code from components into global store and use dirty checking useSelect() to retrieve data from this store. For example:

const Row = component((c) => {
  const getAsyncData = useSelect(c, (id) => (
    STORE.getRowData(id) // when row data isn't available, STORE will start fetching it
                         // from DB and increment internal `dependency` counter, when
                         // it finishes fetching, it should decrement `dependency` counter and
                         // if this counter is equal to 0, then send `onFinish` signal, otherwise
                         // it should send `onUpdate` signal and trigger an update
  ));

  return (id) => {
    const data = getAsyncData(id);
    return data !== void 0 ? div(_, _, data.text) : ProgressIndicator();
  };
});

Supporting invalidate() use cases is a little bit harder, I think that it would require adding something like useServerEffect() that should track unresolved dependencies and use another dependency counter in renderSSR() state. For example, when onFinish signal is undefined, then renderSSR() should wait until its own dependencies are resolved, otherwise it should wait for its own dependencies and onFinish signal before serializing to string.

Also, because it adds a nondeterminism to server side rendering, it can cause problems with autogenerated ids(forms/etc) for hydration (if hydration is implemented).

localvoid avatar Mar 23 '19 02:03 localvoid

This would be really helpful.

In my case I already have mechanism which allows track finishing of async processing. But because my app consists of async components I cannot simply use render-to-string without changing its architecture. I means I would like at least separating rendering and html formatting.

katyo avatar Mar 23 '19 04:03 katyo

I am curious, have you seen any libraries that has such feature? It would be great to go through their source code to get a better understanding of this problem space.

I've started implementing basic primitives for diff/patch SSR in experimental-ssr branch (there are probably many bugs and it isn't optimized).

API

It is an experimental API. I don't like function names, just don't want to waste time on function names right now.

// Creates a stateful tree
function renderSSR(op: Op): RootSSR;

// Updates a stateful tree
function updateSSR(root: RootSSR, next: Op): void

// Performs a dirty checking and updates a stateful tree
function dirtyCheckSSR(root: RootSSR): void;

// Adds invalidate handler that will be invoked when `invalidate()` function is executed
function setInvalidateHandler(h: () => void);

// Serialize stateful tree to HTML string
function serializeSSR(opState: OpState): string;

Basic Example

const root = renderSSR(Main(stateA));
updateSSR(root, Main(stateB));

console.log(serializeSSR(root.state));

Custom Invalidate Handler

async function handleRequest() {
  const r = { root: null };
  setInvalidateHandler(() => {
    dirtyCheckSSR(r.root);
  });
  r.root = renderSSR(Main(state));
  await allDependenciesResolved();
  return serializeSSR(r.root.state);
}

localvoid avatar Mar 23 '19 06:03 localvoid

I have something like this working on next release of Dyo.

The two things that allow this are:

  1. the callback in render(element, target, callback) is always invoked after all sync/async dependencies have resolved, this means suspense boundaries and promise elements.

  2. target in render(element, target, callback) could be any object {}, in which case it shims a noop renderer if it doesn't conform to a renderer spec(limited DOM apis).

The server-side render is implemented by hooking into the callback and serializing the tree after the render has settled.

thysultan avatar Mar 23 '19 10:03 thysultan

@thysultan

First of all, I haven't put alot of effort into researching this topic, it is just some random thoughts.

Are you planning to rely just on suspense boundaries and promises?

If it is an "isomorphic" components, on the client side there could be use cases with streams that can pull either stale data or future data for optimistic updates, so I think that relying on suspense boundaries and promises won't be enough. If it is an observable stream, it is safe to assume that on the server side some streams can be shared among different requests, so server side renderer should also trigger unmount hooks to properly clean up everything. Also, if it requires to properly cleanup everything, simple bugs in the userspace code on the server-side will often lead to memory leaks and crash entire server.

And if it is not an "isomorphic" components, it is better to use old techniques that use data dependency graphs to reduce data IO issues, instead of trying to render UI multiple times to generate inefficient data dependency graph.

localvoid avatar Mar 24 '19 05:03 localvoid

@localvoid

Are you planning to rely just on suspense boundaries and promises?

Yes suspense boundaries with createResource/useResource are the perfect primitives that also avoid leaking into the global namespace, given their per target isolation, which translates perfectly to per request/response target in a server context.

thysultan avatar Mar 24 '19 13:03 thysultan

@localvoid

The experimental SSR API looks pretty fine. But it seems you forget export ssr/diff.ts. And I think it will be appropriate to set invalidate handler by default as you described above.

katyo avatar Mar 25 '19 05:03 katyo

@katyo

The experimental SSR API looks pretty fine.

There are probably many different edge cases that I haven't thought about, this topic requires alot of research since there aren't any widely adopted general purpose solutions. For example, right now it doesn't execute useEffect() hooks and doesn't trigger unmount hooks, but I think that they should be executed to support observable streams.

But it seems you forget export ssr/diff.ts.

I've exported it here: https://github.com/localvoid/ivi/blob/dbbd301b0608c02533bf3cc6b6d16b822e27fc45/packages/ivi/src/index.ts#L161-L163

And I think it will be appropriate to set invalidate handler by default as you described above.

I think that it should also be wrapped in a context that will be able to catch unhandled exceptions and trigger unmounting. It would require alot of time to properly analyze all edge cases and create a good API.

localvoid avatar Mar 25 '19 05:03 localvoid

Oh, I see, excuse me.

In my practice I haven't met cases of using useEffect() on server. As I understand this hook intended for interactive mode of UI which server side doesn't implied.

katyo avatar Mar 26 '19 09:03 katyo