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

feat: Configurable throttling #374

Merged
merged 11 commits into from
Nov 8, 2023
Merged

feat: Configurable throttling #374

merged 11 commits into from
Nov 8, 2023

Conversation

franky47
Copy link
Member

@franky47 franky47 commented Oct 24, 2023

Adds a throttleMs option to slow down the rate of updates to the URL.

Note: internal state (the first item returned by the hook) is not throttled, and behaves just like React.useState to guarantee UI reactivity. Only query string updates to the URL are throttled, to handle browsers rate-limiting the history API, and to slow down sending network requests to the server when using shallow: false.

As an example, to safely bind a high-frequency query state on Safari, which has higher rate limits:

function Search() {
  const [query, setQuery] = useQueryState(
    'q',
    parseAsString
      .withOptions({ throttleMs: 350 })
      .withDefault('')
  )
  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)} 
    />
  )
}

See discussion #373.

@vercel
Copy link

vercel bot commented Oct 24, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
next-usequerystate ✅ Ready (Inspect) Visit Preview 💬 Add feedback Nov 8, 2023 0:36am

@franky47 franky47 mentioned this pull request Oct 24, 2023
@franky47 franky47 changed the title Throttling feat: Throttling Oct 24, 2023
@ShahriarKh
Copy link

I tried with a text input and noticed two problems:

  1. The page gets re-rendered on every keystroke.
  2. The value gets out of sync but sometimes shows the correct text after that (only with shallow: false). Here's a video:
bandicam.2023-10-24.19-08-17-861.mp4

In the video above, left input uses throttleMs, right input uses the code mentioned at #373. (both are 1000ms)

@franky47
Copy link
Member Author

franky47 commented Oct 24, 2023

  1. The page gets re-rendered on every keystroke.

This is expected. Internally, this behaves much like React.useState, which would re-render when the state changes.

If you wish to limit the level of re-rendering, using a <Suspense> boundary will limit where the client code runs, which is what gets re-rendered. There's no need to have the hook at the root of the page, it can be deeply nested and duplicated where it's useful (eg: writing in one component and spying on the query value in others).

The value gets out of sync but sometimes shows the correct text after that (only with shallow: false)

This is probably a cache issue with the RSC client cache, I'll have a look, thanks for the report!

@franky47
Copy link
Member Author

Ok so the second point is actually a race condition between local edits and the RSC payload coming in that triggers a URL change to match what was rendered on the server.

So you type "foo bar". The first throttled request to the server sees and renders "foo", re-updates the local URL to "foo" and " bar" is dropped.

Throttling actually makes things worse because all edits between two responses may be lost, and increasing throttling increases the chance of it happening.

So it looks like the best approach is indeed to use an uncontrolled component, set the default value to the state value, which will deal with page loads / mounts, and use the throttleMs option to control how frequently we tell the server about URL updates.

@franky47
Copy link
Member Author

franky47 commented Nov 1, 2023

After playing for a while with throttling options and how to properly schedule server updates, I don't think there's an ideal setting that can work anywhere anytime.

Taking care of the race-condition of a stale external navigation overwriting the hooks internal states was easy once the right cause was identified: the URL update queue is now merged with incoming external search params to sync internal states.

However, it looks like RSC streams don't get applied to the DOM if the URL that triggered them is different when they arrive (probably due to the client RSC cache being keyed by the URL). This caused the whole app to be unresponsive when using only the Next.js router for query updates, as the URL would only be updated when the appropriate RSC payload resolved, which may never happen if the network connection is poor (or absent) and/or the serverless function times out on the backend.

Instead, query updates now always update the query string locally first (using the Web History API), then notify the server with a replaceState on non-shallow updates. This keeps the URL in sync with the UI, and lets the server catch-up asynchronously and stream-in RSCs.

For high frequency non-shallow updates (like binding a query state to a text input or a range slider), a higher thottleMs is recommended. To keep server-streamed UI updates responsive, it should be set higher than the server round-trip latency (ie: the time it takes to send the request, let the server render RSCs and stream the response back).

This behaviour is showcased in the throttling playground, where artificial delays can be configured. Using the browser devtools to throttle the network connection also helps get an idea of how the server responds to floods of requests.

Saves a few bytes on the output bundle.
When the server streams in a shallow: false navigation,
it may do so with an outdated querystring if there are
updates pending in the queue, and those would
overwrite the internal states.
So before triggering a sync, we merge the queue
onto the incoming search params and sync on the
result.
Kind of a stretch, but it gives an escape hatch for high-frequency
query updates
This is because relying only on the Next.js router for
URL updates makes it dependent on the RSC payload
to resolve, which is not the case in some situations:
- Offline or poor network connection
- Requests waterfalling when updating too quickly

Instead, the browser's history API is used for state management,
and optionally on non-shallow updates we can tell the server
about the updated URL to re-render and stream in RSC payloads.
@franky47 franky47 enabled auto-merge (rebase) November 8, 2023 12:35
@franky47 franky47 merged commit 5ad750c into next Nov 8, 2023
4 checks passed
@franky47 franky47 deleted the feat/throttling branch November 8, 2023 12:39
Copy link

github-actions bot commented Nov 8, 2023

🎉 This PR is included in version 1.10.0-beta.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Copy link

github-actions bot commented Nov 8, 2023

🎉 This PR is included in version 1.10.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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