Skip to content

Commit 8f4ae5b

Browse files
fix: Optimistically update the UI when a workspace action is triggered (#4898)
1 parent 55fe26b commit 8f4ae5b

File tree

2 files changed

+81
-18
lines changed

2 files changed

+81
-18
lines changed

site/src/pages/WorkspacePage/WorkspacePage.test.tsx

+13-11
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,6 @@ afterAll(() => {
9292
})
9393

9494
describe("WorkspacePage", () => {
95-
it("requests a stop job when the user presses Stop", async () => {
96-
const stopWorkspaceMock = jest
97-
.spyOn(api, "stopWorkspace")
98-
.mockResolvedValueOnce(MockWorkspaceBuild)
99-
testButton(
100-
t("actionButton.stop", { ns: "workspacePage" }),
101-
stopWorkspaceMock,
102-
)
103-
})
104-
10595
it("requests a delete job when the user presses Delete and confirms", async () => {
10696
const user = userEvent.setup()
10797
const deleteWorkspaceMock = jest
@@ -140,11 +130,23 @@ describe("WorkspacePage", () => {
140130
const startWorkspaceMock = jest
141131
.spyOn(api, "startWorkspace")
142132
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
143-
testButton(
133+
await testButton(
144134
t("actionButton.start", { ns: "workspacePage" }),
145135
startWorkspaceMock,
146136
)
147137
})
138+
139+
it("requests a stop job when the user presses Stop", async () => {
140+
const stopWorkspaceMock = jest
141+
.spyOn(api, "stopWorkspace")
142+
.mockResolvedValueOnce(MockWorkspaceBuild)
143+
144+
await testButton(
145+
t("actionButton.stop", { ns: "workspacePage" }),
146+
stopWorkspaceMock,
147+
)
148+
})
149+
148150
it("requests cancellation when the user presses Cancel", async () => {
149151
server.use(
150152
rest.get(

site/src/xServices/workspace/workspaceXService.ts

+68-7
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,27 @@ const moreBuildsAvailable = (
4040
return event.data.latest_build.updated_at !== latestBuildInTimeline.updated_at
4141
}
4242

43+
const updateWorkspaceStatus = (
44+
status: TypesGen.WorkspaceStatus,
45+
workspace?: TypesGen.Workspace,
46+
) => {
47+
if (!workspace) {
48+
throw new Error("Workspace not defined")
49+
}
50+
51+
return {
52+
...workspace,
53+
latest_build: {
54+
...workspace.latest_build,
55+
status,
56+
},
57+
}
58+
}
59+
60+
const isUpdated = (newDateStr: string, oldDateStr: string): boolean => {
61+
return new Date(oldDateStr).getTime() - new Date(newDateStr).getTime() > 0
62+
}
63+
4364
const Language = {
4465
getTemplateWarning:
4566
"Error updating workspace: latest template could not be fetched.",
@@ -252,6 +273,7 @@ export const workspaceMachine = createMachine(
252273
on: {
253274
REFRESH_WORKSPACE: {
254275
actions: ["refreshWorkspace"],
276+
cond: "hasUpdates",
255277
},
256278
EVENT_SOURCE_ERROR: {
257279
target: "error",
@@ -325,7 +347,7 @@ export const workspaceMachine = createMachine(
325347
},
326348
},
327349
requestingStart: {
328-
entry: "clearBuildError",
350+
entry: ["clearBuildError", "updateStatusToStarting"],
329351
invoke: {
330352
src: "startWorkspace",
331353
id: "startWorkspace",
@@ -344,7 +366,7 @@ export const workspaceMachine = createMachine(
344366
},
345367
},
346368
requestingStop: {
347-
entry: "clearBuildError",
369+
entry: ["clearBuildError", "updateStatusToStopping"],
348370
invoke: {
349371
src: "stopWorkspace",
350372
id: "stopWorkspace",
@@ -363,7 +385,7 @@ export const workspaceMachine = createMachine(
363385
},
364386
},
365387
requestingDelete: {
366-
entry: "clearBuildError",
388+
entry: ["clearBuildError", "updateStatusToDeleting"],
367389
invoke: {
368390
src: "deleteWorkspace",
369391
id: "deleteWorkspace",
@@ -382,7 +404,11 @@ export const workspaceMachine = createMachine(
382404
},
383405
},
384406
requestingCancel: {
385-
entry: ["clearCancellationMessage", "clearCancellationError"],
407+
entry: [
408+
"clearCancellationMessage",
409+
"clearCancellationError",
410+
"updateStatusToCanceling",
411+
],
386412
invoke: {
387413
src: "cancelWorkspace",
388414
id: "cancelWorkspace",
@@ -430,9 +456,7 @@ export const workspaceMachine = createMachine(
430456
on: {
431457
REFRESH_TIMELINE: {
432458
target: "#workspaceState.ready.timeline.gettingBuilds",
433-
cond: {
434-
type: "moreBuildsAvailable",
435-
},
459+
cond: "moreBuildsAvailable",
436460
},
437461
},
438462
},
@@ -599,9 +623,46 @@ export const workspaceMachine = createMachine(
599623
}),
600624
{ to: "scheduleBannerMachine" },
601625
),
626+
// Optimistically updates. So when the user clicks on stop, we can show
627+
// the "stopping" state right away without having to wait 0.5s ~ 2s to
628+
// display the visual feedback to the user.
629+
updateStatusToStarting: assign({
630+
workspace: ({ workspace }) =>
631+
updateWorkspaceStatus("starting", workspace),
632+
}),
633+
updateStatusToStopping: assign({
634+
workspace: ({ workspace }) =>
635+
updateWorkspaceStatus("stopping", workspace),
636+
}),
637+
updateStatusToDeleting: assign({
638+
workspace: ({ workspace }) =>
639+
updateWorkspaceStatus("deleting", workspace),
640+
}),
641+
updateStatusToCanceling: assign({
642+
workspace: ({ workspace }) =>
643+
updateWorkspaceStatus("canceling", workspace),
644+
}),
602645
},
603646
guards: {
604647
moreBuildsAvailable,
648+
// We only want to update the workspace when there are changes to it to
649+
// avoid re-renderings and allow optimistically updates to improve the UI.
650+
// When updating the workspace every second, the optimistic updates that
651+
// were applied before get lost since it will be rewrite.
652+
hasUpdates: ({ workspace }, event: { data: TypesGen.Workspace }) => {
653+
if (!workspace) {
654+
throw new Error("Workspace not defined")
655+
}
656+
const isWorkspaceUpdated = isUpdated(
657+
event.data.updated_at,
658+
workspace.updated_at,
659+
)
660+
const isBuildUpdated = isUpdated(
661+
event.data.latest_build.updated_at,
662+
workspace.latest_build.updated_at,
663+
)
664+
return isWorkspaceUpdated || isBuildUpdated
665+
},
605666
},
606667
services: {
607668
getWorkspace: async (_, event) => {

0 commit comments

Comments
 (0)