Skip to content

Commit

Permalink
Optimize large strings
Browse files Browse the repository at this point in the history
  • Loading branch information
sebmarkbage committed Jun 12, 2023
1 parent 194d544 commit df81cb6
Show file tree
Hide file tree
Showing 11 changed files with 148 additions and 11 deletions.
40 changes: 36 additions & 4 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,14 @@ function createResolvedModuleChunk<T>(
return new Chunk(RESOLVED_MODULE, value, null, response);
}

function createInitializedTextChunk(
response: Response,
value: string,
): InitializedChunk<string> {
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
return new Chunk(INITIALIZED, value, null, response);
}

function resolveModelChunk<T>(
chunk: SomeChunk<T>,
value: UninitializedModel,
Expand Down Expand Up @@ -704,6 +712,13 @@ function resolveModel(
}
}

function resolveText(response: Response, id: number, text: string): void {
const chunks = response._chunks;
// We assume that we always reference large strings after they've been
// emitted.
chunks.set(id, createInitializedTextChunk(response, text));
}

function resolveModule(
response: Response,
id: number,
Expand Down Expand Up @@ -818,7 +833,7 @@ function resolveHint(
code: string,
model: UninitializedModel,
): void {
const hintModel = parseModel<HintModel>(response, model);
const hintModel: HintModel = parseModel(response, model);
dispatchHint(code, hintModel);
}

Expand Down Expand Up @@ -869,6 +884,10 @@ function processFullRow(
}
return;
}
case 84 /* "T" */: {
resolveText(response, id, row);
return;
}
default: {
// We assume anything else is JSON.
resolveModel(response, id, row);
Expand Down Expand Up @@ -898,17 +917,30 @@ export function processBinaryChunk(
}
case ROW_TAG: {
const resolvedRowTag = chunk[i];
if (resolvedRowTag > 64 && resolvedRowTag < 91) {
if (resolvedRowTag === 84 /* "T" */) {
response._rowTag = resolvedRowTag;
response._rowState = ROW_LENGTH;
i++;
} else if (resolvedRowTag > 64 && resolvedRowTag < 91 /* "A"-"Z" */) {
response._rowTag = resolvedRowTag;
response._rowState = ROW_CHUNK_BY_NEWLINE;
i++;
} else {
response._rowTag = 0;
response._rowState = ROW_CHUNK_BY_NEWLINE;
// This was an unknown tag so it was probably part of the data.
}
response._rowState = ROW_CHUNK_BY_NEWLINE;
continue;
}
case ROW_LENGTH: {
// TODO
const byte = chunk[i++];
if (byte === 44 /* "," */) {
// Finished the rowLength, next we'll buffer up to that length.
response._rowState = ROW_CHUNK_BY_LENGTH;
} else {
response._rowLength =
(response._rowLength << 4) | (byte > 96 ? byte - 87 : byte - 48);
}
continue;
}
case ROW_CHUNK_BY_NEWLINE: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export function clonePrecomputedChunk(
return chunk;
}

export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
throw new Error('Not implemented.');
}

export function closeWithError(destination: Destination, error: mixed): void {
// $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types.
destination.destroy(error);
Expand Down
4 changes: 4 additions & 0 deletions packages/react-server-dom-fb/src/ReactServerStreamConfigFB.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export function clonePrecomputedChunk(
return chunk;
}

export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
throw new Error('Not implemented.');
}

export function closeWithError(destination: Destination, error: mixed): void {
destination.done = true;
destination.fatal = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,25 @@ describe('ReactFlightDOMEdge', () => {
const result = await readResult(ssrStream);
expect(result).toEqual('<span>Client Component</span>');
});

it('should encode long string in a compact format', async () => {
const testString = '"\n\t'.repeat(500) + '🙃';

const stream = ReactServerDOMServer.renderToReadableStream({
text: testString,
});
const [stream1, stream2] = stream.tee();

const serializedContent = await readResult(stream1);
// The content should be compact an unescaped
expect(serializedContent.length).toBeLessThan(2000);
expect(serializedContent).not.toContain('\\n');
expect(serializedContent).not.toContain('\\t');
expect(serializedContent).not.toContain('\\"');
expect(serializedContent).toContain('\t');

const result = await ReactServerDOMClient.createFromReadableStream(stream2);
// Should still match the result when parsed
expect(result.text).toBe(testString);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,31 @@ describe('ReactFlightDOMNode', () => {
const result = await readResult(ssrStream);
expect(result).toEqual('<span>Client Component</span>');
});

it('should encode long string in a compact format', async () => {
const testString = '"\n\t'.repeat(500) + '🙃';

const stream = ReactServerDOMServer.renderToPipeableStream({
text: testString,
});

const readable = new Stream.PassThrough();

const stringResult = readResult(readable);
const parsedResult = ReactServerDOMClient.createFromNodeStream(readable);

stream.pipe(readable);

const serializedContent = await stringResult;
// The content should be compact an unescaped
expect(serializedContent.length).toBeLessThan(2000);
expect(serializedContent).not.toContain('\\n');
expect(serializedContent).not.toContain('\\t');
expect(serializedContent).not.toContain('\\"');
expect(serializedContent).toContain('\t');

const result = await parsedResult;
// Should still match the result when parsed
expect(result.text).toBe(testString);
});
});
44 changes: 37 additions & 7 deletions packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
beginWriting,
writeChunkAndReturn,
stringToChunk,
byteLengthOfChunk,
completeWriting,
close,
closeWithError,
Expand Down Expand Up @@ -171,7 +172,7 @@ export type Request = {
pingedTasks: Array<Task>,
completedImportChunks: Array<Chunk>,
completedHintChunks: Array<Chunk>,
completedJSONChunks: Array<Chunk>,
completedRegularChunks: Array<Chunk>,
completedErrorChunks: Array<Chunk>,
writtenSymbols: Map<symbol, number>,
writtenClientReferences: Map<ClientReferenceKey, number>,
Expand Down Expand Up @@ -230,7 +231,7 @@ export function createRequest(
pingedTasks: pingedTasks,
completedImportChunks: ([]: Array<Chunk>),
completedHintChunks: ([]: Array<Chunk>),
completedJSONChunks: ([]: Array<Chunk>),
completedRegularChunks: ([]: Array<Chunk>),
completedErrorChunks: ([]: Array<Chunk>),
writtenSymbols: new Map(),
writtenClientReferences: new Map(),
Expand Down Expand Up @@ -715,11 +716,25 @@ function serializeServerReference(
metadataId,
serverReferenceMetadata,
);
request.completedJSONChunks.push(processedChunk);
request.completedRegularChunks.push(processedChunk);
writtenServerReferences.set(serverReference, metadataId);
return serializeServerReferenceID(metadataId);
}

function serializeLargeTextString(request: Request, text: string): string {
request.pendingChunks += 2;
const textId = request.nextChunkId++;
const textChunk = stringToChunk(text);
const headerChunk = processTextHeader(
request,
textId,
text,
byteLengthOfChunk(textChunk),
);
request.completedRegularChunks.push(headerChunk, textChunk);
return serializeByValueID(textId);
}

function escapeStringValue(value: string): string {
if (value[0] === '$') {
// We need to escape $ prefixed strings since we use those to encode
Expand Down Expand Up @@ -960,7 +975,12 @@ function resolveModelToJSON(
return serializeDateFromDateJSON(value);
}
}

if (value.length > 1024) {
// For large strings, we encode them outside the JSON payload so that we
// don't have to double encode and double parse the strings. This can also
// be more compact in case the string has a lot of escaped characters.
return serializeLargeTextString(request, value);
}
return escapeStringValue(value);
}

Expand Down Expand Up @@ -1152,7 +1172,7 @@ function emitProviderChunk(
): void {
const contextReference = serializeProviderReference(contextName);
const processedChunk = processReferenceChunk(request, id, contextReference);
request.completedJSONChunks.push(processedChunk);
request.completedRegularChunks.push(processedChunk);
}

function retryTask(request: Request, task: Task): void {
Expand Down Expand Up @@ -1216,7 +1236,7 @@ function retryTask(request: Request, task: Task): void {
}

const processedChunk = processModelChunk(request, task.id, value);
request.completedJSONChunks.push(processedChunk);
request.completedRegularChunks.push(processedChunk);
request.abortableTasks.delete(task);
task.status = COMPLETED;
} catch (thrownValue) {
Expand Down Expand Up @@ -1323,7 +1343,7 @@ function flushCompletedChunks(
hintChunks.splice(0, i);

// Next comes model data.
const jsonChunks = request.completedJSONChunks;
const jsonChunks = request.completedRegularChunks;
i = 0;
for (; i < jsonChunks.length; i++) {
request.pendingChunks--;
Expand Down Expand Up @@ -1545,3 +1565,13 @@ function processHintChunk(
const row = serializeRowHeader('H' + code, id) + json + '\n';
return stringToChunk(row);
}

function processTextHeader(
request: Request,
id: number,
text: string,
binaryLength: number,
): Chunk {
const row = id.toString(16) + ':T' + binaryLength.toString(16) + ',';
return stringToChunk(row);
}
4 changes: 4 additions & 0 deletions packages/react-server/src/ReactServerStreamConfigBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ export function clonePrecomputedChunk(
: precomputedChunk;
}

export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
return chunk.byteLength;
}

export function closeWithError(destination: Destination, error: mixed): void {
// $FlowFixMe[method-unbinding]
if (typeof destination.error === 'function') {
Expand Down
4 changes: 4 additions & 0 deletions packages/react-server/src/ReactServerStreamConfigBun.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export function clonePrecomputedChunk(
return chunk;
}

export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
throw new Error('Not implemented.');
}

export function closeWithError(destination: Destination, error: mixed): void {
if (typeof destination.error === 'function') {
// $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types.
Expand Down
4 changes: 4 additions & 0 deletions packages/react-server/src/ReactServerStreamConfigEdge.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ export function clonePrecomputedChunk(
: precomputedChunk;
}

export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
return chunk.byteLength;
}

export function closeWithError(destination: Destination, error: mixed): void {
// $FlowFixMe[method-unbinding]
if (typeof destination.error === 'function') {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-server/src/ReactServerStreamConfigNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ export function clonePrecomputedChunk(
: precomputedChunk;
}

export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
return typeof chunk === 'string'
? Buffer.byteLength(chunk, 'utf8')
: chunk.byteLength;
}

export function closeWithError(destination: Destination, error: mixed): void {
// $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types.
destination.destroy(error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ export const closeWithError = $$$config.closeWithError;
export const stringToChunk = $$$config.stringToChunk;
export const stringToPrecomputedChunk = $$$config.stringToPrecomputedChunk;
export const clonePrecomputedChunk = $$$config.clonePrecomputedChunk;
export const byteLengthOfChunk = $$$config.byteLengthOfChunk;

0 comments on commit df81cb6

Please sign in to comment.