Skip to content

Commit ceed1bc

Browse files
committed
feat: fix for running use() directly on dependent tanstack react query
1 parent e484c89 commit ceed1bc

File tree

15 files changed

+557
-0
lines changed

15 files changed

+557
-0
lines changed

infra/performance/react/.gitignore

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import baseLintStagedConfig from '@repo/lint-staged-base-isolated/base';
2+
3+
export default {
4+
...baseLintStagedConfig,
5+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sharedPrettierConfig from '@repo/eslint-base-isolated/prettierrc-base';
2+
3+
/** @type {import('@repo/eslint-base-isolated/types').PrettierConfig} */
4+
const config = {
5+
...sharedPrettierConfig,
6+
};
7+
8+
export default config;

infra/performance/react/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# `@repo/react`
2+
3+
Includes:
4+
5+
1. patch for `@tanstack/react-query` to allow starting to prefetch data in
6+
`useQuery()` during rendering on the server
7+
1. a hook that allows directly suspending on dependent `useQuery()` calls
8+
(a.k.a. `useQuery()` is disabled until another one before it completes)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import baseDepsCheckConfig from '@repo/depcheck-base-isolated/base';
2+
3+
export default {
4+
...baseDepsCheckConfig,
5+
ignores: [...baseDepsCheckConfig.ignores],
6+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const {
2+
node: baseEslint,
3+
getCodeEditorTypescriptEslintConfig,
4+
} = require('@repo/eslint-base-isolated/eslint-config');
5+
6+
/** @type {import('@repo/eslint-base-isolated/types').EslintConfig} */
7+
module.exports = [
8+
...baseEslint,
9+
...getCodeEditorTypescriptEslintConfig(__dirname),
10+
{
11+
files: ['**/*.{ts,tsx}'],
12+
rules: {
13+
'@typescript-eslint/naming-convention': [
14+
'error',
15+
// Enum values
16+
{
17+
selector: 'enumMember',
18+
format: ['UPPER_CASE'],
19+
},
20+
{
21+
// Boolean vars convention
22+
selector: 'variable',
23+
types: ['boolean'],
24+
format: ['PascalCase'],
25+
prefix: [
26+
'is',
27+
'should',
28+
'has',
29+
'can',
30+
'did',
31+
'was',
32+
'will',
33+
'show',
34+
].flatMap((prefix) => [prefix, `${prefix.toUpperCase()}_`]),
35+
},
36+
],
37+
},
38+
},
39+
];
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"$schema": "https://json.schemastore.org/package.json",
3+
"name": "@repo/react",
4+
"version": "0.0.1",
5+
"type": "module",
6+
"devEngines": {
7+
"runtime": {
8+
"name": "node",
9+
"version": "22.6.0",
10+
"onFail": "download"
11+
}
12+
},
13+
"exports": {
14+
".": {
15+
"types": "./src/index.ts",
16+
"default": "./dist/index.js"
17+
}
18+
},
19+
"scripts": {
20+
"lint:everything": "pnpm --workspace-root run '/^shared:lint:.*/'",
21+
"lint:everything:fix-autofixable": "pnpm --workspace-root run --sequential '/^shared:fix:/'",
22+
"lint:precommit": "pnpm --workspace-root run '/^shared:(staged:lint:.*|lint:dependencies:.*)/'",
23+
"lint:precommit:fix-autofixable": "pnpm --workspace-root run --sequential '/^shared:staged:fix:/'",
24+
"lint:github-pr": "pnpm --workspace-root run '/^shared:(github-pr:lint|lint:dependencies:.*)/'",
25+
"preinstall": "[ -n \"$(pwd | grep '/node_modules/')\" ] || echo $npm_config_user_agent | grep -q 'pnpm/' || (echo 'PLEASE USE PNPM, not NPM' && exit 1)",
26+
"__SEE_SHARED__": "echo \"You can run any script starting with 'shared:' defined in <repo-root>/package.json by executing `pnpm -w <name of script>` from your package directory\""
27+
},
28+
"devDependencies": {
29+
"@repo/depcheck-base-isolated": "workspace:^",
30+
"@repo/eslint-base-isolated": "workspace:^",
31+
"@repo/eslint-problem-snapshotter": "workspace:^",
32+
"@repo/lint-staged-base-isolated": "workspace:^",
33+
"@repo/typescript-base-isolated": "workspace:^",
34+
"@tanstack/query-core": "5.90.2",
35+
"@tanstack/react-query": "5.90.2",
36+
"@types/react": "19.2.2",
37+
"prettier": "3.4.2",
38+
"react": "19.2.0",
39+
"typescript": "^5.3.3"
40+
},
41+
"devtoolsDependencies": [
42+
"@repo/typescript-base-isolated",
43+
"@repo/eslint-base-isolated",
44+
"@repo/eslint-problem-snapshotter",
45+
"@repo/depcheck-base-isolated",
46+
"@repo/lint-staged-base-isolated"
47+
],
48+
"peerDependencies": {
49+
"@tanstack/query-core": "5.90.2",
50+
"@tanstack/react-query": "5.90.2",
51+
"@types/react": "19.2.2",
52+
"react": "19.2.0"
53+
}
54+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
diff --git a/build/modern/useBaseQuery.js b/build/modern/useBaseQuery.js
2+
index 187d0257c39034a244c82da9dc4450dc40b90965..933d8c34c957e3f231fe35d17b1d7f1c188b7328 100644
3+
--- a/build/modern/useBaseQuery.js
4+
+++ b/build/modern/useBaseQuery.js
5+
@@ -29,6 +29,39 @@ function useBaseQuery(options, Observer, queryClient) {
6+
const errorResetBoundary = useQueryErrorResetBoundary();
7+
const client = useQueryClient(queryClient);
8+
const defaultedOptions = client.defaultQueryOptions(options);
9+
+
10+
+ /**
11+
+ * By default useQuery will not prefetch the query on the server
12+
+ * even if experimental_prefetchInRender is enabled.
13+
+ *
14+
+ * We want that to happen because otherwise useQuery().promise is never resolved
15+
+ * on the server.
16+
+ *
17+
+ * This code is mostly a copy of the code you can find a bit below, which runs
18+
+ * in the browser.
19+
+ *
20+
+ * We only patch this file as we rely on vite to be the bundler
21+
+ * vite will always prefer this ES module version of the file
22+
+ */
23+
+ const shouldDoServerPrefetch =
24+
+ defaultedOptions.experimental_prefetchInRender &&
25+
+ isServer &&
26+
+ (!('enabled' in defaultedOptions) || defaultedOptions.enabled) &&
27+
+ (!('suspense' in defaultedOptions) || !defaultedOptions.suspense);
28+
+
29+
+ if (shouldDoServerPrefetch) {
30+
+ const cacheEntry = client.getQueryCache().get(defaultedOptions.queryHash);
31+
+ const promise = !cacheEntry ? (
32+
+ // Fetch immediately on render in order to ensure `.promise` is resolved even if the component is unmounted
33+
+ client.prefetchQuery(defaultedOptions)
34+
+ ) : (
35+
+ // subscribe to the "cache promise" so that we can finalize the currentThenable once data comes in
36+
+ client.getQueryCache().get(defaultedOptions.queryHash)?.promise
37+
+ );
38+
+ promise?.catch(noop).finally(() => {
39+
+ observer.updateResult();
40+
+ });
41+
+ }
42+
client.getDefaultOptions().queries?._experimental_beforeQuery?.(
43+
defaultedOptions
44+
);
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Motivation
2+
3+
## We don't want to use `useSuspenseQuery` or `useSuspenseQueries` (_in fact we want to disallow them_)
4+
5+
1. Developers can put `useSuspenseQuery` inside custom hooks like
6+
`useUserProfile`. Let's say that happens in 2 such hooks: `useUserProfile`
7+
and `useProducts`. When a specific page/component wants to use **BOTH**
8+
custom hooks, this automatically creates a sequential, aka waterfall
9+
fetching, even if the queries don't depend on each other. We want to solve
10+
that by always using `useQuery` + `experimental_prefetchInRender` instead of
11+
`useSuspenseQuery` and then the custom hooks should return a promise
12+
themselves that the developer of each page can decide when to suspense via
13+
`use()`.
14+
15+
> [!NOTE]
16+
>
17+
> Out of the box `experimental_prefetchInRender` doesn't work on the server.
18+
> That's why we need to
19+
> [patch `@tanstack/react-query`](../patches/@tanstack__react-query.patch).
20+
21+
2. `useSuspenseQuery` provides `refetch` but calling refetch **does not
22+
trigger** the `<Suspense/>` boundary so even if we had a Suspense fallback
23+
that handles the initial loading, now we need another internal loading inside
24+
of the component. We want to solve that by having the promise from `useQuery`
25+
passed to `use` which will trigger again the Suspense.
26+
27+
3. We also want to be able to have
28+
[dependent queries](https://tanstack.com/query/latest/docs/framework/react/guides/dependent-queries)
29+
(_a.k.a. queryA which can only start after queryB_) and wrap them in custom
30+
hooks. These custom hooks should be able to **return a single promise** that
31+
returns specific value. So that pages/components using that hook can control
32+
when to suspend and get the data.
33+
34+
> [!NOTE]
35+
>
36+
> This doesn't work out of the box because having dependent `useQuery` calls
37+
> means that some hooks are disabled until a re-render happens during which
38+
> their required data is available. So if we simply suspend on the last query
39+
> promise (_this query will not be enabled during the first render because it
40+
> relies on the previous queries_) our rendering will get stuck forever
41+
> because we'll never re-render which means we'll never enable the disabled
42+
> query. For that we came up with
43+
> [`useDependentQueryPromise`](./useDependentQueryPromise.ts) which does some
44+
> magic under the hood to help us.
45+
46+
## Invalidating data must trigger re-rendering of relevant components
47+
48+
Generally speaking, there are two main ways to fetch data. To use hooks like
49+
`useQuery` that use the React re-rendering cycle _OR_ to rely on basic async
50+
function calls.
51+
52+
The second option might seem alluringly simple. However that option doesn't come
53+
with a solution on how to trigger refresh of the UI, aka re-rendering of the UI,
54+
when some data is no longer up to date (has changed). Yes, we can put the data
55+
inside a context and then have custom logic on optimizing which components use
56+
which data so we know what to rerender. But even then, we are lacking the
57+
caching layer of solutions like `useQuery`. So basically we'll end up
58+
re-implementing it.
59+
60+
So we use `useQuery`. You might think that we can simplify the complications of
61+
dependent queries & React `use()` by not having dependent queries at all, and
62+
instead have `queryClient.fetchQuery` calls inside of the `queryFn` when we need
63+
more data. And yes this is possible, however this means that the query that uses
64+
`queryClient.fetchQuery` inside of its `queryFn` can't have data returned by
65+
internal `queryClient.fetchQuery` as part of its `queryKey` so this is not
66+
allowing us a good `queryKey` which means well-targeted cache invalidation is
67+
not possible.
68+
69+
# The solution
70+
71+
This comes in 4 parts:
72+
73+
1. Enabling `experimental_prefetchInRender` for react query `QueryClient`
74+
2. The mentioned above
75+
[patch of `@tanstack/react-query`](../patches/@tanstack__react-query.patch)
76+
3. The [`useDependentQueryPromise`](./useDependentQueryPromise.ts) helper to
77+
make React play well with dependent `useQuery` that require re-rendering to
78+
figure out they need to start
79+
4. Strict convention on how to structure hooks that retrieve data, when to call
80+
them and when to suspend their promises.
81+
82+
## Usage
83+
84+
Inside custom hooks:
85+
86+
```tsx
87+
export function useAsyncUserProfile(): {
88+
promise: Promise<UserProfile>;
89+
profile: UserProfile | null;
90+
isFetching: boolean;
91+
} {
92+
const currentUserQuery = useAsyncCurrentUser();
93+
const userProfileQuery = useQuery(
94+
getUserProfileQueryOptions(currentUserQuery.data?.id),
95+
);
96+
const { profile, isFetching } = userProfileQuery;
97+
98+
return {
99+
profile,
100+
isFetching,
101+
promise: useDependentQueryPromise({
102+
resultPromise: userProfileQuery.promise,
103+
orderedQueries: [currentUserQuery, userProfileQuery],
104+
}),
105+
};
106+
}
107+
```
108+
109+
Then, usage of custom hooks inside components:
110+
111+
> [!IMPORTANT]
112+
>
113+
> Async hooks that start the data fetching **_MUST_** all be called before any
114+
> `use()` calls
115+
116+
```tsx
117+
function Homepage() {
118+
// ✅ ALL custom hooks with async logic BEFORE any use() calls
119+
const { promise: settingsPromise } = useAsyncUserSettings();
120+
const { promise: promoProductsPromise } = usePromoProducts();
121+
const { promise: profilePromise } = useAsyncUserProfile();
122+
123+
// at this point, any queries that are not blocked by other queries are already running in parallel
124+
125+
// ✅ ALL use() calls come AFTER all custom hooks with async calls
126+
const settings = use(settingsPromise);
127+
const promoProducts = use(promoProductsPromise);
128+
const profile = use(profilePromise);
129+
130+
return (
131+
<div>
132+
<h1>{settings.name}</h1>
133+
<p>Promo Products: {promoProducts.length}</p>
134+
<p>Profile: {profile.displayName}</p>
135+
</div>
136+
);
137+
}
138+
```
139+
140+
Then, when using this component:
141+
142+
> [!IMPORTANT]
143+
>
144+
> If you have a component that gets async data inside but you render it without
145+
> wrapping in `<Suspense>` this means you will potentially block the whole page
146+
> (_everything until the closest `<Suspense>` up the component tree_)
147+
148+
```tsx
149+
function UserPage() {
150+
return (
151+
<div>
152+
<div>Some static content that doesn't need async data</div>
153+
<Suspense
154+
fallback={<Loading>Example homepage loading fallback...</Loading>}
155+
>
156+
<Homepage />
157+
</Suspense>
158+
<Suspense
159+
fallback={<Loading>Example dynamic chart loading fallback...</Loading>}
160+
>
161+
<SomeDynamicChart />
162+
</Suspense>
163+
<Suspense
164+
fallback={
165+
<Loading>Example other dynamic content loading fallback...</Loading>
166+
}
167+
>
168+
<SomeDynamicContent />
169+
</Suspense>
170+
</div>
171+
);
172+
}
173+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useDependentQueryPromise';

0 commit comments

Comments
 (0)