perf: tag the center column instead of a :has() selector; stop re-stringifying the editor doc per keystroke

The bottom-push rule anchored on #root :has(> [data-slot=conversation]):
a :has() match cache invalidates on every #root subtree mutation, and
the streaming chat mutates that subtree constantly — every layout-push
frame (the bottom drag writes --dsh-sidebar-height per frame) paid a
whole-document selector re-evaluation. The shell's locator (which
already holds the measured column node for the ResizeObserver) now
tags it with [data-dsh-center-col]; the rule evaluates once per node.
The tag retargets with the ref on boot swap / HMR node replacement and
is removed on unmount; the drag lane pins exactly-one-tagged-node and
its identity as the conversation slot wrapper's parent. The dead
[data-pane] branch (renamed away in alpha hosts) is gone.

TextEditor: the CodeMirror updateListener re-stringified the WHOLE
document into React state on every keystroke (O(docLength) per key,
re-rendering the shell per key). The listener now only flips the dirty
flag; the preview's draft is snapshotted from the live view when
entering preview (or on a content swap), and save() already reads the
live document.
This commit is contained in:
Menghuan1918
2026-09-01 16:01:34 +08:00
parent c7d58c9b25
commit cf542d6318
6 changed files with 63 additions and 47 deletions
+8 -1
View File
@@ -756,6 +756,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
?.parentElement as HTMLElement | undefined
if (col === undefined || !col.isConnected) {
if (centerColRef.current !== null) {
centerColRef.current.removeAttribute('data-dsh-center-col')
centerColRef.current = null
observer?.disconnect()
observer = undefined
@@ -768,8 +769,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// measure it once. Same-node size changes are the ResizeObserver's
// job — no forced measurement here, because a forced
// getBoundingClientRect per mutation would reflow the shell at
// mutation cadence.
// mutation cadence. The tag retargets with the ref: layout.css's
// bottom-push rule anchors on [data-dsh-center-col], so exactly the
// measured node carries it (a stale tag on a swapped-out node would
// leave the push rule anchorless or doubled).
centerColRef.current?.removeAttribute('data-dsh-center-col')
centerColRef.current = col
col.setAttribute('data-dsh-center-col', '')
observer?.disconnect()
observer = new ResizeObserver(measureCenter)
observer.observe(col)
@@ -822,6 +828,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
observer?.disconnect()
watcher.disconnect()
htmlStyleWatcher.disconnect()
centerColRef.current?.removeAttribute('data-dsh-center-col')
centerColRef.current = null
}
// Opening the bottom panel re-runs the whole locate/measure chain: a
+18 -6
View File
@@ -114,11 +114,13 @@ export function TextEditor(props: FileViewerProps) {
}, [content])
// Create the CodeMirror editor once the content is loaded. The view owns
// the document; React only tracks dirty/draft state through the update
// listener. For markdown the view stays mounted while previewing (hidden),
// so unsaved edits survive the preview/edit toggle. The theme + syntax
// colors live in a compartment so a scheme flip reconfigures only that
// part — the document, undo history and scroll position survive.
// the document; React only tracks dirty state through the update listener
// (the draft — the preview's text — is snapshotted from the live view on
// entering preview, not re-stringified per keystroke). For markdown the
// view stays mounted while previewing (hidden), so unsaved edits survive
// the preview/edit toggle. The theme + syntax colors live in a compartment
// so a scheme flip reconfigures only that part — the document, undo
// history and scroll position survive.
useEffect(() => {
if (content === undefined) return
const host = hostRef.current
@@ -139,7 +141,6 @@ export function TextEditor(props: FileViewerProps) {
...(language !== null ? [language] : []),
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) {
setDraft(update.state.doc.toString())
setDirty(true)
}
}),
@@ -226,6 +227,17 @@ export function TextEditor(props: FileViewerProps) {
if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode])
// Snapshot the live document into the draft whenever the preview needs
// it: entering preview (the markdown preview renders `draft ?? content`)
// and a content swap (the view was just re-created above, so the read is
// the new document — matching the reset-to-null of a clean tab). The
// updateListener used to re-stringify the WHOLE document on every
// keystroke (O(docLength) per key) for a draft only preview reads.
useEffect(() => {
const view = viewRef.current
setDraft(view === null ? null : view.state.doc.toString())
}, [mode, content])
const save = (): void => {
const view = viewRef.current
if (view === null || savingRef.current) return
+12 -12
View File
@@ -35,14 +35,16 @@
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* The AppFrame's center column, anchored by data attributes (see above).
Composite selector: DSH 0.1.x versions name the center grid item
`[data-pane="conversation"]`, while rc.8-era shells put a
`[data-slot="conversation"]` child inside it — both selectors resolve to
the SAME element on live pages (verified), and keeping both future-proofs
the rule against a host rename without touching shell-specific markup. */
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
locate/measureCenter) tags the measured column node with
[data-dsh-center-col] — one attribute write per node lifetime. The rule
used to anchor on a structural has()-selector over the conversation slot
wrapper, whose match cache invalidates on every #root subtree mutation
(the streaming chat mutates it constantly), re-evaluating the selector
against the whole document; the tag is evaluated once per node instead.
A stretched grid item shrinks by its margins, so the conversation
content (output + input bar) lifts without touching the sidebars. */
#root [data-dsh-center-col] {
margin-bottom: var(--dsh-sidebar-height, 0px);
/* Grid/flex items default to min-height:auto. A long unbreakable token
(OAuth URL) then grows this column past the viewport and clips the
@@ -68,8 +70,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
}
body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"],
body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
transition: none;
}
@@ -94,8 +95,7 @@ body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
@media (prefers-reduced-motion: reduce) {
#root,
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
#root [data-dsh-center-col] {
transition: none;
}
}