query icon indicating copy to clipboard operation
query copied to clipboard

Tanstack query results are not always reactive in SolidJS as they sometimes return a non-proxy object

Open apatrida opened this issue 9 months ago • 6 comments

Describe the bug

In SolidJS / Solid-Start (current versions 1.8.5 / 0.3.10) and current tanstack (5.8.3) if I take the results of a tanstack query and put them into a provider they are not reactive. But the results of routeData Resource is reactive.

In the following code, all values update other than the nested component using the context provider for the tanstack query.

import {RouteDataFuncArgs, useNavigate, useParams, useRouteData, useSearchParams} from "@solidjs/router";
import {isServer} from "solid-js/web";
import {createQuery, QueryFunctionContext, useQueryClient} from "@tanstack/solid-query";
import {createContext, createEffect, onCleanup, onMount, Show, useContext} from "solid-js";
import server$, {createServerData$} from "solid-start/server";

type QueryResultType = { message: string };

const queryOneFunction = server$(async (ctx: QueryFunctionContext): Promise<QueryResultType> => {
        console.debug("queryOne server call", ctx.queryKey);
        return { message: `${ctx.queryKey}` };
    }
);

const manualQuery = server$(async (id: number) => {
    const queryKey = ['queryTwo', id];
    console.debug("queryTwo server call", queryKey);
    return { message: `${queryKey}` };
})

const dataContext = createContext<QueryResultType>();

export function routeData(args: RouteDataFuncArgs) {
    console.log("routeData", isServer ? "on server" : "on client", "id=", args.location.query.id ?? 0);
    if (isServer) {
        const id = Number(args.location.query.id ?? 0);
        if (id == 0) {
            console.debug("redirect to start");
            args.navigate('/reactivity?id=1');
            return;
        }
        const queryClient = useQueryClient();
        void queryClient.prefetchQuery({
            queryKey: ['queryOne', id],
            queryFn: queryOneFunction,
            staleTime: 20 * 1000,
        }).then(() => {
            console.debug("routeData", isServer ? "on server" : "on client", "PRELOAD COMPLETE")
        })
    }

    return createServerData$(async ([id])=> {
        const queryKey = ['queryTwo', id];
        console.debug("queryTwo server call", queryKey);
        return { message: `${queryKey}` };
    }, { key: () => [Number(args.location.query.id ?? 0) ], deferStream: true });
}

export default function TestReactivity() {
    const [searchParams, setSearchParams] = useSearchParams();
    const id = () => Number(searchParams.id ?? 0);

    const queryOne = createQuery(() => ({
        queryKey: ['queryOne', id()],
        queryFn: queryOneFunction,
        staleTime: 20 * 1000,
        placeholderData: (d)=>d
    }));

    const queryTwo = useRouteData<typeof routeData>()!;

    createEffect(() => {
        console.debug("queryOne state", queryOne.isFetching, queryOne.isSuccess, queryOne.isError, JSON.stringify(queryOne.data));
    });

    createEffect(() => {
        console.debug("queryTwo", queryTwo.loading, queryTwo.state, JSON.stringify(queryTwo()));
    });

    onMount(()=> {
        const timer = setInterval(() => {
            let nextId = id() + 1;
            if (nextId === 11) {
                nextId = 1;
            }

            setSearchParams({id: nextId});
        }, 1000)

        onCleanup(()=>clearInterval(timer));
    });

    return <><div>
        <div>Test Reactivity</div>
        <br/>
        <Show when={queryOne.data}>
            <div>tanStack in component: {queryOne.data?.message ?? ''}</div>
        </Show>
        <Show when={queryOne.data}>
            <dataContext.Provider value={queryOne.data}>
                <SubComponent />
            </dataContext.Provider>
        </Show>
        <Show when={queryTwo()}>
            <div>routeData in component: {queryTwo()!.message}</div>
        </Show>
        <Show when={queryTwo()}>
            <dataContext.Provider value={queryTwo()}>
                <SubComponent />
            </dataContext.Provider>
        </Show>
        <br/>
        <div>Test Reactivity</div>
    </div></>
}

function SubComponent() {
    const data = useContext(dataContext);
    return <>
        <div>Using provider: {data?.message}</div>
    </>
}

Your minimal, reproducible example

see in description

Steps to reproduce

see test case in main description.

Expected behavior

The results are reactive via a provider.

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

all /a nyh

Tanstack Query adapter

solid-query

TanStack Query version

5.8.3

TypeScript version

4.4.9

Additional context

No response

apatrida avatar Nov 14 '23 15:11 apatrida

The problem is that the first time data is returned (in this case very quickly) it is NOT a proxy, but later it is. And the first run breaks the reactive graph.

Screenshot 2023-11-14 at 12 24 56 PM

apatrida avatar Nov 14 '23 18:11 apatrida

updated code with the logging:

import {RouteDataFuncArgs, useRouteData, useSearchParams} from "@solidjs/router";
import {isServer} from "solid-js/web";
import {createQuery, QueryFunctionContext, useQueryClient} from "@tanstack/solid-query";
import {createContext, createEffect, onCleanup, onMount, Show, useContext} from "solid-js";
import server$, {createServerData$} from "solid-start/server";

type QueryResultType = { message: string };

const queryOneFunction = server$(async (ctx: QueryFunctionContext): Promise<QueryResultType> => {
        console.debug("queryOne server call", ctx.queryKey);
        return { message: `${ctx.queryKey}` };
    }
);

const manualQuery = server$(async (id: number) => {
    const queryKey = ['queryTwo', id];
    console.debug("queryTwo server call", queryKey);
    return { message: `${queryKey}` };
})

const dataContext = createContext<QueryResultType>();

export function routeData(args: RouteDataFuncArgs) {
    console.log("routeData", isServer ? "on server" : "on client", "id=", args.location.query.id ?? 0);
    if (isServer) {
        const id = Number(args.location.query.id ?? 0);
        if (id == 0) {
            console.debug("redirect to start");
            args.navigate('/reactivity?id=1');
            return;
        }
        const queryClient = useQueryClient();
        void queryClient.prefetchQuery({
            queryKey: ['queryOne', id],
            queryFn: queryOneFunction,
            staleTime: 20 * 1000,
        }).then(() => {
            console.debug("routeData", isServer ? "on server" : "on client", "PRELOAD COMPLETE")
        })
    }

    return createServerData$(async ([id])=> {
        const queryKey = ['queryTwo', id];
        console.debug("queryTwo server call", queryKey);
        return { message: `${queryKey}` };
    }, { key: () => [Number(args.location.query.id ?? 0) ], deferStream: true });
}

export default function TestReactivity() {
    const [searchParams, setSearchParams] = useSearchParams();
    const id = () => Number(searchParams.id ?? 0);

    const queryOne = createQuery(() => ({
        queryKey: ['queryOne', id()],
        queryFn: queryOneFunction,
        staleTime: 20 * 1000,
        // placeholderData: (d)=>d
    }));

    const queryTwo = useRouteData<typeof routeData>()!;

    createEffect(() => {
        console.debug("queryOne state", queryOne.isFetching, queryOne.isSuccess, queryOne.isError, queryOne);
        console.debug("queryOne data", queryOne.data, JSON.stringify(queryOne.data));
    });

    createEffect(() => {
        console.debug("queryTwo state", queryTwo.loading, queryTwo.state, queryTwo(), JSON.stringify(queryTwo()));
    });

    onMount(()=> {
        const timer = setInterval(() => {
            let nextId = id() + 1;
            if (nextId === 11) {
                nextId = 1;
            }

            setSearchParams({id: nextId});
        }, 1000)

        onCleanup(()=>clearInterval(timer));
    });

    return <><div>
        <div>Test Reactivity</div>
        <br/>
        <Show when={queryOne.data}>
            <div>tanStack in component: {queryOne.data?.message ?? ''}</div>
        </Show>
        <dataContext.Provider value={queryOne.data}>
            <SubComponent />
        </dataContext.Provider>
        <Show when={queryTwo()}>
            <div>routeData in component: {queryTwo()!.message}</div>
        </Show>
        <dataContext.Provider value={queryTwo()}> {/* Resource<WhateverMyData> */}
            <SubComponent />
        </dataContext.Provider>
        <br/>
        <div>Test Reactivity</div>
    </div></>
}

function SubComponent() {
    const data = useContext(dataContext);
    console.debug("IS IT A PROXY", data);

    return <>
        <div>Using provider: {data?.message}</div>
    </>
}

apatrida avatar Nov 14 '23 18:11 apatrida

This is solidjs not realted to solid-start problem.

apatrida avatar Nov 14 '23 18:11 apatrida

@marbemac Shouldn't this be changed?

onHydrated(_k, info) {
        const defaultOptions = defaultedOptions()
        if (info.value) {
          hydrate(client(), {
            queries: [
              {
                queryKey: defaultOptions.queryKey,
                queryHash: defaultOptions.queryHash,
                state: info.value,
              },
            ],
          })
        }
        ```
        
to wrap `info.value` in a new store so it is reactive?     

appears broken in https://github.com/TanStack/query/pull/5775 when `unwrapped` on the server needs re-wrapped on the client.   

apatrida avatar Dec 05 '23 14:12 apatrida

to wrap info.value in a new store so it is reactive?

Hmm, possibly? If you make that change does it resolve your reproduction?

marbemac avatar Jan 02 '24 17:01 marbemac

no, appears to happen too late. Ryan was thinking there could be a timing issue with changes he made to solid around that time.

apatrida avatar Jan 03 '24 19:01 apatrida

is this issue fixed? I can see that #5775 got merged already ?

TkDodo avatar Feb 17 '24 18:02 TkDodo

@TkDodo no, that was already present in the codebase before my report, and maybe is part of the cause, OR a change to solid at the same time that caused a timing issue.

apatrida avatar Feb 28 '24 18:02 apatrida

Still broken, checked with latest 5.25.0 and with solid 1.8.7 and 1.8.15

will test with latest solid-start soon in case the issue is there in timing of hydration

apatrida avatar Mar 05 '24 22:03 apatrida

@ardeora

PeterDraex avatar Mar 06 '24 16:03 PeterDraex

Updated to latest SolidJS, SOlid-Start beta 0.6.x and still fails. code updated for latest below. It breaks badly and is not proxied on first hydration, and then goes back to pending state even when it shouldn't, etc.

import {
  redirect,
  RouteLoadFuncArgs,
  useSearchParams,
} from "@solidjs/router";
import { isServer } from "solid-js/web";
import {
  createQuery,
  QueryFunctionContext,
  useQueryClient,
} from "@tanstack/solid-query";
import {
  createContext,
  createEffect,
  onCleanup,
  onMount,
  Show,
  Suspense,
  useContext,
} from "solid-js";

type QueryResultType = { message: string };

const queryOneServerFunction = async (
  ctx: QueryFunctionContext,
): Promise<QueryResultType> => {
  "use server";
  console.debug("queryOne server call", ctx.queryKey);
  return { message: `${ctx.queryKey}` };
};

const queryOneFunction = async (
  ctx: QueryFunctionContext,
): Promise<QueryResultType> => {
  console.debug("queryOne client call", ctx.queryKey);
  return await queryOneServerFunction(ctx);
};

const dataContext = createContext<QueryResultType>();

const loadRouteData = ({ location }: RouteLoadFuncArgs) => {
  const id = Number(location.query.id ?? 0);
  console.log("routeData", isServer ? "on server" : "on client", "id=", id);
  if (isServer) {
    if (id == 0) {
      console.debug("redirect to start");
      return redirect("/reactivity?id=1");
    }
    const queryClient = useQueryClient();
    console.debug("prefetch query");
    void queryClient
      .prefetchQuery({
        queryKey: ["queryOne", id],
        queryFn: queryOneFunction,
        staleTime: 20 * 1000,
      })
      .then(() => {
        console.debug(
          "routeData",
          isServer ? "on server" : "on client",
          "PRELOAD COMPLETE",
        );
      });
  }
};

// autoload when route is loaded
export const route = {
  load: (args: RouteLoadFuncArgs) => loadRouteData(args),
};

// actual page
export default function TestReactivity() {
  const [searchParams, setSearchParams] = useSearchParams();
  const id = () => Number(searchParams.id ?? 0);

  const queryOne = createQuery(() => ({
    queryKey: ["queryOne", id()],
    queryFn: queryOneFunction,
    staleTime: 20 * 1000,
    placeholderData: (d) => d,
  }));

  createEffect(() => {
    console.debug(
      "queryOne state",
      queryOne.status,
      queryOne.isFetching,
      queryOne.isSuccess,
      queryOne.isError,
      queryOne,
    );
    console.debug(
      "queryOne data",
      queryOne.data,
      JSON.stringify(queryOne.data),
    );
  });

  onMount(() => {
    const timer = setInterval(() => {
      let nextId = id() + 1;
      if (nextId === 11) {
        nextId = 1;
      }

      setSearchParams({ id: nextId });
    }, 3000);

    onCleanup(() => clearInterval(timer));
  });

  return (
    <>
      <div>
        <div>Test Reactivity</div>
        <br />
        <Suspense>
          <div>
            <Show when={queryOne.data}>
              <div>tanStack in component: {queryOne.data?.message ?? ""}</div>
            </Show>
            <dataContext.Provider value={queryOne.data}>
              <SubComponent />
            </dataContext.Provider>
          </div>
        </Suspense>
        <br />
        <div>Test Reactivity</div>
      </div>
    </>
  );
}

function SubComponent() {
  const data = useContext(dataContext);
  console.debug("IS IT A PROXY", data);

  return (
    <>
      <div>Using provider: {data?.message}</div>
    </>
  );
}

client logs:

routeData on client id= 7
reactivity.tsx:32 queryOne client call (2) ['queryOne', 7]
reactivity.tsx:136 IS IT A PROXY {message: 'queryOne,7'}
reactivity.tsx:89 queryOne state success false true false Proxy(Object) {status: 'pending', fetchStatus: 'fetching', isPending: true, isSuccess: false, isError: false, …}
reactivity.tsx:94 queryOne data {message: 'queryOne,7'} {"message":"queryOne,7"}
reactivity.tsx:89 queryOne state success true true false Proxy(Object) {status: 'success', fetchStatus: 'fetching', isPending: false, isSuccess: true, isError: false, …}
reactivity.tsx:94 queryOne data Proxy(Object) {message: 'queryOne,7', Symbol(solid-proxy): Proxy(Object), Symbol(store-node): {…}} {"message":"queryOne,7"}
reactivity.tsx:32 queryOne client call (2) ['queryOne', 7]
reactivity.tsx:32 queryOne client call (2) ['queryOne', 8]
reactivity.tsx:89 queryOne state pending true false false Proxy(Object) {status: 'pending', fetchStatus: 'fetching', isPending: true, isSuccess: false, isError: false, …}
reactivity.tsx:94 queryOne data undefined undefined
reactivity.tsx:32 queryOne client call (2) ['queryOne', 8]
reactivity.tsx:32 queryOne client call (2) ['queryOne', 9]
reactivity.tsx:32 queryOne client call (2) ['queryOne', 9]
reactivity.tsx:32 queryOne client call (2) ['queryOne', 10]
reactivity.tsx:32 queryOne client call (2) ['queryOne', 10]
reactivity.tsx:32 queryOne client call (2) ['queryOne', 1]

notice the first is it a proxy is not a proxy. Then that it goes back to pending again even though it has data and uses the old data in next load. The UI goes blank on first timer click that navigates to update the ID.

server log:

routeData on server id= 3
prefetch query
queryOne client call [ 'queryOne', 3 ]
queryOne server call [ 'queryOne', 3 ]
routeData on server PRELOAD COMPLETE
IS IT A PROXY { message: 'queryOne,3' }
IS IT A PROXY { message: 'queryOne,3' }
routeData on server id= 7
prefetch query
queryOne client call [ 'queryOne', 7 ]
queryOne server call [ 'queryOne', 7 ]
routeData on server PRELOAD COMPLETE
IS IT A PROXY { message: 'queryOne,7' }
IS IT A PROXY { message: 'queryOne,7' }

we don't see all intermediate calls because of query caching

apatrida avatar Mar 06 '24 20:03 apatrida

I chopped the sample down maybe I broke something but even with wrapping the query results in an accessor to force it to call it as a signal it still stops tracking.

apatrida avatar Mar 06 '24 20:03 apatrida

@apatrida I think the internals rewrite and the new version has also fixed this issue. I can see these in the logs and I'm hoping this is what you expect?

Screenshot 2024-04-15 at 12 38 09 PM

ardeora avatar Apr 15 '24 16:04 ardeora

Here is the working repro https://stackblitz.com/github/ardeora/solid-query-data-as-context-provider?file=src%2Froutes%2Findex.tsx

It is to be noted that Context works differently in SolidJS and if you want updating context values you should pass in a signal instead https://docs.solidjs.com/concepts/context#updating-context-values

I'll be closing this issue for now. Please feel free to reopen it if you still face any issues!

ardeora avatar Apr 15 '24 17:04 ardeora