diff --git a/.changeset/perf-frame-decoder-zero-copy.md b/.changeset/perf-frame-decoder-zero-copy.md new file mode 100644 index 0000000000..182c81b66a --- /dev/null +++ b/.changeset/perf-frame-decoder-zero-copy.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-client-core': patch +--- + +perf: zero-copy frame payload extraction in the client frame decoder. When a frame's bytes are fully contained in the first buffered chunk (the common case), return a subarray view instead of allocating a new buffer and copying. This is on the hot path for decoding streamed server-function responses and `RawStream` payloads. diff --git a/packages/start-client-core/src/client-rpc/frame-decoder.ts b/packages/start-client-core/src/client-rpc/frame-decoder.ts index dbdd605bf2..9834b10a1b 100644 --- a/packages/start-client-core/src/client-rpc/frame-decoder.ts +++ b/packages/start-client-core/src/client-rpc/frame-decoder.ts @@ -201,6 +201,25 @@ export function createFrameDecoder( function extractFlattened(count: number): Uint8Array { if (count === 0) return EMPTY_BUFFER + // Fast path: the requested bytes are fully contained in the first buffered + // chunk (the common case — most frames arrive within a single network + // read). Return a subarray view instead of allocating a new buffer and + // copying `count` bytes. The view shares the chunk's backing ArrayBuffer, + // which is safe because buffered chunks are never mutated in place after + // being read from the network. + const first = bufferList[0] + if (first && first.length >= count) { + const result = first.subarray(0, count) + if (first.length === count) { + bufferList.shift() + } else { + bufferList[0] = first.subarray(count) + } + totalLength -= count + return result + } + + // Slow path: the requested bytes span multiple chunks — flatten by copying. const result = new Uint8Array(count) let offset = 0 let remaining = count diff --git a/packages/start-client-core/tests/frame-decoder.test.ts b/packages/start-client-core/tests/frame-decoder.test.ts index 29b8974dea..3a05611392 100644 --- a/packages/start-client-core/tests/frame-decoder.test.ts +++ b/packages/start-client-core/tests/frame-decoder.test.ts @@ -558,5 +558,51 @@ describe('frame-decoder', () => { const { done: finalDone } = await reader.read() expect(finalDone).toBe(true) }) + + it('should reassemble a chunk payload that spans many small reads', async () => { + // A large binary payload delivered in tiny network reads forces the + // multi-chunk (copy) path; the contiguous fast path must not change the + // reassembled bytes. + const payload = new Uint8Array(300) + for (let i = 0; i < payload.length; i++) payload[i] = i % 256 + + const jsonFrame = encodeJSONFrame('{"ref":11}') + const chunkFrame = encodeChunkFrame(11, payload) + const endFrame = encodeEndFrame(11) + + const combined = new Uint8Array( + jsonFrame.length + chunkFrame.length + endFrame.length, + ) + combined.set(jsonFrame, 0) + combined.set(chunkFrame, jsonFrame.length) + combined.set(endFrame, jsonFrame.length + chunkFrame.length) + + const input = new ReadableStream({ + start(controller) { + // 7-byte reads: smaller than the 9-byte header and the payload, so + // both header and payload span multiple buffered chunks. + for (let i = 0; i < combined.length; i += 7) { + controller.enqueue(combined.subarray(i, i + 7)) + } + controller.close() + }, + }) + + const { getOrCreateStream, jsonChunks } = createFrameDecoder(input) + const stream11 = getOrCreateStream(11) + + const jsonReader = jsonChunks.getReader() + const { value: jsonValue } = await jsonReader.read() + expect(jsonValue).toBe('{"ref":11}') + + const rawReader = stream11.getReader() + const received: Array = [] + while (true) { + const { done, value } = await rawReader.read() + if (done) break + if (value) received.push(...value) + } + expect(received).toEqual(Array.from(payload)) + }) }) })