authorgravatar for snorteman@gmail.comScott Norteman <snorteman@gmail.com> 2026-10-13 01:08:53-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 22:08:53-07:00
log6abccc1f5e4890374327c837098f09f8d7b5179a
tree5567ce52d566f030128c5a22c87c8575ef51570a
parente9221e44dadd78126c83d2eeed0b51c25dcf0fb4
signature Signed by PGP key B5690EEEBB952194

fix(web): update everything includes npm-installed servers again (#18709)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 74 insertions(+), 2 deletions(-)

apps/web/src/state/npmRelease.test.ts created+68
...@@ -0,0 +1,68 @@
1import { AtomRegistry } from "effect/reactivity";
2import * as AsyncResult from "effect/reactivity/AsyncResult";
3import { afterAll, afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { npmReleasePublishedAtom } from "./npmRelease";
6
7const version = "0.0.46-nightly.20261012.3021";
8
9// FetchHttpClient reads globalThis.fetch once and keeps it, so every test shares this mock.
10const fetchMock = vi.fn<typeof fetch>();
11vi.stubGlobal("fetch", fetchMock);
12
13async function settledCheck() {
14 const atom = npmReleasePublishedAtom(version);
15 const registry = AtomRegistry.make();
16 const unmount = registry.mount(atom);
17 try {
18 await vi.waitFor(() => {
19 const result = registry.get(atom);
20 expect(AsyncResult.isInitial(result) || result.waiting).toBe(false);
21 });
22 return registry.get(atom);
23 } finally {
24 unmount();
25 registry.dispose();
26 }
27}
28
29describe("npmReleasePublishedAtom", () => {
30 afterEach(() => {
31 fetchMock.mockReset();
32 });
33
34 afterAll(() => {
35 vi.unstubAllGlobals();
36 });
37
38 it("checks npm without tracing headers, so the browser skips the preflight npm rejects", async () => {
39 fetchMock.mockImplementation(async () => Response.json({ version }));
40
41 const result = await settledCheck();
42
43 expect(fetchMock).toHaveBeenCalledTimes(1);
44 const request = new Request(fetchMock.mock.calls[0]?.[0] ?? "", fetchMock.mock.calls[0]?.[1]);
45 expect(request.url).toBe(`https://registry.npmjs.org/t3/${version}`);
46 expect(request.headers.get("traceparent")).toBeNull();
47 expect(request.headers.get("b3")).toBeNull();
48 expect(AsyncResult.getOrElse(result, () => false)).toBe(true);
49 });
50
51 it("reports a version npm does not have as unpublished", async () => {
52 fetchMock.mockImplementation(async () => new Response(null, { status: 404 }));
53
54 const result = await settledCheck();
55
56 expect(AsyncResult.isSuccess(result)).toBe(true);
57 expect(AsyncResult.getOrElse(result, () => true)).toBe(false);
58 });
59
60 it("leaves a failed first check unknown, which callers read as unpublished", async () => {
61 fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
62
63 const result = await settledCheck();
64
65 expect(AsyncResult.isFailure(result)).toBe(true);
66 expect(AsyncResult.getOrElse(result, () => false)).toBe(false);
67 });
68});
apps/web/src/state/npmRelease.ts+6-2
...@@ -8,7 +8,7 @@ import { AsyncResult, Atom } from "effect/reactivity";...@@ -8,7 +8,7 @@ import { AsyncResult, Atom } from "effect/reactivity";
8 * registry errors stay unknown. Both re-check every few minutes, so a release8 * registry errors stay unknown. Both re-check every few minutes, so a release
9 * that lands later shows up without a reload.9 * that lands later shows up without a reload.
10 */10 */
11const npmReleasePublishedAtom = Atom.family((version: string) =>11export const npmReleasePublishedAtom = Atom.family((version: string) =>
12 Atom.make(12 Atom.make(
13 Effect.gen(function* () {13 Effect.gen(function* () {
14 const response = yield* HttpClient.get(14 const response = yield* HttpClient.get(
...@@ -21,7 +21,11 @@ const npmReleasePublishedAtom = Atom.family((version: string) =>...@@ -21,7 +21,11 @@ const npmReleasePublishedAtom = Atom.family((version: string) =>
21 return typeof body === "object" && body !== null && "version" in body21 return typeof body === "object" && body !== null && "version" in body
22 ? body.version === version22 ? body.version === version
23 : false;23 : false;
24 }).pipe(Effect.provide(FetchHttpClient.layer)),24 }).pipe(
25 // Tracing headers would make the browser send a CORS preflight, which npm rejects.
26 Effect.provideService(HttpClient.TracerPropagationEnabled, false),
27 Effect.provide(FetchHttpClient.layer),
28 ),
25 ).pipe(29 ).pipe(
26 Atom.withRefresh("5 minutes"),30 Atom.withRefresh("5 minutes"),
27 Atom.keepAlive,31 Atom.keepAlive,