Skip to content

Bug: React 18 emit null chars in renderToPipeableStream #31134

Closed as not planned
@slorber

Description

@slorber

React version: 18.3.1

Steps To Reproduce

Replace renderToString(app) by renderToPipeableStream(app).

The Docusaurus SSG framework own website generates static pages with React, and after the change, some of them will start containing NULL chars \0.

The current behavior

  • renderToString(app) does not emit NULL chars \0
  • renderToPipeableStream(app) emit NULL chars \0`

The expected behavior

  • renderToString(app) does not emit NULL chars \0
  • renderToPipeableStream(app) does not emit NULL chars \0`

Details

renderToPipeableStream is not a 1-1 replacement for renderToString, but I'm using the following code to obtain a string:

const {PassThrough} = require('node:stream');
const {text} = require('node:stream/consumers');

async function renderToHtml(app) {
    return new Promise((resolve, reject) => {
        const passThrough = new PassThrough();
        const {pipe} = ReactDOMServer.renderToPipeableStream(app, {
            onError(error) {
                reject(error);
            },
            onAllReady() {
                pipe(passThrough);
                text(passThrough).then(resolve, reject);
            },
        });
    });
}

I also tried various alternatives suggested by @sebmarkbage, @gnoff and the community in this Twitter thread, including:

  • new Response(webStream).text()
  • TextEncoder.decode(chunk,{stream: true})

All the methods I tried to convert renderToPipeableStream did have occasional NULL chars, unlike renderToString, so I assume it is, in the end, a React bug.

See related Docusaurus issue: facebook/docusaurus#9985

Runnable repro

A full repro is provided here: https://github.com/slorber/react-bug-repro-null-chars

It is standalone, but unfortunately not very minimal because it contains the full server bundle we run SSR/SSR with for the Docusaurus website, and also bundles React and ReactDOMServer.

The repro code:

const {PassThrough, Writable} = require('node:stream');
const {text} = require('node:stream/consumers');
const {
    default: renderApp,
    ReactDOMServer,
} = require('./__server/server.bundle');

async function repro() {
    console.log('REPRO START');
    await renderPathname('/blog/releases/3.0');
    await renderPathname('/blog/releases/3.1');
    await renderPathname('/blog/releases/3.2');
    await renderPathname('/blog/releases/3.3');
    await renderPathname('/blog/releases/3.4');
    await renderPathname('/blog/releases/3.5');
    console.log('REPRO END');
}

repro();

async function renderPathname(pathname) {
    const {app} = await renderApp({
        pathname,
    });

    const htmlRef = await renderToHtmlReference(app);
    const htmlStream1 = await renderToHtmlStream1(app);
    const htmlStream2 = await renderToHtmlStream2(app);

    if (htmlStream1 !== htmlRef || htmlStream2 !== htmlRef) {
        console.error(`HTML difference detected for pathname=${pathname}
htmlRef.length=${htmlRef.length} (${countNulls(htmlRef)} nulls)
htmlStream1.length=${htmlStream1.length} (${countNulls(htmlStream1)} nulls)
htmlStream2.length=${htmlStream2.length} (${countNulls(htmlStream2)} nulls)
    `);
    } else {
        console.log(`Successfully rendered the same HTML for pathname=${pathname}`);
    }
}

function countNulls(str) {
    return (str.match(/\0/g) || []).length;
}

async function renderToHtmlReference(app) {
    return ReactDOMServer.renderToString(app);
}

async function renderToHtmlStream1(app) {
    return new Promise((resolve, reject) => {
        const passThrough = new PassThrough();
        const {pipe} = ReactDOMServer.renderToPipeableStream(app, {
            onError(error) {
                reject(error);
            },
            onAllReady() {
                pipe(passThrough);
                text(passThrough).then(resolve, reject);
            },
        });
    });
}

async function renderToHtmlStream2(app) {
    class WritableStream extends Writable {
        html = '';
        decoder = new TextDecoder();
        _write(chunk, enc, next) {
            this.html += this.decoder.decode(chunk, {stream: true});
            next();
        }
        _final() {
            this.html += this.decoder.decode();
        }
    }

    return new Promise((resolve, reject) => {
        const {pipe} = ReactDOMServer.renderToPipeableStream(app, {
            onError(error) {
                reject(error);
            },
            onAllReady() {
                const writeableStream = new WritableStream();
                pipe(writeableStream);
                resolve(writeableStream.html);
            },
        });
    });
}

Similarly to the Docusaurus website, only the path /blog/releases/3.5 contains NULL chars, and the other tested ones do not:

node repro.js

REPRO START
Successfully rendered the same HTML for pathname=/blog/releases/3.0
Successfully rendered the same HTML for pathname=/blog/releases/3.1
Successfully rendered the same HTML for pathname=/blog/releases/3.2
Successfully rendered the same HTML for pathname=/blog/releases/3.3
Successfully rendered the same HTML for pathname=/blog/releases/3.4

HTML difference detected for pathname=/blog/releases/3.5
htmlRef.length=50894 (0 nulls)
htmlStream1.length=50896 (2 nulls)
htmlStream2.length=50896 (2 nulls)

REPRO END

This problematic page is an MDX release blog post, and the NULL char occurs in the middle of it.

For some unknown reasons, modifying the beginning of the post (adding or removing Markdown **) randomly makes the NULL chars disappear, even if it's far from the NULL occurrence position. Note that the compiled output of MDX doesn't contain NULLs. For these reasons, it might be challenging to strip down the repro and create a more minimal one, although I could try.

Follow-up

This is an initial bug report that I plan to complete depending on your answers:

  • Can we expect a bug like this to be fixed in 18.x?
  • Is this already fixed in v19?
  • Do you have enough information to investigate?
  • Should I invest more time into creating a smaller repro?
  • Do you think html.replace(/\0/g, '') is a good/safe temporary workaround?

Thanks


Edit: the following method using renderToReadableStream behaves as expected (exactly like renderToString) and doesn't produce extra NULL chars:

import type {ReactNode} from 'react';
import {renderToReadableStream} from 'react-dom/server.browser';
import {text} from 'stream/consumers';

export async function renderToHtml(app: ReactNode): Promise<string> {
  const stream = await renderToReadableStream(app);
  await stream.allReady;
  return text(stream);
}

So it looks like the problem only affects renderToPipeableStream

We might use this method in Docusaurus to fix our problem: facebook/docusaurus#10562

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions