Skip to content

Commit 3a574ab

Browse files
committed
fix: ignore language from out-of-order state snapshots
1 parent 19e7fd0 commit 3a574ab

6 files changed

Lines changed: 134 additions & 1 deletion

File tree

‎packages/types/src/vscode-extension-host.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,13 @@ export type ExtensionState = Pick<
448448
* (captured during async getStateToPostToWebview) from overwriting newer messages.
449449
*/
450450
clineMessagesSeq?: number
451+
452+
/**
453+
* Monotonically increasing sequence number for every state snapshot, assigned before the
454+
* snapshot is read. Snapshots are built asynchronously and can be posted out of order, so the
455+
* frontend applies `language` only from a snapshot newer than the last one it applied.
456+
*/
457+
stateSeq?: number
451458
}
452459

453460
export interface Command {

‎src/core/webview/ClineProvider.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,12 @@ export class ClineProvider
350350
*/
351351
private clineMessagesSeq = 0
352352

353+
/**
354+
* Monotonically increasing sequence number for every state snapshot.
355+
* Used by the frontend to ignore the language from snapshots that arrive out-of-order.
356+
*/
357+
private stateSeq = 0
358+
353359
public isViewLaunched = false
354360
public settingsImportedAt?: number
355361
public readonly latestAnnouncementId = "oct-2026-v3.94.0-models-oauth-chat" // v3.94.0 model support, MCP OAuth, and chat reliability
@@ -2793,6 +2799,8 @@ export class ClineProvider
27932799
}
27942800

27952801
async getStateToPostToWebview({ includeTaskHistory = true }: GetStateOptions = {}): Promise<ExtensionState> {
2802+
// Assign the sequence before the first await so it orders snapshots by when they were read.
2803+
const stateSeq = ++this.stateSeq
27962804
// Ensure the store is initialized before reading task history
27972805
await this.taskHistoryStore.initialized
27982806

@@ -3034,6 +3042,7 @@ export class ClineProvider
30343042
showRooIgnoredFiles: showRooIgnoredFiles ?? false,
30353043
enableSubfolderRules: enableSubfolderRules ?? false,
30363044
language: language ?? formatLanguage(vscode.env.language),
3045+
stateSeq,
30373046
renderContext: this.renderContext,
30383047
maxImageFileSize: maxImageFileSize ?? 5,
30393048
maxTotalImageSize: maxTotalImageSize ?? 20,

‎src/core/webview/__tests__/ClineProvider.spec.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,42 @@ describe("ClineProvider", () => {
14651465
expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessagesSeq)).toEqual([2, 1])
14661466
})
14671467

1468+
test.each([
1469+
["postStateToWebview", (currentProvider: ClineProvider) => currentProvider.postStateToWebview()],
1470+
[
1471+
"postStateToWebviewWithoutTaskHistory",
1472+
(currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(),
1473+
],
1474+
[
1475+
"postStateToWebviewWithoutClineMessages",
1476+
(currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutClineMessages(),
1477+
],
1478+
])("%s orders snapshots by when they were read, not when they were posted", async (_methodName, postState) => {
1479+
const baseState = await provider.getState({ includeTaskHistory: false })
1480+
let releaseOlderRead!: () => void
1481+
const olderRead = new Promise<void>((resolve) => {
1482+
releaseOlderRead = resolve
1483+
})
1484+
vi.spyOn(provider, "getState")
1485+
.mockImplementationOnce(async () => {
1486+
await olderRead
1487+
return { ...baseState, language: "en" }
1488+
})
1489+
.mockResolvedValueOnce({ ...baseState, language: "es" })
1490+
const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined)
1491+
1492+
const olderPost = postState(provider)
1493+
await Promise.resolve()
1494+
await postState(provider)
1495+
releaseOlderRead()
1496+
await olderPost
1497+
1498+
const posted = postMessageSpy.mock.calls.map(([message]) => message.state)
1499+
expect(posted.map((state) => state?.language)).toEqual(["es", "en"])
1500+
const [newer, older] = posted.map((state) => state?.stateSeq ?? 0)
1501+
expect(older).toBeLessThan(newer)
1502+
})
1503+
14681504
test.each([
14691505
[
14701506
"postStateToWebviewWithoutTaskHistory",

‎webview-ui/src/context/ExtensionStateContext.tsx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ const messagesAreStale = (incoming: Partial<ExtensionState>, currentSequence: nu
167167
currentSequence !== undefined &&
168168
incoming.clineMessagesSeq <= currentSequence
169169

170+
const snapshotIsStale = (incoming: Partial<ExtensionState>, currentSequence: number | undefined): boolean =>
171+
incoming.stateSeq !== undefined && currentSequence !== undefined && incoming.stateSeq <= currentSequence
172+
170173
function internMessages(messages: ClineMessage[], previousMessages: ClineMessage[], cache: StringCache): void {
171174
const previousByTimestamp = new Map(previousMessages.map((item) => [item.ts, item]))
172175
for (const message of messages) {
@@ -222,6 +225,13 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Partial
222225
rest.currentTaskId = newState.currentTaskId
223226
}
224227

228+
// Snapshots are read asynchronously, so an older one can arrive after a newer one. Keep the
229+
// language from the newest snapshot instead of reverting to the one the stale snapshot read.
230+
if (snapshotIsStale(newState, prevState.stateSeq)) {
231+
rest.language = prevState.language
232+
rest.stateSeq = prevState.stateSeq
233+
}
234+
225235
// Note that we completely replace the previous apiConfiguration and customSupportPrompts objects
226236
// with new ones since the state that is broadcast is the entire objects so merging is not necessary.
227237
return {
@@ -328,6 +338,7 @@ export const ExtensionStateContextProvider: React.FC<{
328338
const messagesRef = useRef(initialState?.clineMessages ?? [])
329339
const currentTaskIdRef = useRef(initialState?.currentTaskId)
330340
const clineMessagesSeqRef = useRef(initialState?.clineMessagesSeq)
341+
const stateSeqRef = useRef(initialState?.stateSeq)
331342

332343
const [state, setState] = useState<ExtensionState>(() => {
333344
const mergedState = mergeExtensionState(createInitialExtensionState(), initialState ?? {})
@@ -424,7 +435,10 @@ export const ExtensionStateContextProvider: React.FC<{
424435
if (newState.currentTaskItem) {
425436
historyCache.intern(newState.currentTaskItem)
426437
}
427-
applyLanguage(i18next, newState.language)
438+
if (!snapshotIsStale(newState, stateSeqRef.current)) {
439+
applyLanguage(i18next, newState.language)
440+
stateSeqRef.current = newState.stateSeq ?? stateSeqRef.current
441+
}
428442
setState((prevState) => mergeExtensionState(prevState, newState))
429443
setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated))
430444
setDidHydrateState(true)

‎webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,4 +1111,37 @@ describe("mergeExtensionState", () => {
11111111
expect(result.clineMessagesSeq).toBe(1)
11121112
})
11131113
})
1114+
1115+
describe("stateSeq protection", () => {
1116+
const prevState: ExtensionState = { ...createInitialExtensionState(), language: "es", stateSeq: 5 }
1117+
1118+
it.each([
1119+
["older", 4],
1120+
["equal", 5],
1121+
])("keeps the language when the snapshot seq is %s", (_label, stateSeq) => {
1122+
const result = mergeExtensionState(prevState, { language: "en", stateSeq, soundEnabled: true })
1123+
1124+
expect(result.language).toBe("es")
1125+
expect(result.stateSeq).toBe(5)
1126+
// Only the language is protected; the rest of the snapshot still applies.
1127+
expect(result.soundEnabled).toBe(true)
1128+
})
1129+
1130+
it.each<[string, Partial<ExtensionState>, number]>([
1131+
["newer", { language: "en", stateSeq: 6 }, 6],
1132+
["unsequenced", { language: "en" }, 5],
1133+
])("applies the language from an %s snapshot", (_label, newState, expectedSeq) => {
1134+
const result = mergeExtensionState(prevState, newState)
1135+
1136+
expect(result.language).toBe("en")
1137+
expect(result.stateSeq).toBe(expectedSeq)
1138+
})
1139+
1140+
it("applies the language from the first sequenced snapshot", () => {
1141+
const result = mergeExtensionState(createInitialExtensionState(), { language: "es", stateSeq: 1 })
1142+
1143+
expect(result.language).toBe("es")
1144+
expect(result.stateSeq).toBe(1)
1145+
})
1146+
})
11141147
})

‎webview-ui/src/i18n/__tests__/hydrationLanguage.spec.tsx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,33 @@ describe("hydrated language", () => {
4848
})
4949
})
5050

51+
describe("out-of-order state pushes", () => {
52+
beforeEach(async () => {
53+
await i18next.changeLanguage("en")
54+
})
55+
56+
const LanguageProbe = () => <span data-testid="language">{useExtensionState().language}</span>
57+
58+
it("keeps the newer language when an older snapshot arrives later", async () => {
59+
render(
60+
<ExtensionStateContextProvider>
61+
<LanguageProbe />
62+
</ExtensionStateContextProvider>,
63+
)
64+
65+
await postState({ language: "es", stateSeq: 2 })
66+
await postState({ language: "en", stateSeq: 1 })
67+
68+
expect(i18next.language).toBe("es")
69+
expect(screen.getByTestId("language")).toHaveTextContent("es")
70+
71+
await postState({ language: "en", stateSeq: 3 })
72+
73+
expect(i18next.language).toBe("en")
74+
expect(screen.getByTestId("language")).toHaveTextContent("en")
75+
})
76+
})
77+
5178
describe("applyLanguage", () => {
5279
beforeEach(async () => {
5380
await i18next.changeLanguage("en")
@@ -58,6 +85,13 @@ describe("applyLanguage", () => {
5885
expect(i18next.language).toBe("es")
5986
})
6087

88+
it("keeps the language from the latest of consecutive pushes", () => {
89+
applyLanguage(i18next, "es")
90+
applyLanguage(i18next, "en")
91+
expect(i18next.language).toBe("en")
92+
expect(i18next.t("history:recentTasks")).toBe("Recent Tasks")
93+
})
94+
6195
it("does not re-emit languageChanged for the active language or a missing one", () => {
6296
const onChanged = vi.fn()
6397
i18next.on("languageChanged", onChanged)

0 commit comments

Comments
 (0)