diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index d6d7434769e4..27492004229a 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -3,6 +3,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { describe, expect, it } from "vite-plus/test"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { ServerBootReload } from "./components/ServerBootReload"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -16,9 +17,10 @@ describe("AppRoot", () => { const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(3); - expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); + expect(children).toHaveLength(4); + expect(isValidElement(children[0]) && children[0].type).toBe(ServerBootReload); + expect(isValidElement(children[1]) && children[1].type).toBe(RouterProvider); + expect(isValidElement(children[2]) && children[2].type).toBe(PreviewAutomationHosts); + expect(isValidElement(children[3]) && children[3].type).toBe(ElectronBrowserHost); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index b1fd21f84fa9..d07c4ef2c8eb 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -4,6 +4,7 @@ import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; +import { ServerBootReload } from "./components/ServerBootReload"; /** * Owns renderer-wide providers. The Electron browser host intentionally sits @@ -13,6 +14,7 @@ import type { AppRouter } from "./router"; export function AppRoot({ router }: { readonly router: AppRouter }) { return ( + diff --git a/apps/web/src/components/ServerBootReload.tsx b/apps/web/src/components/ServerBootReload.tsx new file mode 100644 index 000000000000..f2b93113879a --- /dev/null +++ b/apps/web/src/components/ServerBootReload.tsx @@ -0,0 +1,20 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useEffect, useRef } from "react"; + +import { INITIAL_SERVER_BOOT_RELOAD_STATE, observeServerBoot } from "../serverBootReload"; +import { primaryServerReadyAtom } from "../state/server"; + +export function ServerBootReload() { + const bootIdentity = useAtomValue(primaryServerReadyAtom)?.at ?? null; + const stateRef = useRef(INITIAL_SERVER_BOOT_RELOAD_STATE); + + useEffect(() => { + if (bootIdentity === null) return; + + const transition = observeServerBoot(stateRef.current, bootIdentity); + stateRef.current = transition.state; + if (transition.shouldReload) window.location.reload(); + }, [bootIdentity]); + + return null; +} diff --git a/apps/web/src/serverBootReload.test.ts b/apps/web/src/serverBootReload.test.ts new file mode 100644 index 000000000000..daf477195ef8 --- /dev/null +++ b/apps/web/src/serverBootReload.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { INITIAL_SERVER_BOOT_RELOAD_STATE, observeServerBoot } from "./serverBootReload"; + +function observeAll(bootIdentities: ReadonlyArray) { + let state = INITIAL_SERVER_BOOT_RELOAD_STATE; + let reloads = 0; + for (const bootIdentity of bootIdentities) { + const transition = observeServerBoot(state, bootIdentity); + state = transition.state; + if (transition.shouldReload) reloads += 1; + } + return reloads; +} + +describe("observeServerBoot", () => { + it("does not reload when reconnecting to the same server boot", () => { + expect(observeAll(["boot-a", "boot-a"])).toBe(0); + }); + + it("reloads exactly once when the server boot changes", () => { + expect(observeAll(["boot-a", "boot-b"])).toBe(1); + }); + + it("does not loop when either boot identity is delivered repeatedly", () => { + expect(observeAll(["boot-a", "boot-a", "boot-b", "boot-b", "boot-a"])).toBe(1); + }); +}); diff --git a/apps/web/src/serverBootReload.ts b/apps/web/src/serverBootReload.ts new file mode 100644 index 000000000000..bf09773e1b10 --- /dev/null +++ b/apps/web/src/serverBootReload.ts @@ -0,0 +1,33 @@ +export interface ServerBootReloadState { + readonly initialBootIdentity: string | null; + readonly reloadPending: boolean; +} + +export interface ServerBootReloadTransition { + readonly state: ServerBootReloadState; + readonly shouldReload: boolean; +} + +export const INITIAL_SERVER_BOOT_RELOAD_STATE: ServerBootReloadState = { + initialBootIdentity: null, + reloadPending: false, +}; + +export function observeServerBoot( + state: ServerBootReloadState, + bootIdentity: string, +): ServerBootReloadTransition { + if (state.initialBootIdentity === null) { + return { + state: { initialBootIdentity: bootIdentity, reloadPending: false }, + shouldReload: false, + }; + } + if (state.reloadPending || state.initialBootIdentity === bootIdentity) { + return { state, shouldReload: false }; + } + return { + state: { ...state, reloadPending: true }, + shouldReload: true, + }; +} diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 1071d8209dfe..543566f4682d 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -3,6 +3,7 @@ import { type EditorId, type ServerConfig, type ServerConfigStreamEvent, + type ServerLifecycleReadyPayload, type ServerLifecycleWelcomePayload, type ServerProvider, type ServerSettings, @@ -29,6 +30,7 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ interface PrimaryServerState { readonly config: ServerConfig | null; readonly latestEvent: ServerConfigStreamEvent | null; + readonly ready: ServerLifecycleReadyPayload | null; readonly welcome: ServerLifecycleWelcomePayload | null; } @@ -37,6 +39,7 @@ export const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { config: null, latestEvent: null, + ready: null, welcome: null, }; @@ -51,10 +54,12 @@ export const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { AsyncResult.value(get(serverEnvironment.configProjection(target))), ); const welcome = Option.getOrNull(AsyncResult.value(get(serverEnvironment.welcome(target)))); + const ready = Option.getOrNull(AsyncResult.value(get(serverEnvironment.ready(target)))); return { config: get(serverEnvironment.configValueAtom(environmentId)), latestEvent: configProjection?.latestEvent ?? null, + ready, welcome, }; }).pipe(Atom.withLabel("web-primary-server-state")); @@ -71,6 +76,10 @@ export const primaryServerWelcomeAtom = Atom.make( (get): ServerLifecycleWelcomePayload | null => get(primaryServerStateAtom).welcome, ).pipe(Atom.withLabel("web-primary-server-welcome")); +export const primaryServerReadyAtom = Atom.make( + (get): ServerLifecycleReadyPayload | null => get(primaryServerStateAtom).ready, +).pipe(Atom.withLabel("web-primary-server-ready")); + export const primaryServerSettingsAtom = Atom.make( (get): ServerSettings => get(primaryServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS, ).pipe(Atom.withLabel("web-primary-server-settings")); diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 299e3b641f17..03c849f8a398 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -31,6 +31,9 @@ npx t3@latest service uninstall Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. If a remote update is already in progress, wait for it to finish before retrying a local update. +An open web app reconnects and reloads itself after the server restarts so it uses the server's +current frontend. Brief connection interruptions that do not restart the server do not reload the +page. The systemd unit runs a small stable launcher. Exact T3 Code versions are installed separately, so a failed remote candidate can return to the previous version without rewriting the unit. The diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 8edecae5646e..04f9687d1f23 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, type ServerConfig, type ServerConfigStreamEvent, + type ServerLifecycleReadyPayload, type ServerLifecycleWelcomePayload, WS_METHODS, } from "@t3tools/contracts"; @@ -35,6 +36,7 @@ import { isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, nudgeReconnectDuringUpdateRestart, + projectServerReady, projectServerWelcome, resolveServerConfigValue, resolveServerUpdateProgressResult, @@ -301,6 +303,26 @@ describe("server state projection", () => { expect(emitted).toEqual([]); }); + it("projects ready events from the lifecycle stream", () => { + const ready = { + at: "2026-08-12T12:00:00.000Z", + environment: {} as ServerLifecycleReadyPayload["environment"], + } as ServerLifecycleReadyPayload; + const [afterWelcome, welcomeEmitted] = projectServerReady(Option.none(), { + type: "welcome", + payload: {}, + }); + const [afterReady, readyEmitted] = projectServerReady(afterWelcome, { + type: "ready", + payload: ready, + }); + + expect(Option.isNone(afterWelcome)).toBe(true); + expect(welcomeEmitted).toEqual([]); + expect(Option.getOrThrow(afterReady)).toBe(ready); + expect(readyEmitted).toEqual([ready]); + }); + it("prefers an active session config over cache until a live event arrives", () => { const config = (source: string, serverVersion: string) => ({ diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..5a769a768537 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -2,6 +2,7 @@ import { type EnvironmentId, type ServerConfig, type ServerConfigStreamEvent, + type ServerLifecycleReadyPayload, type ServerLifecycleWelcomePayload, type ServerLifecycleStreamReadyEvent, type ServerSelfUpdateProgressEvent, @@ -442,6 +443,23 @@ export function projectServerWelcome( return [Option.some(welcome), [welcome]]; } +export function projectServerReady( + current: Option.Option, + event: { + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }, +): readonly [ + Option.Option, + ReadonlyArray, +] { + if (event.type !== "ready") { + return [current, []]; + } + const ready = event.payload as ServerLifecycleReadyPayload; + return [Option.some(ready), [ready]]; +} + export function resolveServerConfigValue( projection: ServerConfigProjection | null, initialConfig: ServerConfig | null, @@ -723,6 +741,12 @@ export function createServerEnvironmentAtoms( Stream.mapAccum(Option.none, projectServerWelcome), ), }), + ready: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:ready", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => + stream.pipe(Stream.mapAccum(Option.none, projectServerReady)), + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders,