Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/stale time on query #8313

Draft
wants to merge 26 commits into
base: main
Choose a base branch
from
Draft

Feature/stale time on query #8313

wants to merge 26 commits into from

Conversation

TkDodo
Copy link
Collaborator

@TkDodo TkDodo commented Nov 20, 2024

DO NOT MERGE, contains potentially breaking changes


This PR moves the staleTime handling from QueryObserver to the Query itself, which mostly disallows different staleTimes on the same Query while being mounted simultaneously. Note that we can still have different staleTimes for different screens.

How it worked before

Every observer had a staleTime, and every observer also triggered a timer. When the timer was done, we triggered createResult() for the observer, which would update isStale on it. This means multiple timers were running - one for each observer - which could cause performance problems once you hit a certain threshold, at about 1k observers for the same query. Largely, those observers all have the same staleTime, so using one timer would be a lot better.

Additionally, I think this implementation lead to cases where one observer had isStale: true while another observer could have isStale: false. But conceptually, data is either stale for a screen or it isn’t. It can’t be both, because that would mean we would need to show inconsistent data, which we can’t. As soon as one observer is marked as “stale”, a smart refetch would re-fetch data, thus showing it for all observers. So this isn’t really a case we should be supporting.

How it works now

staleTime moved from QueryObserverOptions to QueryOptions. You can still pass it to useQuery or define a global default - nothing changed here from user perspective. But this means we’ll have staleTime on the Query itself, and since there can be only one Query per key, we’ll also only have one timer.

The timer will update itself when new options come in, or, when new data comes in for the query.

When the timer elapses, it will just set the isInvalidated flag on the Query itself to true. This is the same flag that we use for queryClient.invalidateQueries. Conceptually, I think this is neat because we don’t really need to distinguish between a Query that has been marked as “invalid” because it was invalidated by the user programmatically, or because the timer has elapsed.

Setting this flag will then inform all observers, so all screens are always up-to-date. staleTime: Infinity will just not set a timer.

What’s also neat about this is that we can now persist that setting, because it’s part of the query state. When restoring from localStorage, we already know if that data is stale or not. This wasn’t the case before because we would only know as soon as the first observer mounted.

Alternative design: separate state

Note that I did consider not re-using the isInvalidated flag to keep the distinction between manual invalidations and timer-based ones, and use an isStale flag instead. I don’t think it is necessary, and it would actually lead to invalid states (yay, booleans), so we would need an enum, something like: isStale: 'invalidated' | 'timer' | false and I don't necessarily like that here / want to do a breaking change for that.

staleTime: 0, a special case

Additionally, I’ve changed how staleTime: 0 works, because it’s a special case. Previously, 0 was considered a valid timeout, and we set a setTimeout(1) on the observer, which would then trigger the query to be marked as stale (because we have to add +1 to the timeout for edge cases). This was pretty unnecessary - when staleTime is zero, we want the query to be stale immediately, without a timer.

So I added some special handling for this: When the query is created with staleTime zero, we set the initialState to isInvalidated: true. Also, whenever the query updates, we set it to isInvalidated: true for that staleTime immediately, without setting a timer.

Lastly, when the timeout changes and it would result in a stale query, we also update isInvalidated. Having a query with data from 10 seconds ago with a staleTime of 1 minute will therefore give us isStale: false, but if we update the staleTime (e.g. because it’s a function depending on data) to be 1 second, we will immediately transition to isStale: true)


This refactoring got rid of a lot complexity: We used to have isStale and isStaleByTime functions on the query and and additional isStale function on the observer. Now, it’s just:

isStale(): boolean {
  return this.state.isInvalidated || this.state.data === undefined
}

which is beautiful. Note that queries without data are always stale, this was the same before too (this is largely for the error case).

disabled observers

Disabled observers were previously exempt from being stale; I made that because of some issue of what shows up in the devtools, but I think that was a mistake. Observers just show data, and the stale-ness refers to the data. Disabled observers also show data and update if that data changes, so it’s important to reflect that in the isStale property of those. This is where the majority of test updates comes from.

why this could be breaking

  • I had to delete a test where we used different staleTimes on the same query with two observers mounted at the same time, because it doesn’t make sense anymore with this implementation. If users do this, they might see changes in behaviour.
  • queries are now always isStale: true immediately if they start out with staleTime: 0, while previously, they might have started with isStale: false followed by an immediate update.
  • disabled observers now reflect the stale-ness of data.

All of these could be considered fixes as well 😅

as it doesn't make much sense to have different stale times for the same query
this was part of isStale() before, so it got lost in the refactoring
we use `isValidTimeout` in 3 places:

- gcTime: here, we want 0 to trigger a setTimeout, because otherwise, we don't cleanup
- staleTime: 0 should be invalid because with 0, we instantly mark queries as invalidated
- refetchInterval: 0 was never a valid timer (it's treated the same as false); we had an exception implemented here

2/3 cases want it to _not_ be valid, so that should be the default
options on query level have never been merged with previous options, so not passing staleTime now makes things stale (0)
given that a query is currently stale, a new staleTime that is > 0 should set it back to being not-stale
…a lower one

where the lower number actually makes the query instantly stale

also, isStale() will always return true if we have no data yet (duh)
to achieve that, we move the "early return" until after we've dispatched; all the logic in between also works with staleTime: Infinity - timeUntilStale just returns Infinity as well (added tests for that, too)
setOptions doesn't merge, so we need to call it at the specific places where we want it to overwrite - fetchQuery and ensureQueryData
Copy link

nx-cloud bot commented Nov 20, 2024

☁️ Nx Cloud Report

CI is running/has finished running commands for commit fcea9bf. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this CI Pipeline Execution


🟥 Failed Commands
nx affected --targets=test:sherif,test:knip,test:eslint,test:lib,test:types,test:build,build --parallel=3
✅ Successfully ran 1 target

Sent with 💌 from NxCloud.

Copy link

pkg-pr-new bot commented Nov 20, 2024

Open in Stackblitz

More templates

@tanstack/angular-query-devtools-experimental

pnpm add https://pkg.pr.new/@tanstack/angular-query-devtools-experimental@8313

@tanstack/query-async-storage-persister

pnpm add https://pkg.pr.new/@tanstack/query-async-storage-persister@8313

@tanstack/query-broadcast-client-experimental

pnpm add https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@8313

@tanstack/query-core

pnpm add https://pkg.pr.new/@tanstack/query-core@8313

@tanstack/eslint-plugin-query

pnpm add https://pkg.pr.new/@tanstack/eslint-plugin-query@8313

@tanstack/angular-query-experimental

pnpm add https://pkg.pr.new/@tanstack/angular-query-experimental@8313

@tanstack/query-devtools

pnpm add https://pkg.pr.new/@tanstack/query-devtools@8313

@tanstack/query-persist-client-core

pnpm add https://pkg.pr.new/@tanstack/query-persist-client-core@8313

@tanstack/query-sync-storage-persister

pnpm add https://pkg.pr.new/@tanstack/query-sync-storage-persister@8313

@tanstack/react-query

pnpm add https://pkg.pr.new/@tanstack/react-query@8313

@tanstack/react-query-devtools

pnpm add https://pkg.pr.new/@tanstack/react-query-devtools@8313

@tanstack/react-query-next-experimental

pnpm add https://pkg.pr.new/@tanstack/react-query-next-experimental@8313

@tanstack/react-query-persist-client

pnpm add https://pkg.pr.new/@tanstack/react-query-persist-client@8313

@tanstack/solid-query

pnpm add https://pkg.pr.new/@tanstack/solid-query@8313

@tanstack/solid-query-devtools

pnpm add https://pkg.pr.new/@tanstack/solid-query-devtools@8313

@tanstack/solid-query-persist-client

pnpm add https://pkg.pr.new/@tanstack/solid-query-persist-client@8313

@tanstack/svelte-query

pnpm add https://pkg.pr.new/@tanstack/svelte-query@8313

@tanstack/svelte-query-devtools

pnpm add https://pkg.pr.new/@tanstack/svelte-query-devtools@8313

@tanstack/svelte-query-persist-client

pnpm add https://pkg.pr.new/@tanstack/svelte-query-persist-client@8313

@tanstack/vue-query

pnpm add https://pkg.pr.new/@tanstack/vue-query@8313

@tanstack/vue-query-devtools

pnpm add https://pkg.pr.new/@tanstack/vue-query-devtools@8313

commit: fcea9bf

@TkDodo
Copy link
Collaborator Author

TkDodo commented Nov 20, 2024

Note: I didn’t fix the other framework adapters yet - only the core and react, so that’s why the tests are failing.

@TkDodo TkDodo requested a review from Ephem November 20, 2024 09:40
…ructor

that way, this.state.isInvalidated is set correctly for staleTime: 0
Comment on lines +194 to +199
const nextStaleTime = this.#resolveStaleTime(this.options.staleTime)
if (nextStaleTime === 0) {
this.#initialState.isInvalidated = true
}

this.#updateStaleTimeout(nextStaleTime)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#resolveStaleTime needs access to this.state (because it passes the query to the functional syntax of staleTime, so we need to do this after we have set this.state.

However, this.state might be initiated from the #initialState. While this.#updateStaleTimeout will make sure that this.state.isInvalidated is reflected correctly, we have to “correct” the #initialState here.

Comment on lines +220 to +221
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const prevStaleTime = this.options?.staleTime
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is called from the constructor where this.options isn’t initialized yet. Technically, this.options needs to be optional on type level, but it will be set for every other place so this feels like the best hack.

Comment on lines +224 to +229
const nextStaleTime = this.#resolveStaleTime(nextOptions?.staleTime)

// Update stale interval if needed
if (nextStaleTime !== this.#resolveStaleTime(prevStaleTime)) {
this.#updateStaleTimeout(nextStaleTime)
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when the staleTime option changes, we need to update our timer

Comment on lines 297 to 299
isStale(): boolean {
if (this.state.isInvalidated) {
return true
}

if (this.getObserversCount() > 0) {
return this.observers.some(
(observer) => observer.getCurrentResult().isStale,
)
}

return this.state.data === undefined
}

isStaleByTime(staleTime = 0): boolean {
return (
this.state.isInvalidated ||
this.state.data === undefined ||
!timeUntilStale(this.state.dataUpdatedAt, staleTime)
)
return this.state.isInvalidated || this.state.data === undefined
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is beautifully simple now

Comment on lines +627 to +632
const nextStaleTime = this.#resolveStaleTime(this.options.staleTime)
if (nextStaleTime === 0) {
this.state.isInvalidated = true
} else if (!this.isStale()) {
this.#updateStaleTimeout(nextStaleTime)
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some state updates might re-set this.state.isInvalidated, but if our staleTime is zero, we are basically always stale, so we undo that here. Also, the timer needs to be updated because after the reducer, our dataUpdatedAt might have changed.

Comment on lines +657 to +662
if (this.state.isInvalidated !== newInvalidated) {
this.#dispatch({
type: 'setState',
state: { isInvalidated: newInvalidated },
})
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will “toggle” the isInvalidated state to what it needs to be depending on the new timer we’re setting (or not setting)

Comment on lines -679 to +735
isInvalidated: false,
isInvalidated: !hasData,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change is not strictly necessary, because a query is always considered stale if we have no data, but it’s also technically more correct

Comment on lines +346 to +348
query.setOptions(defaultedOptions)

return query.isStaleByTime(
resolveStaleTime(defaultedOptions.staleTime, query),
)
return query.isStale()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one of the more tricky things is that we now need to call setOptions for fetchQuery and ensureQueryData because they accept staleTime as an option, but they didn’t update the options, so calling query.isStale() wouldn’t reflect that.

I think this is also correct because if you pass different settings here, like a gcTime, you would want that to be reflected.
Note that query.fetch will set the options for us under the hood, but it’s not guaranteed that it is invoked, because we might just return cached data from `fetchQuery. This is likely also an edge case bug in the current version 😅 .

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflected on this a bit more, and I don’t think the change here is correct. When we say:

queryClient.prefetchQuery({ queryKey, queryFn, staleTime: 10 * 60 * 1000 })

we basically want to express: prefetch this query if data is older than 10 minutes.

it doesn’t mean we want to set the staleTime of the query to 10 minutes. For example, if there is an active observer that has a staleTime of 2 minutes - that shouldn’t change.

So I need to revert that change and instead, somehow revive the isStaleByTime check for the queryClient methods.

Comment on lines -398 to +358
!isValidTimeout(this.#currentRefetchInterval) ||
this.#currentRefetchInterval === 0
!isValidTimeout(this.#currentRefetchInterval)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: timeout 0 is now not valid per default, so we can remove the extra check here for refetchInterval, too.

@@ -11,7 +11,7 @@ export abstract class Removable {
protected scheduleGc(): void {
this.clearGcTimeout()

if (isValidTimeout(this.gcTime)) {
if (isValidTimeout(this.gcTime) || this.gcTime === 0) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this was the only place where we wanted 0 to be a valid timeout, so I re-added the extra check here. Otherwise, gcTime 0 would not garbage collect.

@snewell92
Copy link

@TkDodo this is the most important thing here:

...we don’t really need to distinguish between a Query that has been marked as “invalid” because it was invalidated by the user programmatically, or because the timer has elapsed.

You have simplified a core concept while retaining essentially the same API. This will make maintaining and using TSQ easier.

The more you can remove or rip out, the better imo. Stay focused, stay lean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants