Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/react-on-rails-pro/src/streamingUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { PassThrough, Readable } from 'stream';

import createReactOutput from 'react-on-rails/createReactOutput';
import { isPromise, isServerRenderHash } from 'react-on-rails/isServerRenderResult';
import buildConsoleReplay from 'react-on-rails/buildConsoleReplay';
import { consoleReplay } from 'react-on-rails/buildConsoleReplay';
import { createResultObject, convertToError, validateComponent } from 'react-on-rails/serverRenderUtils';
import {
RenderParams,
Expand Down Expand Up @@ -112,7 +112,13 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen
const transformStream = new PassThrough({
transform(chunk: Buffer, _, callback) {
const htmlChunk = chunk.toString();
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
// Get unwrapped console replay JavaScript (not wrapped in <script> tags)
// We use consoleReplay() instead of buildConsoleReplay() because streaming
// contexts handle script tag wrapping separately (e.g., with CSP nonces).
// This returns pure JavaScript without wrapping, which is then embedded
// into the result object JSON payload.
const consoleReplayScript = consoleReplay(consoleHistory, previouslyReplayedConsoleMessages);

previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));
this.push(`${jsonChunk}\n`);
Expand Down
6 changes: 4 additions & 2 deletions packages/react-on-rails/src/buildConsoleReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ declare global {
* This is useful when you want to wrap the code in script tags yourself (e.g., with a CSP nonce).
* @internal Exported for tests and for Ruby helper to wrap with nonce
*/
// eslint-disable-next-line @typescript-eslint/default-param-last
export function consoleReplay(
customConsoleHistory: (typeof console)['history'] | undefined = undefined,
numberOfMessagesToSkip: number = 0,
numberOfMessagesToSkip = 0,
): string {
// console.history is a global polyfill used in server rendering.
const consoleHistory = customConsoleHistory ?? console.history;
Expand Down Expand Up @@ -54,9 +55,10 @@ export function consoleReplay(
return lines.join('\n');
}

// eslint-disable-next-line @typescript-eslint/default-param-last
export default function buildConsoleReplay(
customConsoleHistory: (typeof console)['history'] | undefined = undefined,
numberOfMessagesToSkip: number = 0,
numberOfMessagesToSkip = 0,
nonce?: string,
): string {
const consoleReplayJS = consoleReplay(customConsoleHistory, numberOfMessagesToSkip);
Expand Down
48 changes: 48 additions & 0 deletions packages/react-on-rails/tests/buildConsoleReplay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,52 @@ console.warn.apply(console, ["other message","{\\"c\\":3,\\"d\\":4}"]);
// Verify the dangerous parts (quotes and parens) are removed
expect(actual).not.toMatch(/nonce="[^"]*"[^>]*onload=/);
});

it('consoleReplay skips specified number of messages', () => {
console.history = [
{ arguments: ['skip 1'], level: 'log' },
{ arguments: ['skip 2'], level: 'log' },
{ arguments: ['keep 1'], level: 'log' },
{ arguments: ['keep 2'], level: 'warn' },
];
const actual = consoleReplay(undefined, 2); // Skip first 2 messages

// Should not contain skipped messages
expect(actual).not.toContain('skip 1');
expect(actual).not.toContain('skip 2');

// Should contain kept messages
expect(actual).toContain('console.log.apply(console, ["keep 1"]);');
expect(actual).toContain('console.warn.apply(console, ["keep 2"]);');
});

it('consoleReplay uses custom console history when provided', () => {
console.history = [{ arguments: ['ignored'], level: 'log' }];
const customHistory = [
{ arguments: ['custom message 1'], level: 'warn' },
{ arguments: ['custom message 2'], level: 'error' },
];
const actual = consoleReplay(customHistory);

// Should not contain global console.history
expect(actual).not.toContain('ignored');

// Should contain custom history
expect(actual).toContain('console.warn.apply(console, ["custom message 1"]);');
expect(actual).toContain('console.error.apply(console, ["custom message 2"]);');
});

it('consoleReplay combines numberOfMessagesToSkip with custom history', () => {
const customHistory = [
{ arguments: ['skip this'], level: 'log' },
{ arguments: ['keep this'], level: 'warn' },
];
const actual = consoleReplay(customHistory, 1);

// Should skip first message
expect(actual).not.toContain('skip this');

// Should keep second message
expect(actual).toContain('console.warn.apply(console, ["keep this"]);');
});
});
Loading