diff --git a/CHANGELOG.md b/CHANGELOG.md
index 140791be..3537b38e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+# v1.9.0 (Fri Sep 06 2024)
+
+#### 🚀 Enhancement
+
+- Relay client-side fetch requests to the server using the Storybook channel API [#331](https://github.com/chromaui/addon-visual-tests/pull/331) ([@ghengeveld](https://github.com/ghengeveld))
+
+#### Authors: 1
+
+- Gert Hengeveld ([@ghengeveld](https://github.com/ghengeveld))
+
+---
+
# v1.8.0 (Thu Aug 29 2024)
#### 🚀 Enhancement
diff --git a/package.json b/package.json
index e4f28dc9..ce03eda7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chromatic-com/storybook",
- "version": "1.8.0",
+ "version": "1.9.0",
"description": "Catch unexpected visual changes & UI bugs in your stories",
"keywords": [
"storybook-addons",
diff --git a/src/Panel.tsx b/src/Panel.tsx
index 572a479a..30d82a48 100644
--- a/src/Panel.tsx
+++ b/src/Panel.tsx
@@ -11,7 +11,6 @@ import {
IS_OFFLINE,
IS_OUTDATED,
LOCAL_BUILD_PROGRESS,
- PANEL_ID,
REMOVE_ADDON,
TELEMETRY,
} from "./constants";
@@ -28,9 +27,10 @@ import { ControlsProvider } from "./screens/VisualTests/ControlsContext";
import { RunBuildProvider } from "./screens/VisualTests/RunBuildContext";
import { VisualTests } from "./screens/VisualTests/VisualTests";
import { GitInfoPayload, LocalBuildProgress, UpdateStatusFunction } from "./types";
-import { client, Provider, useAccessToken } from "./utils/graphQLClient";
+import { createClient, GraphQLClientProvider, useAccessToken } from "./utils/graphQLClient";
import { TelemetryProvider } from "./utils/TelemetryContext";
import { useBuildEvents } from "./utils/useBuildEvents";
+import { useChannelFetch } from "./utils/useChannelFetch";
import { useProjectId } from "./utils/useProjectId";
import { clearSessionState, useSessionState } from "./utils/useSessionState";
import { useSharedState } from "./utils/useSharedState";
@@ -93,8 +93,9 @@ export const Panel = ({ active, api }: PanelProps) => {
const trackEvent = useCallback((data: any) => emit(TELEMETRY, data), [emit]);
const { isRunning, startBuild, stopBuild } = useBuildEvents({ localBuildProgress, accessToken });
+ const fetch = useChannelFetch();
const withProviders = (children: React.ReactNode) => (
-
+
{
-
+
);
if (!active) {
@@ -134,7 +135,6 @@ export const Panel = ({ active, api }: PanelProps) => {
if (!accessToken) {
return withProviders(
("experimental_serverAPI");
const corePromise = presets.apply("core");
diff --git a/src/utils/ChannelFetch.test.ts b/src/utils/ChannelFetch.test.ts
new file mode 100644
index 00000000..62bf9a46
--- /dev/null
+++ b/src/utils/ChannelFetch.test.ts
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { FETCH_ABORTED, FETCH_REQUEST, FETCH_RESPONSE } from "../constants";
+import { ChannelFetch } from "./ChannelFetch";
+import { MockChannel } from "./MockChannel";
+
+const resolveAfter = (ms: number, value: any) =>
+ new Promise((resolve) => setTimeout(resolve, ms, value));
+
+const rejectAfter = (ms: number, reason: any) =>
+ new Promise((_, reject) => setTimeout(reject, ms, reason));
+
+describe("ChannelFetch", () => {
+ let channel: MockChannel;
+
+ beforeEach(() => {
+ channel = new MockChannel();
+ });
+
+ it("should handle fetch requests", async () => {
+ const fetch = vi.fn(() => resolveAfter(100, { headers: [], text: async () => "data" }));
+ ChannelFetch.subscribe("req", channel, fetch as any);
+
+ channel.emit(FETCH_REQUEST, {
+ requestId: "req",
+ input: "https://example.com",
+ init: { headers: { foo: "bar" } },
+ });
+
+ await vi.waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith("https://example.com", {
+ headers: { foo: "bar" },
+ signal: expect.any(AbortSignal),
+ });
+ });
+ });
+
+ it("should send fetch responses", async () => {
+ const fetch = vi.fn(() => resolveAfter(100, { headers: [], text: async () => "data" }));
+ const instance = ChannelFetch.subscribe("res", channel, fetch as any);
+
+ const promise = new Promise((resolve) => {
+ channel.on(FETCH_RESPONSE, ({ response, error }) => {
+ expect(response.body).toBe("data");
+ expect(error).toBeUndefined();
+ resolve();
+ });
+ });
+
+ channel.emit(FETCH_REQUEST, { requestId: "res", input: "https://example.com" });
+ await vi.waitFor(() => {
+ expect(instance.abortControllers.size).toBe(1);
+ });
+
+ await promise;
+
+ expect(instance.abortControllers.size).toBe(0);
+ });
+
+ it("should send fetch error responses", async () => {
+ const fetch = vi.fn(() => rejectAfter(100, new Error("oops")));
+ const instance = ChannelFetch.subscribe("err", channel, fetch as any);
+
+ const promise = new Promise((resolve) => {
+ channel.on(FETCH_RESPONSE, ({ response, error }) => {
+ expect(response).toBeUndefined();
+ expect(error).toMatch(/oops/);
+ resolve();
+ });
+ });
+
+ channel.emit(FETCH_REQUEST, { requestId: "err", input: "https://example.com" });
+ await vi.waitFor(() => {
+ expect(instance.abortControllers.size).toBe(1);
+ });
+
+ await promise;
+ expect(instance.abortControllers.size).toBe(0);
+ });
+
+ it("should abort fetch requests", async () => {
+ const fetch = vi.fn((input, init) => new Promise(() => {}));
+ const instance = ChannelFetch.subscribe("abort", channel, fetch);
+
+ channel.emit(FETCH_REQUEST, { requestId: "abort", input: "https://example.com" });
+ await vi.waitFor(() => {
+ expect(instance.abortControllers.size).toBe(1);
+ });
+
+ channel.emit(FETCH_ABORTED, { requestId: "abort" });
+ await vi.waitFor(() => {
+ expect(fetch.mock.lastCall?.[1].signal.aborted).toBe(true);
+ expect(instance.abortControllers.size).toBe(0);
+ });
+ });
+});
diff --git a/src/utils/ChannelFetch.ts b/src/utils/ChannelFetch.ts
new file mode 100644
index 00000000..d6fb1371
--- /dev/null
+++ b/src/utils/ChannelFetch.ts
@@ -0,0 +1,47 @@
+import type { Channel } from "@storybook/channels";
+
+import { FETCH_ABORTED, FETCH_REQUEST, FETCH_RESPONSE } from "../constants";
+
+type ChannelLike = Pick;
+
+const instances = new Map();
+
+export class ChannelFetch {
+ channel: ChannelLike;
+
+ abortControllers: Map;
+
+ constructor(channel: ChannelLike, _fetch = fetch) {
+ this.channel = channel;
+ this.abortControllers = new Map();
+
+ this.channel.on(FETCH_ABORTED, ({ requestId }) => {
+ this.abortControllers.get(requestId)?.abort();
+ this.abortControllers.delete(requestId);
+ });
+
+ this.channel.on(FETCH_REQUEST, async ({ requestId, input, init }) => {
+ const controller = new AbortController();
+ this.abortControllers.set(requestId, controller);
+
+ try {
+ const res = await _fetch(input as RequestInfo, { ...init, signal: controller.signal });
+ const body = await res.text();
+ const headers = Array.from(res.headers as any);
+ const response = { body, headers, status: res.status, statusText: res.statusText };
+ this.channel.emit(FETCH_RESPONSE, { requestId, response });
+ } catch (err) {
+ const error = err instanceof Error ? err.message : String(err);
+ this.channel.emit(FETCH_RESPONSE, { requestId, error });
+ } finally {
+ this.abortControllers.delete(requestId);
+ }
+ });
+ }
+
+ static subscribe(key: string, channel: ChannelLike, _fetch = fetch) {
+ const instance = instances.get(key) || new ChannelFetch(channel, _fetch);
+ if (!instances.has(key)) instances.set(key, instance);
+ return instance;
+ }
+}
diff --git a/src/utils/MockChannel.ts b/src/utils/MockChannel.ts
new file mode 100644
index 00000000..209e4f39
--- /dev/null
+++ b/src/utils/MockChannel.ts
@@ -0,0 +1,16 @@
+export class MockChannel {
+ private listeners: Record void)[]> = {};
+
+ on(event: string, listener: (...args: any[]) => void) {
+ this.listeners[event] = [...(this.listeners[event] ?? []), listener];
+ }
+
+ off(event: string, listener: (...args: any[]) => void) {
+ this.listeners[event] = (this.listeners[event] ?? []).filter((l) => l !== listener);
+ }
+
+ emit(event: string, ...args: any[]) {
+ // setTimeout is used to simulate the asynchronous nature of the real channel
+ (this.listeners[event] || []).forEach((listener) => setTimeout(() => listener(...args)));
+ }
+}
diff --git a/src/utils/SharedState.test.ts b/src/utils/SharedState.test.ts
index 7b2d1ecb..ca98b216 100644
--- a/src/utils/SharedState.test.ts
+++ b/src/utils/SharedState.test.ts
@@ -1,24 +1,8 @@
import { beforeEach, describe, expect, it } from "vitest";
+import { MockChannel } from "./MockChannel";
import { SharedState } from "./SharedState";
-class MockChannel {
- private listeners: Record void)[]> = {};
-
- on(event: string, listener: (...args: any[]) => void) {
- this.listeners[event] = [...(this.listeners[event] ?? []), listener];
- }
-
- off(event: string, listener: (...args: any[]) => void) {
- this.listeners[event] = (this.listeners[event] ?? []).filter((l) => l !== listener);
- }
-
- emit(event: string, ...args: any[]) {
- // setTimeout is used to simulate the asynchronous nature of the real channel
- (this.listeners[event] || []).forEach((listener) => setTimeout(() => listener(...args)));
- }
-}
-
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("SharedState", () => {
diff --git a/src/utils/graphQLClient.tsx b/src/utils/graphQLClient.tsx
index 029c5357..d3b7908e 100644
--- a/src/utils/graphQLClient.tsx
+++ b/src/utils/graphQLClient.tsx
@@ -1,13 +1,11 @@
import { authExchange } from "@urql/exchange-auth";
import React from "react";
import { useAddonState } from "storybook/internal/manager-api";
-import { Client, fetchExchange, mapExchange, Provider } from "urql";
+import { Client, ClientOptions, fetchExchange, mapExchange, Provider } from "urql";
import { v4 as uuid } from "uuid";
import { ACCESS_TOKEN_KEY, ADDON_ID, CHROMATIC_API_URL } from "../constants";
-export { Provider };
-
let currentToken: string | null;
let currentTokenExpiration: number | null;
const setCurrentToken = (token: string | null) => {
@@ -56,56 +54,62 @@ export const getFetchOptions = (token?: string) => ({
},
});
-export const client = new Client({
- url: CHROMATIC_API_URL,
- exchanges: [
- // We don't use cacheExchange, because it would inadvertently share data between stories.
- mapExchange({
- onResult(result) {
- // Not all queries contain the `viewer` field, in which case it will be `undefined`.
- // When we do retrieve the field but the token is invalid, it will be `null`.
- if (result.data?.viewer === null) setCurrentToken(null);
- },
- }),
- authExchange(async (utils) => {
- return {
- addAuthToOperation(operation) {
- if (!currentToken) return operation;
- return utils.appendHeaders(operation, { Authorization: `Bearer ${currentToken}` });
+export const createClient = (options?: Partial) =>
+ new Client({
+ url: CHROMATIC_API_URL,
+ exchanges: [
+ // We don't use cacheExchange, because it would inadvertently share data between stories.
+ mapExchange({
+ onResult(result) {
+ // Not all queries contain the `viewer` field, in which case it will be `undefined`.
+ // When we do retrieve the field but the token is invalid, it will be `null`.
+ if (result.data?.viewer === null) setCurrentToken(null);
},
+ }),
+ authExchange(async (utils) => {
+ return {
+ addAuthToOperation(operation) {
+ if (!currentToken) return operation;
+ return utils.appendHeaders(operation, { Authorization: `Bearer ${currentToken}` });
+ },
- // Determine if the current error is an authentication error.
- didAuthError: (error) =>
- error.response.status === 401 ||
- error.graphQLErrors.some((e) => e.message.includes("Must login")),
+ // Determine if the current error is an authentication error.
+ didAuthError: (error) =>
+ error.response.status === 401 ||
+ error.graphQLErrors.some((e) => e.message.includes("Must login")),
- // If didAuthError returns true, clear the token. Ideally we should refresh the token here.
- // The operation will be retried automatically.
- async refreshAuth() {
- setCurrentToken(null);
- },
+ // If didAuthError returns true, clear the token. Ideally we should refresh the token here.
+ // The operation will be retried automatically.
+ async refreshAuth() {
+ setCurrentToken(null);
+ },
- // Prevent making a request if we know the token is missing, invalid or expired.
- // This handler is called repeatedly so we avoid parsing the token each time.
- willAuthError() {
- if (!currentToken) return true;
- try {
- if (!currentTokenExpiration) {
- const { exp } = JSON.parse(atob(currentToken.split(".")[1]));
- currentTokenExpiration = exp;
+ // Prevent making a request if we know the token is missing, invalid or expired.
+ // This handler is called repeatedly so we avoid parsing the token each time.
+ willAuthError() {
+ if (!currentToken) return true;
+ try {
+ if (!currentTokenExpiration) {
+ const { exp } = JSON.parse(atob(currentToken.split(".")[1]));
+ currentTokenExpiration = exp;
+ }
+ return Date.now() / 1000 > (currentTokenExpiration || 0);
+ } catch (e) {
+ return true;
}
- return Date.now() / 1000 > (currentTokenExpiration || 0);
- } catch (e) {
- return true;
- }
- },
- };
- }),
- fetchExchange,
- ],
- fetchOptions: getFetchOptions(), // Auth header (token) is handled by authExchange
-});
+ },
+ };
+ }),
+ fetchExchange,
+ ],
+ fetchOptions: getFetchOptions(), // Auth header (token) is handled by authExchange
+ ...options,
+ });
-export const GraphQLClientProvider = ({ children }: { children: React.ReactNode }) => {
- return {children};
-};
+export const GraphQLClientProvider = ({
+ children,
+ value = createClient(),
+}: {
+ children: React.ReactNode;
+ value?: Client;
+}) => {children};
diff --git a/src/utils/useChannelFetch.ts b/src/utils/useChannelFetch.ts
new file mode 100644
index 00000000..8eca1d1e
--- /dev/null
+++ b/src/utils/useChannelFetch.ts
@@ -0,0 +1,59 @@
+import { useChannel } from "@storybook/manager-api";
+
+import { FETCH_ABORTED, FETCH_REQUEST, FETCH_RESPONSE } from "../constants";
+
+type SerializedResponse = {
+ status: number;
+ statusText: string;
+ headers: [string, string][];
+ body: string;
+};
+
+const pendingRequests = new Map<
+ string,
+ { resolve: (value: Response) => void; reject: (reason?: any) => void }
+>();
+
+export const useChannelFetch: () => typeof fetch = () => {
+ const emit = useChannel({
+ [FETCH_RESPONSE]: (
+ data:
+ | { requestId: string; response: SerializedResponse }
+ | { requestId: string; error: string }
+ ) => {
+ const request = pendingRequests.get(data.requestId);
+ if (!request) return;
+
+ pendingRequests.delete(data.requestId);
+ if ("error" in data) {
+ request.reject(new Error(data.error));
+ } else {
+ const { body, headers, status, statusText } = data.response;
+ const res = new Response(body, { headers, status, statusText });
+ request.resolve(res);
+ }
+ },
+ });
+
+ return async (input: string | URL | Request, { signal, ...init }: RequestInit = {}) => {
+ if (signal?.aborted) {
+ return Promise.reject(signal.reason);
+ }
+
+ const requestId = Math.random().toString(36).slice(2);
+ signal?.addEventListener("abort", () => {
+ emit(FETCH_ABORTED, { requestId });
+ pendingRequests.get(requestId)?.reject(signal.reason);
+ pendingRequests.delete(requestId);
+ });
+ emit(FETCH_REQUEST, { requestId, input, init });
+
+ return new Promise((resolve, reject) => {
+ pendingRequests.set(requestId, { resolve, reject });
+ setTimeout(() => {
+ reject(new Error("Request timed out"));
+ pendingRequests.delete(requestId);
+ }, 30000);
+ });
+ };
+};