From a54b0eb337bc8a6a6851360107a2fc019a69cdf0 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 19 Oct 2026 13:29:04 -0400 Subject: [PATCH 1/3] Add onAnalysisChanged for Scripture edits (#438) BookService watches each source project's open book, the one whose outline was last asked for, apart from the held-book cache, since the whole-text read replaces the held book. An edit to it releases the held text and fires interlinearizer.onAnalysisChanged with the source project, a change id and the edited book; the WebView reloads the book when the event names its project and book. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../hooks/useInterlinearizerBookData.test.ts | 70 +++++++ src/__tests__/main.test.ts | 52 +++++ src/__tests__/services/bookService.test.ts | 182 ++++++++++++++++++ src/hooks/useInterlinearizerBookData.ts | 21 +- src/main.ts | 13 ++ src/services/bookService.ts | 67 ++++++- src/types/interlinearizer.d.ts | 8 + 8 files changed, 405 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4685ffb8..357af3f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ This is a **Platform.Bible extension** for interlinear Bible text alignment. Pla [src/main.ts](src/main.ts) — called by Platform.Bible on activation. Exports two lifecycle functions: -- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.onUndoFromNotification` network event emitter, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidUpdateWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation. +- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.onUndoFromNotification` and `interlinearizer.onAnalysisChanged` network event emitters, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidUpdateWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation. - `deactivate()` — clears `openWebViewsByProject`, lets go of every book `bookService` keeps, and returns `true`. `openWebViewsByProject` (`Map`) tracks one open WebView ID per project to prevent duplicates; reopening an already-open project brings that tab to front via the `existingId` option. diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts index e5501920..411c26ce 100644 --- a/src/__tests__/hooks/useInterlinearizerBookData.test.ts +++ b/src/__tests__/hooks/useInterlinearizerBookData.test.ts @@ -5,6 +5,7 @@ import { useProjectSetting } from '@papi/frontend/react'; import { act, renderHook, waitFor } from '@testing-library/react'; import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { Book } from 'interlinearizer'; +import { useEvent } from 'platform-bible-react'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; import { getMockedSendCommand, makeSegment, makeWordToken } from '../test-helpers'; @@ -297,4 +298,73 @@ describe('useInterlinearizerBookData', () => { expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); }); }); + + describe('when Scripture text is edited', () => { + /** Stands in for the network event the platform relays a Scripture edit on. */ + const ANALYSIS_CHANGED_EVENT = () => () => true; + + beforeEach(() => { + jest.mocked(papi.network.getNetworkEvent).mockReturnValue(ANALYSIS_CHANGED_EVENT); + }); + + /** Relays an analysis change to the hook, as the platform does. */ + function emitChange(sourceProjectId: string, bookId: string): void { + const handler = [...jest.mocked(useEvent).mock.calls] + .reverse() + .find(([event]) => event === ANALYSIS_CHANGED_EVENT)?.[1]; + if (typeof handler !== 'function') throw new Error('nothing listens for the change event'); + act(() => handler({ sourceProjectId, changeId: 'change-1', scriptureEdit: { bookId } })); + } + + it('listens for analysis changes', async () => { + const { result } = renderBookData(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(papi.network.getNetworkEvent).toHaveBeenCalledWith( + 'interlinearizer.onAnalysisChanged', + ); + }); + + it('fetches the book again when its own text is edited', async () => { + const { result } = renderBookData(); + await waitFor(() => expect(result.current.book).toEqual(TEST_BOOK)); + const revised: Book = { ...TEST_BOOK, textVersion: 'v2' }; + serveBooks(revised); + + emitChange('test-project', 'GEN'); + + await waitFor(() => expect(result.current.book).toEqual(revised)); + }); + + it('fetches nothing more when another project is edited', async () => { + const { result } = renderBookData(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + emitChange('other-project', 'GEN'); + + expect(result.current.isLoading).toBe(false); + expect(mockSendCommand).toHaveBeenCalledTimes(1); + }); + + it('fetches nothing more when another book is edited', async () => { + const { result } = renderBookData(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + emitChange('test-project', 'EXO'); + + expect(result.current.isLoading).toBe(false); + expect(mockSendCommand).toHaveBeenCalledTimes(1); + }); + + it('fetches the book again when the book it moved to is edited', async () => { + const { result, rerender } = renderBookData(); + await waitFor(() => expect(result.current.book).toEqual(TEST_BOOK)); + rerender({ scrRef: EXO_1_1 }); + await waitFor(() => expect(result.current.book).toEqual(EXO_BOOK)); + + emitChange('test-project', 'EXO'); + + await waitFor(() => expect(mockSendCommand).toHaveBeenCalledTimes(3)); + }); + }); }); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index f47663ce..5be29366 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -260,6 +260,58 @@ describe('main', () => { expect(emitter.emit).toHaveBeenCalledWith({ notificationId: 'notification-1' }); }); + describe('interlinearizer.onAnalysisChanged', () => { + /** Gives each event its own emitter, as the platform does. */ + function emittersByEvent() { + const emitters = new Map(); + __mockCreateNetworkEventEmitterAsync.mockImplementation(async (eventType: string) => { + const emitter = { emit: jest.fn(), dispose: jest.fn() }; + emitters.set(eventType, emitter); + return emitter; + }); + return emitters; + } + + it('emits the project and book of an edit to the open book', async () => { + const emitters = emittersByEvent(); + await activate(createTestActivationContext()); + const [listener] = jest.mocked(bookService.onOpenBookTextChanged).mock.calls[0]; + + listener('src-project', 'GEN'); + + expect(emitters.get('interlinearizer.onAnalysisChanged')?.emit).toHaveBeenCalledWith({ + sourceProjectId: 'src-project', + changeId: expect.any(String), + scriptureEdit: { bookId: 'GEN' }, + }); + }); + + it('gives each emitted change its own id', async () => { + const emitters = emittersByEvent(); + await activate(createTestActivationContext()); + const [listener] = jest.mocked(bookService.onOpenBookTextChanged).mock.calls[0]; + + listener('src-project', 'GEN'); + listener('src-project', 'GEN'); + + const emit = emitters.get('interlinearizer.onAnalysisChanged')?.emit; + expect(emit?.mock.calls[0][0].changeId).not.toBe(emit?.mock.calls[1][0].changeId); + }); + + it('disposes the emitter and stops listening on deactivation', async () => { + const emitters = emittersByEvent(); + const unsubscribe = jest.fn(() => true); + jest.mocked(bookService.onOpenBookTextChanged).mockReturnValue(unsubscribe); + const context = createTestActivationContext(); + await activate(context); + + await context.registrations.runAllUnsubscribers(); + + expect(emitters.get('interlinearizer.onAnalysisChanged')?.dispose).toHaveBeenCalled(); + expect(unsubscribe).toHaveBeenCalled(); + }); + }); + it('registers the WebView provider with a callable getWebView handler', async () => { const context = createTestActivationContext(); await activate(context); diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index e6c6d3a8..892cb458 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -12,6 +12,7 @@ import { getSegments, getTokenizedBook, getWritingSystem, + onOpenBookTextChanged, } from '../../services/bookService'; import type { BookOutline, SegmentPage } from '../../types/segment-pages'; import { buildBookOutline } from '../../utils/book-outline'; @@ -533,3 +534,184 @@ describe('closeAllProjects', () => { expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true); }); }); + +describe('onOpenBookTextChanged', () => { + const unsubscribers: Array<() => boolean> = []; + + /** A listener registered for the test, removed once it ends. */ + function listen() { + const listener = jest.fn(); + unsubscribers.push(onOpenBookTextChanged(listener)); + return listener; + } + + beforeEach(() => { + segmentStandIns(); + }); + + afterEach(() => { + unsubscribers.splice(0).forEach((unsubscribe) => unsubscribe()); + }); + + it('reports an edit to the outlined book with its project and book', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listener = listen(); + await getBookOutline('src', 'GEN'); + + project.subscriptions[0].deliver(usj('GEN', 'v2')); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'GEN'); + }); + + it('reports an edit to every listener', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listeners = [listen(), listen()]; + await getBookOutline('src', 'GEN'); + + project.subscriptions[0].deliver(usj('GEN', 'v2')); + + listeners.forEach((listener) => expect(listener).toHaveBeenCalledTimes(1)); + }); + + it('reads the edited text when the book is next requested', async () => { + let version = 'v1'; + const project = serveProject((bookId) => usj(bookId, version)); + await getBookOutline('src', 'GEN'); + version = 'v2'; + + project.subscriptions[0].deliver(usj('GEN', version)); + await settle(); + + expect(parseBook(await getTokenizedBook('src', 'GEN')).textVersion).toBe('v2@en'); + }); + + it('reads the edited text when the held book heard of the edit first', async () => { + let version = 'v1'; + const project = serveProject((bookId) => usj(bookId, version)); + const listener = listen(); + await getBookOutline('src', 'GEN'); + version = 'v2'; + + project.subscriptions[1].deliver(usj('GEN', version)); + project.subscriptions[0].deliver(usj('GEN', version)); + await settle(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'GEN'); + expect(parseBook(await getTokenizedBook('src', 'GEN')).textVersion).toBe('v2@en'); + }); + + it('still reports an edit after other books have been read in between', async () => { + let version = 'v1'; + const project = serveProject((bookId) => usj(bookId, version)); + const listener = listen(); + await getBookOutline('src', 'GEN'); + await getTokenizedBook('src', 'EXO'); + await getTokenizedBook('src', 'LEV'); + version = 'v2'; + + project.subscriptions[0].deliver(usj('GEN', version)); + await settle(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'GEN'); + expect(parseBook(await getTokenizedBook('src', 'GEN')).textVersion).toBe('v2@en'); + }); + + it('keeps the book held for another when the outlined book is edited', async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + await getTokenizedBook('src', 'EXO'); + const held = project.subscriptions.at(-1); + + project.subscriptions[0].deliver(usj('GEN', 'v2')); + await settle(); + + expect(held?.unsubscribe).not.toHaveBeenCalled(); + }); + + it('reports nothing for a book only read whole', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listener = listen(); + await getTokenizedBook('src', 'GEN'); + await getSegments('src', 'GEN', 0, 1); + + project.subscriptions.forEach((subscription) => subscription.deliver(usj('GEN', 'v2'))); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('watches an outlined book once however often it is outlined', async () => { + const project = serveProject((bookId) => usj(bookId)); + + await getBookOutline('src', 'GEN'); + await getBookOutline('src', 'GEN'); + + expect(project.subscriptions).toHaveLength(2); + }); + + it('stops watching the previous book once another is outlined', async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + const [first] = project.subscriptions; + + await getBookOutline('src', 'EXO'); + await settle(); + + expect(first.unsubscribe).toHaveBeenCalled(); + }); + + it('reports edits to the newly outlined book', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listener = listen(); + await getBookOutline('src', 'GEN'); + await getBookOutline('src', 'EXO'); + const exo = project.subscriptions.find((s) => s.bookId === 'EXO'); + + exo?.deliver(usj('EXO', 'v2')); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'EXO'); + }); + + it("stops watching the project's outlined book once it closes", async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + + await closeProject('src'); + + expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true); + }); + + it('closes a project that has an outlined book but nothing held', async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + project.subscriptions[1].deliver(usj('GEN', 'v2')); + await settle(); + + await expect(closeProject('src')).resolves.toBeUndefined(); + }); + + it('stops watching every outlined book once all projects close', async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + await getBookOutline('other', 'EXO'); + + await closeAllProjects(); + + expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true); + }); + + it('does not call a listener once it is removed', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listener = jest.fn(); + const unsubscribe = onOpenBookTextChanged(listener); + await getBookOutline('src', 'GEN'); + + unsubscribe(); + project.subscriptions[0].deliver(usj('GEN', 'v2')); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts index 8d243265..d908185c 100644 --- a/src/hooks/useInterlinearizerBookData.ts +++ b/src/hooks/useInterlinearizerBookData.ts @@ -2,8 +2,10 @@ import papi, { logger } from '@papi/frontend'; import { useProjectSetting } from '@papi/frontend/react'; import { SerializedVerseRef } from '@sillsdev/scripture'; import type { Book } from 'interlinearizer'; +import type { NetworkEvents } from 'papi-shared-types'; +import { useEvent } from 'platform-bible-react'; import { isPlatformError } from 'platform-bible-utils'; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { perfMark, perfTime, perfTrack } from '../utils/perf-marks'; /** Arguments for the {@link useInterlinearizerBookData} hook. */ @@ -45,7 +47,8 @@ interface LoadedBook { /** * Fetches the tokenized book for the given project and scripture reference, once the project's - * language tag has loaded, and again whenever the tag changes or a reload is asked for. + * language tag has loaded, and again whenever the tag changes, the book's Scripture text is edited, + * or a reload is asked for. */ export default function useInterlinearizerBookData({ projectId, @@ -63,6 +66,20 @@ export default function useInterlinearizerBookData({ const [reloadCount, setReloadCount] = useState(0); const reload = useCallback(() => setReloadCount((count) => count + 1), []); + const analysisChangedEvent = useMemo( + () => papi.network.getNetworkEvent('interlinearizer.onAnalysisChanged'), + [], + ); + useEvent( + analysisChangedEvent, + useCallback( + ({ sourceProjectId, scriptureEdit }: NetworkEvents['interlinearizer.onAnalysisChanged']) => { + if (sourceProjectId === projectId && scriptureEdit.bookId === scrRef.book) reload(); + }, + [projectId, scrRef.book, reload], + ), + ); + const key = `${projectId}:${scrRef.book}:${writingSystem}:${reloadCount}`; const [loaded, setLoaded] = useState(); useEffect(() => { diff --git a/src/main.ts b/src/main.ts index b70c0884..de66e23d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1295,6 +1295,17 @@ export async function activate(context: ExecutionActivationContext): Promise + analysisChangedEmitter.emit({ + sourceProjectId, + changeId: crypto.randomUUID(), + scriptureEdit: { bookId }, + }), + ); + const openLexiconChooserCommandRegistration = await papi.commands.registerCommand( 'interlinearizer.openLexiconChooser', // Handled entirely in the WebView; backend registration makes the command known to the platform. @@ -1379,6 +1390,8 @@ export async function activate(context: ExecutionActivationContext): Promise(); /** Tells a request whether its project closed while it was under way. */ const closeCounts = new Map(); +/** + * Each source project's open book, the one whose outline was last asked for, watched apart from + * {@link heldBooks} since reading other books replaces the held one. + */ +const openBooks = new Map(); + +/** Receives the source project and book of each edit to an open book's text. */ +type OpenBookTextListener = (sourceProjectId: string, bookId: string) => void; + +const openBookTextListeners = new Set(); + /** * Resolves the writing system tag of the source project's text, `und` when the project declares * none. @@ -184,9 +195,43 @@ function segmentsOf( return held.segmented.segments; } +/** + * Calls `listener` whenever the Scripture text of a source project's open book changes, until the + * returned function is called. A project's open book is the one whose outline was last asked for, + * and a book requested once the listener is called reads the edit. + */ +export function onOpenBookTextChanged(listener: OpenBookTextListener): () => boolean { + openBookTextListeners.add(listener); + return () => openBookTextListeners.delete(listener); +} + +function watchOpenBook(sourceProjectId: string, bookId: string): void { + const open = openBooks.get(sourceProjectId); + if (open?.bookId === bookId) return; + open?.watch.stop(); + const watch = watchBookText(sourceProjectId, bookId, () => { + // Either watch may hear of the edit first, so the held text goes before any listener refetches. + const held = heldBooks.get(sourceProjectId); + if (held?.bookId === bookId) releaseBook(sourceProjectId, held.watch); + openBookTextListeners.forEach((listener) => listener(sourceProjectId, bookId)); + }); + // Only edits matter here; a failure to read the text reaches whoever requests it. + watch.text.catch(() => undefined); + openBooks.set(sourceProjectId, { bookId, watch }); +} + +/** Stops watching the source project's open book. */ +function closeOpenBook(sourceProjectId: string): Promise { + const open = openBooks.get(sourceProjectId); + if (!open) return Promise.resolve(); + openBooks.delete(sourceProjectId); + return open.watch.stop(); +} + /** * Outlines a book of the source project: its segments, segmented by the boundaries - * `segmentationJson` names, each without its text. + * `segmentationJson` names, each without its text. The book becomes the project's open book, whose + * edits {@link onOpenBookTextChanged} reports. * * @returns The `BookOutline` as JSON, or `undefined` when the project has no text for the book. * @throws If the book cannot be read or tokenized, or `segmentationJson` is not a JSON @@ -197,6 +242,7 @@ export async function getBookOutline( bookId: string, segmentationJson?: string, ): Promise { + watchOpenBook(sourceProjectId, bookId); const held = await heldBook(sourceProjectId, bookId); const book = await held.book; if (!book) return undefined; @@ -236,16 +282,23 @@ export async function getSegments( return perfTime('getSegments:serialize', () => JSON.stringify(page)); } -/** Lets go of the book kept for the source project, and of any a request under way would keep. */ +/** + * Lets go of the book kept for the source project, and of any a request under way would keep, and + * stops watching its open book. + */ export async function closeProject(sourceProjectId: string): Promise { closeCounts.set(sourceProjectId, (closeCounts.get(sourceProjectId) ?? 0) + 1); const held = heldBooks.get(sourceProjectId); - if (held) await releaseBook(sourceProjectId, held.watch); + await Promise.all([ + held && releaseBook(sourceProjectId, held.watch), + closeOpenBook(sourceProjectId), + ]); } -/** Lets go of every book kept. */ +/** Lets go of every book kept, and stops watching every open book. */ export async function closeAllProjects(): Promise { - await Promise.all( - [...heldBooks].map(([sourceProjectId, { watch }]) => releaseBook(sourceProjectId, watch)), - ); + await Promise.all([ + ...[...heldBooks].map(([sourceProjectId, { watch }]) => releaseBook(sourceProjectId, watch)), + ...[...openBooks.keys()].map(closeOpenBook), + ]); } diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index e1766678..af1df76c 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -10,6 +10,14 @@ declare module 'papi-shared-types' { * naming the notification so the WebView that sent it can undo the edit it offered. */ 'interlinearizer.onUndoFromNotification': { notificationId: string | number }; + /** Emitted when a source project's text changes, so views showing what changed refetch it. */ + 'interlinearizer.onAnalysisChanged': { + sourceProjectId: string; + /** Unique to this change. */ + changeId: string; + /** A Scripture edit, naming the book to refetch whole. */ + scriptureEdit: { bookId: string }; + }; } /** Project-level settings contributed by the Interlinearizer extension. */ From 65f5ee6c31de0c6622128920cdf0d752b41adf39 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 19 Oct 2026 14:29:35 -0400 Subject: [PATCH 2/3] Watch the open book again after its watch fails to read it A failed open-book watch stayed current, so later outlines of the same book kept it and edits to that book went unreported. Drop it, if it is still the current watch, and stop it, since an error on the first delivery leaves the subscription open. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/bookService.test.ts | 40 ++++++++++++++++++++++ src/services/bookService.ts | 9 +++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index 892cb458..67533a24 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -631,6 +631,46 @@ describe('onOpenBookTextChanged', () => { expect(held?.unsubscribe).not.toHaveBeenCalled(); }); + it('watches the book again on the next outline once its watch fails to read it', async () => { + const project = serveProject(); + const listener = listen(); + const outline = getBookOutline('src', 'GEN'); + await settle(); + const [failed, held] = project.subscriptions; + failed.deliver(platformError('busy')); + held.deliver(usj('GEN')); + await outline; + await settle(); + + await getBookOutline('src', 'GEN'); + await settle(); + const watch = project.subscriptions[2]; + watch.deliver(usj('GEN')); + watch.deliver(usj('GEN', 'v2')); + + expect(failed.unsubscribe).toHaveBeenCalled(); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'GEN'); + }); + + it('keeps the newer watch when a replaced watch fails to read its book', async () => { + const project = serveProject(); + const listener = listen(); + getBookOutline('src', 'GEN').catch(() => undefined); + await settle(); + const replaced = project.subscriptions[0]; + getBookOutline('src', 'EXO').catch(() => undefined); + await settle(); + const watch = project.subscriptions.find((s) => s.bookId === 'EXO'); + + replaced.deliver(platformError('busy')); + await settle(); + watch?.deliver(usj('EXO')); + watch?.deliver(usj('EXO', 'v2')); + + expect(listener).toHaveBeenCalledWith('src', 'EXO'); + }); + it('reports nothing for a book only read whole', async () => { const project = serveProject((bookId) => usj(bookId)); const listener = listen(); diff --git a/src/services/bookService.ts b/src/services/bookService.ts index 0256ff99..f053047e 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -215,8 +215,13 @@ function watchOpenBook(sourceProjectId: string, bookId: string): void { if (held?.bookId === bookId) releaseBook(sourceProjectId, held.watch); openBookTextListeners.forEach((listener) => listener(sourceProjectId, bookId)); }); - // Only edits matter here; a failure to read the text reaches whoever requests it. - watch.text.catch(() => undefined); + // A read failure reaches whoever requests the text. Forgetting the watch lets the next outline + // watch again; stopping it ends a subscription an error delivery leaves open. + watch.text.catch(() => { + if (openBooks.get(sourceProjectId)?.watch !== watch) return; + openBooks.delete(sourceProjectId); + watch.stop(); + }); openBooks.set(sourceProjectId, { bookId, watch }); } From 64c8462a6c1265a8c8afc893bb104bf022ac07a4 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 19 Oct 2026 17:29:36 -0400 Subject: [PATCH 3/3] Tighten open-book watch tests and docs Give stopping a failed watch its own test, make the replaced-watch test fail without its guard, and have the close test check the open watch is unsubscribed. Document watchOpenBook, scope the onAnalysisChanged doc to the open book, and drop the side panel doc's claim that one project is routinely open in two tabs. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/bookService.test.ts | 28 ++++++++++++++++------ src/components/InterlinearizerLoader.tsx | 3 +-- src/services/bookService.ts | 5 +++- src/types/interlinearizer.d.ts | 5 +++- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index 67533a24..3acdd691 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -631,6 +631,20 @@ describe('onOpenBookTextChanged', () => { expect(held?.unsubscribe).not.toHaveBeenCalled(); }); + it('stops a watch that fails to read its book', async () => { + const project = serveProject(); + const outline = getBookOutline('src', 'GEN'); + await settle(); + const [failed, held] = project.subscriptions; + + failed.deliver(platformError('busy')); + held.deliver(usj('GEN')); + await outline; + await settle(); + + expect(failed.unsubscribe).toHaveBeenCalled(); + }); + it('watches the book again on the next outline once its watch fails to read it', async () => { const project = serveProject(); const listener = listen(); @@ -648,27 +662,25 @@ describe('onOpenBookTextChanged', () => { watch.deliver(usj('GEN')); watch.deliver(usj('GEN', 'v2')); - expect(failed.unsubscribe).toHaveBeenCalled(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith('src', 'GEN'); }); it('keeps the newer watch when a replaced watch fails to read its book', async () => { const project = serveProject(); - const listener = listen(); getBookOutline('src', 'GEN').catch(() => undefined); await settle(); const replaced = project.subscriptions[0]; getBookOutline('src', 'EXO').catch(() => undefined); await settle(); - const watch = project.subscriptions.find((s) => s.bookId === 'EXO'); + project.subscriptions.filter((s) => s.bookId === 'EXO').forEach((s) => s.deliver(usj('EXO'))); + const subscriptionCount = project.subscriptions.length; replaced.deliver(platformError('busy')); await settle(); - watch?.deliver(usj('EXO')); - watch?.deliver(usj('EXO', 'v2')); + await getBookOutline('src', 'EXO'); - expect(listener).toHaveBeenCalledWith('src', 'EXO'); + expect(project.subscriptions).toHaveLength(subscriptionCount); }); it('reports nothing for a book only read whole', async () => { @@ -730,7 +742,9 @@ describe('onOpenBookTextChanged', () => { project.subscriptions[1].deliver(usj('GEN', 'v2')); await settle(); - await expect(closeProject('src')).resolves.toBeUndefined(); + await closeProject('src'); + + expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true); }); it('stops watching every outlined book once all projects close', async () => { diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index b70b36ec..d667b6e7 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -907,8 +907,7 @@ function InterlinearizerLoaderInner({ /** * Which list the side panel is showing, if it is open at all. Tab-scoped rather than a project - * setting: two tabs on one project are routinely opened to look at different things, and a panel - * one of them opened has no business appearing in the other. + * setting, since it is how the tab is laid out, not something the project holds. */ const [sidePanel, setSidePanel] = useWebViewState( 'sidePanel', diff --git a/src/services/bookService.ts b/src/services/bookService.ts index f053047e..0a8f8f7a 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -205,6 +205,10 @@ export function onOpenBookTextChanged(listener: OpenBookTextListener): () => boo return () => openBookTextListeners.delete(listener); } +/** + * Makes `bookId` the source project's open book, watching it in place of the previous open book. A + * no-op when it is already the open book. + */ function watchOpenBook(sourceProjectId: string, bookId: string): void { const open = openBooks.get(sourceProjectId); if (open?.bookId === bookId) return; @@ -225,7 +229,6 @@ function watchOpenBook(sourceProjectId: string, bookId: string): void { openBooks.set(sourceProjectId, { bookId, watch }); } -/** Stops watching the source project's open book. */ function closeOpenBook(sourceProjectId: string): Promise { const open = openBooks.get(sourceProjectId); if (!open) return Promise.resolve(); diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index af1df76c..24754cd8 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -10,7 +10,10 @@ declare module 'papi-shared-types' { * naming the notification so the WebView that sent it can undo the edit it offered. */ 'interlinearizer.onUndoFromNotification': { notificationId: string | number }; - /** Emitted when a source project's text changes, so views showing what changed refetch it. */ + /** + * Emitted when the text of a source project's open book (the one last outlined) changes, so + * views showing it refetch it. Edits to other books emit nothing. + */ 'interlinearizer.onAnalysisChanged': { sourceProjectId: string; /** Unique to this change. */