fix(sync): preserve loader through Strict Mode probe
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user