From b0604e385255d4165cbf1952fb02b6b034509c6f Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:18:49 +0900 Subject: [PATCH 1/2] Optimize transcript rendering performance Reduce transcript word rerenders during playback/search and speed up transcript search matching. --- .../note-input/search/matching.test.ts | 60 +++++ .../components/note-input/search/matching.ts | 48 ++-- .../transcript/renderer/segment.test.tsx | 214 ++++++++++++++++++ .../transcript/renderer/segment.tsx | 91 +++++++- .../transcript/renderer/transcript.tsx | 28 ++- .../transcript/renderer/utils.test.ts | 42 +++- .../note-input/transcript/renderer/utils.ts | 36 +++ .../transcript/renderer/word-span.tsx | 56 ++--- 8 files changed, 505 insertions(+), 70 deletions(-) create mode 100644 apps/desktop/src/session/components/note-input/search/matching.test.ts create mode 100644 apps/desktop/src/session/components/note-input/transcript/renderer/segment.test.tsx diff --git a/apps/desktop/src/session/components/note-input/search/matching.test.ts b/apps/desktop/src/session/components/note-input/search/matching.test.ts new file mode 100644 index 0000000000..68cf2a0ebf --- /dev/null +++ b/apps/desktop/src/session/components/note-input/search/matching.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { getTranscriptMatches, type SearchOptions } from "./matching"; + +const defaultOptions: SearchOptions = { + caseSensitive: false, + wholeWord: false, +}; + +describe("getTranscriptMatches", () => { + it("returns transcript word spans in match order", () => { + const spans = [ + createSpan("word-1", "Hello"), + createSpan("word-2", "world"), + createSpan("word-3", "hello"), + ]; + + expect( + getTranscriptMatches(spans, "hello", defaultOptions).map( + (match) => match.id, + ), + ).toEqual(["word-1", "word-3"]); + }); + + it("maps phrase matches to the first matching word", () => { + const spans = [ + createSpan("word-1", "plan"), + createSpan("word-2", "the"), + createSpan("word-3", "launch"), + ]; + + expect( + getTranscriptMatches(spans, "the launch", defaultOptions).map( + (match) => match.id, + ), + ).toEqual(["word-2"]); + }); + + it("keeps whole-word matching behavior", () => { + const spans = [ + createSpan("word-1", "sync"), + createSpan("word-2", "async"), + createSpan("word-3", "syncing"), + ]; + + expect( + getTranscriptMatches(spans, "sync", { + ...defaultOptions, + wholeWord: true, + }).map((match) => match.id), + ).toEqual(["word-1"]); + }); +}); + +function createSpan(id: string, text: string) { + const span = document.createElement("span"); + span.dataset.wordId = id; + span.textContent = text; + return span; +} diff --git a/apps/desktop/src/session/components/note-input/search/matching.ts b/apps/desktop/src/session/components/note-input/search/matching.ts index c16ab9c616..171811a71c 100644 --- a/apps/desktop/src/session/components/note-input/search/matching.ts +++ b/apps/desktop/src/session/components/note-input/search/matching.ts @@ -94,28 +94,36 @@ export function getTranscriptMatches( const searchText = prepareText(fullText, opts.caseSensitive); const indices = findOccurrences(searchText, prepared, opts.wholeWord); const result: MatchResult[] = []; + let spanIndex = 0; for (const idx of indices) { - for (let i = 0; i < spanPositions.length; i++) { - const { start, end } = spanPositions[i]; - if (idx >= start && idx < end) { - result.push({ - element: allSpans[i], - id: allSpans[i].dataset.wordId || null, - }); - break; - } - if ( - i < spanPositions.length - 1 && - idx >= end && - idx < spanPositions[i + 1].start - ) { - result.push({ - element: allSpans[i + 1], - id: allSpans[i + 1].dataset.wordId || null, - }); - break; - } + while ( + spanIndex < spanPositions.length - 1 && + idx >= spanPositions[spanIndex].end && + idx >= spanPositions[spanIndex + 1].start + ) { + spanIndex += 1; + } + + const { start, end } = spanPositions[spanIndex]; + if (idx >= start && idx < end) { + result.push({ + element: allSpans[spanIndex], + id: allSpans[spanIndex].dataset.wordId || null, + }); + continue; + } + + if ( + spanIndex < spanPositions.length - 1 && + idx >= end && + idx < spanPositions[spanIndex + 1].start + ) { + const nextSpan = allSpans[spanIndex + 1]; + result.push({ + element: nextSpan, + id: nextSpan.dataset.wordId || null, + }); } } diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/segment.test.tsx b/apps/desktop/src/session/components/note-input/transcript/renderer/segment.test.tsx new file mode 100644 index 0000000000..fe986cdaf9 --- /dev/null +++ b/apps/desktop/src/session/components/note-input/transcript/renderer/segment.test.tsx @@ -0,0 +1,214 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + EMPTY_TRANSCRIPT_SEARCH, + SegmentRenderer, + type TranscriptSearchRenderState, +} from "./segment"; + +import type { Segment, SegmentWord } from "~/stt/live-segment"; + +const mocks = vi.hoisted(() => ({ + wordSpan: vi.fn( + ({ + displayText, + isActiveMatch, + }: { + displayText: string; + isActiveMatch?: boolean; + }) => ( + + {displayText} + + ), + ), +})); + +vi.mock("./segment-header", () => ({ + SegmentHeader: () => null, +})); + +vi.mock("./word-span", () => ({ + WordSpan: mocks.wordSpan, +})); + +describe("SegmentRenderer", () => { + beforeEach(() => { + mocks.wordSpan.mockClear(); + }); + + it("skips playback rerenders while the active line is unchanged", () => { + const segment = createSegment(); + const seekAndPlay = vi.fn(); + const view = render( + , + ); + + expect(mocks.wordSpan).toHaveBeenCalledTimes(4); + + view.rerender( + , + ); + + expect(mocks.wordSpan).toHaveBeenCalledTimes(4); + }); + + it("rerenders playback when the active line changes", () => { + const segment = createSegment(); + const seekAndPlay = vi.fn(); + const view = render( + , + ); + + expect(mocks.wordSpan).toHaveBeenCalledTimes(4); + + view.rerender( + , + ); + + expect(mocks.wordSpan).toHaveBeenCalledTimes(8); + }); + + it("skips active-match navigation outside the segment", () => { + const segment = createSegment(); + const seekAndPlay = vi.fn(); + const search = createSearch("outside-1"); + const view = render( + , + ); + + expect(mocks.wordSpan).toHaveBeenCalledTimes(4); + + view.rerender( + , + ); + + expect(mocks.wordSpan).toHaveBeenCalledTimes(4); + }); + + it("rerenders active-match navigation inside the segment", () => { + const segment = createSegment(); + const seekAndPlay = vi.fn(); + const view = render( + , + ); + + expect(mocks.wordSpan).toHaveBeenCalledTimes(4); + + view.rerender( + , + ); + + expect(mocks.wordSpan).toHaveBeenCalledTimes(8); + }); +}); + +function createSearch(activeMatchId: string): TranscriptSearchRenderState { + return { + query: "line", + activeMatchId, + caseSensitive: false, + wholeWord: false, + }; +} + +function createSegment(): Segment { + return { + id: "segment-1", + text: "First line. Second line.", + start_ms: 100, + end_ms: 1800, + key: { + channel: "MixedCapture", + speaker_index: null, + speaker_human_id: null, + }, + words: [ + createWord("word-1", "First", 100, 300), + createWord("word-2", "line.", 300, 900), + createWord("word-3", "Second", 1200, 1400), + createWord("word-4", "line.", 1400, 1800), + ], + }; +} + +function createWord( + id: string, + text: string, + startMs: number, + endMs: number, +): SegmentWord { + return { + id, + text, + start_ms: startMs, + end_ms: endMs, + channel: "MixedCapture", + is_final: true, + }; +} diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/segment.tsx b/apps/desktop/src/session/components/note-input/transcript/renderer/segment.tsx index f2c7bbbfe8..35b30bc780 100644 --- a/apps/desktop/src/session/components/note-input/transcript/renderer/segment.tsx +++ b/apps/desktop/src/session/components/note-input/transcript/renderer/segment.tsx @@ -3,12 +3,31 @@ import { memo, useMemo } from "react"; import { cn } from "@hypr/utils"; import { SegmentHeader } from "./segment-header"; -import { groupWordsIntoLines } from "./utils"; +import { + getActiveLineIndex, + groupWordsIntoLines, + type HighlightSegment, +} from "./utils"; import { WordSpan } from "./word-span"; +import { createHighlightSegments } from "~/session/components/note-input/search/matching"; import type { Segment, SegmentWord } from "~/stt/live-segment"; import { SpeakerLabelManager } from "~/stt/live-segment"; +export type TranscriptSearchRenderState = { + query: string; + activeMatchId: string | null; + caseSensitive: boolean; + wholeWord: boolean; +}; + +export const EMPTY_TRANSCRIPT_SEARCH: TranscriptSearchRenderState = { + query: "", + activeMatchId: null, + caseSensitive: false, + wholeWord: false, +}; + function getSegmentTimeRange( segment: Segment, offsetMs: number, @@ -30,6 +49,7 @@ export const SegmentRenderer = memo( currentMs, seekAndPlay, audioExists, + search, }: { segment: Segment; offsetMs: number; @@ -38,11 +58,31 @@ export const SegmentRenderer = memo( currentMs: number; seekAndPlay: (word: SegmentWord) => void; audioExists: boolean; + search: TranscriptSearchRenderState; }) => { const lines = useMemo( () => groupWordsIntoLines(segment.words), [segment.words], ); + const highlightSegmentsByWord = useMemo(() => { + if (!search.query) { + return null; + } + + const highlights = new Map(); + for (const word of segment.words) { + highlights.set( + word, + createHighlightSegments( + word.text, + search.query, + search.caseSensitive, + search.wholeWord, + ), + ); + } + return highlights; + }, [search.caseSensitive, search.query, search.wholeWord, segment.words]); return (
@@ -83,6 +123,12 @@ export const SegmentRenderer = memo( displayText={word.text} audioExists={audioExists} onClickWord={seekAndPlay} + highlightSegments={ + highlightSegmentsByWord?.get(word) ?? undefined + } + isActiveMatch={ + Boolean(word.id) && word.id === search.activeMatchId + } /> ))} @@ -104,7 +150,10 @@ export const SegmentRenderer = memo( return false; } - // Smart time comparison: only re-render if time change affects which line is active + if (!canReuseSegmentForSearch(prev, next)) { + return false; + } + if (prev.currentMs === next.currentMs) return true; const range = getSegmentTimeRange(prev.segment, prev.offsetMs); @@ -119,11 +168,41 @@ export const SegmentRenderer = memo( next.currentMs >= range.start && next.currentMs <= range.end; - // If neither time is in this segment's range, no visual change if (!prevInRange && !nextInRange) return true; - // If both are in range, the active line might have changed — need to re-render - // If one is in range and the other isn't, definitely need to re-render - return false; + return ( + getActiveLineIndex(prev.segment.words, prev.offsetMs, prev.currentMs) === + getActiveLineIndex(next.segment.words, next.offsetMs, next.currentMs) + ); }, ); + +function canReuseSegmentForSearch( + prev: { segment: Segment; search: TranscriptSearchRenderState }, + next: { segment: Segment; search: TranscriptSearchRenderState }, +) { + if ( + prev.search.query !== next.search.query || + prev.search.caseSensitive !== next.search.caseSensitive || + prev.search.wholeWord !== next.search.wholeWord + ) { + return false; + } + + if (prev.search.activeMatchId === next.search.activeMatchId) { + return true; + } + + return ( + !segmentContainsWordId(prev.segment, prev.search.activeMatchId) && + !segmentContainsWordId(next.segment, next.search.activeMatchId) + ); +} + +function segmentContainsWordId(segment: Segment, wordId: string | null) { + if (!wordId) { + return false; + } + + return segment.words.some((word) => word.id === wordId); +} diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/transcript.tsx b/apps/desktop/src/session/components/note-input/transcript/renderer/transcript.tsx index 0009924f39..812e8cbdc6 100644 --- a/apps/desktop/src/session/components/note-input/transcript/renderer/transcript.tsx +++ b/apps/desktop/src/session/components/note-input/transcript/renderer/transcript.tsx @@ -2,8 +2,13 @@ import { memo, useCallback, useEffect, useMemo } from "react"; import { cn } from "@hypr/utils"; +import { useSearch } from "../../search/context"; import { useRenderedTranscriptData, useTranscriptOffset } from "./data-hooks"; -import { SegmentRenderer } from "./segment"; +import { + EMPTY_TRANSCRIPT_SEARCH, + SegmentRenderer, + type TranscriptSearchRenderState, +} from "./segment"; import { createSegmentKey, segmentsShallowEqual, @@ -97,6 +102,7 @@ const SegmentsList = memo( maxSpeakerNumber?: number; }) => { const store = main.UI.useStore(main.STORE_ID); + const search = useSearch(); const speakerLabelManager = useMemo(() => { if (!store) { return new SpeakerLabelManager(); @@ -104,6 +110,25 @@ const SegmentsList = memo( const ctx = defaultRenderLabelContext(store); return SpeakerLabelManager.fromSegments(segments, ctx, maxSpeakerNumber); }, [maxSpeakerNumber, segments, store]); + const transcriptSearch = useMemo(() => { + const query = search?.query.trim() ?? ""; + if (!search?.isVisible || !query) { + return EMPTY_TRANSCRIPT_SEARCH; + } + + return { + query, + activeMatchId: search.activeMatchId, + caseSensitive: search.caseSensitive, + wholeWord: search.wholeWord, + }; + }, [ + search?.activeMatchId, + search?.caseSensitive, + search?.isVisible, + search?.query, + search?.wholeWord, + ]); const seekAndPlay = useCallback( (word: SegmentWord) => { @@ -143,6 +168,7 @@ const SegmentsList = memo( currentMs={currentMs} seekAndPlay={seekAndPlay} audioExists={audioExists} + search={transcriptSearch} /> ))} diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/utils.test.ts b/apps/desktop/src/session/components/note-input/transcript/renderer/utils.test.ts index 2c2961b2f3..5c0aa71cc4 100644 --- a/apps/desktop/src/session/components/note-input/transcript/renderer/utils.test.ts +++ b/apps/desktop/src/session/components/note-input/transcript/renderer/utils.test.ts @@ -1,9 +1,13 @@ import chroma from "chroma-js"; import { describe, expect, it } from "vitest"; -import { getSegmentColor, getSegmentColorVars } from "./utils"; +import { + getActiveLineIndex, + getSegmentColor, + getSegmentColorVars, +} from "./utils"; -import type { SegmentKey } from "~/stt/live-segment"; +import type { SegmentKey, SegmentWord } from "~/stt/live-segment"; describe("transcript renderer utils", () => { it("uses a brighter speaker color for dark mode", () => { @@ -30,4 +34,38 @@ describe("transcript renderer utils", () => { "--segment-color-dark": getSegmentColor(key, "dark"), }); }); + + it("finds the active transcript line without building line groups", () => { + const words: SegmentWord[] = [ + createWord("word-1", "Hello", 100, 400), + createWord("word-2", "world.", 400, 900), + createWord("word-3", "Next", 1400, 1600), + createWord("word-4", "line!", 1600, 2100), + ]; + + expect(getActiveLineIndex(words, 50, 0)).toBeNull(); + expect(getActiveLineIndex(words, 50, 149)).toBeNull(); + expect(getActiveLineIndex(words, 50, 150)).toBe(0); + expect(getActiveLineIndex(words, 50, 950)).toBe(0); + expect(getActiveLineIndex(words, 50, 1200)).toBeNull(); + expect(getActiveLineIndex(words, 50, 1450)).toBe(1); + expect(getActiveLineIndex(words, 50, 2150)).toBe(1); + expect(getActiveLineIndex(words, 50, 2200)).toBeNull(); + }); }); + +function createWord( + id: string, + text: string, + startMs: number, + endMs: number, +): SegmentWord { + return { + id, + text, + start_ms: startMs, + end_ms: endMs, + channel: "MixedCapture", + is_final: true, + }; +} diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/utils.ts b/apps/desktop/src/session/components/note-input/transcript/renderer/utils.ts index d95c510a78..1fb2499dba 100644 --- a/apps/desktop/src/session/components/note-input/transcript/renderer/utils.ts +++ b/apps/desktop/src/session/components/note-input/transcript/renderer/utils.ts @@ -46,6 +46,42 @@ export function groupWordsIntoLines(words: SegmentWord[]): SentenceLine[] { return lines; } +export function getActiveLineIndex( + words: SegmentWord[], + offsetMs: number, + currentMs: number, +): number | null { + if (currentMs <= 0 || words.length === 0) return null; + + let lineIndex = 0; + let lineStartMs = words[0]!.start_ms; + + for (let index = 0; index < words.length; index += 1) { + const word = words[index]!; + const text = word.text.trim(); + const closesLine = + text.endsWith(".") || + text.endsWith("?") || + text.endsWith("!") || + index === words.length - 1; + + if (!closesLine) { + continue; + } + + const start = offsetMs + lineStartMs; + const end = offsetMs + word.end_ms; + if (currentMs >= start && currentMs <= end) { + return lineIndex; + } + + lineIndex += 1; + lineStartMs = words[index + 1]?.start_ms ?? lineStartMs; + } + + return null; +} + export function formatTimestamp(ms: number): string { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx b/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx index 4ed8e63f03..a274d1ceeb 100644 --- a/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx +++ b/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx @@ -1,11 +1,9 @@ -import { Fragment, useMemo } from "react"; +import { Fragment, memo, useMemo } from "react"; import { cn } from "@hypr/utils"; import type { HighlightSegment } from "./utils"; -import { useSearch } from "~/session/components/note-input/search/context"; -import { createHighlightSegments } from "~/session/components/note-input/search/matching"; import type { SegmentWord } from "~/stt/live-segment"; import { isTranscriptWordSeekable } from "~/stt/timing"; @@ -14,21 +12,16 @@ interface WordSpanProps { displayText: string; audioExists: boolean; onClickWord: (word: SegmentWord) => void; + highlightSegments?: HighlightSegment[]; + isActiveMatch?: boolean; } -export function WordSpan(props: WordSpanProps) { - const searchHighlights = useTranscriptSearchHighlights( - props.word, - props.displayText, - ); - const highlights = searchHighlights ?? { - segments: [{ text: props.displayText, isMatch: false }], - isActive: false, - }; +export const WordSpan = memo(function WordSpan(props: WordSpanProps) { const content = useHighlightedContent( props.word, - highlights.segments, - highlights.isActive, + props.displayText, + props.highlightSegments, + props.isActiveMatch ?? false, ); const canSeek = props.audioExists && isTranscriptWordSeekable(props.word); const className = useMemo( @@ -49,38 +42,19 @@ export function WordSpan(props: WordSpanProps) { {content} ); -} - -function useTranscriptSearchHighlights(word: SegmentWord, displayText: string) { - const search = useSearch(); - const query = search?.query?.trim() ?? ""; - const isVisible = Boolean(search?.isVisible); - const activeMatchId = search?.activeMatchId ?? null; - const caseSensitive = search?.caseSensitive ?? false; - const wholeWord = search?.wholeWord ?? false; - - const segments = useMemo(() => { - const text = displayText ?? ""; - if (!text) { - return [{ text: "", isMatch: false }]; - } - - if (!isVisible || !query) { - return [{ text, isMatch: false }]; - } - - return createHighlightSegments(text, query, caseSensitive, wholeWord); - }, [caseSensitive, displayText, isVisible, query, wholeWord]); - - return { segments, isActive: word.id === activeMatchId }; -} +}); function useHighlightedContent( word: SegmentWord, - segments: HighlightSegment[], + displayText: string, + segments: HighlightSegment[] | undefined, isActive: boolean, ) { return useMemo(() => { + if (!segments) { + return displayText; + } + const baseKey = word.id ?? word.text ?? "word"; return segments.map((segment, index) => @@ -95,5 +69,5 @@ function useHighlightedContent( {segment.text} ), ); - }, [isActive, segments, word.id, word.text]); + }, [displayText, isActive, segments, word.id, word.text]); } From 41debd63df35a486f59cb8b0a6c7754a2154d83c Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:34:18 +0900 Subject: [PATCH 2/2] Narrow transcript row subscriptions Move row revision tracking into TinyBase hooks and avoid whole transcript table subscriptions in session transcript state. --- .../note-input/transcript/index.test.tsx | 32 ++++---- .../transcript/render-request-hooks.ts | 61 +-------------- .../components/note-input/transcript/state.ts | 8 +- .../src/session/components/shared.test.ts | 6 +- .../desktop/src/session/components/shared.tsx | 8 +- .../src/store/tinybase/hooks/index.tsx | 75 ++++++++++++++++++- 6 files changed, 108 insertions(+), 82 deletions(-) diff --git a/apps/desktop/src/session/components/note-input/transcript/index.test.tsx b/apps/desktop/src/session/components/note-input/transcript/index.test.tsx index 52870ed1e6..63b28b6e88 100644 --- a/apps/desktop/src/session/components/note-input/transcript/index.test.tsx +++ b/apps/desktop/src/session/components/note-input/transcript/index.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { createRef } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -7,13 +7,11 @@ import { Transcript } from "./index"; const { useSliceRowIdsMock, useStoreMock, - useTableMock, useListenerMock, useAudioPlayerMock, } = vi.hoisted(() => ({ useSliceRowIdsMock: vi.fn(), useStoreMock: vi.fn(), - useTableMock: vi.fn(), useListenerMock: vi.fn(), useAudioPlayerMock: vi.fn(), })); @@ -26,7 +24,6 @@ vi.mock("~/store/tinybase/store/main", () => ({ UI: { useSliceRowIds: useSliceRowIdsMock, useStore: useStoreMock, - useTable: useTableMock, useCheckpoints: vi.fn(() => null), useIndexes: vi.fn(() => null), }, @@ -86,16 +83,12 @@ describe("Transcript", () => { partialWordsByChannel: Record; partialHintsByChannel: Record; }; + let transcriptRowListener: (() => void) | null; let transcriptWordsJson: string; - let transcriptsTable: Record; beforeEach(() => { + transcriptRowListener = null; transcriptWordsJson = "[]"; - transcriptsTable = { - [transcriptId]: { - words: transcriptWordsJson, - }, - }; listenerState = { getSessionMode: () => "active", @@ -112,6 +105,16 @@ describe("Transcript", () => { useSliceRowIdsMock.mockReturnValue([transcriptId]); useStoreMock.mockReturnValue({ + addRowListener: vi.fn( + (tableId: string, rowId: string, listener: () => void) => { + if (tableId === "transcripts" && rowId === transcriptId) { + transcriptRowListener = listener; + } + + return "listener-1"; + }, + ), + delListener: vi.fn(), getCell: vi.fn( (tableId: string, rowId: string, cellId: "words" | "speaker_hints") => { if ( @@ -126,7 +129,6 @@ describe("Transcript", () => { }, ), }); - useTableMock.mockImplementation(() => transcriptsTable); useListenerMock.mockImplementation((selector) => selector(listenerState)); useAudioPlayerMock.mockReturnValue({ audioExists: false }); }); @@ -140,11 +142,9 @@ describe("Transcript", () => { expect(screen.getByTestId("listening-state").textContent).toBe("listening"); transcriptWordsJson = '[{"id":"word-1","text":" Hello"}]'; - transcriptsTable = { - [transcriptId]: { - words: transcriptWordsJson, - }, - }; + act(() => { + transcriptRowListener?.(); + }); view.rerender(); diff --git a/apps/desktop/src/session/components/note-input/transcript/render-request-hooks.ts b/apps/desktop/src/session/components/note-input/transcript/render-request-hooks.ts index bf2706d216..f1a38ac244 100644 --- a/apps/desktop/src/session/components/note-input/transcript/render-request-hooks.ts +++ b/apps/desktop/src/session/components/note-input/transcript/render-request-hooks.ts @@ -1,10 +1,11 @@ -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; +import { useMemo } from "react"; import type { RenderTranscriptHuman, RenderTranscriptRequest, } from "@hypr/plugin-transcription"; +import { getUniqueRowIds, useStoreRowsRevision } from "~/store/tinybase/hooks"; import * as main from "~/store/tinybase/store/main"; import { buildRenderTranscriptRequestFromRows, @@ -14,7 +15,6 @@ import { } from "~/stt/render-transcript"; import { parseTranscriptHints, parseTranscriptWords } from "~/stt/utils"; -type RenderTableId = "transcripts" | "mapping_session_participant" | "humans"; type UiStore = NonNullable>; export type TranscriptRowWithId = { @@ -146,41 +146,6 @@ function useRenderData( return { request, transcriptRows }; } -function useStoreRowsRevision( - store: UiStore | undefined, - tableId: RenderTableId, - rowIds: readonly string[], -): number { - const revisionRef = useRef(0); - const rowIdsKey = getRowIdsKey(rowIds); - const subscribedRowIds = useMemo(() => getUniqueRowIds(rowIds), [rowIdsKey]); - - const subscribe = useCallback( - (notify: () => void) => { - if (!store || subscribedRowIds.length === 0) { - return noop; - } - - const listenerIds = subscribedRowIds.map((rowId) => - store.addRowListener(tableId, rowId, () => { - revisionRef.current += 1; - notify(); - }), - ); - - return () => { - for (const listenerId of listenerIds) { - store.delListener(listenerId); - } - }; - }, - [store, subscribedRowIds, tableId], - ); - const getSnapshot = useCallback(() => revisionRef.current, []); - - return useSyncExternalStore(subscribe, getSnapshot, getZero); -} - function getTranscriptRow(store: UiStore, transcriptId: string): TranscriptRow { const startedAt = store.getCell("transcripts", transcriptId, "started_at"); @@ -238,26 +203,4 @@ function getRowIdsKey(rowIds: readonly string[]): string { return getUniqueRowIds(rowIds).join("\u0000"); } -function getUniqueRowIds(rowIds: readonly string[]): string[] { - const uniqueRowIds: string[] = []; - const seen = new Set(); - - for (const rowId of rowIds) { - if (!rowId || seen.has(rowId)) { - continue; - } - - uniqueRowIds.push(rowId); - seen.add(rowId); - } - - return uniqueRowIds; -} - -function noop() {} - -function getZero() { - return 0; -} - const emptyIds: string[] = []; diff --git a/apps/desktop/src/session/components/note-input/transcript/state.ts b/apps/desktop/src/session/components/note-input/transcript/state.ts index a9dc556f5e..fbba2e3ec9 100644 --- a/apps/desktop/src/session/components/note-input/transcript/state.ts +++ b/apps/desktop/src/session/components/note-input/transcript/state.ts @@ -3,6 +3,7 @@ import { useMemo } from "react"; import type { DegradedError } from "@hypr/plugin-transcription"; import { useAudioPlayer } from "~/audio-player"; +import { useMainStoreRowsRevision } from "~/store/tinybase/hooks"; import * as main from "~/store/tinybase/store/main"; import { getLiveCaptureUiMode } from "~/store/zustand/listener/general-shared"; import { useListener } from "~/stt/contexts"; @@ -109,7 +110,10 @@ function useTranscriptContent(sessionId: string) { sessionId, main.STORE_ID, ) ?? []; - const transcriptsTable = main.UI.useTable("transcripts", main.STORE_ID); + const transcriptRowsRevision = useMainStoreRowsRevision( + "transcripts", + transcriptIds, + ); const liveSegments = useListener((state) => state.liveSegments); const store = main.UI.useStore(main.STORE_ID); @@ -121,7 +125,7 @@ function useTranscriptContent(sessionId: string) { return transcriptIds.some( (transcriptId) => parseTranscriptWords(store, transcriptId).length > 0, ); - }, [store, transcriptIds, transcriptsTable]); + }, [store, transcriptIds, transcriptRowsRevision]); return { transcriptIds, diff --git a/apps/desktop/src/session/components/shared.test.ts b/apps/desktop/src/session/components/shared.test.ts index b7fccbe47f..698c95968f 100644 --- a/apps/desktop/src/session/components/shared.test.ts +++ b/apps/desktop/src/session/components/shared.test.ts @@ -67,8 +67,10 @@ vi.mock("~/store/tinybase/store/main", () => ({ return []; }, - useStore: () => ({}), - useTable: () => ({}), + useStore: () => ({ + addRowListener: vi.fn(() => "listener-1"), + delListener: vi.fn(), + }), }, })); diff --git a/apps/desktop/src/session/components/shared.tsx b/apps/desktop/src/session/components/shared.tsx index 201056e7de..c107f1a635 100644 --- a/apps/desktop/src/session/components/shared.tsx +++ b/apps/desktop/src/session/components/shared.tsx @@ -5,6 +5,7 @@ import { Button } from "@hypr/ui/components/ui/button"; import { computeCurrentNoteTab } from "./compute-note-tab"; import { extractPlainText } from "~/search/contexts/engine/utils"; +import { useMainStoreRowsRevision } from "~/store/tinybase/hooks"; import * as main from "~/store/tinybase/store/main"; import type { Tab } from "~/store/zustand/tabs/schema"; import { type EditorView } from "~/store/zustand/tabs/schema"; @@ -20,7 +21,10 @@ export function useHasTranscript(sessionId: string): boolean { sessionId, main.STORE_ID, ) ?? []; - const transcriptsTable = main.UI.useTable("transcripts", main.STORE_ID); + const transcriptRowsRevision = useMainStoreRowsRevision( + "transcripts", + transcriptIds, + ); const store = main.UI.useStore(main.STORE_ID); return useMemo(() => { @@ -31,7 +35,7 @@ export function useHasTranscript(sessionId: string): boolean { return transcriptIds.some( (transcriptId) => parseTranscriptWords(store, transcriptId).length > 0, ); - }, [store, transcriptIds, transcriptsTable]); + }, [store, transcriptIds, transcriptRowsRevision]); } export function hasStoredNoteContent(value: unknown): boolean { diff --git a/apps/desktop/src/store/tinybase/hooks/index.tsx b/apps/desktop/src/store/tinybase/hooks/index.tsx index ec74b85ce1..ad8b3770c5 100644 --- a/apps/desktop/src/store/tinybase/hooks/index.tsx +++ b/apps/desktop/src/store/tinybase/hooks/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import type { IgnoredEvent, @@ -206,3 +206,76 @@ export function useIgnoredEvents() { unignoreSeries, }; } + +type UiStore = NonNullable>; +type StoreTableId = Parameters[0]; + +export function useMainStoreRowsRevision( + tableId: StoreTableId, + rowIds: readonly string[], +): number { + const store = main.UI.useStore(main.STORE_ID); + + return useStoreRowsRevision(store, tableId, rowIds); +} + +export function useStoreRowsRevision( + store: UiStore | undefined, + tableId: StoreTableId, + rowIds: readonly string[], +): number { + const revisionRef = useRef(0); + const rowIdsKey = getRowIdsKey(rowIds); + const subscribedRowIds = useMemo(() => getUniqueRowIds(rowIds), [rowIdsKey]); + + const subscribe = useCallback( + (notify: () => void) => { + if (!store || subscribedRowIds.length === 0) { + return noop; + } + + const listenerIds = subscribedRowIds.map((rowId) => + store.addRowListener(tableId, rowId, () => { + revisionRef.current += 1; + notify(); + }), + ); + + return () => { + for (const listenerId of listenerIds) { + store.delListener(listenerId); + } + }; + }, + [store, subscribedRowIds, tableId], + ); + const getSnapshot = useCallback(() => revisionRef.current, []); + + return useSyncExternalStore(subscribe, getSnapshot, getZero); +} + +function getRowIdsKey(rowIds: readonly string[]): string { + return getUniqueRowIds(rowIds).join("\u0000"); +} + +export function getUniqueRowIds(rowIds: readonly string[]): string[] { + const uniqueRowIds: string[] = []; + const seen = new Set(); + + for (const rowId of rowIds) { + if (!rowId || seen.has(rowId)) { + continue; + } + + uniqueRowIds.push(rowId); + seen.add(rowId); + } + + return uniqueRowIds; +} + +function noop() {} + +function getZero() { + return 0; +}