From cf542d6318125723558dc563ba25dd665742445b Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:38:54 +0800 Subject: [PATCH] perf: tag the center column instead of a :has() selector; stop re-stringifying the editor doc per keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/external-plugin-guide.md | 2 +- src/client/Sidebar.tsx | 9 +++++++- src/client/TextEditor.tsx | 24 +++++++++++++++------ src/client/layout.css | 24 ++++++++++----------- tests/e2e/drag-layout.e2e.ts | 39 ++++++++++++++--------------------- tests/layout-css.spec.ts | 12 +++++++---- 6 files changed, 63 insertions(+), 47 deletions(-) diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index ca4c5ee..7a9903d 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -752,7 +752,7 @@ ctx.effect(() => - **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。 - **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。 - **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。 -- **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点用**复合选择器双保险**(禁止 `nth-child`):`#root [data-dsh-frame] > [data-pane="conversation"]` 与 `#root :has(> [data-slot="conversation"])`(两代命名同元素,`drag-layout.e2e.ts` 断言)。 +- **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级)。 - **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数): - 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。 - **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。 diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 4f71da1..4b6c538 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 25114c9..66b0fd7 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -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 diff --git a/src/client/layout.css b/src/client/layout.css index 17075c6..c31a94a 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -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; } } diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts index dbc1ed2..9f131d7 100644 --- a/tests/e2e/drag-layout.e2e.ts +++ b/tests/e2e/drag-layout.e2e.ts @@ -612,35 +612,28 @@ test('bottom panel never flashes full-width after a width drag release (issue #2 } }) -test('the bottom-push anchor resolves through the composite selectors (at least one; same element when both)', async ({ page }) => { - // layout.css pushes the bottom panel via the center column. The selector - // is COMPOSITE on purpose: `[data-pane="conversation"]` (0.1.x naming) - // and `:has(> [data-slot="conversation"])` (rc.8-era naming) — HOST - // VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so - // the contract is "at least one resolves", and when both resolve they - // must hit the SAME element (otherwise the push would land twice). +test('the bottom-push anchor resolves through the shell-written center-column tag', async ({ page }) => { + // layout.css pushes the bottom panel via the center column, anchored on + // the [data-dsh-center-col] tag the Sidebar shell's locator writes onto + // the measured column node (Sidebar.tsx locate — the slot wrapper + // [data-slot="conversation"]'s parent). The tag must sit on exactly ONE + // node: a stale tag on a swapped-out node (boot swap / HMR) would leave + // the push rule anchorless or doubled. await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 }) + await expect + .poll(async () => page.evaluate(() => document.querySelectorAll('#root [data-dsh-center-col]').length), { timeout: 90_000 }) + .toBe(1) const anchors = await page.evaluate(() => { - const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]') - const b = document.querySelector('#root :has(> [data-slot="conversation"])') - const frame = document.querySelector('#root [data-dsh-frame]') + const tagged = document.querySelector('#root [data-dsh-center-col]') + const slotParent = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null return { - a: a !== null, - b: b !== null, - same: a !== null && b !== null && a === b, - frameChildren: frame !== null - ? [...frame.children].map(el => `${el.tagName}[${[...el.attributes].map(attr => attr.name).filter(name => name.startsWith('data-')).join(',')}]`) - : [], + tagged: tagged !== null, + same: tagged !== null && slotParent !== null && tagged === slotParent, } }) - expect( - anchors.a || anchors.b, - `at least one bottom-push anchor must resolve on this host (frame children: ${anchors.frameChildren.join(' / ') || 'no [data-dsh-frame]'})`, - ).toBe(true) - if (anchors.a && anchors.b) { - expect(anchors.same, 'both selectors must hit the SAME center-column element').toBe(true) - } + expect(anchors.tagged, 'the tagged center column must resolve').toBe(true) + expect(anchors.same, 'the tag must sit on the conversation slot wrapper\'s parent (the measured column)').toBe(true) }) /* ── bottom-strip drag with the right panel CLOSED must not touch the host diff --git a/tests/layout-css.spec.ts b/tests/layout-css.spec.ts index c90d889..6613607 100644 --- a/tests/layout-css.spec.ts +++ b/tests/layout-css.spec.ts @@ -9,9 +9,13 @@ import { describe, expect, it } from 'vitest' const css = readFileSync('src/client/layout.css', 'utf8') describe('layout.css conversation column', () => { - it('targets the AppFrame center column', () => { - expect(css).toContain('[data-pane="conversation"]') - expect(css).toContain('[data-slot="conversation"]') + it('targets the AppFrame center column through the shell-written tag', () => { + // The bottom-push rule anchors on the [data-dsh-center-col] tag the + // Sidebar shell's locator writes onto the measured column node (see + // Sidebar.tsx locate) — not on a `:has()` selector, whose cache + // invalidates on every #root subtree mutation (the streaming chat). + expect(css).toContain('[data-dsh-center-col]') + expect(css).not.toContain(':has(') }) it('allows the center column to shrink and wrap long tokens', () => { @@ -21,7 +25,7 @@ describe('layout.css conversation column', () => { it('leaves overflow ownership to the host conversation descendants', () => { const conversationRule = css.match( - /#root \[data-dsh-frame\][\s\S]*?\{([\s\S]*?)\n\}/, + /#root \[data-dsh-center-col\][\s\S]*?\{([\s\S]*?)\n\}/, )?.[1] expect(conversationRule).toBeDefined() expect(conversationRule).not.toMatch(/(?:^|[;\s])overflow\s*:/)