Skip to content

fix(cache): replay "use cache" params under the original cache key - #3430

Merged
james-elicx merged 17 commits into
mainfrom
fix/response-store-replay-params-key
Sep 24, 2026
Merged

james-elicx merged 17 commits into
mainfrom
fix/response-store-replay-params-key

Conversation

@james-elicx

@james-elicx james-elicx commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Problem

Response Store regeneration of a "use cache" function that takes Next.js params writes under a different cache key than the original render, so the stale entry is never replaced.

On a cache miss, the call's arguments are encrypted through Flight (encryptActionBoundArgs) so the entry can be regenerated later. Next.js params are a promise that also exposes the param values as own fields. Flight serializes a promise as its resolved value and drops those fields, and on decode it returns a React chunk whose own fields are status, value and reason. The replay's cache key is built from those internal fields instead of { slug }, so:

  • the replay reads and writes a different key;
  • captureResponseStoreDataRegeneration never sees a write to the target key, so regeneration fails;
  • the original entry keeps serving its first value until it expires.

Reading params.slug synchronously on replay also returned undefined.

Fix

In cache-callable-runtime.ts, the encrypted payload is now { args, thenableObjects }:

  • before encryption, the arguments are walked (arrays, plain objects, Maps, Sets, promise fields and resolved promise values) and each promise-augmented object is recorded once as { fields, promise }. Promises are awaited for this, as Flight awaits them anyway; rejected ones are skipped. The arguments themselves go to Flight unchanged. The walk visits exactly what Flight serializes, including every array index below length (holes and non-enumerable indices too);
  • a getter (own, or inherited through an array hole) can return a different value on every read, so the recorded fields might not match what Flight serializes. Arguments holding one are treated as unreplayable: the encoder throws without running the getter, and the invocation is not recorded, as for other values that cannot be replayed. Captures must always be encoded, so their encoder leaves getters unread and keeps scanning: params elsewhere in the captures keep their fields, and only fields behind a getter are lost. Captures are decoded on every call, the original one included, so they still key the same way on a replay. A setter-only accessor runs no code and does not count as a getter;
  • Flight writes each object once, so each recorded promise decodes to the same promise as every other reference to it, including ones inside resolved values. The fields also keep their shared references with the arguments;
  • after decryption, decoded Flight promises are adopted into native promises, so they key the same way as on the original call. Then each recorded promise gets its fields assigned back.

Because the argument graph is never copied, aliases, cycles, Maps and Sets are preserved by Flight itself. That includes a collection that is both an own field of params and part of its resolved value.

Carrying the promise, instead of rebuilding it from the fields, matters because the params proxy hides params named like promise or React fields (value, status, error) from its own keys but still resolves to them. await params on replay therefore sees every param.

Captured closure values for inline "use cache" functions use the same encoding, which removes React internals from their keys. Response Store metadata is partitioned per worker version, so no compatibility path for the old payload shape is needed.

Tests

  • tests/cache-callable-runtime.test.ts uses the real makeThenableParams proxy and a Flight mock that writes each object once (shared references and cycles) and decodes promises like React's ReactPromise (not a native promise, then returns nothing). It checks that:
    • a replay reads and writes the original key, sees params.slug, and awaits the full params, including a param named value;
    • nested params, non-plain values and plain promises round-trip without mutating the caller's args;
    • shared references and cycles survive, including through Maps and Sets and between a promise's fields and its resolved value;
    • params that are only reachable through a resolved promise value keep their fields;
    • params added while a promise is pending are recorded;
    • sparse arrays, non-enumerable indices and inherited data properties at array holes are scanned the way Flight reads them;
    • arguments with getters (own, on a promise, or inherited at an array hole) are refused without running the getter and record no replay, setter-only accessors stay replayable, and params captured next to a getter keep their fields;
    • malformed payloads are rejected.
  • Response Store e2e: new use-cache-params/[slug] fixture (revalidate: 1). The test asserts the stale read returns the first value and a fresh value replaces it after background regeneration.

Related

#3421 (now merged, and this branch is rebased on it) changes which page-cache arguments are encoded (drops searchParams, adds a $$isPage marker) but does not change how they are encrypted or decoded, so it does not fix this. The two changes compose: the marker passes through the encoding unchanged. isThenableObject is now exported from cache-runtime.ts and used by both unwrapThenableObjects and the argument encoder, so they agree on which promises are keyed by their fields.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 177fdd5f68ac986173fa10c29ca17f8f2c770926 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

Focus areas:

  • the encode/decode codec in packages/vinext/src/shims/cache-callable-runtime.ts (path recording, restore order, validation of decrypted payloads)
  • replay cache-key equality with the original call (unwrapThenableObjects in cache-runtime.ts)
  • params named like promise/React fields (value, status) via the makeThenableParams proxy
  • compatibility with fix(cache): keep "use cache" pages with props prerenderable #3421 (page-cache marker and dropped searchParams)
  • the new unit test and Response Store e2e

@pkg-pr-new

pkg-pr-new Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@3430

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@3430

@vinext/types

npm i https://pkg.pr.new/@vinext/types@3430

vinext

npm i https://pkg.pr.new/vinext@3430

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@3430

commit: 12297ac

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 12297ac against base 01e41b9 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 142.3 KB 142.3 KB ⚫ +0.0%
Client entry size (gzip) vinext 129.7 KB 129.7 KB ⚫ +0.0%
Dev server cold start vinext 3.35 s 3.31 s ⚫ -1.1%
Production build time vinext 3.85 s 3.84 s ⚫ -0.2%
RSC entry closure size (gzip) vinext 128.3 KB 128.3 KB ⚫ -0.0%
Server bundle size (gzip) vinext 226.7 KB 226.6 KB ⚫ -0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
response-store-demo preview production
workers-cache preview production
kv preview production
static-export preview production
web preview production

Comment thread tests/cache-callable-runtime.test.ts Outdated
async function roundTrip(value: unknown): Promise<unknown> {
if (typeof value !== "object" || value === null) return value;
if ("then" in value && typeof value.then === "function") {
return new FlightChunk(await roundTrip(await value));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Preserve the FlightChunk instead of assimilating it in the mock

Because roundTrip is async, returning new FlightChunk(...) causes Promise resolution to assimilate its then method. The mock therefore stores the chunk resolved value rather than a React-style chunk, so adoptFlightThenables never creates the native promise that isEncodedThenableObject requires. Both positive tests fail with Invalid cache function arguments (vp test run tests/cache-callable-runtime.test.ts), and this helper also produces the two warnings that make vp run check fail. Materialize chunks without returning a thenable directly from an async function (and handle the intentional-thenable lint rule) so these tests exercise the production shape and pass.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1d5ddaa. The mock now decodes into boxed results, so an async function never returns a chunk directly, and it builds chunks as native promises with own status/value/reason fields, so it no longer defines then (no unicorn/no-thenable). The thenable check is a type guard, which fixes the await-thenable warning. vp test run tests/cache-callable-runtime.test.ts passes (3/3) and vp check is clean on the changed files.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. The new unit tests and vp run check fail; no additional codec or #3421 compatibility issues found.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 1d5ddaaeca686e18686ef5254df933b317d43346 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

The previous finding (the Flight mock assimilating chunks, which failed the unit tests and vp run check) is addressed in this commit. Please confirm it and review again:

  • the encode/decode codec in packages/vinext/src/shims/cache-callable-runtime.ts (path recording, restore order, validation of decrypted payloads)
  • replay cache-key equality with the original call (unwrapThenableObjects in cache-runtime.ts)
  • params named like promise/React fields (value, status) via the makeThenableParams proxy
  • whether the Flight mock in tests/cache-callable-runtime.test.ts now matches how real Flight decodes promises
  • the new Response Store e2e

export function encodeCacheArguments(args: unknown[]): EncodedCacheArguments {
const thenableObjectPaths: ValuePath[] = [];
const path: ValuePath = [];
const active = new Set<object>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve object-graph identity while encoding

active only protects the current recursion stack. If the same plain object or array containing params appears twice, each occurrence is cloned independently, so replay changes a === b from true to false; a cyclic back-reference similarly points at the unencoded source instead of the encoded placeholder. Flight normally preserves aliases and cycles. Use a source-to-placeholder WeakMap rather than only an active set.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in de0fe69. encodeCacheArguments now maps each source object to one encoded value, created before its children are encoded, so shared references and cycles point at the same placeholder. It only copies arrays and plain records that lead to a promise-augmented object (a reverse-reference pass, findValuesToEncode), and passes everything else to Flight as-is, so Flight also keeps references shared between a params object's fields and its resolved value (for example catch-all slug arrays). Each location of a shared promise-augmented object is recorded and restores the same promise. New tests: "keeps shared references…" and "encodes cycles once…" both fail on the previous commit.

function adoptFlightThenables(value: unknown, active = new Set<object>()): unknown {
if (typeof value !== "object" || value === null || active.has(value)) return value;
if (isThenable(value)) {
const adopted = Promise.resolve(value).then((resolved) => adoptFlightThenables(resolved));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Reuse one adopted promise per Flight chunk

Real Flight caches chunks by row id, so repeated references to one promise decode to the same ReactPromise. This creates a fresh native promise on every encounter. For cached(p, p), replay can therefore serialize two promises instead of the original repeated reference and compute a different key. It also breaks nested thenable params: the chunk in outer.fields.nested and (await outer).nested is shared by Flight, but becomes two promises here, leaving the awaited one without the restored own fields. Carry a WeakMap through both this traversal and the async resolved-value traversal. The mock currently creates a new chunk for each occurrence, so it masks this case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in de0fe69. adoptFlightThenables now carries one WeakMap through the synchronous traversal and the resolved-value traversal, so each chunk is adopted once. For cached(p, p) both arguments decode to the same native promise, and (await outer).nested is the same promise as outer.nested, with the restored own fields. The Flight mock now shares one decoded value between repeated references, as Flight does, and the new "keeps shared references…" test covers both cases.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 2 actionable inline findings.

Confirmed the previous Flight mock issue is fixed: 3/3 targeted tests pass and vp check is clean. No additional e2e findings.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head de0fe69caaa6d08dcf8a9d14ac96adfa45108392 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

Both previous findings (object-graph identity while encoding, and one adopted promise per Flight chunk) are addressed in this commit. Please confirm them and review again:

  • findValuesToEncode and encodeCacheArguments in packages/vinext/src/shims/cache-callable-runtime.ts: only containers that lead to a promise-augmented object are copied; shared references, cycles, and path recording for shared locations
  • adoptFlightThenables: one WeakMap across the sync and resolved-value traversals; restore order with shared locations
  • replay cache-key equality with the original call (unwrapThenableObjects in cache-runtime.ts)
  • the Flight mock and new tests in tests/cache-callable-runtime.test.ts

const visit = (value: unknown, referrer: object | undefined): void => {
if (typeof value !== "object" || value === null) return;
const isThenableValue = isThenableObject(value);
if (!isThenableValue && !Array.isArray(value) && !isPlainRecord(value)) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Traverse Flight-supported containers when finding params

This stops at every non-plain container, but Flight serializes containers such as Map and Set recursively. A cache argument or captured closure like new Map([["params", params]]) therefore reaches Flight unchanged: after decryption map.get("params") is a React chunk with only internal fields, so synchronous slug access is lost on replay (and aliases/cycles through that container can also point back to the unencoded graph). I confirmed this shape against the bundled real Flight runtime. Please include supported containers in the graph/path codec, or otherwise prevent recording a replay invocation that cannot preserve them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3c58677. findValuesToEncode now traverses Map keys/values and Set members, and the codec records Maps and Sets that lead to params as { kind: "map", entries } / { kind: "set", values } alongside { kind: "thenable", fields, promise }. Decoding locates every recorded path first, creates one replacement per encoded value, assigns every location, then fills members — so aliases and cycles through Maps/Sets resolve to the restored graph. Flight-decoded Maps/Sets that don't lead to params also have their chunks adopted in place. New tests cover a params-keyed and params-valued Map, a Set, and cycles through both.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding.

Confirmed both previous identity/adopted-promise findings are fixed. All 5 targeted tests pass, and focused vp check is clean.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 3c58677b17e4401a7354621b6e692ee6cebddc23 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

The previous finding (params inside Flight-supported containers such as Map and Set) is addressed in this commit. Please confirm it and review again:

  • findValuesToEncode / encodeCacheArguments in packages/vinext/src/shims/cache-callable-runtime.ts: Map keys/values and Set members are traversed; Maps/Sets leading to params are encoded as { kind: "map" | "set" } records and every location is recorded
  • decodeCacheArguments: locate all paths, then replace, then fill — aliases and cycles through Maps, Sets and params
  • adoptFlightThenables: in-place adoption inside decoded Maps and Sets
  • replay cache-key equality with the original call (unwrapThenableObjects in cache-runtime.ts)
  • the Flight mock and the new tests in tests/cache-callable-runtime.test.ts

// The promise was adopted by `adoptFlightThenables`, so it is owned here.
return encoded.promise;
case "map":
return new Map();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve collection aliases across a thenable boundary

Creating a fresh Map/Set here splits a collection that is both an own field of a params-like thenable and part of its resolved value. For example, with outer = Object.assign(Promise.resolve({ children }), { children }) where children is a Map containing nested params, replay makes outer.children !== (await outer).children; cycles through that collection are split too. The current tests only put collections directly in the argument graph, so they do not cover this promise boundary. Carry/reuse the Flight-decoded source collection as the restoration target (for both Map and Set) and add a nested alias test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 37bd92c by removing the copy entirely rather than special-casing collections. The arguments now go to Flight unchanged. The payload is { args, thenableObjects }, where each promise-augmented object found while walking arrays, plain objects, Maps, Sets and promise fields is recorded once as { fields, promise }. Flight's writtenObjects dedupes thenables too, so each recorded promise decodes to the same chunk as every other reference to it, including inside resolved values. After adoption, decoding just assigns the fields back onto that promise. Aliases and cycles through any Flight-supported container, across the promise boundary, are preserved by Flight itself. Added the nested alias test (a Map with a cycle and a Set that are both own fields of a thenable and part of its resolved value). The Flight mock now writes each object once, so it models cycles too.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding.

Confirmed prior Map/Set traversal issue is fixed for direct aliases/cycles. All 7 targeted tests pass and focused vp check is clean.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 37bd92cbbd16aed298d990a0a1ad23275db5ba9a without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

The previous finding (collection aliases split across a thenable boundary) is addressed by a redesign in this commit. Please confirm it and review again:

  • encodeCacheArguments in packages/vinext/src/shims/cache-callable-runtime.ts: args now go to Flight unchanged; each promise-augmented object is recorded once as { fields, promise } in thenableObjects. This relies on Flight deduping thenables through writtenObjects, so every reference decodes to one chunk
  • decodeCacheArguments: one adoptFlightThenables pass over the whole payload, then Object.assign(promise, fields), plus validation
  • replay cache-key equality with the original call (unwrapThenableObjects in cache-runtime.ts)
  • the rewritten Flight mock (writes each object once, models cycles) and the tests in tests/cache-callable-runtime.test.ts

@ask-bonk

ask-bonk Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 37bd92cbbd16aed298d990a0a1ad23275db5ba9a without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit — the previous run was cancelled before posting, so please post your findings before the limit even if not every area is covered.

The previous finding (collection aliases split across a thenable boundary) is addressed by a redesign in this commit. Focus on the diff since 3c58677b17e4401a7354621b6e692ee6cebddc23:

  • encodeCacheArguments / decodeCacheArguments in packages/vinext/src/shims/cache-callable-runtime.ts: args now go to Flight unchanged; each promise-augmented object is recorded once as { fields, promise }. This relies on Flight deduping thenables through writtenObjects, and CI's Workers Response Store e2e (real Flight) passes on this head
  • the rewritten Flight mock and the tests in tests/cache-callable-runtime.test.ts

);
thenableObjects.push({ fields, promise: value });
}
pending.push(...flightMembers(value));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Collect augmented promises inside resolved promise values

This traversal only follows a thenable’s enumerable own fields; it never follows its resolved value. For example, if an admitted argument is Promise.resolve({ nested: makeThenableParams({ slug: "a" }) }), the outer promise has no own keys, so nested is absent from thenableObjects. Flight still serializes it while resolving the outer promise, but on replay const { nested } = await arg yields an adopted promise with no restored fields and nested.slug changes from "a" to undefined (while await nested still works). This is a Flight-supported argument shape and can also occur in captured values. Please collect promise-augmented objects discovered in resolved values as well, and cover this wrapper-promise case in the round-trip test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 828a8ed. encodeCacheArguments is now async. It walks the arguments in waves: after each synchronous pass, it awaits every thenable it found (plain promises and promise-augmented objects, with Promise.allSettled) and walks the fulfilled values in the next pass, sharing the same visited set. Flight awaits the same promises while serializing, so this adds no new wait, and rejected promises are skipped because Flight emits them as error chunks. Flight dedupes the recorded promise with the reference inside the resolved value, and the adopt memo maps both to one native promise, so decode is unchanged. Added the wrapper-promise round-trip test: Promise.resolve({ nested: params, deeper: Promise.resolve([params]) }) plus a rejected promise. It checks that nested.slug is restored, await nested works, the doubly nested reference is the same promise, and the rejection survives.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted 8 tests and focused checks pass.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 828a8edef60172e57b420cce7d05744b303fc37d without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit, and post them before the limit even if not every area is covered.

The previous finding (params inside resolved promise values) is addressed. Focus on the diff since 37bd92cbbd16aed298d990a0a1ad23275db5ba9a:

  • encodeCacheArguments in packages/vinext/src/shims/cache-callable-runtime.ts is now async: it awaits every thenable it finds (Promise.allSettled) and walks the fulfilled values, skipping rejections, before Flight encoding
  • the new wrapper-promise test in tests/cache-callable-runtime.test.ts

// Flight serializes a promise as its resolved value, which can hold more
// params. Flight awaits these promises too, and emits rejections as errors.
const settled = await Promise.allSettled(thenables);
pending = settled.flatMap((result) => (result.status === "fulfilled" ? [result.value] : []));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Revisit fulfilled containers that were seen before awaiting

visited spans every wave, so a fulfilled value that aliases an object already reachable from args is skipped here. If that object gains a params thenable while the promise is pending (for example, args contains both shared and a promise whose resolver assigns shared.params = makeThenableParams(...) and then resolves to shared), the initial pass sees the empty object and the fulfilled-value pass refuses to inspect it. Because encryptActionBoundArgs now starts only after this pre-await completes, Flight does serialize the newly added params, but thenableObjects does not record it; replay therefore loses synchronous fields such as params.slug. Please allow fulfilled containers to be traversed again after an async boundary while deduplicating captured/awaited thenables separately, and cover this aliased-mutation case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in ad48dcf. The visited set is now per walk. Each promise is awaited once, with its settled result kept for the duration of the encode call, and then the whole graph is walked again from args, following fulfilled values. This repeats until a walk finds no new promises. Only that last walk's thenableObjects are returned, and it runs right before Flight encoding, so containers that changed while a promise was pending (including aliases of objects seen earlier) are re-read. Added the aliased-mutation test: args = [shared, later], where later assigns shared.params = makeThenableParams({ slug: "a" }) and then resolves to shared. It fails on 828a8ed and passes now.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Confirmed the previous wrapper-promise issue is fixed; all 9 targeted tests and focused checks pass.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head ad48dcfc0984e778480debdcc27491409c1d098a without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit, and post them before the limit even if not every area is covered.

The previous finding (containers mutated while a promise is pending) is addressed. Focus on the diff since 828a8edef60172e57b420cce7d05744b303fc37d:

  • encodeCacheArguments in packages/vinext/src/shims/cache-callable-runtime.ts now awaits each promise once and rewalks the full graph from args with a fresh visited set until a walk finds no unsettled promise; only that final walk's thenableObjects are returned
  • the new aliased-mutation test in tests/cache-callable-runtime.test.ts

// new promises runs right before Flight encoding, so it records the fields
// Flight sees.
const settled = new Map<PromiseLike<unknown>, PromiseSettledResult<unknown>>();
for (;;) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Bound rescans of promise-valued accessors

Each fresh walk re-evaluates enumerable properties, and this loop only terminates when every promise identity it reads is already in settled. A Flight-supported plain record such as { get value() { return Promise.resolve("x") } } returns a new promise on every pass: each one settles, the next walk reads another one, and encodeCacheArguments loops forever, so the cache call/capture never completes. React Flight accepts enumerable accessors on plain objects and would serialize the single value it reads. Please avoid using unseen promise identity as the unbounded termination condition (for example, snapshot member reads or explicitly bound/reject an unstable graph) while retaining the rescan needed for the alias-mutation case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 46ce00f. Own entries are now read through a helper that reads each enumerable accessor once per encodeCacheArguments call and reuses that value in later walks and for the recorded fields. Data properties are still re-read on every walk, so the alias-mutation rescan is kept. After this, a walk can only find a new promise if program code assigned one into the arguments while an earlier promise was pending, or if promises are nested more deeply. Both stop once the arguments settle. Added a test with { get wrapped() { return Promise.resolve({ params }) } }. It checks that the getter is read once, the params are recorded, and they are restored on replay. On ad48dcf the test times out.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Confirmed the prior alias-mutation issue is fixed; all 10 targeted tests and focused checks pass.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 46ce00f3e4ccb5563241dde1384d4946036a91c6 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit, and post them before the limit even if not every area is covered.

The previous finding (promise-returning accessors rescanned forever) is addressed. Focus on the diff since ad48dcfc0984e778480debdcc27491409c1d098a:

  • ownEntries in packages/vinext/src/shims/cache-callable-runtime.ts reads each enumerable accessor once per encodeCacheArguments call (data properties are still re-read on every walk, so alias mutations are still picked up)
  • the new accessor test in tests/cache-callable-runtime.test.ts

reads = new Map();
accessorReads.set(value, reads);
}
if (!reads.has(key)) reads.set(key, Reflect.get(value, key));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Serialize the accessor snapshot that was scanned

Caching this read terminates the pre-scan, but encodeCacheArguments still returns the original args, so encryptActionBoundArgs/Flight invokes this getter again while serializing. If successive reads return distinct graphs, e.g. get wrapped() { return Promise.resolve({ params: makeThenableParams({ slug: String(++reads) }) }) }, thenableObjects records the params from the first read while args.wrapped contains the params from Flight's second read. Decode restores fields only on the first promise, leaving (await restored.wrapped).params.slug undefined and changing replay behavior/keying. The new test masks this because every getter result closes over the same params, and it checks reads before flight.roundTrip reads the getter again. The values passed to Flight need to reuse the accessor snapshot (while preserving graph identity), and the test should use per-read params and assert the final read count/restored synchronous field after serialization.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 261269f. The final walk now also records each object's parents. If any array or plain record Flight will serialize had a getter read, snapshotAccessors copies that object and every value that leads to it (records, arrays, Maps, Sets), filling the getters from the recorded reads, with a memo so shared references and cycles are kept. A promise whose resolved value leads to a getter is replaced by a native promise of the copied value. A promise that reaches one only through its own fields keeps its identity, because Flight doesn't serialize those fields. The recorded fields and promises are mapped through the same copies, so Flight serializes exactly the graph the fields were recorded from. Nothing is copied when there are no getters. The caller's objects are left untouched.

The test now uses per-read params (slug: String(++reads)). It asserts after flight.roundTrip that the getter was read once and (await restored.wrapped).params.slug === "1". It also checks that the self-cycle, the aliases through an array, a promise and a Map, and a params field pointing at the record all stay shared, and that the caller's getter is still a getter. On 46ce00f it fails with reads === 2.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted 11 tests and focused checks pass.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 261269fd10ac439c5767d00aa196e8af51f2f424 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit, and post them before the limit even if not every area is covered.

The previous finding (Flight re-reading getters) is addressed. Focus on the diff since 46ce00f3e4ccb5563241dde1384d4946036a91c6:

  • snapshotAccessors in packages/vinext/src/shims/cache-callable-runtime.ts: copies the values Flight serializes that lead to a getter, using the recorded reads and a memo for identity; a promise whose resolved value leads to a getter becomes a native promise of the copy
  • the rewritten getter test in tests/cache-callable-runtime.test.ts

The params proxy hides params named like promise or React fields (`value`, `status`) from its own keys but still resolves to them. Carry the promise through Flight alongside its own fields so a replay awaits the original resolved value, and cover the real params proxy in the unit tests.
Flight serializes arrays through JSON.stringify, which reads every index below length, including holes and non-enumerable indices. Walk and copy arrays the same way, so getters at those indices are recorded and read once, and copies keep the array's length.
…e" args

An array hole reads through the prototype chain, so an indexed accessor there runs on every read. Look up the chain before treating a read as a data read, so such getters are read once per encode instead of adding a new promise on every walk.
…encoding

unwrapThenableObjects and the "use cache" argument encoder must agree on which promises are keyed by their fields. Export one helper from cache-runtime.ts instead of keeping a copy in cache-callable-runtime.ts.
@james-elicx
james-elicx force-pushed the fix/response-store-replay-params-key branch from 6c6a89d to 0e65c18 Compare September 24, 2026 14:17
A getter in cache function arguments can return a different value on every
read, so the recorded promise fields may not match what Flight serializes.
Instead of reading each getter once and copying every value that leads to
it, treat arguments with getters (own, or inherited through an array hole)
as unreplayable: the invocation is not recorded, as for other values that
cannot be replayed. Captures must always be encoded, so they fall back to
plain Flight encoding without recorded promise fields; they are decoded on
every call, so they still key the same way on a replay.

The Flight mock now decodes promises like React's ReactPromise: not a
native promise, with a `then` that returns nothing.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 046a850b268f39a19a96b0b0407b006fad591e47 without modifying or pushing the branch. You have an 8-minute time limit. Post each actionable finding as soon as you confirm it, and post your summary comment by minute 6 even if not every area is covered.

This head replaces the getter snapshotting in packages/vinext/src/shims/cache-callable-runtime.ts with a bail-out: ownEntries throws UnreplayableCacheArgumentsError (without running the getter) when a read would hit an accessor, own or inherited through an array hole, so the invocation is not recorded. encryptCacheCaptures catches that error and falls back to plain Flight encoding, since captures must always be encoded. The Flight mock in tests/cache-callable-runtime.test.ts now decodes promises like React's ReactPromise. Keep the scope to the diff since 0e65c1847a3f40f427bb5b77ab6051368baed2da.

: Object.keys(value);
return keys.map((key) => {
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
if (descriptor ? !("value" in descriptor) : key in value) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Distinguish inherited data properties from accessors

For an array hole, key in value is true for any inherited property, not only an accessor. A sparse array whose custom prototype provides index 0 as a data property is serialized by Flight/JSON.stringify as that inherited value and is safe to replay, but this branch throws and cache-runtime silently omits cacheFunctionInvocation; stale entries using this argument can therefore never regenerate. Walk the prototype descriptors (as the removed readsAccessor did) and bail only when the first matching descriptor is an accessor; add an inherited-data counterpart to the accessor test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 645f643. ownEntries now walks the prototype chain (readsAccessor) and bails only when the first matching descriptor is an accessor, so an inherited data property at an array hole is scanned and replayed like any other value. Added scans an inherited data property at an array hole next to the accessor test.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted 14 tests and focused checks pass; worktree unchanged.

github run

…holes

An array hole reads through the prototype chain, so treat it as a getter
only when the first matching descriptor is an accessor. An inherited data
property is serialized by Flight like any other value and stays replayable.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 645f6432959768ede3d4cb91bec6773c8515f3e9 without modifying or pushing the branch. You have an 8-minute time limit. Post each actionable finding as soon as you confirm it, and post your summary comment by minute 6 even if not every area is covered.

The previous finding (inherited data properties at array holes treated as accessors) is addressed: ownEntries in packages/vinext/src/shims/cache-callable-runtime.ts now walks the prototype chain via readsAccessor and throws UnreplayableCacheArgumentsError only when the first matching descriptor is an accessor. A test covers the inherited-data case. Please confirm it is resolved. Keep the scope to the diff since 0e65c1847a3f40f427bb5b77ab6051368baed2da.

// cannot run without them. They are decoded on every call, the original
// one included, so they key the same way on a replay even without
// recorded promise fields; only synchronous reads of those fields are lost.
encoded = { args: captures, thenableObjects: [] };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve params fields when another capture has a getter

This fallback drops thenableObjects for the entire capture array. If a cached function captures both an unrelated getter-bearing object and params, Flight decodes params as a chunk and adoptFlightThenables turns it into a native promise without restoring fields, so even the original invocation observes params.slug as undefined. Keep the safely discovered augmented promises when bailing out of the getter branch (or otherwise preserve their fields) rather than degrading every capture in the envelope.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 12297ac. Captures no longer fall back wholesale: encryptCaptures calls encodeCacheArguments(captures, "skip"), which leaves getters unread and keeps scanning, so params elsewhere in the captures keep their recorded fields. Only promise fields behind a getter are lost. Invocation args still reject getters. Test: keeps params fields in captures next to a getter (fails on 645f643).

? Array.from({ length: value.length }, (_, index) => String(index))
: Object.keys(value);
return keys.map((key) => {
if (readsAccessor(value, key)) throw new UnreplayableCacheArgumentsError();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Allow setter-only properties to remain replayable

readsAccessor also returns true for an accessor descriptor with no getter. Reading such a property cannot execute user code and always yields undefined, so this now unnecessarily omits cacheFunctionInvocation; stale entries whose arguments contain an enumerable setter-only property can never regenerate. Reject only descriptors with a callable get (including inherited array indices).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 12297ac. readsAccessor is now readsGetter and returns typeof descriptor.get === "function" for the first matching descriptor on the chain, so setter-only accessors (own or inherited at an array hole) are read as undefined and stay replayable. Test: replays arguments with setter-only properties (fails on 645f643).

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 2 actionable inline findings.

Confirmed the inherited-data array-hole issue is resolved. All 15 targeted tests pass, focused checks are clean, and the worktree remains unchanged.

github run

…only accessors

Captures with a getter no longer drop every recorded promise field: the
capture encoder leaves getters unread and keeps scanning, so params elsewhere
in the captures keep their fields. Only a callable getter blocks a replay;
a setter-only accessor reads as undefined without running code.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 12297acb22a0eb4f7c76fa1147166cb43dec62a2 without modifying or pushing the branch. You have an 8-minute time limit. Post each actionable finding as soon as you confirm it, and post your summary comment by minute 6 even if not every area is covered.

The previous two findings are addressed in packages/vinext/src/shims/cache-callable-runtime.ts: (1) captures now use encodeCacheArguments(captures, "skip"), which leaves getters unread and keeps scanning, so params elsewhere in the captures keep their fields; (2) readsGetter only treats a descriptor with a callable get as a getter, so setter-only accessors stay replayable. Tests cover both. Please confirm they are resolved. Keep the scope to the diff since 0e65c1847a3f40f427bb5b77ab6051368baed2da.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx
james-elicx marked this pull request as ready for review September 24, 2026 17:07
@james-elicx
james-elicx merged commit b9f09b0 into main Sep 24, 2026
84 checks passed
@james-elicx
james-elicx deleted the fix/response-store-replay-params-key branch September 24, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant