产品实测:生时校正答了三题后 46px 顶栏消失。devtools 取证——栅格是对的
(rows "46px 1297px"、panel y=0),但 header 自己的 rect 在 **y=-88**:
面板被程序化滚动了 88px。
根因:`overflow: hidden` 仍然是滚动容器,它只是去掉了滚动条。
BirthTimeChoiceQuestion 在每答完一题后 focus 新题的第一个选项,浏览器
为把它带进视野会滚动所有可滚动祖先,`.chat-panel` 就是其中之一——
而用户没有滚动条可以滚回来,顶栏于是永久消失。
两处都修:
- 病因:面板内 7 处程序化 focus 一律加 { preventScroll: true }。
消息区有自己的 useConversationScrollAnchor,本来就不需要浏览器代劳。
账户弹窗的 closeButton / returnTarget 不在此列——那是对话框焦点管理。
同一教训 use-billing-panel.ts 已经吃过一次(那里早写了 preventScroll)。
- 结构:.chat-panel 与 .chat-app 从 overflow:hidden 改成 overflow:clip。
clip 根本不创建滚动容器,此后任何 focus / scrollIntoView 都无法位移它。
新增 tests/chat-panel-scroll-guard.test.ts:锁住两个容器必须是 clip、
面板内不得有裸 focus(),并遍历 rectification/birth-time/chat- 全部组件,
新组件再写裸 focus 会直接打红。
测试 3372(+3),fail 仍 31 且与基线逐条一致;四个路由标记不变。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
157 lines
11 KiB
TypeScript
157 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { homeSurface as pageSource } from "./home-surface.ts";
|
|
const composerSource = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
|
|
const draftStoreSource = readFileSync(new URL("../src/lib/composer-draft.ts", import.meta.url), "utf8");
|
|
|
|
function sourceBetween(source: string, startMarker: string, endMarker: string) {
|
|
const start = source.indexOf(startMarker);
|
|
const end = source.indexOf(endMarker, start);
|
|
assert.notEqual(start, -1);
|
|
assert.notEqual(end, -1);
|
|
return source.slice(start, end);
|
|
}
|
|
|
|
test("keystrokes never re-render the chat page root", () => {
|
|
// Given: the composer text used to live in page-level React state.
|
|
assert.doesNotMatch(pageSource, /const \[draft, setDraft\] = useState/);
|
|
assert.doesNotMatch(pageSource, /const \[draftTheme, setDraftTheme\] = useState/);
|
|
assert.doesNotMatch(pageSource, /const \[draftEntrypoint, setDraftEntrypoint\] = useState/);
|
|
|
|
// When: the page keeps only the hidden routing values it reads at submit time.
|
|
assert.match(pageSource, /const draftTheme = useRef<Theme \| null>\(null\)/);
|
|
assert.match(pageSource, /const draftEntrypoint = useRef<ConsultationEntrypoint \| null>\(null\)/);
|
|
|
|
// Then: every writer the page still exposes updates a ref or the external store.
|
|
assert.match(pageSource, /function setDraft\(value: string\) \{\n setComposerDraft\(value\);\n \}/);
|
|
assert.match(pageSource, /function setDraftTheme\(theme: Theme \| null\) \{\n draftTheme\.current = theme;\n \}/);
|
|
assert.match(pageSource, /function setDraftEntrypoint\(entrypoint: ConsultationEntrypoint \| null\) \{\n draftEntrypoint\.current = entrypoint;\n \}/);
|
|
assert.match(pageSource, /import \{ composerDraftSnapshot, setComposerDraft \} from "@\/lib\/composer-draft"/);
|
|
assert.match(pageSource, /import \{ showChatNotice as setComposerNotice \} from "@\/lib\/chat-notice"/);
|
|
});
|
|
|
|
test("the textarea lives in an isolated composer that owns the draft subscription", () => {
|
|
// Given: the page renders the composer without handing it the draft text.
|
|
assert.match(pageSource, /import \{ ChatComposer \} from "@\/components\/chat-composer"/);
|
|
const composerElement = sourceBetween(pageSource, "<ChatComposer", "/>");
|
|
assert.doesNotMatch(composerElement, /\bdraft=/);
|
|
assert.doesNotMatch(pageSource, /<Textarea/);
|
|
assert.doesNotMatch(pageSource, /value=\{draft\}/);
|
|
|
|
// When: the composer resolves the draft itself.
|
|
assert.match(composerSource, /useSyncExternalStore\(\n\s*subscribeComposerDraft,\n\s*composerDraftSnapshot,\n\s*serverComposerDraftSnapshot,\n\s*\)/);
|
|
// Former lock: `const draft = useComposerDraft()`. The composer still owns the store
|
|
// subscription; a surface with its own draft (rectification) may pass `value` instead, and
|
|
// must never write the main chat's store (BUG-477).
|
|
assert.match(composerSource, /const storeDraft = useComposerDraft\(\);\n\s*const draft = value \?\? storeDraft;/);
|
|
const rectificationSource = readFileSync(
|
|
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(rectificationSource, /<ChatComposer[\s\S]*?value=\{draft\}/);
|
|
assert.doesNotMatch(rectificationSource, /composer-draft|setComposerDraft|useComposerDraft/);
|
|
|
|
// Then: only the composer subtree reads the value that changes on every keystroke.
|
|
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
|
|
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
|
|
});
|
|
|
|
test("the composer keeps its Chinese input, focus and accessibility contract", () => {
|
|
// Given: Enter submits only outside an IME composition.
|
|
const keyHandler = sourceBetween(pageSource, "function handleComposerKeyDown", "\n\n if (!hydrated");
|
|
assert.match(keyHandler, /if \(event\.nativeEvent\.isComposing\) return/);
|
|
assert.match(keyHandler, /event\.currentTarget\.form\?\.requestSubmit\(\)/);
|
|
assert.match(pageSource, /onKeyDown=\{handleComposerKeyDown\}/);
|
|
|
|
// When: the page still focuses the same textarea element through its own ref.
|
|
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
|
|
assert.match(pageSource, /inputRef=\{composerInput\}/);
|
|
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
|
|
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
|
|
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
|
|
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
|
|
assert.match(pageSource, /composerInput\.current\?\.focus\(\{ preventScroll: true \}\)/);
|
|
|
|
// Then: the form shell, labels, hit targets and the stop control are unchanged.
|
|
assert.match(composerSource, /<form className="composer" onSubmit=\{onSubmit\}>/);
|
|
assert.match(composerSource, /aria-label=\{inputLabel\}/);
|
|
assert.match(composerSource, /rows=\{1\}/);
|
|
assert.match(composerSource, /maxLength=\{maxLength\}/);
|
|
assert.match(composerSource, /className="composer-stop"/);
|
|
assert.match(composerSource, /aria-label=\{stopLabel\}/);
|
|
assert.match(composerSource, /title=\{stopTitle\}/);
|
|
assert.match(composerSource, /aria-label=\{submitLabel\} disabled=/);
|
|
assert.equal(composerSource.match(/size="icon"/g)?.length, 2);
|
|
assert.match(pageSource, /inputLabel=\{!profileComplete && onboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
|
|
assert.match(pageSource, /submitLabel=\{!profileComplete && onboardingStep === "name" \? "确认称呼" : "发送"\}/);
|
|
});
|
|
|
|
test("every external draft writer keeps working through the page-owned setters", () => {
|
|
// Given: editing clears the hidden routing that a suggested question attached.
|
|
assert.match(pageSource, /onChange=\{\(event\) => \{\n\s*setDraft\(event\.target\.value\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);\n\s*setComposerNotice\(""\);\n\s*\}\}/);
|
|
|
|
// When: each existing write path is inspected.
|
|
const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "async function startSuggestedConsultation");
|
|
const startNewChat = sourceBetween(pageSource, "async function startNewChat(", "function selectSession(");
|
|
const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel");
|
|
const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth");
|
|
const stopRestore = sourceBetween(pageSource, "updateSession(pending.sessionId, () => pending.previousSession);", "function completeConsultationInterface");
|
|
const sendClear = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", "if (!resuming && process.env.NODE_ENV === \"development\" && uiPreview.current)");
|
|
const sendRestore = sourceBetween(
|
|
pageSource,
|
|
"if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {",
|
|
"setRequestError({",
|
|
);
|
|
|
|
// Then: synastry still fills the composer; session switches clear, stop restores and send clears.
|
|
assert.match(chooseSuggested, /setDraft\(question\);\n\s*setDraftTheme\(theme \?\? null\);\n\s*setDraftEntrypoint\(entrypoint\);/);
|
|
const startSuggested = sourceBetween(pageSource, "async function startSuggestedConsultation(", "function startDailyStarlanguageConsultation");
|
|
assert.match(startSuggested, /await send\(trimmed, theme, entrypoint, null, targetSession\.id/);
|
|
assert.doesNotMatch(startSuggested, /setDraft\(/);
|
|
assert.match(startNewChat, /setDraft\(""\)/);
|
|
assert.match(selectSession, /setDraft\(""\)/);
|
|
assert.match(saveOnboardingName, /composerDraftSnapshot\(\)\.replace\(/);
|
|
assert.match(saveOnboardingName, /setDraft\(""\)/);
|
|
assert.match(stopRestore, /setDraft\(pending\.question\);\n\s*setDraftTheme\(pending\.theme\);\n\s*setDraftEntrypoint\(pending\.entrypoint\);/);
|
|
assert.match(sendClear, /setDraft\(""\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);/);
|
|
assert.match(sendRestore, /setDraft\(originalQuestion\);\n\s*setDraftTheme\(theme\);\n\s*setDraftEntrypoint\(consultEntrypoint\);/);
|
|
assert.match(pageSource, /void send\(composerDraftSnapshot\(\), draftTheme\.current \?\? undefined, draftEntrypoint\.current\)/);
|
|
|
|
// And: opening a rectification case still empties the composer it replaces.
|
|
const openRectification = sourceBetween(pageSource, "async function openRectificationCase", "async function openRectificationFromHomepage");
|
|
assert.match(openRectification, /setDraft\(""\)/);
|
|
});
|
|
|
|
test("the draft survives a reload without resurrecting a sent question", () => {
|
|
// Given: the draft key follows the existing session-scoped storage convention.
|
|
assert.match(pageSource, /const pendingConsultationStorageKey = "jyotisha\.pending-consultation"/);
|
|
assert.match(draftStoreSource, /export const composerDraftStorageKey = "jyotisha\.composer-draft"/);
|
|
assert.match(draftStoreSource, /return window\.sessionStorage;/);
|
|
|
|
// When: the store restores exactly once and every write mirrors the new value.
|
|
assert.match(draftStoreSource, /function restoreComposerDraft\(\) \{\n if \(restored\) return;\n restored = true;\n draft = readStoredComposerDraft\(\);\n\}/);
|
|
assert.match(draftStoreSource, /export function composerDraftSnapshot\(\): string \{\n restoreComposerDraft\(\);\n return draft;\n\}/);
|
|
assert.match(draftStoreSource, /export function setComposerDraft\(value: string\) \{\n restoreComposerDraft\(\);/);
|
|
assert.match(draftStoreSource, /writeStoredComposerDraft\(value\);\n for \(const listener of \[\.\.\.listeners\]\) listener\(\);/);
|
|
|
|
// Then: clearing after a send removes the key instead of persisting an empty draft.
|
|
assert.match(draftStoreSource, /function writeStoredComposerDraft\(value: string\) \{\n if \(!value\) \{\n clearStoredDraft\(\);\n return;\n \}/);
|
|
|
|
// And: a corrupt, foreign, oversized or stale value is discarded, never surfaced.
|
|
const readStored = sourceBetween(draftStoreSource, "export function readStoredComposerDraft()", "function writeStoredComposerDraft");
|
|
assert.match(readStored, /typeof text !== "string"/);
|
|
assert.match(readStored, /typeof savedAt !== "number"/);
|
|
assert.match(readStored, /!Number\.isFinite\(savedAt\)/);
|
|
assert.match(readStored, /savedAt > Date\.now\(\)/);
|
|
assert.match(readStored, /Date\.now\(\) - savedAt > composerDraftMaxAgeMs/);
|
|
assert.match(readStored, /clearStoredDraft\(\);\n return "";\n \}/);
|
|
assert.match(readStored, /catch \{\n clearStoredDraft\(\);\n return "";\n \}/);
|
|
assert.match(readStored, /text\.slice\(0, composerDraftLimit\)/);
|
|
|
|
// And: an unavailable storage or a server render degrades to an empty composer.
|
|
assert.match(draftStoreSource, /if \(typeof window === "undefined"\) return null;/);
|
|
assert.match(draftStoreSource, /export function serverComposerDraftSnapshot\(\): string \{\n return "";\n\}/);
|
|
});
|