remix/ui: top-frame navigation removes client-set attributes on <html> (syncElementAttributes)
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.
// app/public/entry.ts
import { run } from 'remix/ui'
const app = run({ loadModule: async (url, name) => (await import(url))[name] })
window.__rmx_app = app// 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.ts → syncElementAttributes(container.doc.documentElement, parsed.documentElement) (called right before diffNodes([doc.head], …)), which removes every target attribute missing from the source.
Expected
Either of:
- 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 - An explicit opt-in, e.g.
data-rmx-preserve-attrs="class data-theme"on<html>, or arun()option / event to hook into root-attribute reconciliation.
Workaround used
- Persist the theme in a cookie so the server renders
<html class="dark">itself; derivecolor-schemefrom the class via CSS (html.dark { color-scheme: dark }) instead of an inlinestyle; and re-apply client-only markers with aMutationObserveron<html>attributes. This works but every app on the runtime has to rediscover it.
Environment
remix/@remix-run/ui3.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 thedarkclass andcolor-schemein sync client-side.
Source: remix-run/remix