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

Closed
wants to merge 29 commits into from
Closed

Feature/stale time on query #8313

wants to merge 29 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 ce2847c. 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/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-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/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-persist-client

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

@tanstack/solid-query-devtools

pnpm add https://pkg.pr.new/@tanstack/solid-query-devtools@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: ce2847c

@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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So I tried that, and it sadly led to another can of worms. If we try to re-implement isStaleByTime, we have a problem with manual invalidation. The fetchQuery docs say:

If the query exists and the data is not invalidated or older than the given staleTime, then the data from the cache will be returned. Otherwise it will try to fetch the latest data.

The problem is that, by design, we no longer distinguish between invalidated and stale - the stale timer sets it as invalidated. So I think treating staleTime as any other option of the query, and doing the setOptions call, is okay. What we get in return is that other query options are correctly updated even if no fetch happens. All of this is theoretical of course, because we always encourage users to abstract options into either custom hooks or queryOptions, so I think none of this matters in practice.

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.

@Ephem
Copy link
Collaborator

Ephem commented Nov 23, 2024

First off, let me start by staying I agree with the goal of this PR, having a single timer for a query would be a great thing! I love the clear description and comments you've left. Let me also be clear that right now I don't have an opinion yet whether the approach in this PR is correct or not. Also I have yet only skimmed the technical details, to stay unbiased for this next part.

staleTime, stale & invalidated - My mental model

I'm not quite sure how to start approaching this PR, but since it touches the very foundations of what staleTime, staleness and invalidated IS, how it should work and how we model it, I'll start by taking a step back to try to write down my high level mental model.

Note

I don't write this as a guide or set of truths, but to expose it so you can poke holes in my reasoning.

I think I see staleTime and staleness as less tightly connected than others, let's start with staleTime.

staleTime

In my head, staleTime is an descriptive/declarative option which gets its meaning from the context it is being used. If I useQuery, staleTime means roughly "this is how up to date my data needs to be in this place in the app". (This is complicated by the fact that data doesn't refetch after this time unless you configure it to, it only applies to mount, reconnect and window focus depending on options, but let's ignore that.)

Having the need for different "freshness" at different positions in the app is a natural thing.

Note

Feedback: I don't think having two observers with different staleTime mounted at the same time, even on the same screen, is abnormal or worth warning about. It's something we should support without fuss.

With this warning, just inserting a component which happens to have data fetching someplace new might result in a warning that's hard to take action on.

As you've noted, staleTime in the context of (pre)fetchQuery/ensureQueryData is tricky. On the one hand, it might mean "if this data already exists in the cache and is fresher than this, just return it" - that is, the same meaning as useQuery. On the other hand it might additionally mean "update the query staleTime to this"?

Update: I guess useQuery also updates the config, but in my head that's because it's also an observer, which fetchQuery is not.

Question: Can a query have a staleTime?

I think this is the crux of the problem. Yes, but also no. Obviously, you can set a global staleTime, so in some practical sense, it can. I think I only see this as a default option though, the staleTime should still live on the observer. Can a query with no observers ever be considered stale? It's complex, but I think I would argue no. A query is only stale in the eyes of the observers (pun intended), BUT, see next section.

staleness

While I see staleTime as something that is the eyes of the observer, a query's current staleness is definitely something that lives at the query level (but only if observers exist). It's globally boolean. Though not implemented that way, in my head, I actually see this is a derived state! "If data is older than all current observers needs (expressed as staleTime), the query is stale, otherwise not". This fact is what would let us move the timer to the query. It makes no sense that a query is only stale for one observer, but not for another. While different observers might have different needs, the most needy one wins and when it's stale it should be stale for all.

If it was a derived state, we might not need a timer at all? I'm sure there are good reasons it might not be feasible to implement this way, but it's an interesting thought experiment.

invalidated

In my head this is different from staleness. Staleness is about expressing how important fresh data is in different parts of the application, invalidation is when we know some piece of data is no longer up to date, or at least when strongly suspect it and want to treat it that way. This means the invalidated state belongs to the query, not the observers. Also, while to me it makes no sense for a query without observers to be stale, it does makes sense for a query with no observers to be invalidated.

Reflections

All of the above is high level and doesn't necessarily have to reflect what we call things in the code. When I talk about staleness and invalidation, I'm not talking about the existing variables in the code, It's not a comment on whether we should use isInvalidated to keep track of staleness or not etc, etc.

I wanted to write this out first to see how much common ground we have in our mental models, but I will come back with more practical feedback on the PR. I'll review the code more closely and I have a few test cases in mind I will check if they already exist or if I can write them. 😄

@DamianOsipiuk
Copy link
Contributor

This makes sense and should simplify things. 🚀

One general note though is about current usage, and potential misuse by users.

Considering that same query can be reused in multiple components and have different staleTime set for each usage place.
It might lead to a cascade of updates, where different observers might try to update staleTime one after the other.

  • Observer 1 sets staleTime to 1 marking query as stale
  • Observer 2 sets staleTime to 10 marking query as not stale
  • Observer 3 sets staleTime to 3 marking query as stale
  • etc..

I imagine this can be a problem even now.
Should we actually keep track of quickly changing options like staleTime and warn users in dev that they might have a configuration problem? Should be easier to do that now, when we keep track of it in one place.

@TkDodo
Copy link
Collaborator Author

TkDodo commented Nov 25, 2024

It might lead to a cascade of updates, where different observers might try to update staleTime one after the other.

This is a problem in the new implementation, yes, and the test I deleted was actually failing because of that 😂 . Well spotted.

It’s not an issue in the current implementation because each observer has its own timer, and when the timer elapses, it only updates the result of this observer. Nothing in the query is updated, so there are no chances to influence other observers.


After reading @Ephem’s comment, and talking about this with him some more, I think I want to try a different approach:

  • keep the staleTime option on the observer
  • move only the timer to the query
  • the timer would update itself when an observer is added, or removed, or when the options of an observer change (different staleTime).
  • The timer would then be set to the smallest staleTime value of all current observers. I still need to think about if disabled observer should be taken into account here or not. What do you think?
  • we would not set invalidated:true on the query when the timer elapses - we would just inform observers about that, so that they can re-calculate the isStale prop. There are edge cases around prefetchQuery where it makes sense to keep those two decoupled - e.g. a hard call to invalidateQueries should make a prefetchQuery fetch, even if it has staleTime: Infinity.
  • Another open question is: if one observer has staleTime 5 seconds, and the other 10 minutes, would we update both observer results to show isStale: true after 5 seconds? Right now, I think one would show true while the other would still show false, because it only checks its own options.staleTime. But if we implement the query’s staleTime as: the derived state of all currently subscribed observer’s staleTime, it would be 5 seconds for both of them, in this context.

@TkDodo
Copy link
Collaborator Author

TkDodo commented Nov 25, 2024

The timer would then be set to the smallest staleTime value of all current observers. I still need to think about if disabled observer should be taken into account here or not. What do you think?

My thought process would be:

since a disabled observer can’t trigger fetches on its own, it also shouldn’t influence when that data is considered “outdated”, so it should be excluded from deriving the staleTime value.

would we update both observer results to show isStale: true after 5 seconds?

for this, I would say YES. also, disabled observer should get that update. If we treat isStale as basically derived state of the query (derived from the currently mounted, enabled observers), then we can always know if a “query” is stale. This is what should be displayed in the devtools, and this is what all observers should return, even if it means that you get isStale: true after 5 seconds even though you have set it to 10 minutes, as the 10 minutes might not count in this context when there’s a second observer with a smaller time.

“disabled observers not getting the correct isStale update” was posted as a question recently:

and would be fixed implicitly with this proposal, too.

@DamianOsipiuk
Copy link
Contributor

The timer would then be set to the smallest staleTime value of all current observers. I still need to think about if disabled observer should be taken into account here or not. What do you think?

When i think about disabled observers, those usually hang in that state due to two cases:

  • you disable the observer cause you are missing one of the parameters that is needed to fetch properly
    • but ultimately it will result in a different queryKey, so it should not be a problem here.
  • you want this query to be lazy and fetch only on user interaction
    • again if you are waiting for user input to construct better queryKey, it should not matter
    • you want to control exactly when fetch happens
      • why would you set staleTime in this case? You would potentially use refetch or skipToken
      • since you can potentially not set staleTime it will result in 0 being the default, so IMO it should not be considered for staleTime calculation of other observers

Another open question is: if one observer has staleTime 5 seconds, and the other 10 minutes, would we update both observer results to show isStale: true after 5 seconds? Right now, I think one would show true while the other would still show false, because it only checks its own options.staleTime. But if we implement the query’s staleTime as: the derived state of all currently subscribed observer’s staleTime, it would be 5 seconds for both of them, in this context.

I would say, it should say stale everywhere, and we should potentially show a dev warning about mismatching configuration.
Why? Cause if any of those observers happen to trigger refetch it will update data in all of them, despite them showing as not stale.
On the other hand, it shows as stale, but if i calculate time from fetch it should not be. a bug! -> thus dev warning.

@TkDodo
Copy link
Collaborator Author

TkDodo commented Nov 25, 2024

so IMO it should not be considered for staleTime calculation of other observers

seems we are aligned on that 👍

I would say, it should say stale everywhere

I also agree with that

and we should potentially show a dev warning about mismatching configuration.

I also added a dev warning, but @Ephem is skeptical about it, and I kinda agree. Imagine a scenario where you have two observers on the same query: one selects the count (like the number of issues), and you want that to be always up-to-date, so you set a small staleTime. Then, you have another screen where you show the full list, which has a higher staleTime.

This is fine, but now you refactor a bit and put the counter also in the header of the list page. Yes, this would make the whole list get the smaller staleTime, but is it worth warning about? The only way out would be to make that component customizable to take in a different staleTime, so you have them be the same on the same screen.

It gets worse if you have a component that opens in a dialog that sets a different staleTime, because that’s only for the time when that dialog is opened.

Finally, in React Native, screens might still be mounted even though they are not visible, so you might really have multiple observers with different staleTimes mounted. That could be seen as a config error, but we actually want to ship an improvement to properly disconnect those observers (see #8348).

All in all I don’t think a warning in dev mode is warranted, but it’s not hard to implement and we can think about it. The things we log in dev mode can’t be turned off (because they should be actionable), and I don’t think these warnings would be something that the user can or should act upon.

@TkDodo
Copy link
Collaborator Author

TkDodo commented Nov 25, 2024

Another interesting question that @Ephem had:

If we have two observers, one with staleTime: 2seconds and one with staleTime: 10minutes, and we get, a window focus event after 5 minutes, should we refetchOnWindowFocus ? YES. I think this case is clear - the window focus event will look at all observers to determine query stale-ness.

BUT, what if another observer with staleTime: 1hour mounts after 5 minutes - should we get a refetchOnMount for that one? We could argue both ways:

  • Yes, because the query is, in this context, seen as stale after 2 seconds, so it’s stale right now, so we should refetch.
  • No, because this specific observer that gets mounted says staleTime is 1h, so why should it?

I think the answer should still be YES, just given by the fact that we will return isStale: true (as discussed above), and it would be weird to not trigger a refetch even though we get that value returned.

Just wanted to note down my thoughts on it while they are still fresh 😂

@snewell92
Copy link

@TkDodo your last comment confused me slightly, I'm going to attempt to argue for the "NO" case in the 1hour mounted after 5 minutes case I will argue for both because live is full of surprises, final edit actually I want it configurable and restate some of your scenario to see if I can align my brain properly.

Pls correct me where/when I'm wrong or don't have the concept clear.

Restate concepts: I think of staleTime as declarative, invalidation as imperative, and staleness as derived from staleTime. I will simplistically think of observers as instances of useQuery calls (with or without a selection), and queries as the keyed query function we all know and love.

Key Question: Should staleness be exclusively a property of queries, observers or actually both? To retain the utility of expressing selections of the same query having different priority & use in an app, I think staleness should be exclusively a property of observers. IE a query cannot be stale, only observers of queries can be stale.

On Staleness

I find this approach of observers only having staleness simple and useful. It does not
result in any loss of functionality, because you merge/inherit global staleTime into each observer's staleTime, and the timer is still on the query, TSQ has to iterate over all subscribed observers to determine what is stale (optimization: take minimum of active observers? what is active? is there eagerness?).

This may be false or wrong in ways I currently don't understand.

Restated scenario:

  1. There is one query.
  2. There are two observers on mount, staleTime: 2sec, and staleTime: 10 minutes.
  3. There is a third observer mounted after 5 minutes with staleTime: 1hour after some navigation in an app.
    a. This mount happens on navigation, and the first two observers are not on this new page.
    b. This mount adds to the page, so all observers are 'active'.

I will rephrase your question, instead of refetch on mount, I would simply ask - what is stale when our third observer is mounted? If staleness is only a property of observers, then it is easy imo.

In my sub-scenario a, the active observer, the third one, is not stale so we don't refetch, we can serve from cache and respect what the author has declared via staleTime.

In my sub-scenario b, we have this new 'mount' event, and we have an active observer that has declared staleness, so we could use this as an opportunity to refetch so we respect the first observer's staleness. So maybe both? Buuut.. consider that the new component is a descendant, do we want some deep descendant who is mounting cause a parent to get new data? Actually, probably not. If we navigated away and came back, then yes I would expect fresh data. But if I'm revealing more information on a sub tab or table or expander, I wouldn't expect my top level nav to parent to get fresh information unless I configured it that way, and perhaps there isn't a way to configure that scenario?

This makes me think that in the case of a "far away" observer being mounted, we may want a new option in QueryOptions to mark an observer as "eager" (is this already a thing?) so if this scenario happens, TSQ always iterates and takes the minimum of all active observers to see if anyone wants fresh data, and if so it refetches.

This may cause some weirdness though, we can technically serve stale data to the third component while refetching, and then it could update. But I guess that's what isRefetching is for anyways so nvm (hence my "eager" or opt in suggestion).

Sorry for the essay, here's a picture of one of our cats

This is Pippin and he gets fed because I and all our teams use TSR and TSQ everyday <3.

image

@snewell92
Copy link

snewell92 commented Nov 27, 2024

@TkDodo I'm now realizing your scenario of a window focus event is like going back to the tab after getting distracted on youtube (not me ofc), not navigating in an app. In which case the logic above would say "YES" you have a stale observer of the shared query and need to refetch.

edit: no i think i was right in my first interpretation actually, unless you've combined the window focus with the third mounting? instructions unclear, going to youtube.

@TkDodo
Copy link
Collaborator Author

TkDodo commented Nov 27, 2024

@snewell92 thank you for this, really, there are good insights here. I think we are mostly aligned on what we want, namely:

I think of staleTime as declarative, invalidation as imperative, and staleness as derived from staleTime. I will simplistically think of observers as instances of useQuery calls (with or without a selection), and queries as the keyed query function we all know and love.

This is spot on 👍

I think staleness should be exclusively a property of observers. IE a query cannot be stale, only observers of queries can be stale.

Yep 👍

you merge/inherit global staleTime into each observer's staleTime, and the timer is still on the query, TSQ has to iterate over all subscribed observers to determine what is stale

Also 👍 . What’s currently on main is that each observer gets a timer, so with 100 observers which all have the same staleTime, you get 100 timers instead of 1. This is mainly what we want to change by moving the timer only to the query. This PR moves the staleTime itself there. This is likely a mistake. I will close this PR to avoid further confusion. I’m actually working on a new PR that implements what I’ve described here.


Regarding the scenario:

a: this mount happens on navigation, and the first two observers are not on this new page.

Since the first two observers aren’t on the new page, they are irrelevant, because they get unmounted. So on this new page, there is only one observer with staleTime: 1hour. Since the mount happens after 5 minutes, you would not get a refetchOnMount, and the timer would be set to 55 minutes.

b. This mount adds to the page, so all observers are 'active'.

Now we have 2 observers when we add a 3rd one, so we take the minimum staleTime of all 3 observers and potentially adjust the timer. In this case, the lowest staleTime is 2seconds, so the mount of the 3rd observer with a higher staleTime doesn’t change that. The current timer of 2seconds (lowest staleTime) has already elapsed, so when the 3rd observer mounts, we are already stale, so we will see a background refetch, and after that, a new 2second timer will be started.

It might seem a bit confusing that we get a background refetch here even though the staleTime of the observer who mounted is longer. But it makes more sense for automatic refetches (mount, window focus, reconnect) to consider all observers. window focus would also re-fetch because there is no single observer that triggers it. Here, in this context, the stale-ness is defined by the lowest observer (2seconds), so a new observer mount will have to adhere to the context and refetch.

Does that makes sense?

@TkDodo TkDodo closed this Nov 27, 2024
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.

4 participants