fix(editor): read editor.isEditable reactively in DictationGroup so the mic un-greys after sync (#311)
On an editable page the dictation mic in the byline stayed grey/disabled after collab finished syncing, until an unrelated re-render (view↔edit toggle, navigation) happened. DictationGroup read the NON-reactive `editor.isEditable` field directly in render; `editor` comes from pageEditorAtom (a stable object reference), so `editor.setEditable(true)` after sync (#218 gate) mutates TipTap state without changing the atom reference — the byline never re-renders and disabled=true sticks. Read editable via `useEditorState` (the same reactive read the editor body already uses), so the mic re-enables when the body flips editable and disables again when it loses editable. The #218 pre-sync intent is preserved — just made reactive. Test flips isEditable false→true and asserts the mic goes disabled→enabled (fails against the pre-fix raw-field read). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+75
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { useEffect, useState } from "react";
|
||||
import { render, act } from "@testing-library/react";
|
||||
|
||||
// Regression test for #311: on a page the user can edit, the byline mic stayed
|
||||
// stuck disabled until an unrelated re-render happened, because DictationGroup
|
||||
// read the non-reactive field `editor.isEditable` directly. The fix reads it via
|
||||
// `useEditorState`, which subscribes to the editor's own events.
|
||||
//
|
||||
// The mock below mirrors the real `useEditorState` contract: it runs the
|
||||
// selector, and re-runs it (re-rendering the consumer) whenever the editor emits
|
||||
// an event. This is what makes the test faithful — with the pre-fix code
|
||||
// (`disabled={!editor.isEditable}`) DictationGroup never subscribes, so emitting
|
||||
// an event would NOT re-render and the mic would stay disabled.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
useEditorState: ({ editor, selector }: any) => {
|
||||
const [value, setValue] = useState(() => selector({ editor }));
|
||||
useEffect(() => {
|
||||
const handler = () => setValue(selector({ editor }));
|
||||
editor.on("update", handler);
|
||||
return () => editor.off("update", handler);
|
||||
}, [editor, selector]);
|
||||
return value;
|
||||
},
|
||||
}));
|
||||
|
||||
// The mic only cares about the workspace's streaming flag; return a stable stub.
|
||||
vi.mock("jotai", () => ({
|
||||
useAtomValue: () => ({ settings: { ai: { dictationStreaming: false } } }),
|
||||
}));
|
||||
vi.mock("@/features/user/atoms/current-user-atom.ts", () => ({
|
||||
workspaceAtom: {},
|
||||
}));
|
||||
|
||||
// Detectable stand-in that surfaces the `disabled` prop the component computes.
|
||||
vi.mock("@/features/dictation/components/mic-button", () => ({
|
||||
MicButton: ({ disabled }: any) => (
|
||||
<button data-testid="mic" disabled={disabled} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { DictationGroup } from "./dictation-group";
|
||||
|
||||
// Minimal editor stand-in: a mutable `isEditable` field plus a tiny event
|
||||
// emitter, matching the surface DictationGroup + the mocked useEditorState use.
|
||||
function makeFakeEditor(isEditable: boolean) {
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
isEditable,
|
||||
isDestroyed: false,
|
||||
state: { selection: { from: 0, to: 0 }, doc: { content: { size: 0 } } },
|
||||
on: (_event: string, cb: () => void) => listeners.add(cb),
|
||||
off: (_event: string, cb: () => void) => listeners.delete(cb),
|
||||
emit: () => listeners.forEach((cb) => cb()),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("DictationGroup editable reactivity (#311)", () => {
|
||||
it("re-enables the mic when the editor flips isEditable false -> true", () => {
|
||||
const editor = makeFakeEditor(false);
|
||||
const { getByTestId } = render(<DictationGroup editor={editor} />);
|
||||
|
||||
// Pre-sync: not editable yet, so the mic is disabled (preserves #218 intent).
|
||||
expect(getByTestId("mic").hasAttribute("disabled")).toBe(true);
|
||||
|
||||
// Collab sync flips the editor editable via editor.setEditable(true), which
|
||||
// mutates the field and emits — the mic must react and enable itself.
|
||||
act(() => {
|
||||
editor.isEditable = true;
|
||||
editor.emit();
|
||||
});
|
||||
|
||||
expect(getByTestId("mic").hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -1,5 +1,5 @@
|
||||
import { FC, useRef } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Editor, useEditorState } from "@tiptap/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { MicButton } from "@/features/dictation/components/mic-button";
|
||||
@@ -22,6 +22,14 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
||||
// end so the NEXT segment appends right after it, contiguously, regardless of
|
||||
// where the user's caret currently is. Null until the first segment lands.
|
||||
const insertPosRef = useRef<number | null>(null);
|
||||
// editor.isEditable is a mutable, non-reactive field — read it via
|
||||
// useEditorState so the mic re-enables when the body flips to editable after
|
||||
// collab sync (otherwise it stays stuck disabled). Mirrors the body's own
|
||||
// reactive read.
|
||||
const isEditable = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ctx.editor?.isEditable ?? false,
|
||||
});
|
||||
|
||||
const handleStart = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
@@ -80,7 +88,7 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
||||
streaming={streamingDictation}
|
||||
onStart={handleStart}
|
||||
onText={handleText}
|
||||
disabled={!editor.isEditable}
|
||||
disabled={!isEditable}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user