Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions apps/website/content/docs/streaming/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ See the generated `stream-adapter.api.md` for complete declarations. Putting the

## Types

| Type | Purpose |
| ------------------------------------ | --------------------------------------------------------------------------------------- |
| `RowModelLike<TRow, TRowId>` | Structural atomic-transaction target with string or number IDs. |
| `StreamConnection` | `{ done: Promise<void>; dispose(): void }`. |
| `TransactionBatcher<TRow, TRowId>` | RAF-batched `add`, `{ id, changes }` update, remove, flush, error, and dispose methods. |
| `PartialStreamOptions<TRow, TRowId>` | Fixed `rowId`, optional `onIssue`, and optional complete-row `createRow`. |
| Type | Purpose |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| `RowModelLike<TRow, TRowId>` | Structural atomic-transaction target with string or number IDs. |
| `StreamConnection` | `{ done: Promise<void>; dispose(): void }`. |
| `TransactionBatcher<TRow, TRowId>` | RAF-batched `add`, `{ id, changes }` update, remove, flush, dispose, plus `error` and `subscribeError`. |
| `PartialStreamOptions<TRow, TRowId>` | Fixed `rowId`, optional `onIssue`, and optional complete-row `createRow`. |

## `createBatcher(rowModel)`

Expand All @@ -27,7 +27,9 @@ batcher.remove(["old-row"]);
batcher.flush();
```

Scheduled work coalesces into one transaction per animation frame. `batcher.error` rejects with an asynchronous transaction failure.
Scheduled work coalesces into one transaction per animation frame. Calls are appended, not merged: two `update`s for the same row become two `{ id, changes }` entries inside that one transaction, applied in call order.

`batcher.error` rejects with an asynchronous transaction failure. It is a promise, so it only ever reports the first failure; `batcher.subscribeError(listener)` delivers the same failure to a callback and returns an unsubscribe function. Subscribing after a failure invokes the listener immediately, so there is no race between wiring it up and the failure landing.

## Connectors and parsers

Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/streaming/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ nav: Streaming

`@pretable/stream-adapter` connects an async source to an explicit row model. It coalesces producer events into at most one atomic row-model transaction per animation frame, no matter how fast the source emits.

There are two source shapes, and they behave nothing alike. An element stream appends a whole new row per event; a partial stream never adds a row at all — it keeps patching the same row ID. Watch both:
There are two source shapes, and they behave nothing alike. An element stream appends a whole new row per event; a partial stream patches one fixed row ID over and over. A partial stream adds a row only if you hand it a `createRow` factory, and never more than the one row it targets. Watch both:

## New rows arrive

Expand All @@ -16,7 +16,7 @@ Each yielded value is a complete row. `connectElementStream` appends one as it a

## One row grows

Each yielded value is a `Partial<TRow>` patch to a single, fixed row ID — nothing is ever appended. Row `msg-1` below is seeded before its stream connects; `msg-2` isn't seeded, so `createRow` builds it from the first partial that targets it:
Each yielded value is a `Partial<TRow>` patch to a single, fixed row ID. Row `msg-1` below is seeded before its stream connects; `msg-2` isn't seeded, so its first partial is reported through `onIssue` and `createRow` builds the row from it:

<Example id="partial-row-stream" />

Expand Down
6 changes: 4 additions & 2 deletions apps/website/content/docs/streaming/partial-streams.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The connector never asserts that a partial is a complete row. Supply a factory w

```ts
connectPartialStream(rowModel, partials, {
rowId: "msg-1",
rowId: "msg-2",
createRow(partial, id) {
return {
id,
Expand All @@ -56,7 +56,9 @@ connectPartialStream(rowModel, partials, {
});
```

Without `createRow`, an unknown target is reported through `onIssue` and no row is fabricated — that's the warning `msg-2`'s connection above logs for its first partial, before `createRow` builds the row.
`onIssue` fires whether or not `createRow` is supplied — the connector reports the unknown target first, then builds the row if it has a factory. That ordering is what `msg-2` shows above: it logs the `unknown-update-id` warning for its first partial and _then_ gains its row. Without `createRow`, the report is all that happens and no row is fabricated.

`createRow` receives the changes **accumulated** across that frame, not just the one partial that triggered it: partials arriving in the same animation frame are batched into a single transaction, so a row created on the first frame already carries every field that landed in it.

## Lifecycle

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { connectPartialStream } from "@pretable/stream-adapter";
import { PretableSurface } from "@pretable/react";
import { PretableSurface, useDisposeOnUnmount } from "@pretable/react";
import { createLocalRowModel } from "@pretable/core";
import { useEffect, useMemo } from "react";

Expand Down Expand Up @@ -52,7 +52,11 @@ export function PartialRowGrid() {
};
}, [rowModel]);

useEffect(() => () => rowModel.dispose(), [rowModel]);
// NOT `useEffect(() => () => rowModel.dispose())`: StrictMode rehearses an
// unmount in dev, `useMemo` hands the same model back to the remount, and the
// grid then renders nothing at all. `useDisposeOnUnmount` defers the disposal
// by a microtask so a remount can cancel it.
useDisposeOnUnmount(rowModel);

return (
<PretableSurface
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import { StrictMode, act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import Demo from "../demo";
import { FIRST_REPLY, INTERVAL_MS, SECOND_REPLY } from "../scripted-partials";

const FULL_DURATION_MS =
Math.max(FIRST_REPLY.length, SECOND_REPLY.length) * INTERVAL_MS + INTERVAL_MS;

/**
* The StrictMode twin of `demo.test.tsx`. See the sibling file in
* `streaming-chat-grid/__tests__/strict-mode.test.tsx` for why a non-StrictMode
* render cannot see this class of failure: production builds do not rehearse
* effects, so a row model disposed in an effect cleanup keeps working there
* while `next dev` renders a blank grid.
*
* This example shipped that bug alongside the chat grid. Deleting
* `useDisposeOnUnmount` from `PartialRowGrid.tsx` must fail this test.
*/
describe("partial-row-stream under StrictMode", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it("still seeds, creates and grows its rows when effects are rehearsed", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});

render(
<StrictMode>
<Demo />
</StrictMode>,
);

await act(async () => {
await vi.advanceTimersByTimeAsync(FULL_DURATION_MS);
});

// Header + msg-1 (seeded) + msg-2 (built by createRow). Asserting the rows,
// not just that it mounted: the failure mode renders a header and no data.
expect(screen.getAllByRole("row")).toHaveLength(3);
expect(
screen.getAllByRole("gridcell").map((cell) => cell.textContent ?? ""),
).toContain(FIRST_REPLY);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { connectElementStream } from "@pretable/stream-adapter";
import { PretableSurface } from "@pretable/react";
import { PretableSurface, useDisposeOnUnmount } from "@pretable/react";
import { createLocalRowModel } from "@pretable/core";
import { useEffect, useMemo } from "react";

Expand Down Expand Up @@ -47,7 +47,11 @@ export function ChatGrid({
};
}, [openResponseEvents, prompt, rowModel]);

useEffect(() => () => rowModel.dispose(), [rowModel]);
// NOT `useEffect(() => () => rowModel.dispose())`: StrictMode rehearses an
// unmount in dev, `useMemo` hands the same model back to the remount, and the
// grid then renders nothing at all. `useDisposeOnUnmount` defers the disposal
// by a microtask so a remount can cancel it.
useDisposeOnUnmount(rowModel);

return (
<PretableSurface
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen } from "@testing-library/react";
import { StrictMode, act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ChatGrid } from "../ChatGrid";
import { createScriptedResponseEvents } from "../scripted-response";

const INTERVAL_MS = 200;
const RESPONSE_DURATION_MS = 5 * INTERVAL_MS;

/**
* `demo.test.tsx` renders this same grid WITHOUT StrictMode, and that is
* exactly the blind spot this file exists to close: a production build does not
* rehearse effects, so a model disposed in an effect cleanup keeps working
* there while every contributor running `next dev` sees a blank grid.
*
* This example shipped that bug. `ChatGrid` held its row model in `useMemo` and
* disposed it from a `useEffect` cleanup; StrictMode's rehearsed unmount ran the
* cleanup, the remount got the same (now disposed) model back, and the grid
* threw `A disposed row-layout controller cannot change its columns` and
* rendered nothing. The fix is `useDisposeOnUnmount`.
*
* The repo's dev-mode Playwright gate covers the homepage and
* `/docs/grid/grouping` only — it never loads a streaming page, so nothing
* would have caught this. Deleting `useDisposeOnUnmount` from `ChatGrid.tsx`
* must fail this test.
*/
describe("streaming-chat-grid under StrictMode", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it("still streams rows when effects are rehearsed", async () => {
render(
<StrictMode>
<ChatGrid
prompt="Summarize the last 10 incidents."
openResponseEvents={createScriptedResponseEvents(INTERVAL_MS)}
/>
</StrictMode>,
);

await act(async () => {
await vi.advanceTimersByTimeAsync(RESPONSE_DURATION_MS * 3);
});

// Header row + 3 scripted assistant responses. Asserting the ROWS, not just
// that the component mounted: the failure mode is a grid that renders its
// header and no data, which a "did it mount" check passes.
expect(screen.getAllByRole("row")).toHaveLength(4);
expect(
screen.getAllByRole("gridcell").map((cell) => cell.textContent),
).toContain("assistant");
});
});
13 changes: 9 additions & 4 deletions packages/stream-adapter/src/parse-partial-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import type { StreamState } from "@cacheplane/json-stream";

/**
* Parse a UTF-8 string stream into an `AsyncIterable<Partial<TRow>>`.
* Emits incremental partial rows as a streaming JSON parse fills out
* each top-level array element — useful when an LLM is streaming
* partial JSON and you want field-by-field updates instead of waiting
* for each row to complete.
*
* The root must be a single JSON **object**, not an array — a non-object root
* throws. Each yielded value is the cumulative snapshot of that object as more
* keys resolve, not a delta, so the last value yielded is the complete row.
* Useful when an LLM is streaming partial JSON for one row and you want
* field-by-field updates instead of waiting for the object to close.
*
* For a stream of many complete rows, use {@link parseElementStream}, which
* does take a top-level array.
*
* Pair with {@link connectPartialStream} for end-to-end partial-stream
* → grid wiring.
Expand Down