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.
|
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.
|
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.
|
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.
|
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(
|
ensure(
|
||||||
target: SessionMessageTarget,
|
target: SessionMessageTarget,
|
||||||
options?: { force?: boolean; reason?: "navigation" | "reactive" | "prefetch" },
|
options?: { force?: boolean; reason?: "navigation" | "reactive" | "prefetch" },
|
||||||
|
|||||||
@@ -1781,6 +1781,7 @@ export function SyncProvider(props: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const messageLoader = messageLoaderRef.current
|
const messageLoader = messageLoaderRef.current
|
||||||
|
const messageLoaderDisposalTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
messageLoader.configure({ sdk: props.sdk, runtimeKey })
|
messageLoader.configure({ sdk: props.sdk, runtimeKey })
|
||||||
const routingIndexRef = useRef<EventRoutingIndex | null>(null)
|
const routingIndexRef = useRef<EventRoutingIndex | null>(null)
|
||||||
if (!routingIndexRef.current) routingIndexRef.current = createEventRoutingIndex()
|
if (!routingIndexRef.current) routingIndexRef.current = createEventRoutingIndex()
|
||||||
@@ -2242,9 +2243,22 @@ export function SyncProvider(props: {
|
|||||||
}
|
}
|
||||||
}, [props.sdk, props.directory, childStores, messageLoader, routingIndex])
|
}, [props.sdk, props.directory, childStores, messageLoader, routingIndex])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => {
|
||||||
messageLoader.dispose()
|
if (messageLoaderDisposalTimerRef.current) {
|
||||||
childStores.disposeAll()
|
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])
|
}, [childStores, messageLoader])
|
||||||
|
|
||||||
// Subscribe to child store for streaming state derivation
|
// Subscribe to child store for streaming state derivation
|
||||||
|
|||||||
Reference in New Issue
Block a user