Skip to content

[component] Incorrect DOM node ordering when calling handle.update() after the app has hydrated #11061

@alexanderson1993

Description

@alexanderson1993

This one is a little contrived, but I've got a minimal reproduction here: https://github.com/alexanderson1993/remix/tree/renderToStream-incorrect-dom-order/demos/renderToStream-incorrect-dom-order

You can run that by checking out that branch, navigating to that demo, running pnpm i and then pnpm dev and visiting the server address.

Here's the setup: You've got an app that's server-rendered with renderToStream. You want to use a browser API, like IndexedDB or Geolocation, so you initialize some component state with null, and then call the browser API inside if (typeof window !== "undefined").

    let data: string | null = null

    // Simulating running some kind of browser-only API, like IndexedDB or Geolocation
    if (typeof window !== 'undefined') {
      setTimeout(() => {
        data = '🤖'
        handle.update()
      }, 500)
    }

And then in the component itself, we render null in the place where the data should go until hydration completes, the browser API promise resolves, and the component updates.

    return () => (
      <div>
        <div>The robot should render after me</div>
        {data ? <div>{data}</div> : null}
        <div>
          The robot should <strong>not</strong> render after me
        </div>
      </div>
    )

The expected behavior is that you would see this in your browser when everything settles, where the null is replaced with the newly created DOM node.

The robot should render after me
🤖
The robot should not render after me

The actual behavior is that the DOM node is created and appended to the end of the component, like so:

The robot should render after me
The robot should not render after me
🤖

I've tested this with client-only remix/component using createRoot(document.body).render(<App/>) and it behaves as expected. It's only when rendering with renderToStream, clientEntry, and run().

I'll also note that in my example, removing the if (typeof window !== "undefined") guard results in the server crashing with the following error:

/Users/alexanderson/Projects/remix/packages/component/src/lib/component.ts:199
    throw new Error('scheduleUpdate not implemented')
          ^
Error: scheduleUpdate not implemented
    at scheduleUpdate (/Users/alexanderson/Projects/remix/packages/component/src/lib/component.ts:199:11)
    at <anonymous> (/Users/alexanderson/Projects/remix/packages/component/src/lib/component.ts:214:9)
    at new Promise (<anonymous>)
    at Object.update (/Users/alexanderson/Projects/remix/packages/component/src/lib/component.ts:212:7)
    at Timeout._onTimeout (/Users/alexanderson/Projects/remix/demos/renderToStream-incorrect-dom-order/app/assets/component.tsx:12:14)
    at listOnTimeout (node:internal/timers:605:17)
    at process.processTimers (node:internal/timers:541:7)

I don't think that's relevant to this particular reproduction, since you would need to have the guard in place if you were using a browser API instead of my contrived setInterval example.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions