diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 710127c5..efa5934d 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -164,6 +164,7 @@ Rules: 4. Async commits are generation-checked. Runtime switches, forced refreshes, eviction, and disposal must reject stale completion. 5. Prefetch coverage and persisted directory data are runtime-scoped. Legacy persisted directory entries may seed startup continuity, but they are not live truth. 6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers. +7. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work. Initial loads use smaller pages on constrained VS Code/mobile surfaces. Older pages are fetched through the same loader and merged with optimistic records before publication. diff --git a/packages/ui/src/sync/session-message-loader-lifecycle.test.ts b/packages/ui/src/sync/session-message-loader-lifecycle.test.ts new file mode 100644 index 00000000..92addd1e --- /dev/null +++ b/packages/ui/src/sync/session-message-loader-lifecycle.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'bun:test' +import { createStore } from 'zustand/vanilla' + +import { SessionMessageLoader } from './session-message-loader' + +test('loads messages after a Strict Mode cleanup and effect setup', async () => { + const store = createStore(() => ({ message: {}, part: {} })) + const childStores = { ensureChild: () => store, getChild: () => store } + let messageRequests = 0 + let resolveFirstRequest: ((value: { data: []; response: { headers: { get: () => null } } }) => void) | undefined + const sdk = { session: { messages: async () => { + messageRequests += 1 + if (messageRequests === 1) return new Promise((resolve) => { resolveFirstRequest = resolve }) + return { data: [], response: { headers: { get: () => null } } } + } } } + const loader = new SessionMessageLoader(childStores as never, { sdk: sdk as never, runtimeKey: 'runtime' }) + const firstLoad = loader.ensure({ directory: '/project', sessionID: 'session-1' }) + loader.dispose(); loader.activate(); await loader.ensure({ directory: '/project', sessionID: 'session-1' }) + resolveFirstRequest?.({ data: [], response: { headers: { get: () => null } } }); await firstLoad + expect(messageRequests).toBe(2) + expect(loader.getSnapshot({ directory: '/project', sessionID: 'session-1' }).status).toBe('ready') +}) diff --git a/packages/ui/src/sync/session-message-loader.ts b/packages/ui/src/sync/session-message-loader.ts index 1b423c4a..644d9782 100644 --- a/packages/ui/src/sync/session-message-loader.ts +++ b/packages/ui/src/sync/session-message-loader.ts @@ -161,6 +161,18 @@ export class SessionMessageLoader { } } + /** + * Re-enable a loader which was disposed by a transient React effect cleanup. + * + * React Strict Mode runs effect setup, cleanup, then setup again in + * development. The provider owns one ref-stable loader across that sequence, + * so the second setup must be able to accept new work after the first cleanup + * invalidated its in-flight requests. + */ + activate(): void { + this.disposed = false + } + ensure( target: SessionMessageTarget, options?: { force?: boolean; reason?: "navigation" | "reactive" | "prefetch" }, diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 5a5bb100..465c64ae 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -1781,6 +1781,7 @@ export function SyncProvider(props: { }) } const messageLoader = messageLoaderRef.current + const messageLoaderDisposalTimerRef = useRef | null>(null) messageLoader.configure({ sdk: props.sdk, runtimeKey }) const routingIndexRef = useRef(null) if (!routingIndexRef.current) routingIndexRef.current = createEventRoutingIndex() @@ -2242,9 +2243,22 @@ export function SyncProvider(props: { } }, [props.sdk, props.directory, childStores, messageLoader, routingIndex]) - useEffect(() => () => { - messageLoader.dispose() - childStores.disposeAll() + useEffect(() => { + if (messageLoaderDisposalTimerRef.current) { + clearTimeout(messageLoaderDisposalTimerRef.current) + messageLoaderDisposalTimerRef.current = null + } + messageLoader.activate() + return () => { + // Strict Mode probes effects with setup → cleanup → setup in one task. + // Deferring destruction lets child effects issue their second setup load + // before this provider is installed again and cancels the cleanup. + messageLoaderDisposalTimerRef.current = setTimeout(() => { + messageLoaderDisposalTimerRef.current = null + messageLoader.dispose() + childStores.disposeAll() + }, 0) + } }, [childStores, messageLoader]) // Subscribe to child store for streaming state derivation