refactor(settings): serialize dynamic refreshes

This commit is contained in:
murashit 2026-07-21 10:12:12 +09:00
parent bbe311c4de
commit f3d987ee03
2 changed files with 16 additions and 38 deletions

View file

@ -34,7 +34,6 @@ interface SettingsDynamicSectionsSnapshot {
export class SettingsDynamicSectionsController {
private readonly lifetime = new OwnerLifetime();
private dynamicSectionsAutoLoadStarted = false;
private modelsOperationToken = 0;
private archivedThreadsOperationToken = 0;
private hookMutationOperation: object | null = null;
@ -69,7 +68,6 @@ export class SettingsDynamicSectionsController {
replaceDynamicData(next: SettingsDynamicDataAccess): void {
if (next === this.dynamicData) return;
this.unsubscribe();
this.modelsOperationToken += 1;
this.archivedThreadsOperationToken += 1;
this.hookMutationOperation = null;
this.dynamicData = next;
@ -131,7 +129,6 @@ export class SettingsDynamicSectionsController {
if (this.archivedThreadsLifecycle.kind === "loading") {
this.archivedThreadsLifecycle = createSettingsDynamicSectionLifecycle();
}
this.modelsOperationToken += 1;
this.archivedThreadsOperationToken += 1;
this.unsubscribe();
}
@ -166,10 +163,9 @@ export class SettingsDynamicSectionsController {
async refreshDynamicSections(options: { forceModels?: boolean } = {}): Promise<void> {
const lifetime = this.lifetime.signal();
if (!this.lifetime.isCurrent(lifetime)) return;
if (!this.lifetime.isCurrent(lifetime) || this.isLoading()) return;
const dynamicData = this.dynamicData;
this.dynamicSectionsAutoLoadStarted = true;
const modelsOperationToken = this.nextModelsOperationToken();
const archivedThreadsOperationToken = this.nextArchivedThreadsOperationToken();
this.modelsLifecycle = settingsDynamicSectionLoading("Loading models...");
this.archivedThreadsLifecycle = settingsDynamicSectionLoading("Loading archived threads...");
@ -185,9 +181,7 @@ export class SettingsDynamicSectionsController {
] as const);
if (!this.lifetime.isCurrent(lifetime) || !this.dynamicDataIsCurrent(dynamicData)) return;
if (this.isStaleModelsOperation(modelsOperationToken)) {
// A newer models operation owns this section.
} else if (modelsResult.status === "fulfilled") {
if (modelsResult.status === "fulfilled") {
this.models = [...modelsResult.value];
this.modelsLifecycle = settingsDynamicSectionLoaded(
`Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`,
@ -221,9 +215,7 @@ export class SettingsDynamicSectionsController {
if (!this.lifetime.isCurrent(lifetime) || !this.dynamicDataIsCurrent(dynamicData)) return;
failedCount = 3;
const message = errorMessage(error);
if (!this.isStaleModelsOperation(modelsOperationToken)) {
this.modelsLifecycle = settingsDynamicSectionFailed(`Could not load models: ${message}`);
}
this.modelsLifecycle = settingsDynamicSectionFailed(`Could not load models: ${message}`);
this.hooksLifecycle = settingsDynamicSectionFailed(`Could not load hooks: ${message}`);
if (!this.isStaleArchivedThreadsOperation(archivedThreadsOperationToken)) {
this.archivedThreadsLifecycle = settingsDynamicSectionFailed(`Could not load archived threads: ${message}`);
@ -382,20 +374,11 @@ export class SettingsDynamicSectionsController {
}
}
private nextModelsOperationToken(): number {
this.modelsOperationToken += 1;
return this.modelsOperationToken;
}
private nextArchivedThreadsOperationToken(): number {
this.archivedThreadsOperationToken += 1;
return this.archivedThreadsOperationToken;
}
private isStaleModelsOperation(operationToken: number): boolean {
return operationToken !== this.modelsOperationToken;
}
private isStaleArchivedThreadsOperation(operationToken: number): boolean {
return operationToken !== this.archivedThreadsOperationToken;
}

View file

@ -63,19 +63,12 @@ describe("SettingsDynamicSectionsController", () => {
expect(display).toHaveBeenCalledOnce();
});
it("ignores stale dynamic sections refresh results after a newer refresh completes", async () => {
it("ignores duplicate dynamic section refreshes while one is loading", async () => {
const firstModels = deferred<ModelMetadata[]>();
const firstClient = settingsClient();
const secondClient = settingsClient({ models: [model("gpt-new")] });
useShortLivedClients(firstClient, secondClient);
const refreshModels = vi
.fn()
.mockReturnValueOnce(firstModels.promise)
.mockResolvedValueOnce(modelMetadataFromCatalogModels([model("gpt-new")]));
const refreshArchived = vi
.fn()
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old", archived: true })])
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New", archived: true })]);
useShortLivedClients(firstClient);
const refreshModels = vi.fn(() => firstModels.promise);
const refreshArchived = vi.fn().mockResolvedValue([panelThread({ id: "thread-old", preview: "Old", archived: true })]);
const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), {
display: vi.fn(),
notify: vi.fn(),
@ -85,14 +78,15 @@ describe("SettingsDynamicSectionsController", () => {
await flushPromises();
await controller.refreshDynamicSections();
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
expect(refreshModels).toHaveBeenCalledOnce();
expect(refreshArchived).toHaveBeenCalledOnce();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledOnce();
firstModels.resolve(modelMetadataFromCatalogModels([model("gpt-old")]));
await firstRefresh;
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-old"]);
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["Old"]);
});
it("does not display dynamic refresh results after disposal", async () => {
@ -204,7 +198,7 @@ describe("SettingsDynamicSectionsController", () => {
expect(notify).not.toHaveBeenCalled();
});
it("publishes stale-view archived restore facts without replacing a newer section result", async () => {
it("ignores refresh requests while an archived thread operation is loading", async () => {
const staleRestore = deferred<{ thread: ThreadRecord }>();
const applyThreadCatalogEvent = vi.fn();
const initialClient = settingsClient();
@ -227,7 +221,8 @@ describe("SettingsDynamicSectionsController", () => {
await flushPromises();
await controller.refreshDynamicSections();
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
expect(refreshArchived).toHaveBeenCalledOnce();
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["Old archived"]);
staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) });
await restore;
@ -236,7 +231,7 @@ describe("SettingsDynamicSectionsController", () => {
type: "thread-restored",
thread: expect.objectContaining({ id: "thread-old", preview: "Restored old", archived: false }),
});
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
expect(controller.snapshot().archivedThreads).toEqual([]);
});
it("serializes conflicting archived mutations and publishes their facts in order", async () => {