sveltejs / kit

web development, streamlined
https://svelte.dev/docs/kit
MIT License
18.72k stars 1.94k forks source link

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

Open Rich-Harris opened 1 year ago

Rich-Harris commented 1 year ago

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
  };
}

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

Importance

nice to have

Additional Information

No response

arxpoetica commented 1 year ago

Explicit true for generated key?

const object = teleport(true, () => ({ answer: 42 }))
Rich-Harris commented 1 year ago

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

lukeed commented 1 year ago

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

gtm-nayan commented 1 year ago

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 👀

Rich-Harris commented 1 year ago

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();
}
tmaxmax commented 1 year ago

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.

AlbertMarashi commented 1 year ago

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 commented 6 months ago

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?

giacomoran commented 5 months ago

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.

rChaoz commented 1 month ago

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

Why not both using overloads? I've definitely seen this pattern before:

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

// implementation...
AlbertMarashi commented 3 weeks ago

@Rich-Harris the key is optional if we transform the code to include a unique file location (file, line, column) hash for the teleport method.

teleport(() => ...)
// turns into
teleport(() => ..., GENERATED_HASH)