From 7e29a2d2d1cc7c2139fd7e2a60e40de2e291276a Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sat, 18 Apr 2026 22:17:43 -0400
Subject: [PATCH 1/4] Minor cleanup and review
---
packages/sse/package.json | 6 +-
packages/sse/src/index.ts | 6 ++
packages/sse/src/sse.ts | 131 ++++++++++++++++++++++---------
packages/sse/test/index.test.ts | 82 ++++++++++++++++---
packages/sse/test/server.test.ts | 6 +-
packages/sse/test/worker.test.ts | 10 ++-
packages/sse/vitest.config.ts | 46 +++++++++++
pnpm-lock.yaml | 65 +++++++++++----
8 files changed, 283 insertions(+), 69 deletions(-)
create mode 100644 packages/sse/vitest.config.ts
diff --git a/packages/sse/package.json b/packages/sse/package.json
index 0fd879084..73813ee63 100644
--- a/packages/sse/package.json
+++ b/packages/sse/package.json
@@ -67,7 +67,7 @@
"scripts": {
"dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts",
"build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts",
- "vitest": "vitest -c ../../configs/vitest.config.ts",
+ "vitest": "vitest -c vitest.config.ts",
"test": "pnpm run vitest",
"test:ssr": "pnpm run vitest --mode ssr"
},
@@ -75,9 +75,9 @@
"@solid-primitives/utils": "workspace:^"
},
"peerDependencies": {
- "solid-js": "^1.6.12"
+ "solid-js": "2.0.0-experimental.16"
},
"devDependencies": {
- "solid-js": "^1.9.7"
+ "solid-js": "2.0.0-experimental.16"
}
}
diff --git a/packages/sse/src/index.ts b/packages/sse/src/index.ts
index f6fb9ad0d..38cf1d252 100644
--- a/packages/sse/src/index.ts
+++ b/packages/sse/src/index.ts
@@ -12,3 +12,9 @@ export {
} from "./sse.js";
export { json, ndjson, lines, number, safe, pipe } from "./transform.js";
+
+// Re-export Solid 2.0 async primitives commonly used with createSSE:
+// - isPending(data) — true while awaiting the first SSE message
+// - onSettled(() => ...) — runs when the first message arrives
+// - NotReadyError — thrown by data() while pending (caught by )
+export { isPending, onSettled, NotReadyError } from "solid-js";
diff --git a/packages/sse/src/sse.ts b/packages/sse/src/sse.ts
index 12a626d63..f5dadc3ac 100644
--- a/packages/sse/src/sse.ts
+++ b/packages/sse/src/sse.ts
@@ -1,9 +1,8 @@
-import { type Accessor, createComputed, createSignal, onCleanup, untrack } from "solid-js";
+import { onCleanup, createSignal, createTrackedEffect, untrack, NotReadyError } from "solid-js";
+import type { Accessor } from "solid-js";
import { isServer } from "solid-js/web";
import { access, type MaybeAccessor } from "@solid-primitives/utils";
-// ─── ReadyState ───────────────────────────────────────────────────────────────
-
/**
* Named constants for the SSE connection state, mirroring the `EventSource`
* static properties. Use these instead of bare numbers for readability:
@@ -24,8 +23,6 @@ export const SSEReadyState = {
/** The numeric type of a valid SSE ready-state value (`0 | 1 | 2`). */
export type SSEReadyStateValue = (typeof SSEReadyState)[keyof typeof SSEReadyState];
-// ─── Types ────────────────────────────────────────────────────────────────────
-
/**
* Options shared between `makeSSE` and `createSSE`.
*/
@@ -69,7 +66,13 @@ export type SSESourceFn = (
) => [source: SSESourceHandle, cleanup: VoidFunction];
export type CreateSSEOptions = SSEOptions & {
- /** Initial value of the `data` signal before any message arrives */
+ /**
+ * Initial value of the `data` signal before any message arrives.
+ *
+ * When provided, `data()` returns this value immediately (no pending state).
+ * When omitted, `data()` throws `NotReadyError` until the first message
+ * arrives, integrating with Solid's `` for a loading fallback.
+ */
initialValue?: T;
/**
* Transform raw string data from each message event.
@@ -98,7 +101,17 @@ export type CreateSSEOptions = SSEOptions & {
export type SSEReturn = {
/** The underlying source instance. `undefined` on SSR or before first connect. */
source: Accessor;
- /** The latest message data, parsed through `transform` if provided. */
+ /**
+ * The latest message data, parsed through `transform` if provided.
+ *
+ * **Pending until the first message arrives** (unless `initialValue` is set).
+ * Reading this inside a component wrapped with `` will show the
+ * fallback while the connection is establishing. After the first message the
+ * signal updates reactively on every subsequent message.
+ *
+ * Use `pending()` to check the pending state imperatively, and
+ * `onSettled(() => ...)` to react when the first value arrives.
+ */
data: Accessor;
/** The latest error event, `undefined` when no error has occurred. */
error: Accessor;
@@ -109,13 +122,27 @@ export type SSEReturn = {
* - `SSEReadyState.CLOSED` (2)
*/
readyState: Accessor;
- /** Close the connection. */
+ /**
+ * `true` until the first message arrives (or after `reconnect()` / URL
+ * change until the next message). Use this for imperative pending checks;
+ * use `` for declarative loading UI (it catches the `NotReadyError`
+ * that `data()` throws while pending).
+ */
+ pending: Accessor;
+ /** Close the connection. Resets `data` to pending on the next `reconnect()`. */
close: VoidFunction;
- /** Force-close the current connection and open a new one. */
+ /**
+ * Force-close the current connection and open a new one.
+ * Resets `data` to pending until the next message arrives.
+ */
reconnect: VoidFunction;
};
-// ─── makeSSE ─────────────────────────────────────────────────────────────────
+// Internal sentinel marking "no message received yet". When rawData holds this
+// value, the data accessor throws NotReadyError so Solid's Suspense boundary
+// can show a fallback while the connection is establishing.
+const NOT_SET: unique symbol = Symbol();
+type NotSet = typeof NOT_SET;
/**
* Creates a raw `EventSource` connection without Solid lifecycle management.
@@ -162,15 +189,17 @@ export const makeSSE = (
return [source, cleanup];
};
-// ─── createSSE ───────────────────────────────────────────────────────────────
-
/**
* Creates a reactive SSE (Server-Sent Events) connection that integrates with
- * the Solid reactive system and owner lifecycle.
+ * Solid async reactivity system and owner lifecycle.
*
- * - Accepts a reactive URL — reconnects automatically when the URL signal changes
- * - Closes the connection on owner disposal via `onCleanup`
- * - SSR-safe: returns static stubs on the server
+ * - `data` is **pending** (throws `NotReadyError`) until the first message
+ * arrives, enabling `` to show a loading fallback. Provide
+ * `initialValue` to start with a settled value instead.
+ * - Accepts a reactive URL — reconnects automatically when the URL signal
+ * changes, resetting `data` to pending.
+ * - Closes the connection on owner disposal via `onCleanup`.
+ * - SSR-safe: returns static stubs on the server.
*
* ```ts
* const { data, readyState, error, close, reconnect } = createSSE<{ msg: string }>(
@@ -178,7 +207,12 @@ export const makeSSE = (
* { transform: JSON.parse, reconnect: { retries: 3, delay: 2000 } },
* );
*
- * return
{data()?.msg}
;
+ * // In JSX — Suspense shows fallback while connecting:
+ * return (
+ * Connecting…
}>
+ *
{data()?.msg}
+ *
+ * );
* ```
*
* @param url Static URL string or reactive `Accessor`
@@ -195,6 +229,7 @@ export const createSSE = (
data: () => options.initialValue,
error: () => undefined,
readyState: () => SSEReadyState.CLOSED,
+ pending: () => options.initialValue === undefined,
close: () => void 0,
reconnect: () => void 0,
};
@@ -202,11 +237,35 @@ export const createSSE = (
// ── Reactive state ────────────────────────────────────────────────────────
const [source, setSource] = createSignal(undefined);
- const [data, setData] = createSignal(options.initialValue);
+
+ // rawData holds either the latest message value or the NOT_SET sentinel.
+ // The cast to `Exclude | typeof NOT_SET` selects overload 2 of
+ // createSignal (plain value, not compute function). NOT_SET is a unique symbol
+ // so it's never a Function; for initialValue, SSE data types are never functions.
+ const [rawData, setRawData] = createSignal(
+ (options.initialValue !== undefined ? options.initialValue : NOT_SET) as
+ | Exclude
+ | typeof NOT_SET,
+ );
+
+ // A computed signal: throws NotReadyError when rawData is NOT_SET so that
+ // shows a fallback while awaiting the first message. After the
+ // first message it updates reactively on every subsequent message.
+ const [data] = createSignal(() => {
+ const val = rawData();
+ if (val === NOT_SET) throw new NotReadyError("SSE awaiting first message");
+ return val as T | undefined;
+ });
+
const [error, setError] = createSignal(undefined);
const [readyState, setReadyState] = createSignal(SSEReadyState.CONNECTING);
- // ── Reconnect config ──────────────────────────────────────────────────────
+ // Explicit pending flag — true until the first message arrives (or after
+ // reconnect). The `data` computed throws NotReadyError for , but
+ // Solid isPending() can't detect the initial STATUS_UNINITIALIZED
+ // state, so we expose this for imperative checks.
+ const [pending, setPending] = createSignal(options.initialValue === undefined);
+
const reconnectConfig: SSEReconnectOptions =
options.reconnect === true
? { retries: Infinity, delay: 3000 }
@@ -245,7 +304,8 @@ export const createSSE = (
const handleMessage = (e: MessageEvent) => {
const value = options.transform ? options.transform(e.data as string) : (e.data as T);
- setData(() => value);
+ setRawData(() => value);
+ setPending(false);
options.onMessage?.(e);
};
@@ -277,7 +337,7 @@ export const createSSE = (
currentCleanup = cleanup;
};
- const disconnect = () => {
+ const close = () => {
clearReconnectTimer();
retriesLeft = 0;
currentCleanup?.();
@@ -286,44 +346,43 @@ export const createSSE = (
setReadyState(SSEReadyState.CLOSED);
};
- const manualReconnect = () => {
+ const reconnect = () => {
const currentUrl = untrack(() => access(url));
- disconnect();
+ close();
+ // Reset to pending so Suspense shows a fallback during reconnect.
+ setRawData(NOT_SET);
+ setPending(true);
connect(currentUrl);
};
- // ── Initial connection (synchronous) ─────────────────────────────────────
- // createEffect is deferred until after the current synchronous code block,
- // so we connect immediately here to ensure signals are populated as soon as
- // createSSE returns.
connect(untrack(() => access(url)));
- // ── Reactive URL handling ─────────────────────────────────────────────────
- // Only needed when url is an accessor. `createComputed` runs synchronously
- // on creation (unlike `createEffect`, which is deferred), so the reactive
- // subscription to `url` is established immediately. The `prevUrl` guard
- // prevents a redundant reconnect on the first pass (we already connected).
+ // createTrackedEffect runs synchronously so the reactive subscription
+ // to `url` is established immediately. The prevUrl guard prevents a
+ // redundant reconnect on the first pass.
if (typeof url === "function") {
let prevUrl = untrack(url);
- createComputed(() => {
+ createTrackedEffect(() => {
const resolvedUrl = url();
if (resolvedUrl !== prevUrl) {
prevUrl = resolvedUrl;
untrack(() => {
currentCleanup?.();
currentCleanup = undefined;
+ // Reset to pending — new connection, new loading state.
+ setRawData(NOT_SET);
+ setPending(true);
+ connect(resolvedUrl);
});
- connect(resolvedUrl);
}
});
}
- // ── Lifecycle cleanup ─────────────────────────────────────────────────────
onCleanup(() => {
clearReconnectTimer();
currentCleanup?.();
currentCleanup = undefined;
});
- return { source, data, error, readyState, close: disconnect, reconnect: manualReconnect };
+ return { source, data, error, readyState, pending, close, reconnect };
};
diff --git a/packages/sse/test/index.test.ts b/packages/sse/test/index.test.ts
index 7cd09d3bb..9bf4ffd05 100644
--- a/packages/sse/test/index.test.ts
+++ b/packages/sse/test/index.test.ts
@@ -1,6 +1,6 @@
import "./setup";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import { createRoot, createSignal } from "solid-js";
+import { createRoot, createSignal, flush } from "solid-js";
import { makeSSE, createSSE, SSEReadyState } from "../src/index.js";
import { MockEventSource } from "./setup.js";
@@ -83,36 +83,65 @@ describe("createSSE", () => {
createRoot(dispose => {
const { readyState } = createSSE("https://example.com/events");
vi.advanceTimersByTime(20);
+ flush();
expect(readyState()).toBe(SSEReadyState.OPEN);
dispose();
}));
- it("provides latest message via data signal", () =>
+ it("data is pending before first message arrives", () =>
createRoot(dispose => {
- const { data, source } = createSSE("https://example.com/events");
- expect(data()).toBeUndefined();
+ const { data, pending } = createSSE("https://example.com/events");
+ expect(pending()).toBe(true);
+ expect(() => data()).toThrow();
+ dispose();
+ }));
+
+ it("provides latest message via data signal after first message", () =>
+ createRoot(dispose => {
+ const { data, source, pending } = createSSE("https://example.com/events");
vi.advanceTimersByTime(20);
+ flush();
(source() as unknown as MockEventSource).simulateMessage("hello");
+ flush();
+ expect(pending()).toBe(false);
expect(data()).toBe("hello");
dispose();
}));
+ it("updates data on subsequent messages", () =>
+ createRoot(dispose => {
+ const { data, source } = createSSE("https://example.com/events");
+ vi.advanceTimersByTime(20);
+ flush();
+ const mock = source() as unknown as MockEventSource;
+ mock.simulateMessage("first");
+ flush();
+ expect(data()).toBe("first");
+ mock.simulateMessage("second");
+ flush();
+ expect(data()).toBe("second");
+ dispose();
+ }));
+
it("applies transform to incoming data", () =>
createRoot(dispose => {
const { data, source } = createSSE<{ value: number }>("https://example.com/events", {
transform: JSON.parse,
});
vi.advanceTimersByTime(20);
+ flush();
(source() as unknown as MockEventSource).simulateMessage(JSON.stringify({ value: 42 }));
+ flush();
expect(data()).toEqual({ value: 42 });
dispose();
}));
- it("returns initialValue before any message arrives", () =>
+ it("returns initialValue before any message arrives (no pending state)", () =>
createRoot(dispose => {
- const { data } = createSSE("https://example.com/events", {
+ const { data, pending } = createSSE("https://example.com/events", {
initialValue: "loading",
});
+ expect(pending()).toBe(false);
expect(data()).toBe("loading");
dispose();
}));
@@ -123,11 +152,15 @@ describe("createSSE", () => {
reconnect: { retries: 1, delay: 50 },
});
vi.advanceTimersByTime(20);
+ flush();
(source() as unknown as MockEventSource).simulateError();
+ flush();
expect(error()).toBeTruthy();
// reconnect fires after delay; new source opens
vi.advanceTimersByTime(100);
+ flush();
vi.advanceTimersByTime(20); // new source opens
+ flush();
expect(error()).toBeUndefined();
dispose();
}));
@@ -138,7 +171,9 @@ describe("createSSE", () => {
reconnect: false,
});
vi.advanceTimersByTime(20);
+ flush();
(source() as unknown as MockEventSource).simulateError();
+ flush();
expect(readyState()).toBe(SSEReadyState.CLOSED);
expect(error()).toBeTruthy();
dispose();
@@ -151,8 +186,11 @@ describe("createSSE", () => {
reconnect: { retries: 5, delay: 50 },
});
vi.advanceTimersByTime(20);
+ flush();
(source() as unknown as MockEventSource).simulateTransientError();
+ flush();
vi.advanceTimersByTime(300);
+ flush();
// readyState stayed CONNECTING → no new EventSource was created
expect(SSEInstances.length).toBe(initialCount + 1);
dispose();
@@ -164,10 +202,13 @@ describe("createSSE", () => {
reconnect: { retries: 1, delay: 100 },
});
vi.advanceTimersByTime(20);
+ flush();
const first = source();
(first as unknown as MockEventSource).simulateError();
+ flush();
expect(source()).toBe(first); // no change yet
vi.advanceTimersByTime(150);
+ flush();
expect(source()).not.toBe(first); // new connection opened
dispose();
}));
@@ -178,14 +219,20 @@ describe("createSSE", () => {
reconnect: { retries: 1, delay: 50 },
});
vi.advanceTimersByTime(20);
+ flush();
const first = source();
(first as unknown as MockEventSource).simulateError();
+ flush();
vi.advanceTimersByTime(100); // first retry
+ flush();
const second = source();
expect(second).not.toBe(first);
vi.advanceTimersByTime(20); // second opens
+ flush();
(second as unknown as MockEventSource).simulateError();
+ flush();
vi.advanceTimersByTime(200); // no more retries
+ flush();
expect(source()).toBe(second); // still the same source
dispose();
}));
@@ -194,30 +241,44 @@ describe("createSSE", () => {
createRoot(dispose => {
const { readyState, close } = createSSE("https://example.com/events");
vi.advanceTimersByTime(20);
+ flush();
expect(readyState()).toBe(SSEReadyState.OPEN);
close();
+ flush();
expect(readyState()).toBe(SSEReadyState.CLOSED);
dispose();
}));
- it("reconnect() opens a fresh connection", () =>
+ it("reconnect() opens a fresh connection and resets data to pending", () =>
createRoot(dispose => {
- const { source, reconnect } = createSSE("https://example.com/events");
+ const { data, source, pending, reconnect } = createSSE("https://example.com/events");
vi.advanceTimersByTime(20);
+ flush();
const first = source();
+ (first as unknown as MockEventSource).simulateMessage("hello");
+ flush();
+ expect(data()).toBe("hello");
reconnect();
+ flush();
+ expect(pending()).toBe(true); // pending again after reconnect
expect(source()).not.toBe(first);
expect(first?.readyState).toBe(SSEReadyState.CLOSED); // old one closed
dispose();
}));
- it("reconnects when the URL signal changes", () =>
+ it("reconnects when the URL signal changes and resets data to pending", () =>
createRoot(dispose => {
const [url, setUrl] = createSignal("https://example.com/v1/events");
- const { source } = createSSE(url);
+ const { data, source, pending } = createSSE(url);
vi.advanceTimersByTime(20);
+ flush();
const first = source();
+ (first as unknown as MockEventSource).simulateMessage("v1 data");
+ flush();
+ expect(data()).toBe("v1 data");
setUrl("https://example.com/v2/events");
+ flush();
+ expect(pending()).toBe(true); // pending for new URL
expect(source()).not.toBe(first);
expect(first?.readyState).toBe(SSEReadyState.CLOSED);
dispose();
@@ -228,6 +289,7 @@ describe("createSSE", () => {
createRoot(dispose => {
const { source } = createSSE("https://example.com/events");
vi.advanceTimersByTime(20);
+ flush();
const es = source();
vi.spyOn(es as unknown as MockEventSource, "close").mockImplementation(() => resolve());
dispose();
diff --git a/packages/sse/test/server.test.ts b/packages/sse/test/server.test.ts
index ab7ea714f..3d1bd6abe 100644
--- a/packages/sse/test/server.test.ts
+++ b/packages/sse/test/server.test.ts
@@ -7,9 +7,10 @@ describe("SSR", () => {
createRoot(dispose => {
const sse = createSSE("https://example.com/events");
expect(sse.source()).toBeUndefined();
- expect(sse.data()).toBeUndefined();
+ expect(sse.data()).toBeUndefined(); // SSR returns undefined, not pending
expect(sse.error()).toBeUndefined();
expect(sse.readyState()).toBe(2);
+ expect(sse.pending()).toBe(true); // no initialValue → pending
expect(() => sse.close()).not.toThrow();
expect(() => sse.reconnect()).not.toThrow();
dispose();
@@ -17,10 +18,11 @@ describe("SSR", () => {
it("exposes initialValue in SSR data stub", () =>
createRoot(dispose => {
- const { data } = createSSE("https://example.com/events", {
+ const { data, pending } = createSSE("https://example.com/events", {
initialValue: "loading",
});
expect(data()).toBe("loading");
+ expect(pending()).toBe(false);
dispose();
}));
});
diff --git a/packages/sse/test/worker.test.ts b/packages/sse/test/worker.test.ts
index ee635674a..74c7f2851 100644
--- a/packages/sse/test/worker.test.ts
+++ b/packages/sse/test/worker.test.ts
@@ -1,6 +1,6 @@
import "./setup";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import { createRoot } from "solid-js";
+import { createRoot, flush } from "solid-js";
import { createSSE, SSEReadyState } from "../src/sse.js";
import { makeSSEWorker, type SSEWorkerMessage, type SSEWorkerTarget } from "../src/worker.js";
@@ -227,6 +227,7 @@ describe("createSSE with worker source", () => {
});
const id = (target.sent[0] as Extract).id;
target.respond({ type: "open", id });
+ flush();
expect(readyState()).toBe(SSEReadyState.OPEN);
dispose();
}));
@@ -240,6 +241,7 @@ describe("createSSE with worker source", () => {
const id = (target.sent[0] as Extract).id;
target.respond({ type: "open", id });
target.respond({ type: "message", id, data: "world", eventType: "message" });
+ flush();
expect(data()).toBe("world");
dispose();
}));
@@ -254,6 +256,7 @@ describe("createSSE with worker source", () => {
const id = (target.sent[0] as Extract).id;
target.respond({ type: "open", id });
target.respond({ type: "message", id, data: JSON.stringify({ n: 7 }), eventType: "message" });
+ flush();
expect(data()).toEqual({ n: 7 });
dispose();
}));
@@ -288,12 +291,15 @@ describe("createSSE with worker source", () => {
});
const id1 = (target.sent[0] as Extract).id;
target.respond({ type: "open", id: id1 });
+ flush();
target.respond({ type: "error", id: id1, readyState: SSEReadyState.CLOSED });
+ flush();
// Before the reconnect timer fires, only 1 connect
expect(target.sent.filter(m => m.type === "connect")).toHaveLength(1);
vi.advanceTimersByTime(150);
+ flush();
// After the delay, a new connect should have been sent
expect(target.sent.filter(m => m.type === "connect")).toHaveLength(2);
@@ -308,8 +314,10 @@ describe("createSSE with worker source", () => {
});
const id = (target.sent[0] as Extract).id;
target.respond({ type: "open", id });
+ flush();
expect(readyState()).toBe(SSEReadyState.OPEN);
close();
+ flush();
expect(readyState()).toBe(SSEReadyState.CLOSED);
expect(target.sent.some(m => m.type === "disconnect")).toBe(true);
dispose();
diff --git a/packages/sse/vitest.config.ts b/packages/sse/vitest.config.ts
new file mode 100644
index 000000000..65854a03f
--- /dev/null
+++ b/packages/sse/vitest.config.ts
@@ -0,0 +1,46 @@
+import { defineConfig } from "vitest/config";
+import solidPlugin from "vite-plugin-solid";
+
+export default defineConfig(({ mode }) => {
+ const testSSR = mode === "test:ssr" || mode === "ssr";
+ return {
+ plugins: [
+ solidPlugin({
+ hot: false,
+ solid: { generate: testSSR ? "ssr" : "dom", omitNestedClosingTags: false },
+ }),
+ ],
+ resolve: {
+ conditions: testSSR
+ ? ["@solid-primitives/source", "node"]
+ : ["@solid-primitives/source", "browser", "development"],
+ alias: {
+ "solid-js/web": new URL(
+ testSSR
+ ? "../../node_modules/.pnpm/@solidjs+web@2.0.0-experimental.16_solid-js@2.0.0-experimental.16/node_modules/@solidjs/web/dist/server.js"
+ : "../../node_modules/.pnpm/@solidjs+web@2.0.0-experimental.16_solid-js@2.0.0-experimental.16/node_modules/@solidjs/web/dist/web.js",
+ import.meta.url,
+ ).pathname,
+ "@solidjs/web": new URL(
+ testSSR
+ ? "../../node_modules/.pnpm/@solidjs+web@2.0.0-experimental.16_solid-js@2.0.0-experimental.16/node_modules/@solidjs/web/dist/server.js"
+ : "../../node_modules/.pnpm/@solidjs+web@2.0.0-experimental.16_solid-js@2.0.0-experimental.16/node_modules/@solidjs/web/dist/web.js",
+ import.meta.url,
+ ).pathname,
+ },
+ },
+ test: {
+ watch: false,
+ isolate: false,
+ passWithNoTests: true,
+ environment: testSSR ? "node" : "jsdom",
+ transformMode: { web: [/\.[jt]sx$/] },
+ ...(testSSR
+ ? { include: ["test/server.test.{ts,tsx}"] }
+ : {
+ include: ["test/*.test.{ts,tsx}"],
+ exclude: ["test/server.test.{ts,tsx}"],
+ }),
+ },
+ };
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ecadfdb95..bd5ce25e9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -869,8 +869,8 @@ importers:
version: link:../utils
devDependencies:
solid-js:
- specifier: ^1.9.7
- version: 1.9.7
+ specifier: 2.0.0-experimental.16
+ version: 2.0.0-experimental.16
packages/state-machine:
devDependencies:
@@ -1048,10 +1048,10 @@ importers:
version: link:../packages/utils
'@solidjs/meta':
specifier: ^0.29.3
- version: 0.29.4(solid-js@1.9.7)
+ version: 0.29.4(solid-js@2.0.0-experimental.16)
'@solidjs/router':
specifier: ^0.13.1
- version: 0.13.6(solid-js@1.9.7)
+ version: 0.13.6(solid-js@2.0.0-experimental.16)
clsx:
specifier: ^2.0.0
version: 2.1.1
@@ -1078,13 +1078,13 @@ importers:
version: 1.77.8
solid-dismiss:
specifier: ^1.7.121
- version: 1.8.2(solid-js@1.9.7)
+ version: 1.8.2(solid-js@2.0.0-experimental.16)
solid-icons:
specifier: ^1.1.0
- version: 1.1.0(solid-js@1.9.7)
+ version: 1.1.0(solid-js@2.0.0-experimental.16)
solid-tippy:
specifier: ^0.2.1
- version: 0.2.1(solid-js@1.9.7)(tippy.js@6.3.7)
+ version: 0.2.1(solid-js@2.0.0-experimental.16)(tippy.js@6.3.7)
tippy.js:
specifier: ^6.3.7
version: 6.3.7
@@ -2587,6 +2587,9 @@ packages:
peerDependencies:
solid-js: ^1.5.3
+ '@solidjs/signals@0.11.3':
+ resolution: {integrity: sha512-udMfutYPOlcxKUmc5+n1QtarsxOiAlC6LJY2TqFyaMwdXgo+reiYUcYGDlOiAPXfCLE0lavZHQ/6GT5pJbXKBA==}
+
'@solidjs/start@1.1.4':
resolution: {integrity: sha512-ma1TBYqoTju87tkqrHExMReM5Z/+DTXSmi30CCTavtwuR73Bsn4rVGqm528p4sL2koRMfAuBMkrhuttjzhL68g==}
peerDependencies:
@@ -5892,10 +5895,20 @@ packages:
peerDependencies:
seroval: ^1.0
+ seroval-plugins@1.5.2:
+ resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
seroval@1.3.2:
resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==}
engines: {node: '>=10'}
+ seroval@1.5.2:
+ resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==}
+ engines: {node: '>=10'}
+
serve-placeholder@2.0.2:
resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==}
@@ -6001,6 +6014,9 @@ packages:
solid-js@1.9.7:
resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==}
+ solid-js@2.0.0-experimental.16:
+ resolution: {integrity: sha512-zZ1dU7cR0EnvLnrYiRLQbCFiDw5blLdlqmofgLzKUYE1TCMWDcisBlSwz0Ez8l4yXB4adbdhtaYCuynH4xSq9A==}
+
solid-refresh@0.6.3:
resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==}
peerDependencies:
@@ -8576,18 +8592,20 @@ snapshots:
dependencies:
solid-js: 1.9.7
- '@solidjs/meta@0.29.4(solid-js@1.9.7)':
+ '@solidjs/meta@0.29.4(solid-js@2.0.0-experimental.16)':
dependencies:
- solid-js: 1.9.7
+ solid-js: 2.0.0-experimental.16
- '@solidjs/router@0.13.6(solid-js@1.9.7)':
+ '@solidjs/router@0.13.6(solid-js@2.0.0-experimental.16)':
dependencies:
- solid-js: 1.9.7
+ solid-js: 2.0.0-experimental.16
'@solidjs/router@0.8.4(solid-js@1.9.7)':
dependencies:
solid-js: 1.9.7
+ '@solidjs/signals@0.11.3': {}
+
'@solidjs/start@1.1.4(solid-js@1.9.7)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))':
dependencies:
'@tanstack/server-functions-plugin': 1.121.0(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))
@@ -12441,8 +12459,14 @@ snapshots:
dependencies:
seroval: 1.3.2
+ seroval-plugins@1.5.2(seroval@1.5.2):
+ dependencies:
+ seroval: 1.5.2
+
seroval@1.3.2: {}
+ seroval@1.5.2: {}
+
serve-placeholder@2.0.2:
dependencies:
defu: 6.1.4
@@ -12557,13 +12581,13 @@ snapshots:
dot-case: 3.0.4
tslib: 2.8.1
- solid-dismiss@1.8.2(solid-js@1.9.7):
+ solid-dismiss@1.8.2(solid-js@2.0.0-experimental.16):
dependencies:
- solid-js: 1.9.7
+ solid-js: 2.0.0-experimental.16
- solid-icons@1.1.0(solid-js@1.9.7):
+ solid-icons@1.1.0(solid-js@2.0.0-experimental.16):
dependencies:
- solid-js: 1.9.7
+ solid-js: 2.0.0-experimental.16
solid-js@1.9.7:
dependencies:
@@ -12571,6 +12595,13 @@ snapshots:
seroval: 1.3.2
seroval-plugins: 1.3.2(seroval@1.3.2)
+ solid-js@2.0.0-experimental.16:
+ dependencies:
+ '@solidjs/signals': 0.11.3
+ csstype: 3.1.3
+ seroval: 1.5.2
+ seroval-plugins: 1.5.2(seroval@1.5.2)
+
solid-refresh@0.6.3(solid-js@1.9.7):
dependencies:
'@babel/generator': 7.27.5
@@ -12580,9 +12611,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
- solid-tippy@0.2.1(solid-js@1.9.7)(tippy.js@6.3.7):
+ solid-tippy@0.2.1(solid-js@2.0.0-experimental.16)(tippy.js@6.3.7):
dependencies:
- solid-js: 1.9.7
+ solid-js: 2.0.0-experimental.16
tippy.js: 6.3.7
solid-transition-group@0.2.3(solid-js@1.9.7):
From ce9b27a881ea264d30ba6f866df367f85233ebd4 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sun, 19 Apr 2026 21:14:51 -0400
Subject: [PATCH 2/4] Experimental improvement for Solid 2.0
---
.changeset/sse-solid2-async-reactivity.md | 68 +++++
packages/sse/README.md | 183 +++++++++++--
packages/sse/src/index.ts | 9 +-
packages/sse/src/sse.ts | 308 +++++++++++++++++-----
packages/sse/test/index.test.ts | 143 ++++++++--
packages/sse/test/server.test.ts | 16 +-
6 files changed, 604 insertions(+), 123 deletions(-)
create mode 100644 .changeset/sse-solid2-async-reactivity.md
diff --git a/.changeset/sse-solid2-async-reactivity.md b/.changeset/sse-solid2-async-reactivity.md
new file mode 100644
index 000000000..9c23f83f2
--- /dev/null
+++ b/.changeset/sse-solid2-async-reactivity.md
@@ -0,0 +1,68 @@
+---
+"@solid-primitives/sse": minor
+---
+
+Align `createSSE` with Solid 2.0 async reactivity patterns
+
+### Breaking changes
+
+**`pending` removed from `SSEReturn`**
+
+Use `` for initial load UI and `isPending(() => data())` for stale-while-revalidating. Both are re-exported from this package.
+
+```tsx
+// Before
+const { data, pending } = createSSE(url);
+
{data()}
+
+// After — declarative initial load
+Connecting…}>
+
{data()}
+
+
+// After — stale-while-revalidating (only true once a value exists and new data is pending)
+ data())}>
Refreshing…
+```
+
+**`error` removed from `SSEReturn`**
+
+Terminal errors (connection CLOSED with no retries left) now propagate through `data()` to ``. Non-terminal errors (browser reconnecting) are still surfaced via `onError` callback.
+
+```tsx
+// Before
+const { data, error } = createSSE(url);
+
Error: {error()?.type}
+
+// After — single error path via Errored boundary
+
Connection failed
}>
+ Connecting…}>
+
{data()}
+
+
+```
+
+**`data` type narrowed from `Accessor` to `Accessor`**
+
+The `| undefined` loading hole is removed. When `data()` is not ready it throws `NotReadyError` (caught by ``) or the terminal error (caught by ``); it never returns `undefined` due to pending state.
+
+**SSR stub**: `data()` now throws `NotReadyError` on the server when no `initialValue` is provided (consistent with browser behaviour). Provide `initialValue` for a non-throwing SSR default.
+
+### New primitives
+
+**`makeSSEAsyncIterable(url, options?)`**
+
+Wraps an SSE endpoint as a standard `AsyncIterable`. Each message is one yielded value; terminal errors are thrown. Cleanup runs automatically when the iterator is abandoned.
+
+```ts
+for await (const msg of makeSSEAsyncIterable(url)) {
+ console.log(msg);
+}
+```
+
+**`createSSEStream(url, options?)`**
+
+Minimal reactive alternative to `createSSE` — returns only a `data: Accessor` backed by an async iterable. Same `` / `` integration, no `source` / `readyState` / `close` / `reconnect`.
+
+```ts
+const data = createSSEStream<{ msg: string }>(url, { transform: JSON.parse });
+```
diff --git a/packages/sse/README.md b/packages/sse/README.md
index 88fc99cc3..2342b8042 100644
--- a/packages/sse/README.md
+++ b/packages/sse/README.md
@@ -8,10 +8,12 @@
[](https://www.npmjs.com/package/@solid-primitives/sse)
[](https://github.com/solidjs-community/solid-primitives#contribution-process)
-Primitives for [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) using the browser's built-in `EventSource` API.
+Primitives for [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) using the browser's built-in `EventSource` API. Designed for Solid 2.0's async reactivity model.
- [`makeSSE`](#makesse) — Base non-reactive primitive. Creates an `EventSource` and returns a cleanup function. No Solid lifecycle.
-- [`createSSE`](#createsse) — Reactive primitive. Accepts a reactive URL, integrates with Solid's owner lifecycle, and returns signals for `data`, `error`, and `readyState`.
+- [`createSSE`](#createsse) — Reactive primitive. Accepts a reactive URL, integrates with Solid's owner lifecycle, and returns signals for `data` and `readyState`.
+- [`makeSSEAsyncIterable`](#makesseasynciterable) — Wraps an SSE endpoint as an `AsyncIterable`. Non-reactive foundation.
+- [`createSSEStream`](#createssesstream) — Minimal reactive stream: just a `data` accessor backed by an async iterable.
- [`makeSSEWorker`](#running-sse-in-a-worker) — Runs the SSE connection inside a Web Worker or SharedWorker.
- [Built-in transformers](#built-in-transformers) — `json`, `ndjson`, `lines`, `number`, `safe`, `pipe`.
@@ -70,42 +72,87 @@ Reactive SSE primitive. Connects on creation, closes when the owner is disposed,
```ts
import { createSSE, SSEReadyState } from "@solid-primitives/sse";
-const { data, readyState, error, close, reconnect } = createSSE<{ message: string }>(
+const { data, readyState, close, reconnect } = createSSE<{ message: string }>(
"https://api.example.com/events",
{
transform: JSON.parse,
reconnect: { retries: 3, delay: 2000 },
},
);
+```
+
+### Loading and error boundaries
+
+`data()` integrates with Solid 2.0's async reactivity:
+
+- **``** — shows fallback while `data()` is pending (before the first message arrives).
+- **``** — catches terminal errors (connection CLOSED with no retries left) thrown through `data()`.
+
+```tsx
+import { Loading, Errored } from "solid-js";
+import { createSSE } from "@solid-primitives/sse";
+
+const { data, close, reconnect } = createSSE<{ message: string }>(
+ "https://api.example.com/events",
+ { transform: JSON.parse },
+);
return (
-
- Connecting…}>
-
Latest: {data()?.message ?? "—"}
-
-
-
Connection error
+
Connection failed
}>
+ Connecting…}>
+
Latest: {data().message}
+
+
+);
+```
+
+Non-terminal errors (while the browser is reconnecting automatically) are surfaced via the `onError` callback only — they don't interrupt the reactive graph.
+
+### Stale-while-revalidating with `isPending`
+
+After the first message has arrived, subsequent reconnects (URL change, `reconnect()` call) put the connection back into a pending state. Use `isPending` from Solid to show a subtle "refreshing" indicator without replacing the whole subtree:
+
+```tsx
+import { isPending } from "solid-js";
+import { createSSE } from "@solid-primitives/sse";
+
+const { data } = createSSE<{ msg: string }>(url, { transform: JSON.parse });
+
+return (
+ <>
+ data())}>
+
Refreshing…
-
-
-
+ Connecting…}>
+
{data().msg}
+
+ >
);
```
-### Reactive URL
+> **Note:** `isPending` is `false` during the initial `` fallback (no stale value yet). It becomes `true` only when a stale value exists and new data is pending — i.e., after a URL change or reconnect.
-When the URL is a signal accessor, the connection is replaced whenever the URL changes:
+### Reactive URL with ``
-```ts
+When the URL is a signal accessor, the connection is replaced whenever the URL changes. Use ``'s `on` prop to re-show the fallback on each URL change:
+
+```tsx
const [userId, setUserId] = createSignal("user-1");
const { data } = createSSE(
() => `https://api.example.com/notifications/${userId()}`,
{ transform: JSON.parse },
);
+
+return (
+ // on={userId()} re-shows the fallback each time userId changes while pending
+ Connecting…}>
+
{data().message}
+
+);
```
-Changing `userId()` will close the existing connection and open a new one to the updated URL.
+Without `on`, `` keeps showing stale content during revalidation. With `on`, it re-shows the fallback whenever the key changes and a new connection is establishing.
### Options
@@ -114,7 +161,7 @@ Changing `userId()` will close the existing connection and open a new one to the
| `withCredentials` | `boolean` | `false` | Send credentials with the request |
| `onOpen` | `(e: Event) => void` | — | Called when the connection opens |
| `onMessage` | `(e: MessageEvent) => void` | — | Called on each unnamed `message` event |
-| `onError` | `(e: Event) => void` | — | Called on error |
+| `onError` | `(e: Event) => void` | — | Called on error (terminal and transient) |
| `events` | `Record void>` | — | Handlers for named SSE event types |
| `initialValue` | `T` | `undefined` | Initial value of the `data` signal |
| `transform` | `(raw: string) => T` | identity | Parse raw string data, e.g. `JSON.parse` |
@@ -129,14 +176,22 @@ Changing `userId()` will close the existing connection and open a new one to the
### Return value
-| Property | Type | Description |
-| ------------ | ---------------------------------------- | ------------------------------------------------ |
-| `source` | `Accessor` | Underlying source instance; `undefined` on SSR |
-| `data` | `Accessor` | Latest message data |
-| `error` | `Accessor` | Latest error event |
-| `readyState` | `Accessor` | `SSEReadyState.CONNECTING` / `.OPEN` / `.CLOSED` |
-| `close` | `VoidFunction` | Close the connection |
-| `reconnect` | `VoidFunction` | Force-close and reopen |
+| Property | Type | Description |
+| ------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------- |
+| `source` | `Accessor` | Underlying source instance; `undefined` on SSR |
+| `data` | `Accessor` | Latest message data; throws `NotReadyError` until first message, terminal errors thereafter |
+| `readyState` | `Accessor` | `SSEReadyState.CONNECTING` / `.OPEN` / `.CLOSED` |
+| `close` | `VoidFunction` | Close the connection |
+| `reconnect` | `VoidFunction` | Force-close and reopen; resets `data` to pending |
+
+### Initial value
+
+Provide `initialValue` to skip the pending state entirely — `data()` returns it immediately with no `` fallback needed:
+
+```ts
+const { data } = createSSE(url, { initialValue: [] as string[] });
+// data() === [] immediately, no Loading needed
+```
### `SSEReadyState`
@@ -154,6 +209,80 @@ SSEReadyState.CLOSED; // 2
`EventSource` has native browser-level reconnection built in. For transient network drops the browser automatically retries. The `reconnect` option in `createSSE` is for _application-level_ reconnection — it fires only when `readyState` becomes `SSEReadyState.CLOSED`, meaning the browser has given up entirely. You generally do not need `reconnect: true` for normal usage.
+## `makeSSEAsyncIterable`
+
+Wraps an SSE endpoint as a standard `AsyncIterable`. Each SSE message becomes one yielded value; terminal errors (connection CLOSED) are thrown by the iterator. Cleanup runs automatically when the iterator is abandoned via `return()`.
+
+Use this as a non-reactive building block: integrate it with a `for await…of` loop, pass it to your own `createMemo`, or compose it with other async utilities.
+
+```ts
+import { makeSSEAsyncIterable } from "@solid-primitives/sse";
+
+const iterable = makeSSEAsyncIterable("https://api.example.com/events");
+
+for await (const msg of iterable) {
+ console.log(msg);
+}
+```
+
+### Definition
+
+```ts
+function makeSSEAsyncIterable(
+ url: string | URL,
+ options?: CreateSSEStreamOptions,
+): AsyncIterable;
+
+type CreateSSEStreamOptions = {
+ withCredentials?: boolean;
+ onOpen?: (event: Event) => void;
+ onError?: (event: Event) => void;
+ transform?: (raw: string) => T;
+ events?: Record void>;
+ source?: SSESourceFn;
+};
+```
+
+## `createSSEStream`
+
+A minimal reactive alternative to `createSSE` that returns only a `data` accessor. Internally it drives an `AsyncIterable` produced by `makeSSEAsyncIterable`, giving the same `` / `` integration with less API surface.
+
+Use this when you only need the stream values and don't need access to `source`, `readyState`, `close`, or `reconnect`.
+
+```ts
+import { createSSEStream } from "@solid-primitives/sse";
+
+const data = createSSEStream<{ msg: string }>(url, { transform: JSON.parse });
+
+return (
+
Connection failed
}>
+ Connecting…}>
+
{data().msg}
+
+
+);
+```
+
+Reactive URL is supported — the stream reconnects automatically when the URL signal changes:
+
+```ts
+const [userId, setUserId] = createSignal("user-1");
+
+const data = createSSEStream(
+ () => `https://api.example.com/notifications/${userId()}`,
+ { transform: JSON.parse },
+);
+```
+
+### Definition
+
+```ts
+function createSSEStream(
+ url: MaybeAccessor,
+ options?: CreateSSEStreamOptions,
+): Accessor;
+```
+
## Integration with `@solid-primitives/event-bus`
Because `bus.emit` matches the `(event: MessageEvent) => void` shape of `onMessage`, you can wire them directly:
@@ -214,7 +343,7 @@ return {msg =>
{msg}
};
## Built-in transformers
-Ready-made `transform` functions for the most common SSE data formats. Pass one as the `transform` option to `createSSE`:
+Ready-made `transform` functions for the most common SSE data formats. Pass one as the `transform` option to `createSSE` or `createSSEStream`:
```ts
import { createSSE, json } from "@solid-primitives/sse";
@@ -357,7 +486,7 @@ const worker = new Worker(new URL("@solid-primitives/sse/worker-handler", import
type: "module",
});
-const { data, readyState, error, close, reconnect } = createSSE<{ msg: string }>(
+const { data, readyState, close, reconnect } = createSSE<{ msg: string }>(
"https://api.example.com/events",
{
source: makeSSEWorker(worker),
diff --git a/packages/sse/src/index.ts b/packages/sse/src/index.ts
index 38cf1d252..190e64d03 100644
--- a/packages/sse/src/index.ts
+++ b/packages/sse/src/index.ts
@@ -1,6 +1,8 @@
export {
makeSSE,
createSSE,
+ makeSSEAsyncIterable,
+ createSSEStream,
SSEReadyState,
type SSEOptions,
type SSEReconnectOptions,
@@ -9,12 +11,13 @@ export {
type SSEReadyStateValue,
type CreateSSEOptions,
type SSEReturn,
+ type CreateSSEStreamOptions,
} from "./sse.js";
export { json, ndjson, lines, number, safe, pipe } from "./transform.js";
-// Re-export Solid 2.0 async primitives commonly used with createSSE:
-// - isPending(data) — true while awaiting the first SSE message
+// Re-export Solid 2.0 async primitives commonly used with SSE primitives:
+// - isPending(() => data()) — true during stale-while-revalidating (not initial load)
// - onSettled(() => ...) — runs when the first message arrives
-// - NotReadyError — thrown by data() while pending (caught by )
+// - NotReadyError — thrown by data() while pending (caught by )
export { isPending, onSettled, NotReadyError } from "solid-js";
diff --git a/packages/sse/src/sse.ts b/packages/sse/src/sse.ts
index f5dadc3ac..0d1b87706 100644
--- a/packages/sse/src/sse.ts
+++ b/packages/sse/src/sse.ts
@@ -33,7 +33,13 @@ export type SSEOptions = {
onOpen?: (event: Event) => void;
/** Called on every unnamed `"message"` event */
onMessage?: (event: MessageEvent) => void;
- /** Called on error */
+ /**
+ * Called on error. For non-terminal errors (browser is reconnecting,
+ * `readyState` is still `CONNECTING`) this is purely informational.
+ * For terminal errors (`readyState` is `CLOSED` with no retries left),
+ * the error also propagates through the reactive graph so ``
+ * can catch it without any extra wiring.
+ */
onError?: (event: Event) => void;
/** Handlers for custom named SSE event types, e.g. `{ update: handler }` */
events?: Record void>;
@@ -71,7 +77,7 @@ export type CreateSSEOptions = SSEOptions & {
*
* When provided, `data()` returns this value immediately (no pending state).
* When omitted, `data()` throws `NotReadyError` until the first message
- * arrives, integrating with Solid's `` for a loading fallback.
+ * arrives, integrating with Solid's `` for a loading fallback.
*/
initialValue?: T;
/**
@@ -105,16 +111,18 @@ export type SSEReturn = {
* The latest message data, parsed through `transform` if provided.
*
* **Pending until the first message arrives** (unless `initialValue` is set).
- * Reading this inside a component wrapped with `` will show the
+ * Reading this inside a component wrapped with `` will show the
* fallback while the connection is establishing. After the first message the
* signal updates reactively on every subsequent message.
*
- * Use `pending()` to check the pending state imperatively, and
- * `onSettled(() => ...)` to react when the first value arrives.
+ * For stale-while-revalidating UI (after reconnect or URL change), use
+ * `isPending(() => data())` — it is `false` during initial load (handled by
+ * ``) and `true` only once a stale value exists and new data is pending.
+ *
+ * Terminal errors (connection CLOSED with no retries left) are thrown through
+ * `data()` so `` can catch them without any extra wiring.
*/
- data: Accessor;
- /** The latest error event, `undefined` when no error has occurred. */
- error: Accessor;
+ data: Accessor;
/**
* The current connection state. Use `SSEReadyState` for named comparisons:
* - `SSEReadyState.CONNECTING` (0)
@@ -122,14 +130,7 @@ export type SSEReturn = {
* - `SSEReadyState.CLOSED` (2)
*/
readyState: Accessor;
- /**
- * `true` until the first message arrives (or after `reconnect()` / URL
- * change until the next message). Use this for imperative pending checks;
- * use `` for declarative loading UI (it catches the `NotReadyError`
- * that `data()` throws while pending).
- */
- pending: Accessor;
- /** Close the connection. Resets `data` to pending on the next `reconnect()`. */
+ /** Close the connection. */
close: VoidFunction;
/**
* Force-close the current connection and open a new one.
@@ -139,7 +140,7 @@ export type SSEReturn = {
};
// Internal sentinel marking "no message received yet". When rawData holds this
-// value, the data accessor throws NotReadyError so Solid's Suspense boundary
+// value, the data accessor throws NotReadyError so Solid's Loading boundary
// can show a fallback while the connection is establishing.
const NOT_SET: unique symbol = Symbol();
type NotSet = typeof NOT_SET;
@@ -191,27 +192,31 @@ export const makeSSE = (
/**
* Creates a reactive SSE (Server-Sent Events) connection that integrates with
- * Solid async reactivity system and owner lifecycle.
+ * Solid's async reactivity system and owner lifecycle.
*
* - `data` is **pending** (throws `NotReadyError`) until the first message
- * arrives, enabling `` to show a loading fallback. Provide
+ * arrives, enabling `` to show a loading fallback. Provide
* `initialValue` to start with a settled value instead.
+ * - Terminal errors (CLOSED with no retries) are thrown through `data()` so
+ * `` can catch them. Non-terminal errors call `onError` only.
* - Accepts a reactive URL — reconnects automatically when the URL signal
* changes, resetting `data` to pending.
* - Closes the connection on owner disposal via `onCleanup`.
* - SSR-safe: returns static stubs on the server.
*
* ```ts
- * const { data, readyState, error, close, reconnect } = createSSE<{ msg: string }>(
+ * const { data, readyState, close, reconnect } = createSSE<{ msg: string }>(
* "https://api.example.com/events",
* { transform: JSON.parse, reconnect: { retries: 3, delay: 2000 } },
* );
*
- * // In JSX — Suspense shows fallback while connecting:
+ * // In JSX — Loading shows fallback while connecting, Errored catches terminal failures:
* return (
- * Connecting…}>
- *
{data()?.msg}
- *
+ *
Connection failed
}>
+ * Connecting…}>
+ *
{data()?.msg}
+ *
+ *
* );
* ```
*
@@ -222,50 +227,44 @@ export const createSSE = (
url: MaybeAccessor,
options: CreateSSEOptions = {},
): SSEReturn => {
- // ── SSR stub ──────────────────────────────────────────────────────────────
if (isServer) {
return {
source: () => undefined,
- data: () => options.initialValue,
- error: () => undefined,
+ data:
+ options.initialValue !== undefined
+ ? () => options.initialValue!
+ : () => {
+ throw new NotReadyError("SSE awaiting first message");
+ },
readyState: () => SSEReadyState.CLOSED,
- pending: () => options.initialValue === undefined,
close: () => void 0,
reconnect: () => void 0,
};
}
- // ── Reactive state ────────────────────────────────────────────────────────
const [source, setSource] = createSignal(undefined);
// rawData holds either the latest message value or the NOT_SET sentinel.
- // The cast to `Exclude | typeof NOT_SET` selects overload 2 of
- // createSignal (plain value, not compute function). NOT_SET is a unique symbol
- // so it's never a Function; for initialValue, SSE data types are never functions.
const [rawData, setRawData] = createSignal(
- (options.initialValue !== undefined ? options.initialValue : NOT_SET) as
- | Exclude
- | typeof NOT_SET,
+ options.initialValue !== undefined ? options.initialValue : NOT_SET,
);
- // A computed signal: throws NotReadyError when rawData is NOT_SET so that
- // shows a fallback while awaiting the first message. After the
- // first message it updates reactively on every subsequent message.
- const [data] = createSignal(() => {
+ // Terminal error signal: set when the connection closes with no retries left.
+ // data() re-throws this so can catch it — single error path.
+ const [terminalError, setTerminalError] = createSignal(undefined);
+
+ // Computed data signal: throws terminal error (→ Errored boundary) or
+ // NotReadyError (→ Loading boundary) when not ready.
+ const [data] = createSignal(() => {
+ const err = terminalError();
+ if (err !== undefined) throw err;
const val = rawData();
if (val === NOT_SET) throw new NotReadyError("SSE awaiting first message");
- return val as T | undefined;
+ return val;
});
- const [error, setError] = createSignal(undefined);
const [readyState, setReadyState] = createSignal(SSEReadyState.CONNECTING);
- // Explicit pending flag — true until the first message arrives (or after
- // reconnect). The `data` computed throws NotReadyError for , but
- // Solid isPending() can't detect the initial STATUS_UNINITIALIZED
- // state, so we expose this for imperative checks.
- const [pending, setPending] = createSignal(options.initialValue === undefined);
-
const reconnectConfig: SSEReconnectOptions =
options.reconnect === true
? { retries: Infinity, delay: 3000 }
@@ -283,12 +282,12 @@ export const createSSE = (
}
};
- // ── Connection management ─────────────────────────────────────────────────
let currentCleanup: VoidFunction | undefined;
- /** Open a fresh connection, resetting the retry counter. */
+ /** Open a fresh connection, resetting the retry counter and terminal error. */
const connect = (resolvedUrl: string) => {
retriesLeft = reconnectConfig.retries ?? 0;
+ setTerminalError(undefined);
_open(resolvedUrl);
};
@@ -298,28 +297,29 @@ export const createSSE = (
const handleOpen = (e: Event) => {
setReadyState(SSEReadyState.OPEN);
- setError(undefined);
options.onOpen?.(e);
};
const handleMessage = (e: MessageEvent) => {
const value = options.transform ? options.transform(e.data as string) : (e.data as T);
setRawData(() => value);
- setPending(false);
options.onMessage?.(e);
};
const handleError = (e: Event) => {
const es = e.target as SSESourceHandle;
setReadyState(es.readyState as SSEReadyStateValue);
- setError(() => e);
options.onError?.(e);
- // Only app-level reconnect when the browser has given up (CLOSED).
- // When readyState is still CONNECTING the browser is handling retries.
- if (es.readyState === SSEReadyState.CLOSED && retriesLeft > 0) {
- retriesLeft--;
- reconnectTimer = setTimeout(() => _open(resolvedUrl), reconnectConfig.delay ?? 3000);
+ if (es.readyState === SSEReadyState.CLOSED) {
+ if (retriesLeft > 0) {
+ // Browser gave up but we have retries: schedule app-level reconnect.
+ retriesLeft--;
+ reconnectTimer = setTimeout(() => _open(resolvedUrl), reconnectConfig.delay ?? 3000);
+ } else {
+ // Terminal: no more retries — propagate through Errored boundary.
+ setTerminalError(() => e);
+ }
}
};
@@ -349,9 +349,8 @@ export const createSSE = (
const reconnect = () => {
const currentUrl = untrack(() => access(url));
close();
- // Reset to pending so Suspense shows a fallback during reconnect.
setRawData(NOT_SET);
- setPending(true);
+ setTerminalError(undefined);
connect(currentUrl);
};
@@ -369,9 +368,8 @@ export const createSSE = (
untrack(() => {
currentCleanup?.();
currentCleanup = undefined;
- // Reset to pending — new connection, new loading state.
setRawData(NOT_SET);
- setPending(true);
+ setTerminalError(undefined);
connect(resolvedUrl);
});
}
@@ -384,5 +382,191 @@ export const createSSE = (
currentCleanup = undefined;
});
- return { source, data, error, readyState, pending, close, reconnect };
+ return { source, data, readyState, close, reconnect };
+};
+
+/** Options for `makeSSEAsyncIterable` and `createSSEStream`. */
+export type CreateSSEStreamOptions = SSEOptions & {
+ /** Transform raw string data from each message event. */
+ transform?: (raw: string) => T;
+ /** Custom source factory (defaults to `makeSSE`). */
+ source?: SSESourceFn;
+};
+
+/**
+ * Wraps an SSE endpoint as an `AsyncIterable`. Each SSE message becomes
+ * one yielded value. Terminal errors (connection CLOSED) are thrown by the
+ * iterator. Cleanup (closing the `EventSource`) runs automatically when the
+ * iterator is abandoned via `return()`.
+ *
+ * This is the non-reactive foundation primitive. Use `createSSEStream` if you
+ * want Solid reactivity, or pass this directly to a `createMemo` that accepts
+ * async iterables.
+ *
+ * ```ts
+ * const iterable = makeSSEAsyncIterable("https://api.example.com/events");
+ * for await (const msg of iterable) {
+ * console.log(msg);
+ * }
+ * ```
+ *
+ * @param url The SSE endpoint URL
+ * @param options Event handlers and transform
+ */
+export const makeSSEAsyncIterable = (
+ url: string | URL,
+ options: CreateSSEStreamOptions = {},
+): AsyncIterable => ({
+ [Symbol.asyncIterator](): AsyncIterator {
+ const queue: T[] = [];
+ let notify: (() => void) | undefined;
+ let done = false;
+ let terminalErr: Event | undefined;
+
+ const sourceFn: SSESourceFn = options.source ?? makeSSE;
+ const [, cleanup] = sourceFn(String(url), {
+ withCredentials: options.withCredentials,
+ onOpen: options.onOpen,
+ onError: (e: Event) => {
+ const es = e.target as SSESourceHandle;
+ if (es.readyState === SSEReadyState.CLOSED) {
+ terminalErr = e;
+ done = true;
+ notify?.();
+ notify = undefined;
+ }
+ options.onError?.(e);
+ },
+ onMessage: (e: MessageEvent) => {
+ const value = options.transform ? options.transform(e.data as string) : (e.data as T);
+ queue.push(value);
+ notify?.();
+ notify = undefined;
+ },
+ events: options.events,
+ });
+
+ return {
+ async next(): Promise> {
+ while (!done && queue.length === 0) {
+ await new Promise(r => {
+ notify = r;
+ });
+ }
+ if (queue.length > 0) return { value: queue.shift()!, done: false };
+ if (terminalErr) throw terminalErr;
+ return { value: undefined as unknown as T, done: true };
+ },
+ return(): Promise> {
+ done = true;
+ notify?.();
+ notify = undefined;
+ cleanup();
+ return Promise.resolve({ value: undefined as unknown as T, done: true });
+ },
+ throw(err?: unknown): Promise> {
+ done = true;
+ cleanup();
+ return Promise.reject(err);
+ },
+ };
+ },
+});
+
+/**
+ * Creates a reactive SSE stream using Solid's async computation model.
+ * Returns a single `Accessor` backed by an `AsyncIterable` of SSE data values.
+ *
+ * Compared to `createSSE`, this is a minimal API: no `source`, `readyState`,
+ * `close`, or `reconnect` — just the data stream. Use it when you only need
+ * the values and want the simplest possible integration with ``.
+ *
+ * - Suspends (``) until the first message arrives.
+ * - Reactively reconnects when `url` changes (closes old iterator, starts new one).
+ * - Terminal errors propagate through the accessor to ``.
+ * - Owner disposal closes the underlying `EventSource` via `onCleanup`.
+ *
+ * ```ts
+ * const data = createSSEStream<{ msg: string }>(url, { transform: JSON.parse });
+ *
+ * return (
+ *