authorgravatar for 155588579+spiky02plateau@users.noreply.github.comspiky02plateau <155588579+spiky02plateau@users.noreply.github.com> 2026-08-24 11:59:57+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 09:59:57+00:00
log6f5c951a494e6ed0111222959a4bd9c7c8af6c23
tree043d427bae65a09f4f78f06b8f287da778cc07c4
parent229b05df042f896bbb44660c8524ffe222b85b8d
signature Signed by PGP key B5690EEEBB952194

fix(web): compare client and server versions as semver, not strings (#7579)


2 files changed, 90 insertions(+), 16 deletions(-)

apps/web/src/versionSkew.test.ts+61-11
......@@ -1,5 +1,10 @@
11import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4// Pinned so the direction cases below read as fixed versions instead of
5// arithmetic on whatever version this checkout happens to be at.
6const branding = vi.hoisted(() => ({ APP_VERSION: "0.0.34" }));
7vi.mock("./branding", () => branding);
38
49import { APP_VERSION } from "./branding";
510import {
......@@ -13,19 +18,64 @@ import {
1318 serverUpdateGuidance,
1419} from "./versionSkew";
1520
21const MISMATCH_HINT =
22 "Version mismatch. Try syncing the client and server to the same T3 Code version.";
23
1624describe("versionSkew", () => {
25 beforeEach(() => {
26 branding.APP_VERSION = "0.0.34";
27 });
28
1729 it("does not warn when versions match", () => {
1830 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();
1931 });
2032
21 it("returns a mismatch when the server version differs from the client", () => {
22 expect(resolveVersionMismatch("9.9.9")).toEqual({
23 clientVersion: APP_VERSION,
24 serverVersion: "9.9.9",
25 hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.",
33 it("returns a mismatch when the server is behind the client", () => {
34 expect(resolveVersionMismatch("0.0.33")).toEqual({
35 clientVersion: "0.0.34",
36 serverVersion: "0.0.33",
37 hint: MISMATCH_HINT,
2638 });
2739 });
2840
41 it("does not warn when the server is ahead of the client", () => {
42 expect(resolveVersionMismatch("0.0.35")).toBeNull();
43 expect(resolveVersionMismatch("9.9.9")).toBeNull();
44 });
45
46 it("does not warn when a nightly and a stable build share a core version", () => {
47 expect(resolveVersionMismatch("0.0.34-nightly.20260818.1124")).toBeNull();
48
49 branding.APP_VERSION = "0.0.34-nightly.20260818.1124";
50 expect(resolveVersionMismatch("0.0.34")).toBeNull();
51 });
52
53 it("treats a nightly server built past the client as ahead, not skew", () => {
54 expect(resolveVersionMismatch("0.0.35-nightly.20260818.1124")).toBeNull();
55 });
56
57 it("still warns when a nightly client outruns the server by a release", () => {
58 branding.APP_VERSION = "0.0.35-nightly.20260818.1124";
59
60 expect(resolveVersionMismatch("0.0.34")).toEqual({
61 clientVersion: "0.0.35-nightly.20260818.1124",
62 serverVersion: "0.0.34",
63 hint: MISMATCH_HINT,
64 });
65 });
66
67 it("falls back to string inequality when a version is not semver", () => {
68 expect(resolveVersionMismatch("dev")).toEqual({
69 clientVersion: "0.0.34",
70 serverVersion: "dev",
71 hint: MISMATCH_HINT,
72 });
73
74 branding.APP_VERSION = "dev";
75 expect(resolveVersionMismatch("dev")).toBeNull();
76 expect(resolveVersionMismatch("0.0.34")).toMatchObject({ serverVersion: "0.0.34" });
77 });
78
2979 it("reads the server version from config descriptors", () => {
3080 expect(
3181 resolveServerConfigVersionMismatch({
......@@ -36,14 +86,14 @@ describe("versionSkew", () => {
3686 os: "darwin",
3787 arch: "arm64",
3888 },
39 serverVersion: "9.9.9",
89 serverVersion: "0.0.33",
4090 capabilities: {
4191 repositoryIdentity: true,
4292 },
4393 },
4494 }),
4595 ).toMatchObject({
46 serverVersion: "9.9.9",
96 serverVersion: "0.0.33",
4797 });
4898 });
4999
......@@ -70,11 +120,11 @@ describe("versionSkew", () => {
70120 ).toBe(false);
71121 });
72122
73 it("appends a hint to connection errors when versions differ", () => {
74 const mismatch = resolveVersionMismatch("9.9.9");
123 it("appends a hint to connection errors when the server is behind", () => {
124 const mismatch = resolveVersionMismatch("0.0.33");
75125
76126 expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe(
77 "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.",
127 `Socket closed. Hint: ${MISMATCH_HINT}`,
78128 );
79129 });
80130
apps/web/src/versionSkew.ts+29-5
......@@ -1,4 +1,5 @@
11import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";
2import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
23import * as Schema from "effect/Schema";
34
45import { APP_VERSION } from "./branding";
......@@ -23,16 +24,39 @@ function normalizeVersion(version: string | null | undefined): string | null {
2324 return trimmed && trimmed.length > 0 ? trimmed : null;
2425}
2526
27/** Core `major.minor.patch`, dropping any prerelease or build suffix. */
28function versionCore(version: string): string {
29 return version.replace(/[-+].*$/, "");
30}
31
32/**
33 * The skew a user can act on: the connected server runs an older T3 Code than
34 * this client, so the server is the side that needs updating.
35 *
36 * Versions compare as semver on their core `major.minor.patch` only. Nightlies
37 * are `<core>-nightly.<date>.<run>` builds of the release they precede, so a
38 * stable client on a nightly server (or the reverse, at the same core) shares a
39 * wire contract and is not skew. A server *ahead* of the client is not skew
40 * either: the client is the stale side, and every consumer of this result tells
41 * the user to update their server. Versions that do not parse as semver fall
42 * back to plain string inequality.
43 */
2644export function resolveVersionMismatch(
2745 serverVersion: string | null | undefined,
2846): VersionMismatch | null {
2947 const normalizedClientVersion = normalizeVersion(APP_VERSION);
3048 const normalizedServerVersion = normalizeVersion(serverVersion);
31 if (
32 !normalizedClientVersion ||
33 !normalizedServerVersion ||
34 normalizedClientVersion === normalizedServerVersion
35 ) {
49 if (!normalizedClientVersion || !normalizedServerVersion) {
50 return null;
51 }
52
53 const clientCore = versionCore(normalizedClientVersion);
54 const serverCore = versionCore(normalizedServerVersion);
55 const serverIsBehind =
56 parseSemver(clientCore) && parseSemver(serverCore)
57 ? compareSemverVersions(serverCore, clientCore) < 0
58 : normalizedServerVersion !== normalizedClientVersion;
59 if (!serverIsBehind) {
3660 return null;
3761 }
3862