-
Notifications
You must be signed in to change notification settings - Fork 136
🤖 feat: show a Restarting screen immediately on Install & restart #4244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
449e2c5
🤖 feat: show a full-screen restarting screen while an update installs
ibetitsmike 7e330f3
🤖 fix: keep the restart screen up during channel switches and dialog …
ibetitsmike 494e51c
🤖 tests: load the boot loader styles in Storybook
ibetitsmike 50dbb7b
🤖 fix: keep the restart screen accessible and classify restart-time e…
ibetitsmike File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
52 changes: 52 additions & 0 deletions
52
src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.stories.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { useState } from "react"; | ||
| import type { Meta, StoryObj } from "@storybook/react-vite"; | ||
| import { expect, within } from "@storybook/test"; | ||
| import { APIProvider } from "@/browser/contexts/API"; | ||
| import { lightweightMeta } from "@/browser/stories/meta"; | ||
| import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; | ||
| import { UpdateRestartOverlay } from "./UpdateRestartOverlay"; | ||
|
|
||
| function RestartOverlayStory() { | ||
| const [client] = useState(() => | ||
| createMockORPCClient({ updateStatus: { type: "restarting", info: { version: "0.29.0" } } }) | ||
| ); | ||
| return ( | ||
| <APIProvider client={client}> | ||
| <div className="text-foreground space-y-2 p-6"> | ||
| <h1 className="text-lg font-medium">Workspace content</h1> | ||
| <p className="text-muted text-sm"> | ||
| Everything here must stay hidden behind the restart screen. | ||
| </p> | ||
| </div> | ||
| <UpdateRestartOverlay /> | ||
| </APIProvider> | ||
| ); | ||
| } | ||
|
|
||
| const meta: Meta = { | ||
| ...lightweightMeta, | ||
| title: "Components/UpdateRestartOverlay", | ||
| component: RestartOverlayStory, | ||
| }; | ||
| export default meta; | ||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| async function expectOverlayCoversViewport() { | ||
| const overlay = await within(document.body).findByTestId("update-restart-overlay"); | ||
| await expect(within(overlay).getByText("Restarting Xum…")).toBeVisible(); | ||
| const rect = overlay.getBoundingClientRect(); | ||
| await expect(rect.top).toBeLessThanOrEqual(0); | ||
| await expect(rect.left).toBeLessThanOrEqual(0); | ||
| await expect(rect.right).toBeGreaterThanOrEqual(window.innerWidth); | ||
| await expect(rect.bottom).toBeGreaterThanOrEqual(window.innerHeight); | ||
| } | ||
|
|
||
| export const Restarting: Story = { | ||
| play: expectOverlayCoversViewport, | ||
| }; | ||
|
|
||
| export const RestartingPhone: Story = { | ||
| parameters: { pixel: { matrix: { viewports: ["phone"] } } }, | ||
| globals: { viewport: { value: "mobile1", isRotated: false } }, | ||
| play: expectOverlayCoversViewport, | ||
| }; |
159 changes: 159 additions & 0 deletions
159
src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import "../../../../tests/ui/dom"; | ||
|
|
||
| import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; | ||
| import { act, cleanup, render, waitFor } from "@testing-library/react"; | ||
| import type { UpdateStatus } from "@/common/orpc/types"; | ||
| import type * as APIModule from "@/browser/contexts/API"; | ||
| import type { APIClient } from "@/browser/contexts/API"; | ||
| import { installDom } from "../../../../tests/ui/dom"; | ||
| import { ThemeProvider } from "../../contexts/ThemeContext"; | ||
|
|
||
| // SVG ?react imports don't work in happy-dom; stub them as simple svgs. | ||
| void mock.module("@/browser/assets/logos/xum-logo-dark.svg?react", () => ({ | ||
| __esModule: true, | ||
| default: () => <svg data-testid="xum-logo-mock" />, | ||
| })); | ||
| void mock.module("@/browser/assets/logos/xum-logo-light.svg?react", () => ({ | ||
| __esModule: true, | ||
| default: () => <svg data-testid="xum-logo-mock" />, | ||
| })); | ||
|
|
||
| /** Push-driven stand-in for the update.onStatus subscription. */ | ||
| function createStatusStream() { | ||
| const queue: UpdateStatus[] = []; | ||
| let wake: (() => void) | null = null; | ||
| const onStatus = async function* (_input: undefined, options: { signal: AbortSignal }) { | ||
| while (!options.signal.aborted) { | ||
| const next = queue.shift(); | ||
| if (next) { | ||
| yield next; | ||
| continue; | ||
| } | ||
| await new Promise<void>((resolve) => { | ||
| wake = resolve; | ||
| options.signal.addEventListener("abort", () => resolve(), { once: true }); | ||
| }); | ||
| } | ||
| }; | ||
| return { | ||
| api: { update: { onStatus } } as unknown as APIClient, | ||
| push(status: UpdateStatus) { | ||
| queue.push(status); | ||
| wake?.(); | ||
| wake = null; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| let apiState: { api: APIClient | null; status: "connected" | "reconnecting" } = { | ||
| api: null, | ||
| status: "reconnecting", | ||
| }; | ||
|
|
||
| /* eslint-disable @typescript-eslint/no-require-imports */ | ||
| const actualAPI = require("@/browser/contexts/API?real=1") as typeof APIModule; | ||
| /* eslint-enable @typescript-eslint/no-require-imports */ | ||
|
|
||
| // Spread the real module: replacing it outright deletes exports other test files import | ||
| // statically (module mocks are process-wide and persist across files). | ||
| void mock.module("@/browser/contexts/API", () => ({ | ||
| ...actualAPI, | ||
| useAPI: () => ({ | ||
| api: apiState.api, | ||
| status: apiState.status, | ||
| error: null, | ||
| attempt: 1, | ||
| authenticate: () => undefined, | ||
| retry: () => undefined, | ||
| }), | ||
| })); | ||
|
|
||
| import type { UpdateRestartOverlay as UpdateRestartOverlayComponent } from "./UpdateRestartOverlay"; | ||
|
|
||
| // Required after the mocks above so the svg stubs are in place when LoadingScreen evaluates. | ||
| /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment */ | ||
| const { | ||
| UpdateRestartOverlay, | ||
| }: { | ||
| UpdateRestartOverlay: typeof UpdateRestartOverlayComponent; | ||
| } = require("./UpdateRestartOverlay"); | ||
| /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment */ | ||
|
|
||
| const OVERLAY = "update-restart-overlay"; | ||
|
|
||
| function renderOverlay() { | ||
| return render( | ||
| <ThemeProvider> | ||
| <UpdateRestartOverlay /> | ||
| </ThemeProvider> | ||
| ); | ||
| } | ||
|
|
||
| async function flushStream() { | ||
| await act(async () => { | ||
| await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| }); | ||
| } | ||
|
|
||
| let cleanupDom: (() => void) | null = null; | ||
|
|
||
| describe("UpdateRestartOverlay", () => { | ||
| beforeEach(() => { | ||
| cleanupDom = installDom(); | ||
| apiState = { api: null, status: "reconnecting" }; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| cleanup(); | ||
| cleanupDom?.(); | ||
| cleanupDom = null; | ||
| }); | ||
|
|
||
| test("covers the app from the restarting status until a relaunched server reports otherwise", async () => { | ||
| const before = createStatusStream(); | ||
| apiState = { api: before.api, status: "connected" }; | ||
| const view = renderOverlay(); | ||
|
|
||
| before.push({ type: "downloaded", info: { version: "0.29.0" } }); | ||
| await flushStream(); | ||
| expect(view.queryByTestId(OVERLAY)).toBeNull(); | ||
|
|
||
| before.push({ type: "restarting", info: { version: "0.29.0" } }); | ||
| await waitFor(() => expect(view.getByTestId(OVERLAY)).toBeTruthy()); | ||
|
|
||
| // The server goes away while it restarts: the client has no api during reconnects. | ||
| apiState = { api: null, status: "reconnecting" }; | ||
| view.rerender( | ||
| <ThemeProvider> | ||
| <UpdateRestartOverlay /> | ||
| </ThemeProvider> | ||
| ); | ||
| await flushStream(); | ||
| expect(view.getByTestId(OVERLAY)).toBeTruthy(); | ||
|
|
||
| // The relaunched server's first status clears the cover. | ||
| const after = createStatusStream(); | ||
| apiState = { api: after.api, status: "connected" }; | ||
| view.rerender( | ||
| <ThemeProvider> | ||
| <UpdateRestartOverlay /> | ||
| </ThemeProvider> | ||
| ); | ||
| await flushStream(); | ||
| expect(view.getByTestId(OVERLAY)).toBeTruthy(); | ||
| after.push({ type: "idle" }); | ||
| await waitFor(() => expect(view.queryByTestId(OVERLAY)).toBeNull()); | ||
| }); | ||
|
|
||
| test("hides again when the install fails after restarting was announced", async () => { | ||
| const stream = createStatusStream(); | ||
| apiState = { api: stream.api, status: "connected" }; | ||
| const view = renderOverlay(); | ||
|
|
||
| stream.push({ type: "restarting", info: { version: "0.29.0" } }); | ||
| await waitFor(() => expect(view.getByTestId(OVERLAY)).toBeTruthy()); | ||
|
|
||
| stream.push({ type: "error", phase: "install", message: "activation failed" }); | ||
| await waitFor(() => expect(view.queryByTestId(OVERLAY)).toBeNull()); | ||
| }); | ||
| }); |
65 changes: 65 additions & 0 deletions
65
src/browser/components/UpdateRestartOverlay/UpdateRestartOverlay.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { useEffect, useState } from "react"; | ||
| import { createPortal } from "react-dom"; | ||
| import { useAPI } from "@/browser/contexts/API"; | ||
| import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen"; | ||
|
|
||
| /** | ||
| * Full-viewport cover shown from the moment an update install is confirmed (status | ||
| * "restarting") until the relaunched backend reports a different status. It replaces the old | ||
| * UI immediately, while the desktop app is still quitting or the server is tearing down. | ||
| */ | ||
| export function UpdateRestartOverlay() { | ||
| const { api } = useAPI(); | ||
| const [restarting, setRestarting] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| // Deliberately no reset when api is null: the client passes through "reconnecting" with | ||
| // api === null while the server restarts, and the overlay must stay up until the relaunched | ||
| // server's first status event (idle/unsupported) replaces it. | ||
| if (!api) { | ||
| return; | ||
| } | ||
|
|
||
| const controller = new AbortController(); | ||
| const { signal } = controller; | ||
|
|
||
| (async () => { | ||
| try { | ||
| const iterator = await api.update.onStatus(undefined, { signal }); | ||
| for await (const status of iterator) { | ||
| if (signal.aborted) { | ||
| break; | ||
| } | ||
| setRestarting(status.type === "restarting"); | ||
| } | ||
| } catch (error) { | ||
| if (!signal.aborted) { | ||
| console.error("Update status stream error:", error); | ||
| } | ||
| } | ||
| })(); | ||
|
|
||
| return () => { | ||
| controller.abort(); | ||
| }; | ||
| }, [api]); | ||
|
|
||
| if (!restarting) { | ||
| return null; | ||
| } | ||
|
|
||
| // Portaled to <body>, outside the app root: an open Radix modal (the About dialog is the usual | ||
| // install trigger) marks the app root aria-hidden and locks body pointer events, so rendering | ||
| // inside it would hide the status from assistive technology; pointer-events-auto keeps clicks | ||
| // from falling through the cover. Stacked above dialogs, toasts, and menus so nothing from the | ||
| // old UI peeks through. | ||
| return createPortal( | ||
| <div | ||
| className="bg-surface-primary pointer-events-auto fixed inset-0 z-[10002]" | ||
| data-testid="update-restart-overlay" | ||
| > | ||
| <LoadingScreen statusText="Restarting Xum…" /> | ||
| </div>, | ||
| document.body | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.