#11809·remix

remix/ui: top-frame navigation removes client-set attributes on <html> (syncElementAttributes)

Author: qisthidevCreated Sep 6, 2026Updated Sep 15, 2026

Summary

In @remix-run/ui 3.0.0-rc.1, a top-frame navigation/reload (app.frames.top.reload(), intercepted link/form navigation) rewrites the attributes of <html> from the server response: every attribute the client added after hydration that is not present in the server HTML is removed (syncElementAttributes in runtime/frame.ts).

This makes it hard to keep client-owned state on the root element across navigations — the common example is a dark-mode class (<html class="dark">) or data-* flags set by the client — and produces a visible theme flash on every navigation when color-scheme / dark is re-applied asynchronously.

Reproduction (minimal, no framework code)

Server renders two plain pages with the runtime entry; the client sets an attribute and a class on <html> after app.ready(), then clicks a same-origin link.

javascript
// app/public/entry.ts
import { run } from 'remix/ui'
const app = run({ loadModule: async (url, name) => (await import(url))[name] })
window.__rmx_app = app
javascript
// repro.mjs — node --import remix/node-tsx repro.mjs  (playwright + remix/assets + remix/node-fetch-server/test)
import { chromium } from 'playwright'
import { createAssetServer } from 'remix/assets'
import { createTestServer } from 'remix/node-fetch-server/test'

const assets = createAssetServer({ rootDir: '.', basePath: '/assets', allowFiles: ['app/**/public/**'], allowPackages: ['remix'] })
const entryHref = await assets.getHref('app/public/entry.ts')
const page = (title, next) => `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${title}</title>
<script type="module" src="${entryHref}"></script></head>
<body><h1>${title}</h1><a id="nav" href="${next}">next</a></body></html><!-- rmx:flush document -->`

const server = await createTestServer(async (req) => {
  const { pathname } = new URL(req.url)
  if (pathname.startsWith('/assets/')) return (await assets.fetch(req)) ?? new Response('', { status: 404 })
  if (pathname === '/page-b') return new Response(page('Page B', '/page-a'), { headers: { 'content-type': 'text/html' } })
  return new Response(page('Page A', '/page-b'), { headers: { 'content-type': 'text/html' } })
})
const browser = await chromium.launch(); const p = await browser.newPage()
await p.goto(server.baseUrl + '/page-a')
await p.waitForFunction(() => window.__rmx_app?.ready()); await p.evaluate(() => window.__rmx_app.ready())
await p.evaluate(() => { document.documentElement.setAttribute('data-x', '1'); document.documentElement.classList.add('dark') })
await p.click('#nav'); await p.waitForFunction(() => document.querySelector('h1')?.textContent === 'Page B')
console.log(await p.evaluate(() => ({ dataX: document.documentElement.getAttribute('data-x'), className: document.documentElement.className })))
await browser.close(); await server.close()

Output:

before navigation: { dataX: '1', className: 'dark' }
after navigation:  { dataX: null, className: '' }

Code path: runtime/frame.tssyncElementAttributes(container.doc.documentElement, parsed.documentElement) (called right before diffNodes([doc.head], …)), which removes every target attribute missing from the source.

Expected

Either of:

  1. Attributes on <html> (and ideally <body>) that the server response does not mention are left untouched — i.e. sync only the attributes present in the server HTML (set/update), instead of removing unknown ones; or
  2. An explicit opt-in, e.g. data-rmx-preserve-attrs="class data-theme" on <html>, or a run() option / event to hook into root-attribute reconciliation.

Workaround used

  • Persist the theme in a cookie so the server renders <html class="dark"> itself; derive color-scheme from the class via CSS (html.dark { color-scheme: dark }) instead of an inline style; and re-apply client-only markers with a MutationObserver on <html> attributes. This works but every app on the runtime has to rediscover it.

Environment

  • remix / @remix-run/ui 3.0.0-rc.1, Node v26.8.1, Playwright 1.62.1 (Chromium), Linux aarch64.
  • Found while building a village-cooperative app on top of laravolt/volt (Remix 3 starter), where the shell keeps the dark class and color-scheme in sync client-side.