fix(mobile): retain editor styling when switching color schemes (#35171)

* fix(mobile): retain editor styling when switching color schemes

* test(mobile): fit theme-switch harness hooks under the file line cap

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 19:28:48 -07:00
committed by GitHub
co-authored by OrcaWin
parent 8a5c96349b
commit 2c4eb87240
4 changed files with 60 additions and 12 deletions
@@ -14,6 +14,7 @@ import {
lucideBarrelPlugin
} from '../build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import { themeSwitchTest } from './mobile-web-app-theme-switch.mjs'
import { textInputFontSizeFloor } from './platform-seams/mobile-web-app-text-input-font-size-seam.mjs'
import {
createBundleServer,
@@ -77,6 +78,7 @@ import { createElement, useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { MobileRichMarkdownEditor } from './MobileRichMarkdownEditor'
import { applyMobileColorScheme } from '../theme/mobile-theme'
/**
* What the route's own navigator supplies and a bare mount does not: react-navigation wraps every
@@ -101,6 +103,7 @@ function Harness() {
useEffect(() => {
globalThis.__orcaEditor = {
set: (next) => setState((previous) => ({ ...previous, ...next })),
setScheme: applyMobileColorScheme,
changes: [],
secondChanges: [],
links: [],
@@ -293,9 +296,8 @@ async function setContentWithin(page, markdown, selector) {
// Nested ul-in-p serialization once returned unchanged text, leaving next command against previous document.
const bodyFor = (index) => `body text ${String(index)}`
function readSurface(page, surface = '#first-surface') {
return page.evaluate((id) => document.querySelector(`${id} #editor`)?.innerHTML ?? null, surface)
}
const readSurface = (page, surface = '#first-surface') =>
page.evaluate((id) => document.querySelector(`${id} #editor`)?.innerHTML ?? null, surface)
async function select(page, how) {
await page.evaluate((mode) => {
@@ -328,9 +330,9 @@ describeEditor(
browser = await engine.launch()
}, 180_000)
afterAll(async () => {
await browser?.close()
})
afterAll(() => browser?.close())
themeSwitchTest(() => browser, openPage)
it('mounts the document, reports ready through the seam and never touches the bridge', async () => {
const { page, consoleErrors } = await openPage(browser)
@@ -0,0 +1,42 @@
import { expect, it } from 'vitest'
const readStyling = (page) =>
page.evaluate(() => {
const editor = document.querySelector('#editor')
return {
scheme: document.getElementById('orca-rich-markdown-document-style').dataset.scheme,
scoped: editor.parentElement.classList.contains('orca-rich-markdown-document-host'),
taskListStyle: getComputedStyle(editor.querySelector('ul[data-type="taskList"]'))
.listStyleType
}
})
async function setScheme(page, scheme) {
await page.evaluate((next) => globalThis.__orcaEditor.setScheme(next), scheme)
await page.waitForFunction(
(next) => document.getElementById('orca-rich-markdown-document-style').dataset.scheme === next,
scheme
)
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
)
}
// React Native Web swaps the themed host View's className on a scheme change.
export function themeSwitchTest(getBrowser, openPage) {
it('keeps a mounted editor styled through color scheme changes', async () => {
const { page, consoleErrors } = await openPage(getBrowser())
try {
await setScheme(page, 'dark')
await page.evaluate(() => globalThis.__orcaEditor.set({ content: '- [ ] Open task' }))
await page.waitForSelector('#editor ul[data-type="taskList"]')
for (const scheme of ['dark', 'light', 'dark']) {
await setScheme(page, scheme)
expect(await readStyling(page)).toEqual({ scheme, scoped: true, taskListStyle: 'none' })
}
expect(consoleErrors).toEqual([])
} finally {
await page.close()
}
}, 180_000)
}
@@ -4,6 +4,7 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -178,9 +179,11 @@ function MobileRichMarkdownEditorWebInner(
// Mount document once; read promptForUrl via closure because rerunning on changing callback props discards live caret/document.
}, [])
useEffect(() => {
if (documentRef.current) {
ensureDocumentStyle()
// Layout effect: restore the host's scope class before paint after a themed className swap.
useLayoutEffect(() => {
const host = hostRef.current
if (documentRef.current && host instanceof HTMLElement) {
ensureDocumentStyle(host)
}
}, [colorScheme])
@@ -28,7 +28,9 @@ export const RICH_MARKDOWN_HOST_CLASS = 'orca-rich-markdown-document-host'
// Plant sheet once per page, scoped to host class; move document-wide theme/color/font globals onto host itself.
// Leave sheet in head on unmount: removing host class makes it inert, and next mount reuses it.
// Rewrite it in place when the color scheme changes; live editors keep their DOM and caret.
export function ensureDocumentStyle() {
export function ensureDocumentStyle(host: HTMLElement) {
// React Native Web replaces themed View classes when its background changes.
host.classList.add(RICH_MARKDOWN_HOST_CLASS)
const scheme = getMobileColorScheme()
const existing = document.getElementById(STYLE_ELEMENT_ID)
if (existing?.dataset.scheme === scheme) {
@@ -51,8 +53,7 @@ export function mountRichMarkdownWebDocument(
host: HTMLElement,
hooks: RichMarkdownWebDocumentHooks
): RichMarkdownWebDocument {
ensureDocumentStyle()
host.classList.add(RICH_MARKDOWN_HOST_CLASS)
ensureDocumentStyle(host)
host.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
const started = startDocumentOrGiveTheHostBack(host, hooks)
return {