docs: Fix the documented contract for custom cache handlers - #98039
Open
unstubbable wants to merge 1 commit into
Open
docs: Fix the documented contract for custom cache handlers#98039unstubbable wants to merge 1 commit into
unstubbable wants to merge 1 commit into
Conversation
The documented in-memory handler stored the resolved entry and returned it from every `get`. A stored stream keeps the request that produced the entry reachable. The second cache hit also fails, because it reads a stream that another reader already consumed. The render then reports "Invalid state: ReadableStream is locked". The "Handling Streams" section described a second, milder mistake. It told a handler to tee the stored stream in `get`. One branch goes back into the store, and the other serves the current read. That pattern serves every hit correctly. The built-in handler used it until #97941, so the page described the framework's own implementation. The stored branch retains the same request, and it also holds the source stream and its buffered chunks. The size a store accounts for covers none of that. The page now states that cost, because a reader who thinks of teeing would otherwise read the rule as inapplicable. `get` told a handler to drop an entry once it is past `revalidate`. Next.js compares `timestamp` against `expire` on every read, and it treats a too-old entry as a miss. It applies the same check to `revalidate` when the result goes into another server cache. A handler therefore needs no age check at all. The built-in handler still drops at `revalidate`, as a deliberate policy for an in-memory cache, and the page now presents that as a policy instead of a rule. Something still has to reclaim entries, so the page states that eviction belongs to the handler. Next.js never deletes from it. An age check at serve time is no substitute, because it only reaches the keys that a reader still asks for. The page now follows an order in which no rule appears after the code that depends on it. The stream rules sit in one section ahead of the API reference. `CacheEntry Type` moves above the methods that reference it. `set` precedes `get`, because a handler writes an entry before it reads one. The tag and error sections move above the examples. `types.ts` described `refreshTags` as periodic, and the page described it as a call before each request. Neither is accurate. It runs once per request, before the first cache read for its kind. A request that reads nothing from a handler never calls it. Neither `types.ts` nor the page documented the `revalidate: -1` signal, which the built-in handler relies on. A negative value always lies in the past, so it serves an entry with a stale tag one more time while Next.js generates a fresh one. `types.ts` now states these rules as well, so a handler author no longer has to read the built-in implementation to find them. It also covers the `pendingEntry` promise, which a handler must not retain either. `streamFromBuffer` now records why it has to stay a default stream. A byte stream transfers the buffer of every chunk it receives. That would detach the buffer an in-memory handler serves many reads from. The comment in `tiered-cache-handler.ts` about cancelling a teed stream now says that the backing handler is user-configured, because the built-in one no longer returns such a branch. The handler test from #97941 now records a limit of its own. It only fails on Node 20 and 22, whose `AsyncLocalStorage` attaches the active store to every promise. CI runs Node 20.9, so a regression fails there. A local run on a newer Node passes. Nothing in this change affects runtime behavior.
Contributor
Tests PassedCommit: 36b4594 |
unstubbable
marked this pull request as ready for review
August 28, 2026 18:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The documented in-memory handler stored the resolved entry and returned it from every
get. A stored stream keeps the request that produced the entry reachable. The second cache hit also fails, because it reads a stream that another reader already consumed. The render then reports "Invalid state: ReadableStream is locked".The "Handling Streams" section described a second, milder mistake. It told a handler to tee the stored stream in
get. One branch goes back into the store, and the other serves the current read. That pattern serves every hit correctly. The built-in handler used it until #97941, so the page described the framework's own implementation. The stored branch retains the same request, and it also holds the source stream and its buffered chunks. The size a store accounts for covers none of that. The page now states that cost, because a reader who thinks of teeing would otherwise read the rule as inapplicable.gettold a handler to drop an entry once it is pastrevalidate. Next.js comparestimestampagainstexpireon every read, and it treats a too-old entry as a miss. It applies the same check torevalidatewhen the result goes into another server cache. A handler therefore needs no age check at all. The built-in handler still drops atrevalidate, as a deliberate policy for an in-memory cache, and the page now presents that as a policy instead of a rule.Something still has to reclaim entries, so the page states that eviction belongs to the handler. Next.js never deletes from it. An age check at serve time is no substitute, because it only reaches the keys that a reader still asks for.
The page now follows an order in which no rule appears after the code that depends on it. The stream rules sit in one section ahead of the API reference.
CacheEntry Typemoves above the methods that reference it.setprecedesget, because a handler writes an entry before it reads one. The tag and error sections move above the examples.types.tsdescribedrefreshTagsas periodic, and the page described it as a call before each request. Neither is accurate. It runs once per request, before the first cache read for its kind. A request that reads nothing from a handler never calls it.Neither
types.tsnor the page documented therevalidate: -1signal, which the built-in handler relies on. A negative value always lies in the past, so it serves an entry with a stale tag one more time while Next.js generates a fresh one.types.tsnow states these rules as well, so a handler author no longer has to read the built-in implementation to find them. It also covers thependingEntrypromise, which a handler must not retain either.streamFromBuffernow records why it has to stay a default stream. A byte stream transfers the buffer of every chunk it receives. That would detach the buffer an in-memory handler serves many reads from. The comment intiered-cache-handler.tsabout cancelling a teed stream now says that the backing handler is user-configured, because the built-in one no longer returns such a branch.The handler test from #97941 now records a limit of its own. It only fails on Node 20 and 22, whose
AsyncLocalStorageattaches the active store to every promise. CI runs Node 20.9, so a regression fails there. A local run on a newer Node passes.Nothing in this change affects runtime behavior.