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

Lines changed: 13 additions & 11 deletions
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

Lines changed: 68 additions & 7 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy