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/package.json b/packages/sse/package.json
index 0fd879084..97831f484 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-beta.7"
},
"devDependencies": {
- "solid-js": "^1.9.7"
+ "solid-js": "2.0.0-beta.7"
}
}
diff --git a/packages/sse/src/index.ts b/packages/sse/src/index.ts
index f6fb9ad0d..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,6 +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 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 )
+export { isPending, onSettled, NotReadyError } from "solid-js";
diff --git a/packages/sse/src/sse.ts b/packages/sse/src/sse.ts
index 12a626d63..3d6736ced 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`.
*/
@@ -36,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>;
@@ -69,7 +72,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,10 +107,22 @@ 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. */
- data: Accessor;
- /** The latest error event, `undefined` when no error has occurred. */
- error: Accessor;
+ /**
+ * 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.
+ *
+ * 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 current connection state. Use `SSEReadyState` for named comparisons:
* - `SSEReadyState.CONNECTING` (0)
@@ -111,11 +132,18 @@ export type SSEReturn = {
readyState: Accessor;
/** Close the connection. */
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 Loading 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,23 +190,34 @@ 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's 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.
+ * - 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 } },
* );
*
- * return