kit icon indicating copy to clipboard operation
kit copied to clipboard

Teleport data from server to client in universal `load` functions

Open Rich-Harris opened this issue 2 years ago • 11 comments

Describe the problem

One point of confusion with load functions is that universal loads run during SSR and upon hydration (and then for subsequent client-side navigations). This is most visible if you return something non-deterministic:

// src/routes/+page.js
export function load() {
  return {
    random: Math.random()
  };
}

The value of data.random inside +page.svelte will differ between the server-rendered HTML and the hydrated document. If it were a +page.server.js instead, the result of calling the load function would be serialized, and would therefore be consistent.

There are good reasons for re-running universal load functions upon hydration:

  1. It allows you to return different data between server and browser, in cases where that's desirable
  2. It means we don't need to serialize the output, which is often larger than the input (for example, on a project at the NYT we were doing statistical analysis of some JSON data. The input was large, in the 100s of kbs, but the output was huge — megabytes of moving averages and so on)
  3. We can return non-serializable objects such as components and stores, enabling advanced whizzbangery

(Note that event.fetch calls are not repeated — the responses are serialized into, and read from, the HTML.)

Despite that, it would be useful to have a mechanism to avoid re-running code in cases where you just want to use the same value between server and client, and are happy with the serialization constraints.

Describe the proposed solution

I propose a new event.teleport helper:

export function load({ teleport }) {
  const random = teleport(() => Math.random());
  return {
    random
  };
}
  • During SSR, the result of calling the callback would be serialized (using the same mechanisms we use for server load functions) and assigned to an automatically-generated ID
  • During hydration, teleport(...) would just return the data associated with the automatically-generated ID
  • During client-side navigation, teleport would just be fn => fn()

Automatically generating IDs requires that teleport be called synchronously inside the load body (and not be inside if blocks etc — in other words, hooks rules). In some circumstances that might be untenable, so users could specify a key (if a key is reused, we would throw an error):

// either this...
const random = teleport('random', () => Math.random());

// ...or this:
const random = teleport(() => Math.random(), 'random');

Bikeshedding alert

If the key is optional, having it be the second argument would be more logical. But having it be the first argument would result in neater code:

// prettier prefers string-first-function-second...
const object = teleport('object', () => ({
  answer: 42
}));

// ...to string-second-function-first:
const object = teleport(
  () => ({
    answer: 42
  }),
  'object'
)

Reusing entire load functions

If you wanted to, you could easily reuse the entire function body:

export const load = (event) => {
  return event.teleport(() => ({
    stuff: get_stuff(event.params.stuff),
    more_stuff: get_more_stuff(event.params.stuff),
  }));
};

Promises

We could use the same promise serialization mechanism we currently use:

export async function load({ teleport }) {
  const randomized = await teleport(async () => {
    const response = await fetch('https://api.example.com/things');
    const { things } = await response.json();
    return randomize(things);
  });

  return {
   randomized
  };
}

Alternatives considered

  • different name — keep, sticky, reuse etc
  • key-first, key-second, key-optional

Importance

nice to have

Additional Information

No response

Rich-Harris avatar Feb 21 '23 22:02 Rich-Harris

Explicit true for generated key?

const object = teleport(true, () => ({ answer: 42 }))

arxpoetica avatar Feb 21 '23 22:02 arxpoetica

that would defeat the object, really. if you're going to add true you may as well just add a key

Rich-Harris avatar Feb 21 '23 23:02 Rich-Harris

I like keeping the ID as a second param (always)

lukeed avatar Feb 22 '23 01:02 lukeed

Can you elaborate on "We could use the same promise serialization mechanism we currently use:"?

From the example snippets, I'd think teleport simply acts like

function teleport(input) {
  if (input_is_promise)
    return input.then((result) => {
     serialize_somehow(result);
     return result;
    })
  else {
    serialize_somehow(result);
    return result;
  }
}

PS, if someone's looking for more name suggestions https://github.com/sveltejs/kit/issues/3729 👀

gtm-nayan avatar Feb 22 '23 02:02 gtm-nayan

input would never be a promise, it would only ever be a function that returns a promise

Can you elaborate

Yeah, it would basically use the same logic we use for serializing data from a server load function: https://github.com/sveltejs/kit/blob/40e85888d7cf53fb4c92bdca9efaa79c1fe5c682/packages/kit/src/runtime/server/page/render.js#L489-L553

On the server it would be something like this:

const teleported = [];
const streamed = [];

function teleport(fn, id = uid++) {
  const result = fn();

  // this calls `devalue.uneval` with a replacer that deals with Promises
  const { data, chunks } = serialize(result);

  // `data` contains `__sveltekit_xyz123.defer(someid)` etc
  teleported.push(`__sveltekit_xyz123.teleported.set(${id}, ${data})`);

  // `chunks` is an `AsyncIterable<string>` where each string contains
  // `__sveltekit_xyz123.resolve(someid, somedata)`
  if (chunks) streamed.push(chunks); 

  return result;
}

On the client it would be this:

// during hydration
function teleport(fn, id = uid++) {
  return __sveltekit_xyz123.teleported.get(id);
}

// during navigation
function teleport(fn, id) {
  return fn();
}

Rich-Harris avatar Feb 22 '23 19:02 Rich-Harris

We could have two overloads for teleport, to have both nice formatting and optional key:

declare function teleport<T>(data: () => T): T
declare function teleport<T>(key: string, data: () => T): T

The implementation would then check which type is the first argument.

tmaxmax avatar May 10 '23 13:05 tmaxmax

Need this badly

Any kind of workarounds? It's slowing our app when we need to do the exact same request multiple times despite the data having not changed

AlbertMarashi avatar May 28 '23 14:05 AlbertMarashi

This would improve app mounting speeds significantly since it no longer needs to reload data loaded during SSR.

The overload which @tmaxmax suggested seems to be ideal, although I wonder what will happen if teleports are called in different orders from server/client side (eg: if blocks or etc) without an explicit key parameter?

Does this relate in any way to Svelte 5 rune syntax?

AlbertMarashi avatar Apr 19 '24 11:04 AlbertMarashi

Use case – JSON-RPC like API with tracing

We hit our API by batching multiple RPC requests in a single HTTP request. The body of the HTTP request is the array of RPC request bodies, which are JSON objects. For tracing, each RPC request is assigned separate trace ID and span ID.

This is incompatible with SvelteKit fetch caching in SSR. On the server, SvelteKit custom fetch caches responses in the HTML; during hydration, fetch retrieves the responses from the HTML cache. The problem is that the cache key is an hash of the request header and body. The HTTP request body includes each RPC's trace ID and span ID, which differ between SSR and hydration, resulting in cache misses.

For context, we are using @effect/rpc but I think this applies more generally.

giacomoran avatar Jun 06 '24 21:06 giacomoran