fix(sync): preserve loader through Strict Mode probe

This commit is contained in:
Bohdan Triapitsyn
2026-07-30 17:43:39 +03:00
parent 7a16290ff3
commit ededdc14f9
4 changed files with 52 additions and 3 deletions
+1
View File
@@ -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.
@@ -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')
})
@@ -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" },
+17 -3
View File
@@ -1781,6 +1781,7 @@ export function SyncProvider(props: {
})
}
const messageLoader = messageLoaderRef.current
const messageLoaderDisposalTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
messageLoader.configure({ sdk: props.sdk, runtimeKey })
const routingIndexRef = useRef<EventRoutingIndex | null>(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