vishalbalaji / trpc-svelte-query-adapter

A simple adapter to use `@tanstack/svelte-query` with trpc, similar to `@trpc/react-query`.
71 stars 7 forks source link

How to stream queries to Pages/Components? #22

Closed dihmeetree closed 2 months ago

dihmeetree commented 8 months ago

Instead of doing the following (to prerender queries on the server):

// +page.ts
import { trpc } from '$lib/trpc/client';
import type { PageLoad } from './$types';

export const load: PageLoad = async (event) => {
  const { queryClient } = await event.parent();
  const api = trpc(event, queryClient);
  return {
    queries: await api.createServerQueries((t) => [
      t.authed.todos.all(),
      t.public.hello.get()
    ])
  };
};

I'd like to be able to stream the result of queries for example on my page. I'm new to Svelte, but I know that Svelte does support streaming data via promises. Apparently if you don't await the data in the server load function, you can use the await block on the client to show a loading fallback while the promise resolves.

For example (From: https://kit.svelte.dev/docs/load#streaming-with-promises):

// +page.svelte.ts
{#await data.streamed.comments}
  Loading...
{:then value}
  {value}
{/await}

In relation to createServerQueries, is there a way I can do streaming with it, so I can have faster page loads? Any advice/guidance would be super appreciated!

vishalbalaji commented 8 months ago

Hey @dihmeetree, to stream stuff from the load function in Sveltekit, you would need to return it as a nested property, something like so:

// +page.ts
import { trpc } from '$lib/trpc/client';
import type { PageLoad } from './$types';

export const load: PageLoad = async (event) => {
  const { queryClient } = await event.parent();
  const api = trpc(event, queryClient);
  return {
    streamed: { // Doesn't have to be named 'streamed'
      queries: await api.createServerQueries((t) => [
        t.authed.todos.all(),
        t.public.hello.get()
      ])
    }
  };
}

https://svelte.dev/blog/streaming-snapshots-sveltekit#stream-non-essential-data-in-load-functions

I've never tried to do it with queries before and am honestly not really sure if it will work as intended. Sounds really interesting, though.

I hope that this is helpful, give it a try and please update me if it works!

dihmeetree commented 8 months ago

Hmmm @vishalbalaji, wouldn't queries need to be returned as a promise? Look's like you're awaiting the api.createServerQueries function, which I don't think would work. Also not really sure how the queries would work as a promise on the front end tbh 😢

vishalbalaji commented 7 months ago

@dihmeetree You are right about the fact that you can't await queries, because the queries themselves are essentially stores and Svelte stores can only be created at the top level. However, one thing to note here is that createServerQuery is mainly just a wrapper around createQuery that calls queryClient.prefetchQuery and returns the query wrapped in a function, which SvelteKit's load function can serialize.

This means that you should be able to achieve what you are trying to do by streaming the prefetch call and creating the query manually in the component, somewhat like so:

// src/routes/+page.ts
import { trpc } from "$lib/trpc/client";
import type { PageLoadEvent } from "./$types";

export async function load(event: PageLoadEvent) {
  const { queryClient } = await event.parent();
  const api = trpc(event, queryClient);
  const utils = api.createUtils();

  return {
    // This query has to be disabled to prevent data being fetched again
    // from the client side.
    foo: () => api.greeting.createQuery('foo', { enabled: false }),
    bar: await api.greeting.createServerQuery('bar'),
    nested: {
      foo: (async () => {
        await new Promise((r) => setTimeout(r, 2000)); // delay to simulate a network response
        await utils.greeting.prefetch('foo');
      })(),
    }
  }
}
<!-- src/routes/+page.svelte -->
<script lang="ts">
  export let data;

  const foo = data.foo();
  const bar = data.bar();
</script>

<p>
  {#if $bar.isPending}
    Loading...
  {:else if $bar.isError}
    Error: {$bar.error.message}
  {:else}
    {$bar.data}
  {/if}
</p>

{#await data.nested.foo}
  <!--  -->
{/await}

<p>
  {#if $foo.isPending}
    Streaming...
  {:else if $foo.isError}
    Error: {$foo.error.message}
  {:else}
    {$foo.data}
  {/if}
</p>
<button on:click={() => $foo.refetch()}>Refetch foo</button>

I am demonstrating here using createQuery, but the prefetching would apply to each query in the server queries as well.

Here's a StackBlitz reproduction to see this in action: https://stackblitz.com/edit/stackblitz-starters-dvtn9s?file=src%2Froutes%2F%2Bpage.ts

Maybe this could be simplified by creating a separate abstraction around this, called createStreamedQuery, which will return an array with [query, prefetchQuery]. I envision this being used like so:

export async function load(event: PageLoadEvent) {
  const { queryClient } = await event.parent();
  const api = trpc(event, queryClient);
  const [comments, prefetchComments] = createStreamedQuery(...);

  return {
    api,
    comments,
    nested: {
      loadComments: await prefetchComments(),
    }
  }
}

And in the template:

<script lang="ts">
  export let data;
  const comments = data.comments();
</script>

{#await data.nested.loadComments}
  Loading comments...
{:then}
  <p>
    {#if $comments.isLoading || $comments.isFetching}
      Loading...
    {:else if $comments.isError}
      Error: {$comments.error.message}
    {:else}
      {$comments.data}
    {/if}
  </p>
  <button on:click={() => $comments.refetch()}>Refetch comments</button>
{/await}

Let me know if this is something that you see being useful in the library, but I think the above solution should be enough to resolve this issue.

Anyway, thanks a lot for bringing this to my attention, this is quite interesting and I hadn't thought this far when I was initially working on this library.

vishalbalaji commented 6 months ago

Hey @dihmeetree, closing this issue due to inactivity. Hope that your query was resolved.

dihmeetree commented 5 months ago

@vishalbalaji can we re-open this topic if you don't mind? I'm re-exploring TRPC and this package again. Apologies for not responding back to you.

Regarding your last post..your example unfortunately doesn't do what I'm looking for. Streaming involves the use of await in the .svelte page (i.e - https://svelte.dev/blog/streaming-snapshots-sveltekit). In your example, it looks like the foo query still runs on the client (after the timeout). This query should be fetched on the server only. Any refetches could be done on the client from there on out.

So the server load function should return a promise of the query and then the frontend uses await to stream the data to the page.

vishalbalaji commented 5 months ago

Hi @dihmeetree, I've been looking into this for the past couple of days now and found out that the client request seems to be occurring due to the tRPC request itself, as demonstrated with this example where I am trying to construct this scenario manually using the tRPC request and tanstack query:

// src/routes/+page.ts
import { trpc } from "$lib/trpc/client";
import type { PageLoadEvent } from "./$types";
import { createQuery } from "@tanstack/svelte-query";

async function foo(event: PageLoadEvent) {
  await new Promise((r) => setTimeout(r, 2000)); // delay to simulate a network response

  // non-trpc request
  // client request is not made
  return 'foo';

  // trpc-request
  // client request is made
  // const client = trpc(event)
  // return client.greeting.query('foo');
}

export async function load(event: PageLoadEvent) {
  const { queryClient } = await event.parent();
  const fooQuery = { queryKey: ['foo'], queryFn: () => foo(event) };

  return {
    foo: () => createQuery(fooQuery),
    streamedFoo: queryClient.prefetchQuery(fooQuery),
  }
}
<!-- src/routes/+page.svelte -->
<script lang="ts">
  export let data;
  const foo = data.foo();
</script>

<p>
  {#await data.streamedFoo}
    Streaming...
  {:then}
    {#if $foo.isPending || $foo.isLoading || $foo.isFetching}
      Loading...
    {:else if $foo.isError}
      Error: {$foo.error.message}
    {:else}
      {$foo.data}
    {/if}
  {/await}
</p>

<button on:click={() => $foo.refetch()}>Refetch foo</button>

Seems like it might be occurring due to the fact that the trpc call is happening on both client and server since we are calling it in +page.ts instead of +page.server.ts, which would involve creating a server caller, like detailed here: https://trpc.io/docs/server/server-side-calls.

Let me re-open this issue until I figure out a solution for this.

natedunn commented 3 months ago

I am awaiting (😏) any good ideas on this. Any further suggestions? I thought of using the server caller instead but I'd miss out on the query features that we use this library for. 😞

vishalbalaji commented 3 months ago

Hey @natedunn, sorry for the long standing issue. I haven't been able to look into this issue much because I've been a bit busy with work and other things the past few weeks.

From what I have seen though, the problem here seems to be an issue with tRPC itself. For some reason, the tRPC client doesn't dedup requests made on the server even if we use the fetch we get from the load function.

For example, this doesn't work as expected:

// +page.ts
import type { PageLoad } from "./$types";
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { Router } from "$lib/trpc/router";

export const load = (async (event) => {
  const api = createTRPCProxyClient<Router>({
    links: [httpBatchLink({
      fetch: event.fetch,
      url: `${event.url.origin}/trpc`,
    })]
  });

  return {
    waitFoo: (async () => {
      await new Promise((r) => setTimeout(r, 2000));
      return api.greeting.query('foo');
    })()
  };
}) satisfies PageLoad;
<script lang="ts">
  import type { PageData } from "./$types";

  export let data: PageData;
</script>

{#await data.waitFoo}
  Awaiting...
{:then foo}
  {foo}
{/await}

The client side request doesn't happen when doing the same with a regular API endpoint. If anyone has any insight into this, I would love to know why.

vishalbalaji commented 3 months ago

Hey all, just thought that I would post an update to this issue since its been open for way longer that it should have been. I recently got some spare time to look into this a bit more and here's what I have gathered so far.

First off, I came upon the realization that you can't really stream from a page.ts file like I had suggested in earlier comments. You can only stream requests from +page.server.ts. I could have sworn that I had streamed data from a +page.ts file before, but I might have just misunderstood something.

With this in mind, the steps to stream query data from the server become a bit simpler:

  1. Use a tRPC server caller in a +page.server.ts file to either fetch or stream requests.
  2. Use a reactive query in the +page.svelte file, with the enabled property set to false.
  3. Await the promise in the template to get the query's data, populate the query cache and then enable the query.

This, in implementation might look something like this:

// $lib/trpc/router.ts
import type { Context } from '$lib/trpc/context';
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

export const t = initTRPC.context<Context>().create();
export const router = t.router({
  getPosts: t.procedure.input(z.number()).query(async ({ input: id }) => {
    const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
    return res.json();
  }),

  getComments: t.procedure.input(z.number()).query(async ({ input: id }) => {
    await new Promise((r) => setTimeout(r, 5000)); // 5 second simulated delay
    const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}/comments`);
    return res.json();
  })
});

export const createCaller = t.createCallerFactory(router);

export type Router = typeof router;
// +page.server.ts
import { createContext } from '$lib/trpc/context';
import { createCaller } from '$lib/trpc/router';

export async function load(event) {
  const api = createCaller(await createContext(event));

  return {
    comments: api.getComments(1),
    post: await api.getPost(1)
  };
}
<!-- +page.svelte -->
<script lang="ts">
  import { page } from '$app/stores';
  import { trpc } from '$lib/trpc/client';
  import type { PageData } from './$types';

  export let data: PageData;

  const queryClient = data.queryClient;
  const api = trpc($page);

  const post = api.getPost.createQuery(1, { initialData: data.post });

  let enabled = false;
  $: comments = api.getComments.createQuery(1, { enabled });

  async function resolveQuery(key: QueryKey, promise: Promise<unknown>) {
    queryClient.setQueryData(api.getComments.getQueryKey(1, 'query'), await promise);
    enabled = true;
  }
</script>

<h1>{$post.data?.title}</h1>
<p>{$post.data?.body}</p>

<h2>Comments</h2>
{#await resolveQuery(data.comments)}
  Loading comments...
{:then}
  {#each $comments.data as comment}
    <p><b>{comment.email}</b>: {comment.body}</p>
  {/each}
{/await}

This in its raw form is a bit janky, especially if this needs to be repeated for multiple queries, but this can be done.

Now, I was thinking of modifying the implementation to have an abstracted version of this in the library. However, I took a look at the updated docs for svelte-query v5 and realized that they have changed the way on how reactive queries are to be done and are recommending using stores instead of the $ label. I feel that it would be better moving forward if I fixed the library to support that first and might be implementing support for store opts, potentially in the next few days.

But if you want to have streamed queries in the meantime, the above example still works as expected.