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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user